feat: SAC agent command poll qwinsta/logoff (2.0.39-SAC)

Poll GET /api/v1/agent/commands every SacCommandPollIntervalSec;
execute qwinsta/logoff with run_as from SAC; POST result.
This commit is contained in:
PTah
2026-06-19 23:37:58 +10:00
parent 7f38c00094
commit 5d2b90779c
4 changed files with 236 additions and 3 deletions
+7 -2
View File
@@ -90,7 +90,7 @@ $script:SkipLogDetailLimit = 15
# строки ниже, если правки «мелкие» и вы не хотите менять отображаемую версию в логах).
# Рекомендация: при значимых релизах меняйте и $ScriptVersion, и version.txt одинаково; при только
# исправлениях на шаре — достаточно поднять patch в version.txt (например 1.3.0.1).
$ScriptVersion = "2.0.38-SAC"
$ScriptVersion = "2.0.39-SAC"
# Логи (все под InstallRoot)
$LogFile = Join-Path $script:InstallRoot "Logs\login_monitor.log"
@@ -108,7 +108,7 @@ $StartupRebootDetectMinutes = 5
# Инвентаризация железа/ПО для SAC (agent.inventory); интервал опроса, сек (12 ч).
$InventoryIntervalSec = 43200
$GetInventory = 1
$HeartbeatStaleAlertMultiplier = 2
$SacCommandPollIntervalSec = 60
# Один RDP-вход часто даёт 2+ события 4624 с одним временем — не слать дубли в Telegram/SAC.
$LoginSuccessNotifyDedupSeconds = 90
$HeartbeatFile = Join-Path $script:InstallRoot "Logs\last_heartbeat.txt"
@@ -4404,6 +4404,7 @@ function Start-LoginMonitor {
$adminShareMonitoringEnabled = Test-MonitorFeatureEnabled -Value $EnableAdminShareMonitoring
$nextHeartbeatTime = (Get-Date).AddSeconds($HeartbeatInterval)
$script:NextSacCommandPollTime = Get-Date
$nextInventoryTime = (Get-Date).AddSeconds($InventoryIntervalSec)
$nextRotationCheck = Check-AndRotateLog
$nextReportCheck = Check-AndSendDailyReport
@@ -4934,6 +4935,10 @@ function Start-LoginMonitor {
}
if ($script:SacClientLoaded) {
Invoke-SacFlushSpool -MaxFiles 5 | Out-Null
if ((Get-SacNormalizedMode) -ne 'off' -and $now -ge $script:NextSacCommandPollTime) {
Invoke-SacProcessPendingCommands | Out-Null
$script:NextSacCommandPollTime = $now.AddSeconds($SacCommandPollIntervalSec)
}
}
} catch {
if ($_.Exception -is [System.Management.Automation.PipelineStoppedException]) { throw }
+226
View File
@@ -853,6 +853,232 @@ function Invoke-SacFlushSpool {
}
}
function Get-SacAgentCommandsUrl {
$base = Get-SacBaseUrl
if ([string]::IsNullOrWhiteSpace($base)) { return $null }
return "$base/api/v1/agent/commands"
}
function Get-SacAgentCommandResultUrl {
param([Parameter(Mandatory = $true)][string]$CommandId)
$base = Get-SacBaseUrl
if ([string]::IsNullOrWhiteSpace($base)) { return $null }
return "$base/api/v1/agent/commands/$CommandId/result"
}
function Invoke-SacHttpGet {
param(
[Parameter(Mandatory = $true)][string]$Uri,
[int]$TimeoutSec = 12
)
Invoke-SacTlsPrep
try {
$resp = Invoke-WebRequest -Uri $Uri -Method Get -UseBasicParsing -TimeoutSec $TimeoutSec `
-Headers @{ Authorization = "Bearer $SacApiKey" }
return @{ StatusCode = [int]$resp.StatusCode; Content = [string]$resp.Content }
} catch {
$code = 0
$content = ''
if ($_.Exception.Response) {
$code = [int]$_.Exception.Response.StatusCode
try {
$reader = New-Object System.IO.StreamReader($_.Exception.Response.GetResponseStream())
$content = $reader.ReadToEnd()
$reader.Close()
} catch { }
}
return @{ StatusCode = $code; Content = $content; Error = $_.Exception.Message }
}
}
function Invoke-SacHttpPostJson {
param(
[Parameter(Mandatory = $true)][string]$Uri,
[Parameter(Mandatory = $true)][string]$JsonBody,
[int]$TimeoutSec = 12
)
Invoke-SacTlsPrep
$bytes = Get-SacUtf8Bytes -Text $JsonBody
try {
$resp = Invoke-WebRequest -Uri $Uri -Method Post -UseBasicParsing -TimeoutSec $TimeoutSec `
-Headers @{ Authorization = "Bearer $SacApiKey" } `
-ContentType 'application/json; charset=utf-8' `
-Body $bytes
return @{ StatusCode = [int]$resp.StatusCode; Content = [string]$resp.Content }
} catch {
$code = 0
$content = ''
if ($_.Exception.Response) {
$code = [int]$_.Exception.Response.StatusCode
try {
$reader = New-Object System.IO.StreamReader($_.Exception.Response.GetResponseStream())
$content = $reader.ReadToEnd()
$reader.Close()
} catch { }
}
return @{ StatusCode = $code; Content = $content; Error = $_.Exception.Message }
}
}
function Invoke-SacCaptureProcess {
param(
[Parameter(Mandatory = $true)][string]$CommandLine
)
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = 'cmd.exe'
$psi.Arguments = "/c $CommandLine"
$psi.UseShellExecute = $false
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
$psi.CreateNoWindow = $true
$p = New-Object System.Diagnostics.Process
$p.StartInfo = $psi
[void]$p.Start()
$stdout = $p.StandardOutput.ReadToEnd()
$stderr = $p.StandardError.ReadToEnd()
if (-not $p.WaitForExit(60000)) {
try { $p.Kill() } catch { }
return @{ ExitCode = -1; Stdout = $stdout; Stderr = 'timeout' }
}
return @{ ExitCode = $p.ExitCode; Stdout = $stdout; Stderr = $stderr }
}
function Invoke-SacRunWithRunAs {
param(
[Parameter(Mandatory = $true)][string]$CommandLine,
$RunAs = $null
)
if ($null -eq $RunAs -or [string]::IsNullOrWhiteSpace($RunAs.user)) {
return (Invoke-SacCaptureProcess -CommandLine $CommandLine)
}
$user = [string]$RunAs.user
$password = [string]$RunAs.password
if ([string]::IsNullOrWhiteSpace($password)) {
return (Invoke-SacCaptureProcess -CommandLine $CommandLine)
}
$taskName = "SacCmd_$([guid]::NewGuid().ToString('N').Substring(0, 12))"
$outFile = Join-Path $env:TEMP "$taskName.out.txt"
$errFile = Join-Path $env:TEMP "$taskName.err.txt"
if (Test-Path -LiteralPath $outFile) { Remove-Item -LiteralPath $outFile -Force -ErrorAction SilentlyContinue }
if (Test-Path -LiteralPath $errFile) { Remove-Item -LiteralPath $errFile -Force -ErrorAction SilentlyContinue }
$wrapped = "/c `"$CommandLine > `"$outFile`" 2> `"$errFile`"`""
try {
$action = New-ScheduledTaskAction -Execute 'cmd.exe' -Argument $wrapped
Register-ScheduledTask -TaskName $taskName -Action $action -User $user -Password $password `
-RunLevel Highest -Force | Out-Null
Start-ScheduledTask -TaskName $taskName
$deadline = (Get-Date).AddSeconds(45)
do {
Start-Sleep -Milliseconds 300
$state = (Get-ScheduledTask -TaskName $taskName).State
} while ($state -eq 'Running' -and (Get-Date) -lt $deadline)
$stdout = if (Test-Path -LiteralPath $outFile) { Get-Content -LiteralPath $outFile -Raw -ErrorAction SilentlyContinue } else { '' }
$stderr = if (Test-Path -LiteralPath $errFile) { Get-Content -LiteralPath $errFile -Raw -ErrorAction SilentlyContinue } else { '' }
return @{ ExitCode = 0; Stdout = [string]$stdout; Stderr = [string]$stderr }
} catch {
return @{ ExitCode = 1; Stdout = ''; Stderr = $_.Exception.Message }
} finally {
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $outFile, $errFile -Force -ErrorAction SilentlyContinue
}
}
function Invoke-SacAgentCommand {
param(
[Parameter(Mandatory = $true)]$Command,
$RunAs = $null
)
$type = [string]$Command.type
switch ($type) {
'qwinsta' {
return (Invoke-SacRunWithRunAs -CommandLine 'qwinsta.exe' -RunAs $RunAs)
}
'logoff' {
$sid = $null
if ($Command.params -and $Command.params.session_id) {
$sid = [int]$Command.params.session_id
}
if (-not $sid) {
return @{ ExitCode = 1; Stdout = ''; Stderr = 'missing session_id' }
}
$line = "logoff.exe $sid /v"
return (Invoke-SacRunWithRunAs -CommandLine $line -RunAs $RunAs)
}
default {
return @{ ExitCode = 1; Stdout = ''; Stderr = "unknown command type: $type" }
}
}
}
function Submit-SacAgentCommandResult {
param(
[Parameter(Mandatory = $true)][string]$CommandId,
[Parameter(Mandatory = $true)][string]$Status,
[string]$Stdout = '',
[string]$Stderr = ''
)
$url = Get-SacAgentCommandResultUrl -CommandId $CommandId
if ([string]::IsNullOrWhiteSpace($url)) { return $false }
$body = @{
status = $Status
stdout = $Stdout
stderr = $Stderr
} | ConvertTo-Json -Compress
$timeout = if ($SacTimeoutSec) { [int]$SacTimeoutSec } else { 12 }
$resp = Invoke-SacHttpPostJson -Uri $url -JsonBody $body -TimeoutSec $timeout
return ($resp.StatusCode -in 200, 201)
}
function Invoke-SacProcessPendingCommands {
if ((Get-SacNormalizedMode) -eq 'off') { return 0 }
if (-not (Test-SacConfigured)) { return 0 }
$pollUrl = Get-SacAgentCommandsUrl
if ([string]::IsNullOrWhiteSpace($pollUrl)) { return 0 }
$agentId = Get-SacAgentInstanceId
$timeout = if ($SacTimeoutSec) { [int]$SacTimeoutSec } else { 12 }
$uri = "$pollUrl?agent_instance_id=$([uri]::EscapeDataString($agentId))"
$get = Invoke-SacHttpGet -Uri $uri -TimeoutSec $timeout
if ($get.StatusCode -ne 200) {
if ($get.StatusCode -gt 0) {
Write-SacLog "WARN: SAC agent commands poll HTTP $($get.StatusCode)"
}
return 0
}
$parsed = $null
try {
$parsed = $get.Content | ConvertFrom-Json
} catch {
Write-SacLog "WARN: SAC agent commands poll: invalid JSON"
return 0
}
$commands = @($parsed.commands)
if ($commands.Count -eq 0) { return 0 }
$done = 0
foreach ($cmd in $commands) {
$cmdId = [string]$cmd.id
if ([string]::IsNullOrWhiteSpace($cmdId)) { continue }
Write-SacLog "SAC agent command: $cmdId type=$($cmd.type)"
$runAs = $cmd.run_as
$result = Invoke-SacAgentCommand -Command $cmd -RunAs $runAs
$status = if ([int]$result.ExitCode -eq 0) { 'completed' } else { 'failed' }
$stderr = [string]$result.Stderr
if ([int]$result.ExitCode -ne 0 -and [string]::IsNullOrWhiteSpace($stderr)) {
$stderr = "exit code $($result.ExitCode)"
}
if (Submit-SacAgentCommandResult -CommandId $cmdId -Status $status `
-Stdout ([string]$result.Stdout) -Stderr $stderr) {
$done++
Write-SacLog "SAC agent command result submitted: $cmdId status=$status"
} else {
Write-SacLog "WARN: SAC agent command result failed: $cmdId"
}
}
return $done
}
function Test-SacConnection {
Write-Host 'SAC check (rdp-login-monitor)'
Write-Host "UseSAC=$(Get-SacNormalizedMode)"
+2
View File
@@ -47,6 +47,8 @@ $DailyReportEnabled = 1
# --- Heartbeat SAC (agent.heartbeat): интервал в секундах; 14400 = 4 ч ---
$HeartbeatInterval = 14400
# Poll SAC на команды qwinsta/logoff (сек); см. security-alert-center/docs/agent-control-plane.md
# $SacCommandPollIntervalSec = 60
# Окно (мин): LastBootUpTime + System 41/1074/6005/6008/6009 → «старт после перезагрузки ОС»
$StartupRebootDetectMinutes = 5
+1 -1
View File
@@ -1 +1 @@
2.0.38-SAC
2.0.39-SAC