feat: graceful restart without Stop-Process; SAC key in settings example

This commit is contained in:
PTah
2026-05-27 14:43:04 +10:00
parent bd8076a1d0
commit 22a32bc1cb
8 changed files with 223 additions and 27 deletions
+46 -2
View File
@@ -147,8 +147,52 @@ function Test-CommandLineIsWatchdog {
return ($CommandLine -match '(?i)(^|\s)-Watchdog(\s|$)') 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 { function Stop-RdpLoginMonitorMainProcesses {
param([int]$GracefulWaitSec = 90)
$canonical = [System.IO.Path]::GetFullPath($LocalScript) $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 { try {
$procs = Get-CimInstance Win32_Process -Filter "Name = 'powershell.exe' OR Name = 'pwsh.exe'" -ErrorAction Stop $procs = Get-CimInstance Win32_Process -Filter "Name = 'powershell.exe' OR Name = 'pwsh.exe'" -ErrorAction Stop
foreach ($proc in $procs) { foreach ($proc in $procs) {
@@ -159,11 +203,11 @@ function Stop-RdpLoginMonitorMainProcesses {
if ($null -eq $sp) { continue } if ($null -eq $sp) { continue }
if ([System.IO.Path]::GetFullPath($sp) -ne $canonical) { continue } if ([System.IO.Path]::GetFullPath($sp) -ne $canonical) { continue }
if ([int]$proc.ProcessId -eq $PID) { 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 Stop-Process -Id $proc.ProcessId -Force -ErrorAction SilentlyContinue
} }
} catch { } catch {
Write-DeployLog "Предупреждение при остановке монитора: $($_.Exception.Message)" Write-DeployLog "Предупреждение при принудительной остановке: $($_.Exception.Message)"
} }
} }
+1
View File
@@ -18,6 +18,7 @@
- `Sac-Client.ps1` - `Sac-Client.ps1`
- `version.txt` - `version.txt`
- `Deploy-LoginMonitor.ps1` - `Deploy-LoginMonitor.ps1`
- `Restart-RdpLoginMonitor.ps1`
- `Exchange-MailSecurity.ps1` - `Exchange-MailSecurity.ps1`
- `Notify-Common.ps1` - `Notify-Common.ps1`
- `Install-DomainMonitors.ps1` - `Install-DomainMonitors.ps1`
+22
View File
@@ -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 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) ### D. Первичная установка (ещё нет ProgramData)
1. Deploy (как в C) — создаст каталог и при отсутствии settings скопирует **`login_monitor.settings.ps1`** из example. 1. Deploy (как в C) — создаст каталог и при отсутствии settings скопирует **`login_monitor.settings.ps1`** из example.
+101 -2
View File
@@ -36,7 +36,9 @@ param(
[switch]$Watchdog, [switch]$Watchdog,
[switch]$InstallTasks, [switch]$InstallTasks,
[switch]$SkipScheduledTaskMaintenance, [switch]$SkipScheduledTaskMaintenance,
[switch]$CheckSac [switch]$CheckSac,
[switch]$RequestRestart,
[switch]$Recycle
) )
Set-StrictMode -Version Latest Set-StrictMode -Version Latest
@@ -69,13 +71,16 @@ $script:ScheduledTaskNameMain = "RDP-Login-Monitor"
$script:ScheduledTaskNameWatchdog = "RDP-Login-Monitor-Watchdog" $script:ScheduledTaskNameWatchdog = "RDP-Login-Monitor-Watchdog"
# Один экземпляр: эксклюзивная блокировка файла в InstallRoot (SYSTEM и интерактивный админ — одинаково; Global mutex давал «Отказано в доступе» между контекстами). # Один экземпляр: эксклюзивная блокировка файла в InstallRoot (SYSTEM и интерактивный админ — одинаково; Global mutex давал «Отказано в доступе» между контекстами).
$script:MonitorSingletonLockStream = $null $script:MonitorSingletonLockStream = $null
$script:MonitorRestartRequestFile = Join-Path $script:InstallRoot 'restart.request'
$script:MonitorRecycleRequested = $false
$script:MonitorLoopInitialized = $false
# Версия: пишется в лог и в Telegram. При доменном развёртывании через шару см. DEPLOY.md — # Версия: пишется в лог и в Telegram. При доменном развёртывании через шару см. DEPLOY.md —
# триггер обновления на клиентах даёт файл version.txt на шаре (его номер можно поднять и без смены # триггер обновления на клиентах даёт файл version.txt на шаре (его номер можно поднять и без смены
# строки ниже, если правки «мелкие» и вы не хотите менять отображаемую версию в логах). # строки ниже, если правки «мелкие» и вы не хотите менять отображаемую версию в логах).
# Рекомендация: при значимых релизах меняйте и $ScriptVersion, и version.txt одинаково; при только # Рекомендация: при значимых релизах меняйте и $ScriptVersion, и version.txt одинаково; при только
# исправлениях на шаре — достаточно поднять patch в version.txt (например 1.3.0.1). # исправлениях на шаре — достаточно поднять patch в version.txt (например 1.3.0.1).
$ScriptVersion = "1.2.0-SAC" $ScriptVersion = "1.2.1-SAC"
# Логи (все под InstallRoot) # Логи (все под InstallRoot)
$LogFile = Join-Path $script:InstallRoot "Logs\login_monitor.log" $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" 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 { function Test-RdpMonitorScheduledTaskMatches {
param( param(
[Parameter(Mandatory = $true)][string]$TaskName, [Parameter(Mandatory = $true)][string]$TaskName,
@@ -865,6 +923,13 @@ if ($CheckSac) {
exit $code 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 Invoke-RdpMonitorProcessMigrationAndRelaunch
Lock-RdpMonitorSingleInstance Lock-RdpMonitorSingleInstance
Ensure-RdpMonitorScheduledTasks Ensure-RdpMonitorScheduledTasks
@@ -2487,9 +2552,15 @@ function Start-LoginMonitor {
$script:MonitorStartedAt = Get-Date $script:MonitorStartedAt = Get-Date
$script:HeartbeatStaleAlertActive = $false $script:HeartbeatStaleAlertActive = $false
do {
$script:MonitorRecycleRequested = $false
if (-not $script:MonitorLoopInitialized) {
Cleanup-OldLogs Cleanup-OldLogs
Send-Heartbeat -IsStartup Send-Heartbeat -IsStartup
Enable-SecurityAudit Enable-SecurityAudit
$script:MonitorLoopInitialized = $true
}
$rdGatewayAvailable = $false $rdGatewayAvailable = $false
if ($EnableRDGatewayMonitoring) { $rdGatewayAvailable = Test-RDGatewayLog } if ($EnableRDGatewayMonitoring) { $rdGatewayAvailable = Test-RDGatewayLog }
@@ -2509,6 +2580,19 @@ function Start-LoginMonitor {
$monitorEvents = @(4624, 4625, 4648) $monitorEvents = @(4624, 4625, 4648)
while ($true) { 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
}
try { try {
# ignore.lst: сверка mtime и лог при изменении файла. # ignore.lst: сверка mtime и лог при изменении файла.
[void](Get-RdpMonitorIgnoreListEntries) [void](Get-RdpMonitorIgnoreListEntries)
@@ -2762,6 +2846,11 @@ function Start-LoginMonitor {
} }
Start-Sleep -Seconds $MonitorInterval Start-Sleep -Seconds $MonitorInterval
} }
if ($script:MonitorRecycleRequested) {
return
}
} while ($true)
} }
$script:StopNotificationSent = $false $script:StopNotificationSent = $false
@@ -2792,6 +2881,16 @@ try {
} }
} }
Start-LoginMonitor -MonitorInterval 5 -MonitorInteractiveOnly 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 { } catch {
if ($_.Exception -is [System.Management.Automation.PipelineStoppedException]) { if ($_.Exception -is [System.Management.Automation.PipelineStoppedException]) {
Write-Log "Выполнение прервано (Ctrl+C / Stop-Pipeline)." Write-Log "Выполнение прервано (Ctrl+C / Stop-Pipeline)."
+29
View File
@@ -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
+3 -3
View File
@@ -29,9 +29,9 @@ $NotifyOrder = 'tg'
# --- Security Alert Center (SAC) --- # --- Security Alert Center (SAC) ---
# off | exclusive | dual | fallback — см. security-alert-center/docs/agent-integration.md # off | exclusive | dual | fallback — см. security-alert-center/docs/agent-integration.md
# $UseSAC = 'exclusive' $UseSAC = 'dual'
# $SacUrl = 'https://sac.kalinamall.ru' $SacUrl = 'https://sac.kalinamall.ru'
# $SacApiKey = 'sac_xxxxxxxx' $SacApiKey = 'sac_UkOsAT3UWiQS54KK5OJPBDCSucysQDrKFju28wmYiz8'
# $SacSpoolDir = 'C:\ProgramData\RDP-login-monitor\sac-spool' # $SacSpoolDir = 'C:\ProgramData\RDP-login-monitor\sac-spool'
# $SacTimeoutSec = 12 # $SacTimeoutSec = 12
# $SacTlsSkipVerify = $false # $SacTlsSkipVerify = $false
+1
View File
@@ -25,6 +25,7 @@ $DistFiles = @(
'Sac-Client.ps1', 'Sac-Client.ps1',
'version.txt', 'version.txt',
'Deploy-LoginMonitor.ps1', 'Deploy-LoginMonitor.ps1',
'Restart-RdpLoginMonitor.ps1',
'Exchange-MailSecurity.ps1', 'Exchange-MailSecurity.ps1',
'Notify-Common.ps1', 'Notify-Common.ps1',
'Install-DomainMonitors.ps1', 'Install-DomainMonitors.ps1',
+1 -1
View File
@@ -1 +1 @@
1.2.0-SAC 1.2.1-SAC