From 22a32bc1cb7078364cf8d583d93764d1fd0935a6 Mon Sep 17 00:00:00 2001 From: PTah Date: Wed, 27 May 2026 14:43:04 +1000 Subject: [PATCH] feat: graceful restart without Stop-Process; SAC key in settings example --- Deploy-LoginMonitor.ps1 | 48 +++++++++- Docs/deploy-netlogon-publish.md | 1 + Docs/deploy-rdp-login-monitor.md | 22 +++++ Login_Monitor.ps1 | 141 ++++++++++++++++++++++++----- Restart-RdpLoginMonitor.ps1 | 29 ++++++ login_monitor.settings.example.ps1 | 6 +- update-rdp-monitor.ps1 | 1 + version.txt | 2 +- 8 files changed, 223 insertions(+), 27 deletions(-) create mode 100644 Restart-RdpLoginMonitor.ps1 diff --git a/Deploy-LoginMonitor.ps1 b/Deploy-LoginMonitor.ps1 index 1ba3424..0734c11 100644 --- a/Deploy-LoginMonitor.ps1 +++ b/Deploy-LoginMonitor.ps1 @@ -147,8 +147,52 @@ function Test-CommandLineIsWatchdog { return ($CommandLine -match '(?i)(^|\s)-Watchdog(\s|$)') } +function Test-RdpMonitorMainProcessRunning { + param([string]$CanonicalScript) + try { + $procs = Get-CimInstance Win32_Process -Filter "Name = 'powershell.exe' OR Name = 'pwsh.exe'" -ErrorAction Stop + foreach ($proc in $procs) { + $cl = [string]$proc.CommandLine + if ($cl -notmatch 'Login_Monitor\.ps1') { continue } + if (Test-CommandLineIsWatchdog -CommandLine $cl) { continue } + $sp = Get-ScriptPathFromCommandLine -CommandLine $cl + if ($null -eq $sp) { continue } + if ([System.IO.Path]::GetFullPath($sp) -ne $CanonicalScript) { continue } + if ([int]$proc.ProcessId -eq $PID) { continue } + return $true + } + } catch { } + return $false +} + function Stop-RdpLoginMonitorMainProcesses { + param([int]$GracefulWaitSec = 90) + $canonical = [System.IO.Path]::GetFullPath($LocalScript) + if (-not (Test-RdpMonitorMainProcessRunning -CanonicalScript $canonical)) { + Write-DeployLog "Монитор не запущен — остановка не требуется." + return + } + + if (Test-Path -LiteralPath $LocalScript) { + Write-DeployLog "Graceful recycle: Login_Monitor.ps1 -RequestRestart -Recycle (без Stop-Process)." + $recycleArgs = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $LocalScript, '-RequestRestart', '-Recycle') + $p = Start-Process -FilePath $PsExe -ArgumentList $recycleArgs -Wait -PassThru -WindowStyle Hidden + if ($p.ExitCode -ne 0) { + Write-DeployLog "Предупреждение: -RequestRestart -Recycle завершился с кодом $($p.ExitCode)." + } + } + + $deadline = (Get-Date).AddSeconds($GracefulWaitSec) + while ((Get-Date) -lt $deadline) { + if (-not (Test-RdpMonitorMainProcessRunning -CanonicalScript $canonical)) { + Write-DeployLog "Монитор завершился gracefully (ожидание $($GracefulWaitSec) с)." + return + } + Start-Sleep -Seconds 2 + } + + Write-DeployLog "Таймаут graceful recycle — принудительная остановка оставшихся процессов монитора." try { $procs = Get-CimInstance Win32_Process -Filter "Name = 'powershell.exe' OR Name = 'pwsh.exe'" -ErrorAction Stop foreach ($proc in $procs) { @@ -159,11 +203,11 @@ function Stop-RdpLoginMonitorMainProcesses { if ($null -eq $sp) { continue } if ([System.IO.Path]::GetFullPath($sp) -ne $canonical) { continue } if ([int]$proc.ProcessId -eq $PID) { continue } - Write-DeployLog "Останавливаю процесс монитора PID $($proc.ProcessId) перед обновлением файла." + Write-DeployLog "Stop-Process -Force PID $($proc.ProcessId) (fallback)." Stop-Process -Id $proc.ProcessId -Force -ErrorAction SilentlyContinue } } catch { - Write-DeployLog "Предупреждение при остановке монитора: $($_.Exception.Message)" + Write-DeployLog "Предупреждение при принудительной остановке: $($_.Exception.Message)" } } diff --git a/Docs/deploy-netlogon-publish.md b/Docs/deploy-netlogon-publish.md index e4deba4..38a7564 100644 --- a/Docs/deploy-netlogon-publish.md +++ b/Docs/deploy-netlogon-publish.md @@ -18,6 +18,7 @@ - `Sac-Client.ps1` - `version.txt` - `Deploy-LoginMonitor.ps1` +- `Restart-RdpLoginMonitor.ps1` - `Exchange-MailSecurity.ps1` - `Notify-Common.ps1` - `Install-DomainMonitors.ps1` diff --git a/Docs/deploy-rdp-login-monitor.md b/Docs/deploy-rdp-login-monitor.md index 8ec0c36..80f57b8 100644 --- a/Docs/deploy-rdp-login-monitor.md +++ b/Docs/deploy-rdp-login-monitor.md @@ -155,6 +155,28 @@ Get-ScheduledTask -TaskName 'RDP-Login-Monitor','RDP-Login-Monitor-Watchdog' -Er Get-Content 'C:\ProgramData\RDP-login-monitor\Logs\deploy.log' -Tail 15 ``` +### Graceful restart (без убийства PowerShell) + +После правки **`login_monitor.settings.ps1`** (SAC, Telegram): + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass ` + -File "C:\ProgramData\RDP-login-monitor\Login_Monitor.ps1" -RequestRestart +``` + +Или скрипт из репозитория/шары: **`Restart-RdpLoginMonitor.ps1`**. + +Чтобы подхватить **новый `Login_Monitor.ps1` с диска** (после Deploy), нужен **recycle** — новый скрытый процесс, старый завершается сам: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass ` + -File "C:\ProgramData\RDP-login-monitor\Login_Monitor.ps1" -RequestRestart -Recycle +``` + +**`Deploy-LoginMonitor.ps1`** при обновлении вызывает `-RequestRestart -Recycle` и ждёт до 90 с; **`Stop-Process -Force`** только если таймаут. + +Сигнал: файл **`C:\ProgramData\RDP-login-monitor\restart.request`** (создаётся автоматически, не редактировать вручную). + ### D. Первичная установка (ещё нет ProgramData) 1. Deploy (как в C) — создаст каталог и при отсутствии settings скопирует **`login_monitor.settings.ps1`** из example. diff --git a/Login_Monitor.ps1 b/Login_Monitor.ps1 index 0b83ae2..e4a8e96 100644 --- a/Login_Monitor.ps1 +++ b/Login_Monitor.ps1 @@ -36,7 +36,9 @@ param( [switch]$Watchdog, [switch]$InstallTasks, [switch]$SkipScheduledTaskMaintenance, - [switch]$CheckSac + [switch]$CheckSac, + [switch]$RequestRestart, + [switch]$Recycle ) Set-StrictMode -Version Latest @@ -69,13 +71,16 @@ $script:ScheduledTaskNameMain = "RDP-Login-Monitor" $script:ScheduledTaskNameWatchdog = "RDP-Login-Monitor-Watchdog" # Один экземпляр: эксклюзивная блокировка файла в InstallRoot (SYSTEM и интерактивный админ — одинаково; Global mutex давал «Отказано в доступе» между контекстами). $script:MonitorSingletonLockStream = $null +$script:MonitorRestartRequestFile = Join-Path $script:InstallRoot 'restart.request' +$script:MonitorRecycleRequested = $false +$script:MonitorLoopInitialized = $false # Версия: пишется в лог и в Telegram. При доменном развёртывании через шару см. DEPLOY.md — # триггер обновления на клиентах даёт файл version.txt на шаре (его номер можно поднять и без смены # строки ниже, если правки «мелкие» и вы не хотите менять отображаемую версию в логах). # Рекомендация: при значимых релизах меняйте и $ScriptVersion, и version.txt одинаково; при только # исправлениях на шаре — достаточно поднять patch в version.txt (например 1.3.0.1). -$ScriptVersion = "1.2.0-SAC" +$ScriptVersion = "1.2.1-SAC" # Логи (все под InstallRoot) $LogFile = Join-Path $script:InstallRoot "Logs\login_monitor.log" @@ -385,6 +390,59 @@ function Get-RdpMonitorPowerShellExe { return "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" } +function Set-RdpMonitorRestartRequest { + param( + [ValidateSet('settings', 'recycle')] + [string]$Mode = 'settings', + [string]$Reason = 'manual' + ) + + if (-not (Test-Path -LiteralPath $script:InstallRoot)) { + New-Item -ItemType Directory -Path $script:InstallRoot -Force | Out-Null + } + $lines = @( + "mode=$Mode" + "reason=$Reason" + "requested_at=$((Get-Date).ToString('o'))" + ) + Write-TextFileUtf8Bom -Path $script:MonitorRestartRequestFile -Text (($lines -join "`r`n") + "`r`n") +} + +function Get-RdpMonitorRestartRequest { + if (-not (Test-Path -LiteralPath $script:MonitorRestartRequestFile)) { + return $null + } + $mode = 'settings' + $reason = '' + try { + foreach ($ln in (Get-Content -LiteralPath $script:MonitorRestartRequestFile -ErrorAction Stop)) { + if ($ln -match '^\s*mode\s*=\s*(.+)\s*$') { $mode = $Matches[1].Trim().ToLowerInvariant() } + if ($ln -match '^\s*reason\s*=\s*(.+)\s*$') { $reason = $Matches[1].Trim() } + } + } catch { } + try { + Remove-Item -LiteralPath $script:MonitorRestartRequestFile -Force -ErrorAction SilentlyContinue + } catch { } + if ($mode -ne 'recycle') { $mode = 'settings' } + return [pscustomobject]@{ Mode = $mode; Reason = $reason } +} + +function Invoke-RdpMonitorReloadSettings { + if (-not (Test-Path -LiteralPath $script:LoginMonitorSettingsFile)) { + Write-Log "Graceful restart: login_monitor.settings.ps1 не найден, настройки не перечитаны." + return $false + } + try { + . $script:LoginMonitorSettingsFile + $script:LoginMonitorSettingsLoaded = $true + Write-Log "Graceful restart: login_monitor.settings.ps1 перечитан (каналы: $(Get-NotifyChainHuman))." + return $true + } catch { + Write-Log "Graceful restart: ошибка чтения settings: $($_.Exception.Message)" + return $false + } +} + function Test-RdpMonitorScheduledTaskMatches { param( [Parameter(Mandatory = $true)][string]$TaskName, @@ -865,6 +923,13 @@ if ($CheckSac) { exit $code } +if ($RequestRestart) { + $restartMode = if ($Recycle) { 'recycle' } else { 'settings' } + Set-RdpMonitorRestartRequest -Mode $restartMode -Reason 'RequestRestart' + Write-Log "Запрошен graceful restart (mode=$restartMode, файл restart.request). Активный монитор обработает запрос без Stop-Process." + exit 0 +} + Invoke-RdpMonitorProcessMigrationAndRelaunch Lock-RdpMonitorSingleInstance Ensure-RdpMonitorScheduledTasks @@ -2487,28 +2552,47 @@ function Start-LoginMonitor { $script:MonitorStartedAt = Get-Date $script:HeartbeatStaleAlertActive = $false - Cleanup-OldLogs - Send-Heartbeat -IsStartup - Enable-SecurityAudit + do { + $script:MonitorRecycleRequested = $false - $rdGatewayAvailable = $false - if ($EnableRDGatewayMonitoring) { $rdGatewayAvailable = Test-RDGatewayLog } + if (-not $script:MonitorLoopInitialized) { + Cleanup-OldLogs + Send-Heartbeat -IsStartup + Enable-SecurityAudit + $script:MonitorLoopInitialized = $true + } - $rcmMonitoringEnabled = ($osKind.IsWorkstation -and (Test-RcmLogAvailable)) - if ($osKind.IsWorkstation -and -not $rcmMonitoringEnabled) { - Write-Log "Рабочая станция: журнал Remote Connection Manager недоступен — уведомления только по Security 4624/4625 (LogonType 10). Проверьте, что включён удалённый рабочий стол." - } + $rdGatewayAvailable = $false + if ($EnableRDGatewayMonitoring) { $rdGatewayAvailable = Test-RDGatewayLog } - $nextHeartbeatTime = (Get-Date).AddSeconds($HeartbeatInterval) - $nextRotationCheck = Check-AndRotateLog - $nextReportCheck = Check-AndSendDailyReport - $lastCheckTime = (Get-Date).AddSeconds(-10) - $lastGatewayCheckTime = (Get-Date).AddSeconds(-10) - $lastRcmCheckTime = (Get-Date).AddSeconds(-10) - $lastLockout4740CheckTime = (Get-Date).AddSeconds(-10) - $monitorEvents = @(4624, 4625, 4648) + $rcmMonitoringEnabled = ($osKind.IsWorkstation -and (Test-RcmLogAvailable)) + if ($osKind.IsWorkstation -and -not $rcmMonitoringEnabled) { + Write-Log "Рабочая станция: журнал Remote Connection Manager недоступен — уведомления только по Security 4624/4625 (LogonType 10). Проверьте, что включён удалённый рабочий стол." + } + + $nextHeartbeatTime = (Get-Date).AddSeconds($HeartbeatInterval) + $nextRotationCheck = Check-AndRotateLog + $nextReportCheck = Check-AndSendDailyReport + $lastCheckTime = (Get-Date).AddSeconds(-10) + $lastGatewayCheckTime = (Get-Date).AddSeconds(-10) + $lastRcmCheckTime = (Get-Date).AddSeconds(-10) + $lastLockout4740CheckTime = (Get-Date).AddSeconds(-10) + $monitorEvents = @(4624, 4625, 4648) + + while ($true) { + $restartReq = Get-RdpMonitorRestartRequest + if ($null -ne $restartReq) { + $script:StopNotificationSent = $true + if ($restartReq.Mode -eq 'recycle') { + Write-Log "Graceful recycle: запрос '$($restartReq.Reason)' — выход для запуска нового процесса (обновлённый скрипт с диска)." + $script:MonitorRecycleRequested = $true + break + } + Write-Log "Graceful restart (settings): запрос '$($restartReq.Reason)' — перечитываю настройки в этом же процессе PowerShell." + Invoke-RdpMonitorReloadSettings | Out-Null + break + } - while ($true) { try { # ignore.lst: сверка mtime и лог при изменении файла. [void](Get-RdpMonitorIgnoreListEntries) @@ -2761,7 +2845,12 @@ function Start-LoginMonitor { Write-Log "Ошибка цикла мониторинга: $($_.Exception.Message)" } Start-Sleep -Seconds $MonitorInterval - } + } + + if ($script:MonitorRecycleRequested) { + return + } + } while ($true) } $script:StopNotificationSent = $false @@ -2792,6 +2881,16 @@ try { } } Start-LoginMonitor -MonitorInterval 5 -MonitorInteractiveOnly + + if ($script:MonitorRecycleRequested) { + $script:StopNotificationSent = $true + $canonicalScript = Join-Path $script:InstallRoot $script:CanonicalScriptName + Write-Log "Graceful recycle: запуск нового процесса монитора ($canonicalScript)." + Start-Process -FilePath (Get-RdpMonitorPowerShellExe) -ArgumentList @( + '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $canonicalScript + ) -WindowStyle Hidden | Out-Null + exit 0 + } } catch { if ($_.Exception -is [System.Management.Automation.PipelineStoppedException]) { Write-Log "Выполнение прервано (Ctrl+C / Stop-Pipeline)." diff --git a/Restart-RdpLoginMonitor.ps1 b/Restart-RdpLoginMonitor.ps1 new file mode 100644 index 0000000..cd6140d --- /dev/null +++ b/Restart-RdpLoginMonitor.ps1 @@ -0,0 +1,29 @@ +<# +.SYNOPSIS + Graceful restart RDP Login Monitor без Stop-Process. +.DESCRIPTION + settings — перечитать login_monitor.settings.ps1 в том же процессе PowerShell. + recycle — корректно завершить монитор и запустить новый процесс (после обновления Login_Monitor.ps1). +.EXAMPLE + powershell -ExecutionPolicy Bypass -File Restart-RdpLoginMonitor.ps1 + powershell -ExecutionPolicy Bypass -File Restart-RdpLoginMonitor.ps1 -Recycle +#> +[CmdletBinding()] +param( + [switch]$Recycle +) + +$ErrorActionPreference = 'Stop' +$installRoot = [System.IO.Path]::GetFullPath("$env:ProgramData\RDP-login-monitor") +$monitorScript = Join-Path $installRoot 'Login_Monitor.ps1' + +if (-not (Test-Path -LiteralPath $monitorScript)) { + Write-Error "Не найден: $monitorScript" + exit 1 +} + +$args = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $monitorScript, '-RequestRestart') +if ($Recycle) { $args += '-Recycle' } + +& "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" @args +exit $LASTEXITCODE diff --git a/login_monitor.settings.example.ps1 b/login_monitor.settings.example.ps1 index 089d3bd..6c57fa1 100644 --- a/login_monitor.settings.example.ps1 +++ b/login_monitor.settings.example.ps1 @@ -29,9 +29,9 @@ $NotifyOrder = 'tg' # --- Security Alert Center (SAC) --- # off | exclusive | dual | fallback — см. security-alert-center/docs/agent-integration.md -# $UseSAC = 'exclusive' -# $SacUrl = 'https://sac.kalinamall.ru' -# $SacApiKey = 'sac_xxxxxxxx' +$UseSAC = 'dual' +$SacUrl = 'https://sac.kalinamall.ru' +$SacApiKey = 'sac_UkOsAT3UWiQS54KK5OJPBDCSucysQDrKFju28wmYiz8' # $SacSpoolDir = 'C:\ProgramData\RDP-login-monitor\sac-spool' # $SacTimeoutSec = 12 # $SacTlsSkipVerify = $false diff --git a/update-rdp-monitor.ps1 b/update-rdp-monitor.ps1 index 9b9f5e5..fd7fcb6 100644 --- a/update-rdp-monitor.ps1 +++ b/update-rdp-monitor.ps1 @@ -25,6 +25,7 @@ $DistFiles = @( 'Sac-Client.ps1', 'version.txt', 'Deploy-LoginMonitor.ps1', + 'Restart-RdpLoginMonitor.ps1', 'Exchange-MailSecurity.ps1', 'Notify-Common.ps1', 'Install-DomainMonitors.ps1', diff --git a/version.txt b/version.txt index 682dea4..98bc6a6 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.2.0-SAC +1.2.1-SAC