From 6d102603c2650f2cda68a92ba3c761b741d1b8a5 Mon Sep 17 00:00:00 2001 From: PTah Date: Tue, 14 Jul 2026 20:44:01 +1000 Subject: [PATCH] chore(home): mirror from kalinamall (038363c) with papatramp URLs --- .editorconfig | 11 + .gitattributes | 2 + .gitignore | 8 + DEPLOY.md | 11 + Deploy-DomainMonitors.ps1 | 167 + Deploy-LoginMonitor.ps1 | 1696 +++++++ Diagnose-RdpLoginMonitor.ps1 | 447 ++ Docs/README.md | 13 + Docs/deploy-netlogon-publish.md | 81 + Docs/deploy-rdp-login-monitor.md | 237 + Docs/exchange-mail-security.md | 268 ++ Encrypt-DpapiForRdpMonitor.ps1 | 12 + Exchange-MailSecurity.ps1 | 935 ++++ Install-DeployScheduledTask.ps1 | 45 + Install-DomainMonitors.ps1 | 51 + LICENSE | 21 + Login_Monitor.ps1 | 5424 ++++++++++++++++++++++ Notify-Common.ps1 | 214 + README.md | 62 + README_eng.md | 150 + RdpMonitor-TaskQuery.ps1 | 184 + Restart-RdpLoginMonitor.ps1 | 29 + Sac-Client.ps1 | 1270 +++++ exchange_monitor.settings.example.ps1 | 54 + ignore.lst.example | 44 + login_monitor.settings.example.ps1 | 93 + scripts/Push-GitHubMirror.ps1 | 39 + scripts/Push-Mirror.ps1 | 41 + scripts/Push-PrivateMirror.ps1 | 67 + scripts/Rewrite-GitHostUrls.ps1 | 62 + scripts/Sanitize-ForGitHub.ps1 | 169 + scripts/Test-NoSecretsForGitHub.ps1 | 47 + tools/Run-RdpMonitorTests.ps1 | 46 + tools/Show-Rdp4624Recent.ps1 | 51 + tools/Test-DeploySyntax.ps1 | 8 + tools/Test-RDGatewayEventInfo.ps1 | 55 + tools/Test-ScriptSyntax.ps1 | 8 + tools/tests/Test-DeployTaskLimit.ps1 | 33 + tools/tests/Test-ScriptSyntaxAll.ps1 | 22 + tools/tests/Test-SecurityPollCursor.ps1 | 49 + tools/tests/Test-SendDeploySacNotice.ps1 | 75 + tools/tests/Test-TaskQueryModule.ps1 | 15 + tools/tests/_DeployFunctionsLoader.ps1 | 16 + tools/tests/_TestLib.ps1 | 39 + update-rdp-monitor.ps1 | 240 + version.txt | 1 + 46 files changed, 12612 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 DEPLOY.md create mode 100644 Deploy-DomainMonitors.ps1 create mode 100644 Deploy-LoginMonitor.ps1 create mode 100644 Diagnose-RdpLoginMonitor.ps1 create mode 100644 Docs/README.md create mode 100644 Docs/deploy-netlogon-publish.md create mode 100644 Docs/deploy-rdp-login-monitor.md create mode 100644 Docs/exchange-mail-security.md create mode 100644 Encrypt-DpapiForRdpMonitor.ps1 create mode 100644 Exchange-MailSecurity.ps1 create mode 100644 Install-DeployScheduledTask.ps1 create mode 100644 Install-DomainMonitors.ps1 create mode 100644 LICENSE create mode 100644 Login_Monitor.ps1 create mode 100644 Notify-Common.ps1 create mode 100644 README.md create mode 100644 README_eng.md create mode 100644 RdpMonitor-TaskQuery.ps1 create mode 100644 Restart-RdpLoginMonitor.ps1 create mode 100644 Sac-Client.ps1 create mode 100644 exchange_monitor.settings.example.ps1 create mode 100644 ignore.lst.example create mode 100644 login_monitor.settings.example.ps1 create mode 100644 scripts/Push-GitHubMirror.ps1 create mode 100644 scripts/Push-Mirror.ps1 create mode 100644 scripts/Push-PrivateMirror.ps1 create mode 100644 scripts/Rewrite-GitHostUrls.ps1 create mode 100644 scripts/Sanitize-ForGitHub.ps1 create mode 100644 scripts/Test-NoSecretsForGitHub.ps1 create mode 100644 tools/Run-RdpMonitorTests.ps1 create mode 100644 tools/Show-Rdp4624Recent.ps1 create mode 100644 tools/Test-DeploySyntax.ps1 create mode 100644 tools/Test-RDGatewayEventInfo.ps1 create mode 100644 tools/Test-ScriptSyntax.ps1 create mode 100644 tools/tests/Test-DeployTaskLimit.ps1 create mode 100644 tools/tests/Test-ScriptSyntaxAll.ps1 create mode 100644 tools/tests/Test-SecurityPollCursor.ps1 create mode 100644 tools/tests/Test-SendDeploySacNotice.ps1 create mode 100644 tools/tests/Test-TaskQueryModule.ps1 create mode 100644 tools/tests/_DeployFunctionsLoader.ps1 create mode 100644 tools/tests/_TestLib.ps1 create mode 100644 update-rdp-monitor.ps1 create mode 100644 version.txt diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..a892658 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,11 @@ +root = true + +[*] +charset = utf-8 +end_of_line = crlf +insert_final_newline = true + +[*.ps1] +charset = utf-8-bom +end_of_line = crlf + diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..9b3db83 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +*.ps1 text eol=crlf + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d4ea9b1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.cursor/ +tools/*.log +*.log +*.bak +Logs/ +sac-spool/ +login_monitor.settings.ps1 +exchange_monitor.settings.ps1 diff --git a/DEPLOY.md b/DEPLOY.md new file mode 100644 index 0000000..f498ade --- /dev/null +++ b/DEPLOY.md @@ -0,0 +1,11 @@ +# Развёртывание + +Документация перенесена в каталог **[Docs/](Docs/README.md)**: + +| Руководство | Файл | +|-------------|------| +| RDP Login Monitor (NETLOGON, GPO) | [Docs/deploy-rdp-login-monitor.md](Docs/deploy-rdp-login-monitor.md) | +| Exchange Mail Security | [Docs/exchange-mail-security.md](Docs/exchange-mail-security.md) | +| Публикация на шару | [Docs/deploy-netlogon-publish.md](Docs/deploy-netlogon-publish.md) | + +Оглавление: **[Docs/README.md](Docs/README.md)** diff --git a/Deploy-DomainMonitors.ps1 b/Deploy-DomainMonitors.ps1 new file mode 100644 index 0000000..e8a1327 --- /dev/null +++ b/Deploy-DomainMonitors.ps1 @@ -0,0 +1,167 @@ +<# +.SYNOPSIS + Deploy domain monitor scripts (Exchange / AD) from share to ProgramData. +.PARAMETER Target + Exchange - Exchange-MailSecurity.ps1 + Notify-Common.ps1; Ad - reserved. +#> +[CmdletBinding()] +param( + [ValidateSet('Exchange', 'Ad')] + [Parameter(Mandatory = $true)] + [string]$Target, + [string]$SourceShareRoot = '', + [switch]$WhatIf, + [switch]$SkipInstallTasks, + [switch]$AllowDowngrade +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$InstallRoot = [System.IO.Path]::GetFullPath("$env:ProgramData\RDP-login-monitor") +$VersionStampPath = Join-Path $InstallRoot 'deployed_domain_monitors_version.txt' +$DeployLogPath = Join-Path $InstallRoot 'Logs\deploy_domain_monitors.log' +$VersionFileName = 'version.txt' +$Utf8Bom = New-Object System.Text.UTF8Encoding $true +$PsExe = "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" + +$ExchangeFiles = @( + 'Exchange-MailSecurity.ps1', + 'Notify-Common.ps1', + 'Install-DomainMonitors.ps1', + 'exchange_monitor.settings.example.ps1' +) + +function Write-DeployLog { + param([string]$Message) + $line = "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') - [$Target] $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 { } + Write-Host $line.TrimEnd("`r`n") +} + +function Write-TextFileUtf8Bom { + param([string]$Path, [string]$Text) + [System.IO.File]::WriteAllText($Path, $Text, $Utf8Bom) +} + +function Copy-ScriptFileWithUtf8Bom { + param( + [Parameter(Mandatory = $true)][string]$SourcePath, + [Parameter(Mandatory = $true)][string]$DestPath + ) + $raw = [System.IO.File]::ReadAllBytes($SourcePath) + $utf8NoBom = New-Object System.Text.UTF8Encoding $false + $text = $utf8NoBom.GetString($raw) + if ($text.Length -gt 0 -and [int][char]$text[0] -eq 0xFEFF) { + $text = $text.Substring(1) + } + [System.IO.File]::WriteAllText($DestPath, $text, $Utf8Bom) +} + +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 'Specify -SourceShareRoot or run this script by full UNC path to the .ps1 file on the share.' + } + 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 Compare-SemVerLike { + param([string]$A, [string]$B) + try { + $va = [version]$A + $vb = [version]$B + return $va.CompareTo($vb) + } catch { + return [string]::Compare($A, $B, [StringComparison]::OrdinalIgnoreCase) + } +} + +$shareRoot = Resolve-SourceShareRoot +$filesToCopy = switch ($Target) { + 'Exchange' { $ExchangeFiles } + 'Ad' { throw 'Target Ad is not supported yet.' } +} + +$sourceVersionFile = Join-Path $shareRoot $VersionFileName +if (-not (Test-Path -LiteralPath $sourceVersionFile)) { + Write-DeployLog "ERROR: missing on share: $sourceVersionFile" + exit 1 +} + +$shareVer = Read-VersionLineFromFile -Path $sourceVersionFile +$localVer = Read-VersionLineFromFile -Path $VersionStampPath +Write-DeployLog "Share version=$shareVer local stamp=$localVer" + +if (-not [string]::IsNullOrWhiteSpace($localVer)) { + $cmp = Compare-SemVerLike -A $shareVer -B $localVer + if ($cmp -eq 0) { + Write-DeployLog 'Version match - skip copy.' + if (-not $SkipInstallTasks) { + Write-DeployLog 'Running InstallTasks check...' + if (-not $WhatIf) { + & $PsExe -NoProfile -ExecutionPolicy Bypass -File (Join-Path $InstallRoot 'Install-DomainMonitors.ps1') -Target $Target + } + } + exit 0 + } + if ($cmp -lt 0 -and -not $AllowDowngrade) { + Write-DeployLog 'Share version older than local - skip (use -AllowDowngrade to force).' + exit 0 + } +} + +if (-not (Test-Path -LiteralPath $InstallRoot)) { + if ($WhatIf) { Write-DeployLog "WhatIf: create $InstallRoot"; exit 0 } + New-Item -ItemType Directory -Path $InstallRoot -Force | Out-Null +} + +foreach ($name in $filesToCopy) { + $src = Join-Path $shareRoot $name + $dst = Join-Path $InstallRoot $name + if (-not (Test-Path -LiteralPath $src)) { + Write-DeployLog "ERROR: missing on share: $name" + exit 1 + } + if ($WhatIf) { + Write-DeployLog "WhatIf: copy $src -> $dst" + continue + } + if ($name -like '*.ps1') { + Copy-ScriptFileWithUtf8Bom -SourcePath $src -DestPath $dst + } else { + Copy-Item -LiteralPath $src -Destination $dst -Force + } + Write-DeployLog "Copied: $name" +} + +if (-not $WhatIf) { + Write-TextFileUtf8Bom -Path $VersionStampPath -Text $shareVer + if (-not $SkipInstallTasks) { + $installer = Join-Path $InstallRoot 'Install-DomainMonitors.ps1' + & $PsExe -NoProfile -ExecutionPolicy Bypass -File $installer -Target $Target + if ($LASTEXITCODE -ne 0) { + Write-DeployLog "Install-DomainMonitors exit code: $LASTEXITCODE" + exit $LASTEXITCODE + } + } +} + +Write-DeployLog 'Deploy-DomainMonitors finished.' +exit 0 diff --git a/Deploy-LoginMonitor.ps1 b/Deploy-LoginMonitor.ps1 new file mode 100644 index 0000000..4339f5a --- /dev/null +++ b/Deploy-LoginMonitor.ps1 @@ -0,0 +1,1696 @@ +<# +.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-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 { + param([string]$SettingsPath) + $c = Get-RdpMonitorSettingsRaw -Path $SettingsPath + if ([string]::IsNullOrWhiteSpace($c)) { return $true } + + if (Test-RdpMonitorSettingsHasPlaceholderSecrets -SettingsPath $SettingsPath) { 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 # отчёт с агента (report.daily.rdp); $false или 0 — только SAC' + $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() + } + Write-DeployLog "Предупреждение: в example нет блока Security Alert Center — patch SAC пропущен." + return $null +} + +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[''"]') { + if (-not (Test-RdpMonitorSettingsHasPlaceholderSecrets -SettingsPath $LocalSettings)) { + 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)