<# .SYNOPSIS Доставка Login_Monitor.ps1 с файловой шары по версии (домен: ПК и серверы). .DESCRIPTION Читает version.txt на шаре, сравнивает с локальной меткой (или с версией в установленном скрипте). При необходимости копирует Login_Monitor.ps1 в C:\ProgramData\RDP-login-monitor\, регистрирует задачи (-InstallTasks), перезапускает процесс монитора. Предназначен для GPO «Сценарий запуска компьютера» (SYSTEM); можно запускать вручную от администратора. СТРУКТУРА НА ШАРЕ (пример): \\dc\share\RDP-login-monitor\Login_Monitor.ps1 \\dc\share\RDP-login-monitor\Sac-Client.ps1 — обязателен для SAC \\dc\share\RDP-login-monitor\login_monitor.settings.example.ps1 \\dc\share\RDP-login-monitor\version.txt — одна строка, например: 1.3.0 \\dc\share\RDP-login-monitor\Deploy-LoginMonitor.ps1 Если Deploy-LoginMonitor.ps1 запускают с этой шары, параметр -SourceShareRoot можно не указывать — корень шары берётся из расположения этого файла. .NOTES Лог: C:\ProgramData\RDP-login-monitor\Logs\deploy.log Сравнение версий: метки вида 1.2.27-SAC сравниваются по полному тексту (без учёта регистра); для upgrade/downgrade используется числовой префикс (1.2.27). См. Docs/deploy-rdp-login-monitor.md. #> [CmdletBinding()] param( # UNC-каталог, где лежат Login_Monitor.ps1 и version.txt. Пусто = родительский каталог этого скрипта. [string]$SourceShareRoot = "", [switch]$WhatIf, # После обновления не стартовать монитор (только файлы и задачи). [switch]$SkipStartMonitorAfterUpdate, # Разрешить установку более старой версии с шары (по умолчанию откаты блокируются). [switch]$AllowDowngrade ) Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" function Test-DeployRunningElevated { try { $id = [Security.Principal.WindowsIdentity]::GetCurrent() # LocalSystem (GPO startup / задачи SYSTEM): не всегда даёт true на BuiltInRole::Administrator. if ($null -ne $id.User -and $id.User.Value -eq 'S-1-5-18') { return $true } $p = New-Object Security.Principal.WindowsPrincipal($id) return $p.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) } catch { return $false } } $InstallRoot = [System.IO.Path]::GetFullPath("$env:ProgramData\RDP-login-monitor") $LocalScript = Join-Path $InstallRoot "Login_Monitor.ps1" $VersionStampPath = Join-Path $InstallRoot "deployed_version.txt" $DeployUpdateMarkerPath = Join-Path $InstallRoot "deploy_last_update.txt" $DeployLogPath = Join-Path $InstallRoot "Logs\deploy.log" $ScriptName = "Login_Monitor.ps1" $SacClientName = "Sac-Client.ps1" $VersionFileName = "version.txt" $DeployBundleFiles = @($ScriptName, $SacClientName, 'Diagnose-RdpLoginMonitor.ps1', 'RdpMonitor-TaskQuery.ps1') $PsExe = "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" $Utf8Bom = New-Object System.Text.UTF8Encoding $true function Write-DeployLog { param([string]$Message) $line = "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') - $Message" + [Environment]::NewLine try { $dir = Split-Path $DeployLogPath -Parent if (-not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } [System.IO.File]::AppendAllText($DeployLogPath, $line, $Utf8Bom) } catch { } if ($Host.Name -eq 'ConsoleHost' -or $Host.Name -eq 'Windows PowerShell ISE Host') { Write-Host $line.TrimEnd("`r`n") } } function Resolve-SourceShareRoot { if (-not [string]::IsNullOrWhiteSpace($SourceShareRoot)) { return [System.IO.Path]::GetFullPath($SourceShareRoot.TrimEnd('\')) } $here = $PSCommandPath if ([string]::IsNullOrWhiteSpace($here)) { $here = $MyInvocation.MyCommand.Path } if ([string]::IsNullOrWhiteSpace($here)) { throw "Укажите -SourceShareRoot или запускайте Deploy-LoginMonitor.ps1 с путём к файлу (например с UNC-шары)." } return [System.IO.Path]::GetFullPath((Split-Path -Parent $here)) } function Read-VersionLineFromFile { param([string]$Path) if (-not (Test-Path -LiteralPath $Path)) { return $null } $raw = (Get-Content -LiteralPath $Path -TotalCount 3 -ErrorAction Stop | Where-Object { $_ -match '\S' }) | Select-Object -First 1 if ($null -eq $raw) { return $null } return ([string]$raw).Trim() -replace '^v', '' } function Get-LocalDeployedVersion { $fromStamp = Read-VersionLineFromFile -Path $VersionStampPath if (-not [string]::IsNullOrWhiteSpace($fromStamp)) { return $fromStamp } if (-not (Test-Path -LiteralPath $LocalScript)) { return $null } try { $head = Get-Content -LiteralPath $LocalScript -TotalCount 120 -ErrorAction Stop foreach ($ln in $head) { if ($ln -match '^\s*\$ScriptVersion\s*=\s*["'']([^"'']+)["'']') { return ([string]$Matches[1]).Trim() -replace '^v', '' } } } catch { } return $null } function Normalize-DeployVersionLabel { param([string]$Text) if ([string]::IsNullOrWhiteSpace($Text)) { return $null } return ([string]$Text).Trim() -replace '^v', '' } function Normalize-VersionOrNull { param([string]$Text) $t = Normalize-DeployVersionLabel -Text $Text if ($null -eq $t) { return $null } try { return [version]$t } catch { } if ($t -match '^([0-9]+(?:\.[0-9]+){0,3})') { try { return [version]$Matches[1] } catch { } } return $null } function Compare-VersionStrings { param([string]$Left, [string]$Right) if ([string]::IsNullOrWhiteSpace($Left) -and [string]::IsNullOrWhiteSpace($Right)) { return 0 } if ([string]::IsNullOrWhiteSpace($Left)) { return -1 } if ([string]::IsNullOrWhiteSpace($Right)) { return 1 } $lt = Normalize-DeployVersionLabel -Text $Left $rt = Normalize-DeployVersionLabel -Text $Right if ($lt.Equals($rt, [StringComparison]::OrdinalIgnoreCase)) { return 0 } $a = Normalize-VersionOrNull -Text $Left $b = Normalize-VersionOrNull -Text $Right if ($null -eq $a -or $null -eq $b) { return $null } return $a.CompareTo($b) } function Get-ScriptPathFromCommandLine { param([string]$CommandLine) if ([string]::IsNullOrWhiteSpace($CommandLine)) { return $null } $m = [regex]::Match($CommandLine, '(?i)-File\s+"([^"]+)"') if ($m.Success) { try { return [System.IO.Path]::GetFullPath($m.Groups[1].Value) } catch { return $null } } $m2 = [regex]::Match($CommandLine, '(?i)-File\s+(\S+)') if ($m2.Success) { try { return [System.IO.Path]::GetFullPath($m2.Groups[1].Value) } catch { return $null } } return $null } function Test-CommandLineIsWatchdog { param([string]$CommandLine) 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 Set-RdpMonitorRestartRequestFromDeploy { param( [ValidateSet('settings', 'recycle', 'stop')] [string]$Mode = 'stop', [string]$Reason = 'deploy' ) if (-not (Test-Path -LiteralPath $InstallRoot)) { New-Item -ItemType Directory -Path $InstallRoot -Force | Out-Null } $restartFile = Join-Path $InstallRoot 'restart.request' $content = @( "mode=$Mode" "reason=$Reason" "requested_at=$((Get-Date).ToString('o'))" ) -join "`r`n" [System.IO.File]::WriteAllText($restartFile, $content + "`r`n", $Utf8Bom) } function Get-RdpMonitorSettingsRaw { param([string]$Path) if (-not (Test-Path -LiteralPath $Path)) { return $null } try { return Get-Content -LiteralPath $Path -Raw -ErrorAction Stop } catch { return $null } } function Get-DeployFileSha256 { param([string]$Path) if (-not (Test-Path -LiteralPath $Path)) { return $null } return (Get-FileHash -LiteralPath $Path -Algorithm SHA256 -ErrorAction Stop).Hash } function Test-RdpMonitorDeployBundleNeedsSync { param([string]$ShareRoot) foreach ($rel in $DeployBundleFiles) { $src = Join-Path $ShareRoot $rel if (-not (Test-Path -LiteralPath $src)) { if ($rel -eq $SacClientName) { Write-DeployLog "ОШИБКА: на шаре нет обязательного $SacClientName — опубликуйте пакет (update-rdp-monitor.ps1)." } continue } $dst = Join-Path $InstallRoot $rel if (-not (Test-Path -LiteralPath $dst)) { return $true } $hs = Get-DeployFileSha256 -Path $src $hd = Get-DeployFileSha256 -Path $dst if ($hs -ne $hd) { return $true } } return $false } function Copy-RdpMonitorDeployBundle { param([string]$ShareRoot) foreach ($rel in $DeployBundleFiles) { $src = Join-Path $ShareRoot $rel $dst = Join-Path $InstallRoot $rel if (-not (Test-Path -LiteralPath $src)) { if ($rel -eq $SacClientName) { Write-DeployLog "Предупреждение: на шаре нет $SacClientName — SAC недоступен до публикации файла на шару." } elseif ($rel -eq 'RdpMonitor-TaskQuery.ps1') { Write-DeployLog "Предупреждение: на шаре нет RdpMonitor-TaskQuery.ps1 — выполните update-rdp-monitor.ps1 на сервере публикации." } continue } Copy-Item -LiteralPath $src -Destination $dst -Force Write-DeployLog "Файл скопирован: $dst" } } function Test-RdpMonitorSettingsNeedsSacBootstrap { param([string]$SettingsPath) $c = Get-RdpMonitorSettingsRaw -Path $SettingsPath if ([string]::IsNullOrWhiteSpace($c)) { return $true } if ($c -notmatch '(?m)^\s*\$UseSAC\s*=') { return $true } if ($c -match '(?m)^\s*\$UseSAC\s*=\s*[''"]off[''"]') { if ($c -notmatch '(?m)^\s*\$SacApiKey\s*=\s*[''"]sac_[^''"]+[''"]') { return $true } } if ($c -match '(?m)^\s*\$SacApiKey\s*=\s*[''"]\s*[''"]') { return $true } if ($c -notmatch '(?m)^\s*\$SacApiKey\s*=\s*[''"]sac_[^''"]+[''"]') { return $true } if ($c -notmatch '(?m)^\s*\$SacUrl\s*=\s*[''"]https?://[^''"]+[''"]') { return $true } return $false } function Test-RdpMonitorSettingsNeedsServerDisplayNameHint { 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*)?\$ServerDisplayName\s*=') { return $false } return $true } function Test-RdpMonitorSettingsDailyReportEnabledIsTrue { 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*\$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 } function Test-RdpMonitorSettingsHasInvalidDailyReportAssignment { 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*\$DailyReportEnabled\s*=\s*\$(?:true|false)\b') { return $false } if ($c -match '(?m)^\s*\$DailyReportEnabled\s*=\s*(?:0|1)\s*(?:#.*)?$') { return $false } if ($c -match '(?m)^\s*\$DailyReportEnabled\s*=\s*(?:true|false)\s*(?:#.*)?$') { return $true } return $false } function Repair-RdpMonitorSettingsDailyReportAssignmentIfInvalid { param([string]$LocalSettings) if (-not (Test-Path -LiteralPath $LocalSettings)) { return $false } if (-not (Test-RdpMonitorSettingsHasInvalidDailyReportAssignment -SettingsPath $LocalSettings)) { return $false } $c = Get-RdpMonitorSettingsRaw -Path $LocalSettings $newContent = [regex]::Replace( $c, '(?m)^(\s*\$DailyReportEnabled\s*=\s*)(true|false)(\s*(?:#.*)?)$', { param($m) "$($m.Groups[1].Value)`$$($m.Groups[2].Value)$($m.Groups[3].Value)" } ) if ($newContent -eq $c) { return $false } $bak = "$LocalSettings.bak.$(Get-Date -Format 'yyyyMMdd_HHmmss')" Copy-Item -LiteralPath $LocalSettings -Destination $bak -Force [System.IO.File]::WriteAllText($LocalSettings, $newContent.TrimEnd() + "`r`n", $Utf8Bom) Write-DeployLog "login_monitor.settings.ps1: исправлено DailyReportEnabled = true/false без dollar — заменено на `$true/`$false (резервная копия: $bak)" return $true } function Sync-RdpMonitorSettingsDailyReportEnabledToTrue { param([string]$LocalSettings) if (-not (Test-Path -LiteralPath $LocalSettings)) { return $false } if (-not (Test-RdpMonitorSettingsNeedsDailyReportHint -SettingsPath $LocalSettings)) { return $false } $c = Get-RdpMonitorSettingsRaw -Path $LocalSettings if ([string]::IsNullOrWhiteSpace($c)) { return $false } $dailyLine = '$DailyReportEnabled = $true # по умолчанию: только SAC; $true или 1 — отчёт с агента' $hintBlock = @( '# --- Суточный отчёт report.daily.rdp (агент или только SAC) ---' $dailyLine '# Не пишите "= false" без $ — PowerShell воспримет false как команду.' ) -join "`r`n" $bak = "$LocalSettings.bak.$(Get-Date -Format 'yyyyMMdd_HHmmss')" Copy-Item -LiteralPath $LocalSettings -Destination $bak -Force if ($c -match '(?m)^\s*(\#\s*)?\$DailyReportEnabled\s*=') { Write-DeployLog "login_monitor.settings.ps1: `$DailyReportEnabled → `$true (резервная копия: $bak)" $newContent = [regex]::Replace( $c, '(?m)^\s*(\#\s*)?\$DailyReportEnabled\s*=.*$', $dailyLine, 1 ) } else { 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) return $true } function Sync-RdpMonitorSettingsDailyReportPatches { param([string]$LocalSettings) if (-not (Test-Path -LiteralPath $LocalSettings)) { return $false } $changed = $false if (Repair-RdpMonitorSettingsDailyReportAssignmentIfInvalid -LocalSettings $LocalSettings) { $changed = $true } if (Sync-RdpMonitorSettingsDailyReportEnabledToTrue -LocalSettings $LocalSettings) { $changed = $true } return $changed } function Test-RdpMonitorExchangeServerRole { if (-not [string]::IsNullOrWhiteSpace($env:ExchangeInstallPath)) { if (Test-Path -LiteralPath $env:ExchangeInstallPath) { return $true } } foreach ($regPath in @( 'HKLM:\SOFTWARE\Microsoft\ExchangeServer\v15\Setup', 'HKLM:\SOFTWARE\Microsoft\ExchangeServer\v14\Setup' )) { try { if ($null -ne (Get-ItemProperty -LiteralPath $regPath -ErrorAction Stop)) { return $true } } catch { } } foreach ($root in @( 'C:\Program Files\Microsoft\Exchange Server\V15', 'C:\Program Files\Microsoft\Exchange Server\V14' )) { if (Test-Path -LiteralPath (Join-Path $root 'bin\RemoteExchange.ps1')) { return $true } } try { $svc = Get-Service -Name 'MSExchangeIS', 'MSExchangeTransport' -ErrorAction SilentlyContinue | Where-Object { $_.Status -eq 'Running' } | Select-Object -First 1 if ($null -ne $svc) { return $true } } catch { } return $false } function Get-RdpMonitorExchangeNoiseSettingDefinitions { return @( @{ Pattern = '(?m)^\s*(\#\s*)?\$\{Ignore4624-LT3-EmptyIP-Event\}\s*=' Line = '${Ignore4624-LT3-EmptyIP-Event} = $true' }, @{ Pattern = '(?m)^\s*(\#\s*)?\$WinRmIgnoreLocalSource\s*=' Line = '$WinRmIgnoreLocalSource = 1' }, @{ Pattern = '(?m)^\s*(\#\s*)?\$WinRmIgnoreMachineAccounts\s*=' Line = '$WinRmIgnoreMachineAccounts = 1' }, @{ Pattern = '(?m)^\s*(\#\s*)?\$WinRmExchangeStrictMode\s*=' Line = '$WinRmExchangeStrictMode = 1' } ) } function Test-RdpMonitorSettingsNeedsExchangeNoisePatch { param([string]$SettingsPath) if (-not (Test-RdpMonitorExchangeServerRole)) { return $false } if (-not (Test-Path -LiteralPath $SettingsPath)) { return $false } $c = Get-RdpMonitorSettingsRaw -Path $SettingsPath if ([string]::IsNullOrWhiteSpace($c)) { return $false } foreach ($def in Get-RdpMonitorExchangeNoiseSettingDefinitions) { if ($c -notmatch $def.Pattern) { return $true } } return $false } function Sync-RdpMonitorSettingsExchangeNoisePatches { param([string]$LocalSettings) if (-not (Test-RdpMonitorExchangeServerRole)) { return $false } if (-not (Test-Path -LiteralPath $LocalSettings)) { return $false } if (-not (Test-RdpMonitorSettingsNeedsExchangeNoisePatch -SettingsPath $LocalSettings)) { return $false } $c = Get-RdpMonitorSettingsRaw -Path $LocalSettings $linesToAdd = [System.Collections.Generic.List[string]]::new() [void]$linesToAdd.Add('# --- Exchange: подавление шумов WinRM/4624 (добавлено Deploy-LoginMonitor) ---') foreach ($def in Get-RdpMonitorExchangeNoiseSettingDefinitions) { if ($c -notmatch $def.Pattern) { [void]$linesToAdd.Add($def.Line) } } if ($linesToAdd.Count -le 1) { return $false } $bak = "$LocalSettings.bak.$(Get-Date -Format 'yyyyMMdd_HHmmss')" Copy-Item -LiteralPath $LocalSettings -Destination $bak -Force Write-DeployLog "Добавление Exchange noise settings в login_monitor.settings.ps1; резервная копия: $bak" $block = ($linesToAdd -join "`r`n") $insertBefore = '(?m)^\s*#\s*---\s*Exchange noise filter' if ($c -match $insertBefore) { $newContent = [regex]::Replace($c, $insertBefore, ($block + "`r`n`r`n" + '$0'), 1) } else { $insertLockout = '(?m)^\s*#\s*---\s*Блокировка учётной записи' if ($c -match $insertLockout) { $newContent = [regex]::Replace($c, $insertLockout, ($block + "`r`n`r`n" + '$0'), 1) } else { $newContent = ($c.TrimEnd() + "`r`n`r`n" + $block + "`r`n") } } [System.IO.File]::WriteAllText($LocalSettings, $newContent.TrimEnd() + "`r`n", $Utf8Bom) Write-DeployLog ("login_monitor.settings.ps1: Exchange noise — добавлено строк: {0}" -f ($linesToAdd.Count - 1)) return $true } function Get-RdpMonitorWinRmInboundSettingDefinitions { return @( @{ Pattern = '(?m)^\s*(\#\s*)?\$EnableWinRmInboundMonitoring\s*=' Line = '$EnableWinRmInboundMonitoring = 1' }, @{ Pattern = '(?m)^\s*(\#\s*)?\$WinRmLogName\s*=' Line = '$WinRmLogName = ''Microsoft-Windows-WinRM/Operational''' }, @{ Pattern = '(?m)^\s*(\#\s*)?\$WinRmInboundShellEventIds\s*=' Line = '$WinRmInboundShellEventIds = @(91)' }, @{ Pattern = '(?m)^\s*(\#\s*)?\$WinRmCorrelateSecurity4624\s*=' Line = '$WinRmCorrelateSecurity4624 = 1' }, @{ Pattern = '(?m)^\s*(\#\s*)?\$WinRm4624CorrelationWindowSeconds\s*=' Line = '$WinRm4624CorrelationWindowSeconds = 15' } ) } function Test-RdpMonitorSettingsNeedsWinRmInboundBlock { param([string]$SettingsPath) if (-not (Test-Path -LiteralPath $SettingsPath)) { return $false } $c = Get-RdpMonitorSettingsRaw -Path $SettingsPath if ([string]::IsNullOrWhiteSpace($c)) { return $false } foreach ($def in Get-RdpMonitorWinRmInboundSettingDefinitions) { if ($c -notmatch $def.Pattern) { return $true } } return $false } function Repair-RdpMonitorSettingsWinRmLinesIfInvalid { param([string]$LocalSettings) if (-not (Test-Path -LiteralPath $LocalSettings)) { return $false } $c = Get-RdpMonitorSettingsRaw -Path $LocalSettings if ([string]::IsNullOrWhiteSpace($c)) { return $false } $winRmNames = @( 'EnableWinRmInboundMonitoring', 'WinRmLogName', 'WinRmInboundShellEventIds', 'WinRmCorrelateSecurity4624', 'WinRm4624CorrelationWindowSeconds', 'WinRmIgnoreLocalSource', 'WinRmIgnoreMachineAccounts', 'WinRmExchangeStrictMode' ) $newContent = $c $fixed = $false foreach ($name in $winRmNames) { $escaped = [regex]::Escape($name) $pattern = "(?m)^(\s*)(?&1 foreach ($line in @($out)) { if ($null -ne $line -and "$line".Trim().Length -gt 0) { Write-DeployLog "wevtutil sl WinRM/Operational: $line" } } } finally { $ErrorActionPreference = $runEa } } catch { Write-DeployLog "WinRM Operational журнал: ошибка авто-включения ($($_.Exception.Message))." } $after = Get-RdpMonitorWinRmOperationalLogStatus if ($after -eq 'available-enabled') { Write-DeployLog "WinRM Operational журнал: успешно включён deploy-скриптом." return $true } Write-DeployLog "WinRM Operational журнал: после попытки включения остаётся отключён/недоступен." return $false } function Test-RdpMonitorSettingsNeedsHeartbeatInterval { 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*\$HeartbeatInterval\s*=\s*\d+') } function Sync-RdpMonitorSettingsHeartbeatInterval { param( [string]$LocalSettings, [int]$TargetSeconds = 14400 ) if (-not (Test-Path -LiteralPath $LocalSettings)) { return $false } $c = Get-RdpMonitorSettingsRaw -Path $LocalSettings if ([string]::IsNullOrWhiteSpace($c)) { return $false } if ($c -match '(?m)^\s*\$HeartbeatInterval\s*=\s*(\d+)') { $current = [int]$Matches[1] if ($current -eq $TargetSeconds) { return $false } $bak = "$LocalSettings.bak.$(Get-Date -Format 'yyyyMMdd_HHmmss')" Copy-Item -LiteralPath $LocalSettings -Destination $bak -Force $newContent = [regex]::Replace( $c, '(?m)^(\s*\$HeartbeatInterval\s*=\s*)\d+', "`${1}$TargetSeconds" ) Write-DeployLog "login_monitor.settings.ps1: HeartbeatInterval $current → $TargetSeconds с (резервная копия: $bak)" } else { $bak = "$LocalSettings.bak.$(Get-Date -Format 'yyyyMMdd_HHmmss')" Copy-Item -LiteralPath $LocalSettings -Destination $bak -Force $block = @( '# --- Heartbeat SAC (agent.heartbeat): интервал в секундах; 14400 = 4 ч ---' "`$HeartbeatInterval = $TargetSeconds" ) -join "`r`n" $insertBeforeInventory = '(?m)^\s*#\s*---\s*Инвентаризация железа' if ($c -match $insertBeforeInventory) { $newContent = [regex]::Replace($c, $insertBeforeInventory, ($block + "`r`n`r`n" + '$0'), 1) } else { $newContent = ($c.TrimEnd() + "`r`n`r`n" + $block + "`r`n") } Write-DeployLog "login_monitor.settings.ps1: добавлен `$HeartbeatInterval = $TargetSeconds (резервная копия: $bak)" } [System.IO.File]::WriteAllText($LocalSettings, $newContent.TrimEnd() + "`r`n", $Utf8Bom) 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 { 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*\$StartupRebootDetectMinutes\s*=\s*\d+') } function Sync-RdpMonitorSettingsStartupRebootDetectMinutesIfMissing { param( [string]$LocalSettings, [int]$TargetMinutes = 5 ) if (-not (Test-RdpMonitorSettingsNeedsStartupRebootDetectMinutes -SettingsPath $LocalSettings)) { return $false } if (-not (Test-Path -LiteralPath $LocalSettings)) { return $false } $c = Get-RdpMonitorSettingsRaw -Path $LocalSettings $block = @( '# --- Окно (мин): LastBootUpTime + System 41/1074/6005/6008/6009 → os_reboot при старте ---' "`$StartupRebootDetectMinutes = $TargetMinutes" ) -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`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: добавлен `$StartupRebootDetectMinutes = $TargetMinutes (резервная копия: $bak)" return $true } function Invoke-RdpMonitorSettingsPostPatches { param([string]$LocalSettings) if (-not (Test-Path -LiteralPath $LocalSettings)) { return $false } $changed = $false if (Update-RdpMonitorSettingsServerDisplayNameHintIfMissing -LocalSettings $LocalSettings) { $changed = $true } if (Update-RdpMonitorSettingsServerIPv4HintIfMissing -LocalSettings $LocalSettings) { $changed = $true } if (Sync-RdpMonitorSettingsDailyReportPatches -LocalSettings $LocalSettings) { $changed = $true } if (Sync-RdpMonitorSettingsExchangeNoisePatches -LocalSettings $LocalSettings) { $changed = $true } if (Repair-RdpMonitorSettingsWinRmLinesIfInvalid -LocalSettings $LocalSettings) { $changed = $true } if (Sync-RdpMonitorSettingsWinRmInboundBlock -LocalSettings $LocalSettings) { $changed = $true } if (Sync-RdpMonitorSettingsMaxBackupDaysIfMissing -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 } return $changed } function Test-RdpMonitorSettingsNeedsMaxBackupDays { param([string]$SettingsPath) if (-not (Test-Path -LiteralPath $SettingsPath)) { return $false } $c = Get-RdpMonitorSettingsRaw -Path $SettingsPath if ([string]::IsNullOrWhiteSpace($c)) { return $true } return ($c -notmatch '(?m)^\s*\$MaxBackupDays\s*=') } function Sync-RdpMonitorSettingsMaxBackupDaysIfMissing { param([string]$LocalSettings) if (-not (Test-RdpMonitorSettingsNeedsMaxBackupDays -SettingsPath $LocalSettings)) { return $false } $c = Get-RdpMonitorSettingsRaw -Path $LocalSettings $block = @( '# --- Ротация login_monitor.log и хранение бэкапов (Logs\Backup\LoginLog_*.bak) ---' '# $LogRotationHour = 0' '# $LogRotationMinute = 0' '$MaxBackupDays = 31' ) -join "`r`n" $bak = "$LocalSettings.bak.$(Get-Date -Format 'yyyyMMdd_HHmmss')" if (Test-Path -LiteralPath $LocalSettings) { Copy-Item -LiteralPath $LocalSettings -Destination $bak -Force Write-DeployLog "Добавление `$MaxBackupDays в login_monitor.settings.ps1; резервная копия: $bak" } else { Write-DeployLog "Создание login_monitor.settings.ps1 с `$MaxBackupDays = 31" } $insertBefore4740 = '(?m)^\s*#\s*---\s*Блокировка учётной записи AD' if (-not [string]::IsNullOrWhiteSpace($c) -and $c -match $insertBefore4740) { $newContent = [regex]::Replace($c, $insertBefore4740, ($block + "`r`n`r`n" + '$0'), 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: добавлен `$MaxBackupDays = 31" return $true } function Test-RdpMonitorSettingsNeedsGetInventory { param([string]$SettingsPath) if (-not (Test-Path -LiteralPath $SettingsPath)) { return $false } $c = Get-RdpMonitorSettingsRaw -Path $SettingsPath if ([string]::IsNullOrWhiteSpace($c)) { return $true } return ($c -notmatch '(?m)^\s*\$GetInventory\s*=') } function Sync-RdpMonitorSettingsGetInventoryIfMissing { param([string]$LocalSettings) if (-not (Test-RdpMonitorSettingsNeedsGetInventory -SettingsPath $LocalSettings)) { return $false } $c = Get-RdpMonitorSettingsRaw -Path $LocalSettings $block = @( '# --- Инвентаризация железа/ПО для SAC (agent.inventory, раз в 12 ч) ---' '$GetInventory = $true' ) -join "`r`n" $bak = "$LocalSettings.bak.$(Get-Date -Format 'yyyyMMdd_HHmmss')" if (Test-Path -LiteralPath $LocalSettings) { Copy-Item -LiteralPath $LocalSettings -Destination $bak -Force Write-DeployLog "Добавление `$GetInventory в login_monitor.settings.ps1; резервная копия: $bak" } else { Write-DeployLog "Создание login_monitor.settings.ps1 с `$GetInventory = `$true" } $insertAfterDaily = '(?m)^(\s*\$DailyReportEnabled\s*=.*)$' if (-not [string]::IsNullOrWhiteSpace($c) -and $c -match $insertAfterDaily) { $newContent = [regex]::Replace($c, $insertAfterDaily, ('$1' + "`r`n`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: добавлен $GetInventory = $true' return $true } function Update-RdpMonitorSettingsServerDisplayNameHintIfMissing { param([string]$LocalSettings) if (-not (Test-Path -LiteralPath $LocalSettings)) { return $false } if (-not (Test-RdpMonitorSettingsNeedsServerDisplayNameHint -SettingsPath $LocalSettings)) { return $false } $c = Get-RdpMonitorSettingsRaw -Path $LocalSettings if ([string]::IsNullOrWhiteSpace($c)) { return $false } $hostLabel = if ([string]::IsNullOrWhiteSpace($env:COMPUTERNAME)) { 'New-PC' } else { $env:COMPUTERNAME.Trim() } $hintBlock = @( '# --- Подпись сервера в Telegram и SAC (host.display_name); раскомментируйте при необходимости ---' "# `$ServerDisplayName = '$hostLabel'" ) -join "`r`n" $bak = "$LocalSettings.bak.$(Get-Date -Format 'yyyyMMdd_HHmmss')" Copy-Item -LiteralPath $LocalSettings -Destination $bak -Force Write-DeployLog "Добавление закомментированного `$ServerDisplayName в login_monitor.settings.ps1; резервная копия: $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) Write-DeployLog "login_monitor.settings.ps1: добавлена подсказка # `$ServerDisplayName = '$hostLabel'" 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 { param([string]$ExamplePath) if (-not (Test-Path -LiteralPath $ExamplePath)) { return $null } $ex = Get-RdpMonitorSettingsRaw -Path $ExamplePath if ([string]::IsNullOrWhiteSpace($ex)) { return $null } if ($ex -match '(?ms)(#\s*---\s*Security Alert Center.*?)(?=\r?\n#\s*---|\z)') { return $Matches[1].TrimEnd() } return @( '# --- Security Alert Center (SAC) ---' '$UseSAC = ''fallback''' '$SacUrl = ''https://sac.example.com''' '$SacApiKey = ''sac_CHANGE_ME''' ) -join "`r`n" } function Sync-RdpMonitorUseSacFallbackMode { param([string]$LocalSettings) if (-not (Test-Path -LiteralPath $LocalSettings)) { return $false } $c = Get-RdpMonitorSettingsRaw -Path $LocalSettings if ([string]::IsNullOrWhiteSpace($c)) { return $false } if ($c -match '(?m)^\s*\$UseSAC\s*=\s*[''"]fallback[''"]') { return $false } if ($c -notmatch '(?m)^\s*\$UseSAC\s*=') { return $false } $newContent = [regex]::Replace( $c, '(?m)^(\s*\$UseSAC\s*=\s*)[''"][^''"]+[''"]', '$1''fallback''' ) if ($newContent -eq $c) { return $false } $bak = "$LocalSettings.bak.$(Get-Date -Format 'yyyyMMdd_HHmmss')" Copy-Item -LiteralPath $LocalSettings -Destination $bak -Force [System.IO.File]::WriteAllText($LocalSettings, $newContent.TrimEnd() + "`r`n", $Utf8Bom) Write-DeployLog "login_monitor.settings.ps1: UseSAC переключён на fallback (резервная копия: $bak)." return $true } function Update-RdpMonitorSettingsSacBlockIfMissing { param( [string]$LocalSettings, [string]$ExampleOnShare ) if (-not (Test-Path -LiteralPath $LocalSettings)) { return $false } $c = Get-RdpMonitorSettingsRaw -Path $LocalSettings if ([string]::IsNullOrWhiteSpace($c)) { return $false } 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[''"]') { Write-DeployLog "login_monitor.settings.ps1: блок SAC уже задан, файл не меняем." return $false } } $sacBlock = Get-RdpMonitorSacBlockFromExample -ExamplePath $ExampleOnShare if ([string]::IsNullOrWhiteSpace($sacBlock)) { Write-DeployLog "Предупреждение: не удалось извлечь блок SAC из example — пропуск patch settings." return $false } $bak = "$LocalSettings.bak.$(Get-Date -Format 'yyyyMMdd_HHmmss')" Copy-Item -LiteralPath $LocalSettings -Destination $bak -Force Write-DeployLog "Добавление блока SAC (UseSAC=fallback) в login_monitor.settings.ps1; резервная копия: $bak" if ($c -match '(?m)^\s*\$UseSAC\s*=') { $c = [regex]::Replace($c, '(?m)^\s*\$UseSAC\s*=.*$', '') $c = [regex]::Replace($c, '(?m)^\s*\$SacUrl\s*=.*$', '') $c = [regex]::Replace($c, '(?m)^\s*\$SacApiKey\s*=.*$', '') $c = [regex]::Replace($c, '(?m)^\s*\$SacSpoolDir\s*=.*$', '') $c = [regex]::Replace($c, '(?m)^\s*\$SacTimeoutSec\s*=.*$', '') $c = [regex]::Replace($c, '(?m)^\s*\$SacTlsSkipVerify\s*=.*$', '') $c = [regex]::Replace($c, '(?m)^\s*\$SacFallbackFailures\s*=.*$', '') } $newContent = ($c.TrimEnd() + "`r`n`r`n" + $sacBlock + "`r`n").TrimEnd() + "`r`n" [System.IO.File]::WriteAllText($LocalSettings, $newContent, $Utf8Bom) Write-DeployLog "login_monitor.settings.ps1: добавлен блок SAC (UseSAC=fallback из example на шаре)." return $true } function Sync-RdpMonitorSettingsFromShare { param( [string]$ExampleOnShare, [string]$LocalSettings ) if (-not (Test-Path -LiteralPath $ExampleOnShare)) { Write-DeployLog "Предупреждение: на шаре нет login_monitor.settings.example.ps1 — настройки SAC/Telegram не применены." return } $needsCreate = -not (Test-Path -LiteralPath $LocalSettings) $needsBootstrap = $needsCreate -or (Test-RdpMonitorSettingsNeedsSacBootstrap -SettingsPath $LocalSettings) if (-not $needsBootstrap) { Write-DeployLog "login_monitor.settings.ps1: SAC уже настроен, файл не перезаписываем." Invoke-RdpMonitorSettingsPostPatches -LocalSettings $LocalSettings | Out-Null return } if (-not (Test-Path -LiteralPath $InstallRoot)) { New-Item -ItemType Directory -Path $InstallRoot -Force | Out-Null } if ($needsCreate) { Write-DeployLog "Создан login_monitor.settings.ps1 из example (Telegram + SAC fallback)." Copy-Item -LiteralPath $ExampleOnShare -Destination $LocalSettings -Force Invoke-RdpMonitorSettingsPostPatches -LocalSettings $LocalSettings | Out-Null return } if (Update-RdpMonitorSettingsSacBlockIfMissing -LocalSettings $LocalSettings -ExampleOnShare $ExampleOnShare) { Invoke-RdpMonitorSettingsPostPatches -LocalSettings $LocalSettings | Out-Null return } $bak = "$LocalSettings.bak.$(Get-Date -Format 'yyyyMMdd_HHmmss')" Copy-Item -LiteralPath $LocalSettings -Destination $bak -Force Write-DeployLog "Апгрейд settings: резервная копия $bak, применяем example с шары." Copy-Item -LiteralPath $ExampleOnShare -Destination $LocalSettings -Force Invoke-RdpMonitorSettingsPostPatches -LocalSettings $LocalSettings | Out-Null } function Stop-RdpLoginMonitorMainProcesses { param([int]$GracefulWaitSec = 35) $canonical = [System.IO.Path]::GetFullPath($LocalScript) if (-not (Test-RdpMonitorMainProcessRunning -CanonicalScript $canonical)) { Write-DeployLog "Монитор не запущен — остановка не требуется." return } Write-DeployLog "Graceful stop: запись restart.request (mode=stop), без дочернего PowerShell." Set-RdpMonitorRestartRequestFromDeploy -Mode 'stop' -Reason 'deploy' $deadline = (Get-Date).AddSeconds($GracefulWaitSec) while ((Get-Date) -lt $deadline) { if (-not (Test-RdpMonitorMainProcessRunning -CanonicalScript $canonical)) { Write-DeployLog "Монитор завершился gracefully (до $($GracefulWaitSec) с)." return } Start-Sleep -Seconds 1 } Write-DeployLog "Таймаут graceful stop — принудительная остановка оставшихся процессов монитора." 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 $canonical) { continue } if ([int]$proc.ProcessId -eq $PID) { continue } Write-DeployLog "Stop-Process -Force PID $($proc.ProcessId) (fallback)." Stop-Process -Id $proc.ProcessId -Force -ErrorAction SilentlyContinue } } catch { Write-DeployLog "Предупреждение при принудительной остановке: $($_.Exception.Message)" } } function Convert-RdpMonitorDeployTaskExecutionTimeLimitValue { param($Limit) if ($null -eq $Limit) { return $null } if ($Limit -is [TimeSpan]) { return $Limit } $text = [string]$Limit if ([string]::IsNullOrWhiteSpace($text)) { return $null } $t = $text.Trim() if ($t -eq 'PT0S') { return [TimeSpan]::Zero } try { return [System.Xml.XmlConvert]::ToTimeSpan($t) } catch { } try { return [TimeSpan]::Parse($t) } catch { } return $null } function Test-RdpMonitorDeployTaskExecutionLimitUnlimitedValue { param($Limit) $normalized = Convert-RdpMonitorDeployTaskExecutionTimeLimitValue -Limit $Limit if ($null -eq $normalized) { return $false } if ($normalized.Ticks -le 0) { return $true } if ($normalized.TotalDays -ge 999) { return $true } return $false } function Get-RdpMonitorDeployTaskExecutionTimeLimitLabelFromResolved { param($Resolved) if ($null -eq $Resolved) { return '(null)' } if ($Resolved.Source -eq 'missing') { return '(task missing)' } $limit = Convert-RdpMonitorDeployTaskExecutionTimeLimitValue -Limit $Resolved.Limit if ($null -eq $limit) { return '(null)' } if ($limit.Ticks -le 0) { return 'PT0S' } return $limit.ToString() } function Test-RdpMonitorDeployTaskQueryReady { return [bool](Get-Command Get-RdpMonitorScheduledTaskExecutionTimeLimitResolved -ErrorAction SilentlyContinue) } function Publish-RdpMonitorDeployTaskQueryFunctionsToScriptScope { foreach ($name in @( 'Get-RdpMonitorSchtasksExe', 'Get-RdpMonitorScheduledTaskXmlDocument', 'Test-RdpMonitorScheduledTaskExistsViaSchtasks', 'Convert-RdpMonitorScheduledTaskExecutionTimeLimitText', 'Get-RdpMonitorScheduledTaskExecutionTimeLimitFromDocument', 'Get-RdpMonitorScheduledTaskActionFromDocument', 'Test-RdpMonitorScheduledTaskExecutionTimeLimitUnlimitedValue', 'Get-RdpMonitorScheduledTaskExecutionTimeLimitResolved', 'Test-RdpMonitorScheduledTaskExecutionTimeLimitUnlimited', 'Test-RdpMonitorScheduledTaskNeedsUnlimitedExecutionTimeLimit', 'Get-RdpMonitorScheduledTaskExecutionTimeLimitLabel', 'Test-RdpMonitorScheduledTaskActionMatchesViaSchtasks' )) { $cmd = Get-Command -Name $name -ErrorAction SilentlyContinue if ($null -ne $cmd -and $cmd.CommandType -eq 'Function') { Set-Item -Path "function:script:$name" -Value $cmd.ScriptBlock } } } function Import-RdpMonitorDeployTaskQueryModuleAtPath { param( [Parameter(Mandatory = $true)][string]$Candidate ) if (-not (Test-Path -LiteralPath $Candidate)) { return $false } $loadPath = $Candidate $tempCopy = $null try { if ($Candidate.StartsWith('\\')) { $tempCopy = Join-Path $env:TEMP ("rdp-taskquery-{0}.ps1" -f [guid]::NewGuid().ToString('N')) Copy-Item -LiteralPath $Candidate -Destination $tempCopy -Force $loadPath = $tempCopy } . $loadPath if (Test-RdpMonitorDeployTaskQueryReady) { Publish-RdpMonitorDeployTaskQueryFunctionsToScriptScope return $true } Write-DeployLog "Предупреждение: $Candidate загружен, но функции TaskQuery недоступны — пробуем следующий источник." } catch { Write-DeployLog "Предупреждение: не удалось загрузить $Candidate — $($_.Exception.Message)" } finally { if ($null -ne $tempCopy -and (Test-Path -LiteralPath $tempCopy)) { Remove-Item -LiteralPath $tempCopy -Force -ErrorAction SilentlyContinue } } return $false } function Import-RdpMonitorDeployTaskQueryModule { param([string]$ShareRoot = '') $candidates = [System.Collections.Generic.List[string]]::new() $installCandidate = Join-Path $InstallRoot 'RdpMonitor-TaskQuery.ps1' $candidates.Add($installCandidate) | Out-Null $deployScriptPath = $PSCommandPath if ([string]::IsNullOrWhiteSpace($deployScriptPath)) { $deployScriptPath = $MyInvocation.MyCommand.Path } if (-not [string]::IsNullOrWhiteSpace($deployScriptPath)) { $deployCandidate = Join-Path (Split-Path -Parent $deployScriptPath) 'RdpMonitor-TaskQuery.ps1' if ($deployCandidate -ne $installCandidate) { $candidates.Add($deployCandidate) | Out-Null } } if (-not [string]::IsNullOrWhiteSpace($ShareRoot)) { $shareCandidate = Join-Path $ShareRoot 'RdpMonitor-TaskQuery.ps1' if ($shareCandidate -ne $installCandidate -and -not ($candidates -contains $shareCandidate)) { $candidates.Add($shareCandidate) | Out-Null } } foreach ($candidate in @($candidates)) { if (Import-RdpMonitorDeployTaskQueryModuleAtPath -Candidate $candidate) { return $true } } return $false } function Initialize-RdpMonitorDeployTaskQuery { param([string]$ShareRoot = '') if (Test-RdpMonitorDeployTaskQueryReady) { return $true } $loaded = $false if (Import-RdpMonitorDeployTaskQueryModule -ShareRoot $ShareRoot) { $loaded = $true } else { Write-DeployLog "RdpMonitor-TaskQuery.ps1 недоступен — deploy использует встроенную проверку schtasks /XML." . $script:RdpMonitorDeployTaskQueryInlineScript $loaded = (Test-RdpMonitorDeployTaskQueryReady) } if ($loaded) { Publish-RdpMonitorDeployTaskQueryFunctionsToScriptScope } return $loaded } $script:RdpMonitorDeployTaskQueryInlineScript = { function Get-RdpMonitorSchtasksExe { return Join-Path $env:SystemRoot 'System32\schtasks.exe' } function Get-RdpMonitorScheduledTaskXmlDocument { param([Parameter(Mandatory = $true)][string]$TaskName) $exe = Get-RdpMonitorSchtasksExe $prevEa = $ErrorActionPreference try { $ErrorActionPreference = 'SilentlyContinue' $raw = & $exe /Query /TN $TaskName /XML 2>&1 if ($LASTEXITCODE -ne 0) { return $null } $text = ($raw | Out-String).Trim() if ([string]::IsNullOrWhiteSpace($text)) { return $null } if ($text -notmatch '(?s)