Files
RDP-login-monitor/Deploy-LoginMonitor.ps1
T

357 lines
16 KiB
PowerShell
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<#
.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\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
#>
[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"
$VersionFileName = "version.txt"
$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*["'']([0-9]+(?:\.[0-9]+){0,3})["'']') {
return $Matches[1]
}
}
} catch { }
return $null
}
function Normalize-VersionOrNull {
param([string]$Text)
if ([string]::IsNullOrWhiteSpace($Text)) { return $null }
$t = $Text.Trim() -replace '^v', ''
try {
return [version]$t
} catch {
return $null
}
}
function Compare-VersionStrings {
param([string]$Left, [string]$Right)
$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')]
[string]$Mode = 'recycle',
[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 Stop-RdpLoginMonitorMainProcesses {
param([int]$GracefulWaitSec = 35)
$canonical = [System.IO.Path]::GetFullPath($LocalScript)
if (-not (Test-RdpMonitorMainProcessRunning -CanonicalScript $canonical)) {
Write-DeployLog "Монитор не запущен — остановка не требуется."
return
}
Write-DeployLog "Graceful recycle: запись restart.request (mode=recycle), без дочернего PowerShell."
Set-RdpMonitorRestartRequestFromDeploy -Mode 'recycle' -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 recycle — принудительная остановка оставшихся процессов монитора."
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)"
}
}
# --- main ---
try {
$shareRoot = Resolve-SourceShareRoot
$sourceScript = Join-Path $shareRoot $ScriptName
$sourceVersionFile = Join-Path $shareRoot $VersionFileName
Write-DeployLog "Deploy: корень дистрибутива: $shareRoot"
$settingsLocal = Join-Path $InstallRoot 'login_monitor.settings.ps1'
$settingsExampleShare = Join-Path $shareRoot 'login_monitor.settings.example.ps1'
if (-not (Test-Path -LiteralPath $settingsLocal) -and (Test-Path -LiteralPath $settingsExampleShare)) {
if (-not (Test-Path -LiteralPath $InstallRoot)) {
New-Item -ItemType Directory -Path $InstallRoot -Force | Out-Null
}
Copy-Item -LiteralPath $settingsExampleShare -Destination $settingsLocal -Force
Write-DeployLog "Создан login_monitor.settings.ps1 из example (при следующих обновлениях не перезаписывается)."
}
if (-not (Test-Path -LiteralPath $sourceScript)) {
Write-DeployLog "ОШИБКА: на шаре нет файла: $sourceScript"
exit 0
}
if (-not (Test-Path -LiteralPath $sourceVersionFile)) {
Write-DeployLog "ОШИБКА: на шаре нет version.txt: $sourceVersionFile (добавьте одну строку с версией, как в Login_Monitor.ps1)."
exit 0
}
$shareVerRaw = Read-VersionLineFromFile -Path $sourceVersionFile
if ([string]::IsNullOrWhiteSpace($shareVerRaw)) {
Write-DeployLog "ОШИБКА: version.txt пустой или не читается: $sourceVersionFile"
exit 0
}
$localVerRaw = Get-LocalDeployedVersion
Write-DeployLog "Версия на шаре: $shareVerRaw; локально установлено: $(if ($localVerRaw) { $localVerRaw } else { '(нет)' })."
$cmp = Compare-VersionStrings -Left $shareVerRaw -Right $localVerRaw
if ($null -ne $cmp) {
if ($cmp -eq 0) {
Write-DeployLog "Актуально, копирование не требуется."
exit 0
}
if ($cmp -lt 0 -and -not $AllowDowngrade) {
Write-DeployLog "На шаре версия старше локальной — пропуск (используйте -AllowDowngrade для отката)."
exit 0
}
}
if (-not (Test-Path -LiteralPath $InstallRoot)) {
if ($WhatIf) {
Write-DeployLog "[WhatIf] Создать каталог $InstallRoot"
} else {
New-Item -ItemType Directory -Path $InstallRoot -Force | Out-Null
}
}
if ($WhatIf) {
Write-DeployLog "[WhatIf] Скопировать $sourceScript -> $LocalScript; InstallTasks; версия $shareVerRaw"
exit 0
}
if (-not (Test-DeployRunningElevated)) {
Write-DeployLog "ОШИБКА: запустите Deploy из повышенной консоли PowerShell («Запуск от имени администратора»). Без этого дочерний Login_Monitor.ps1 -InstallTasks завершится с кодом 1 (нет прав на регистрацию задач)."
exit 0
}
Stop-RdpLoginMonitorMainProcesses
Copy-Item -LiteralPath $sourceScript -Destination $LocalScript -Force
Write-DeployLog "Файл скопирован: $LocalScript"
$sourceSac = Join-Path $shareRoot 'Sac-Client.ps1'
$localSac = Join-Path $InstallRoot 'Sac-Client.ps1'
if (Test-Path -LiteralPath $sourceSac) {
Copy-Item -LiteralPath $sourceSac -Destination $localSac -Force
Write-DeployLog "Файл скопирован: $localSac"
} else {
Write-DeployLog "Предупреждение: на шаре нет Sac-Client.ps1 — SAC будет недоступен до следующего деплоя."
}
$installArgs = @(
'-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $LocalScript, '-InstallTasks'
)
$instOut = Join-Path $InstallRoot "Logs\deploy_installtasks_stdout.log"
$instErr = Join-Path $InstallRoot "Logs\deploy_installtasks_stderr.log"
$p = Start-Process -FilePath $PsExe -ArgumentList $installArgs -Wait -PassThru -WindowStyle Hidden `
-RedirectStandardOutput $instOut -RedirectStandardError $instErr
if ($p.ExitCode -ne 0) {
Write-DeployLog "Предупреждение: InstallTasks завершился с кодом $($p.ExitCode)."
foreach ($pair in @(@($instOut, 'stdout'), @($instErr, 'stderr'))) {
$lp = $pair[0]
$lbl = $pair[1]
if (Test-Path -LiteralPath $lp) {
$tail = Get-Content -LiteralPath $lp -Tail 40 -ErrorAction SilentlyContinue
if ($tail) {
Write-DeployLog "InstallTasks $lbl (хвост): $($tail -join ' | ')"
}
}
}
Write-DeployLog "Подсказка: полный вывод в Logs\deploy_installtasks_*.log; также см. login_monitor.log за это время."
} else {
Write-DeployLog "InstallTasks выполнен (код 0)."
}
[System.IO.File]::WriteAllText($VersionStampPath, "$shareVerRaw`r`n", $Utf8Bom)
Write-DeployLog "Записана метка версии: $VersionStampPath"
$updStamp = Get-Date -Format "dd.MM.yyyy HH:mm:ss"
$updMarker = @(
"Version=$shareVerRaw"
"UpdatedAt=$updStamp"
"PendingStartupNotice=1"
) -join "`r`n"
[System.IO.File]::WriteAllText($DeployUpdateMarkerPath, "$updMarker`r`n", $Utf8Bom)
Write-DeployLog "Записана метка обновления: $DeployUpdateMarkerPath (Version=$shareVerRaw; UpdatedAt=$updStamp)."
if (-not $SkipStartMonitorAfterUpdate) {
Start-Process -FilePath $PsExe -ArgumentList @(
'-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $LocalScript
) -WindowStyle Hidden
Write-DeployLog "Запущен процесс монитора (новый файл)."
} else {
Write-DeployLog "Запуск монитора пропущен (-SkipStartMonitorAfterUpdate); поднимется при следующей загрузке или watchdog."
}
exit 0
} catch {
Write-DeployLog "КРИТИЧЕСКАЯ ОШИБКА: $($_.Exception.Message)"
exit 0
}