682 lines
26 KiB
PowerShell
682 lines
26 KiB
PowerShell
[CmdletBinding()]
|
|
param(
|
|
[string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json',
|
|
[ValidateSet('shadow', 'enforce')]
|
|
[string]$Mode = 'shadow',
|
|
[int]$LoopSeconds = 60,
|
|
[int]$InteractiveMaxAgeSeconds = 900,
|
|
[int]$HeadlessMaxAgeSeconds = 900,
|
|
[int]$RestartWindowSeconds = 600,
|
|
[int]$MaxRestarts = 3,
|
|
[int]$ActionCooldownSeconds = 300,
|
|
[int]$InteractiveActionCooldownSeconds = 60,
|
|
[switch]$Once,
|
|
[switch]$HeadlessEndpointEnabled,
|
|
[switch]$HeadlessFileOpsEnabled,
|
|
[switch]$SelfTest
|
|
)
|
|
|
|
Set-StrictMode -Version Latest
|
|
$ErrorActionPreference = 'Stop'
|
|
|
|
$modulePath = Join-Path $PSScriptRoot 'ActivityWatch.Windows.Common.psm1'
|
|
Import-Module $modulePath -Force
|
|
|
|
function New-GuardLock {
|
|
param([string]$StateRoot)
|
|
|
|
if (-not (Test-Path -LiteralPath $StateRoot)) {
|
|
New-Item -Path $StateRoot -ItemType Directory -Force | Out-Null
|
|
}
|
|
|
|
$lockPath = Join-Path $StateRoot 'collector-guard.lock'
|
|
if (Test-Path -LiteralPath $lockPath) {
|
|
try {
|
|
$lockData = Get-Content -LiteralPath $lockPath -Raw | ConvertFrom-Json
|
|
$existingPid = [int]$lockData.pid
|
|
if ($existingPid -gt 0 -and (Get-Process -Id $existingPid -ErrorAction SilentlyContinue)) {
|
|
return $null
|
|
}
|
|
}
|
|
catch {
|
|
}
|
|
}
|
|
|
|
$payload = @{
|
|
pid = $PID
|
|
createdAt = (Get-Date).ToUniversalTime().ToString('o')
|
|
} | ConvertTo-Json -Compress
|
|
Set-Content -LiteralPath $lockPath -Value $payload -Encoding UTF8
|
|
return $lockPath
|
|
}
|
|
|
|
function Write-GuardLog {
|
|
param(
|
|
[string]$LogPath,
|
|
[string]$Message
|
|
)
|
|
|
|
try {
|
|
$directory = Split-Path -Path $LogPath -Parent
|
|
if ($directory -and -not (Test-Path -LiteralPath $directory)) {
|
|
New-Item -Path $directory -ItemType Directory -Force | Out-Null
|
|
}
|
|
Add-Content -LiteralPath $LogPath -Value ('{0} {1}' -f (Get-Date -Format s), $Message)
|
|
}
|
|
catch {
|
|
}
|
|
}
|
|
|
|
function Get-AwApiBase {
|
|
param([pscustomobject]$Config)
|
|
|
|
$scheme = if ($Config.server.PSObject.Properties.Name -contains 'scheme') { [string]$Config.server.scheme } else { 'http' }
|
|
$hostName = [string]$Config.server.host
|
|
$port = [int]$Config.server.port
|
|
return ('{0}://{1}:{2}/api/0' -f $scheme, $hostName, $port)
|
|
}
|
|
|
|
function Invoke-AwJson {
|
|
param(
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$Method,
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$Uri,
|
|
[object]$Body
|
|
)
|
|
|
|
$params = @{
|
|
Method = $Method
|
|
Uri = $Uri
|
|
TimeoutSec = 15
|
|
ErrorAction = 'Stop'
|
|
}
|
|
if ($null -ne $Body) {
|
|
$params.Body = ($Body | ConvertTo-Json -Depth 16 -Compress)
|
|
$params.ContentType = 'application/json'
|
|
}
|
|
return Invoke-RestMethod @params
|
|
}
|
|
|
|
function Ensure-AwBucket {
|
|
param(
|
|
[string]$ApiBase,
|
|
[string]$BucketId,
|
|
[string]$ClientName,
|
|
[string]$BucketType,
|
|
[string]$Hostname
|
|
)
|
|
|
|
try {
|
|
Invoke-AwJson -Method 'GET' -Uri "$ApiBase/buckets/$BucketId" | Out-Null
|
|
return $true
|
|
}
|
|
catch {
|
|
}
|
|
|
|
try {
|
|
$body = @{
|
|
client = $ClientName
|
|
type = $BucketType
|
|
hostname = $Hostname
|
|
}
|
|
Invoke-AwJson -Method 'POST' -Uri "$ApiBase/buckets/$BucketId" -Body $body | Out-Null
|
|
return $true
|
|
}
|
|
catch {
|
|
return $false
|
|
}
|
|
}
|
|
|
|
function Get-LatestBucketAge {
|
|
param(
|
|
[string]$ApiBase,
|
|
[string]$BucketId
|
|
)
|
|
|
|
try {
|
|
$events = Invoke-AwJson -Method 'GET' -Uri "$ApiBase/buckets/$BucketId/events?limit=20"
|
|
$latest = @($events | Where-Object { $null -ne $_.timestamp } | Sort-Object timestamp -Descending | Select-Object -First 1)
|
|
if (-not $latest) {
|
|
return [pscustomobject]@{ bucket = $BucketId; found = $false; timestamp = $null; ageSeconds = $null }
|
|
}
|
|
$ts = [DateTimeOffset]::Parse([string]$latest.timestamp).UtcDateTime
|
|
$age = [Math]::Max(0, [int]((Get-Date).ToUniversalTime() - $ts).TotalSeconds)
|
|
return [pscustomobject]@{ bucket = $BucketId; found = $true; timestamp = [string]$latest.timestamp; ageSeconds = $age }
|
|
}
|
|
catch {
|
|
return [pscustomobject]@{ bucket = $BucketId; found = $false; timestamp = $null; ageSeconds = $null; error = $_.Exception.Message }
|
|
}
|
|
}
|
|
|
|
function Send-GuardHeartbeat {
|
|
param(
|
|
[string]$ApiBase,
|
|
[string]$Hostname,
|
|
[object]$State,
|
|
[int]$PulseSeconds
|
|
)
|
|
|
|
$bucketId = "aw-rus-collector-guard_$Hostname"
|
|
if (-not (Ensure-AwBucket -ApiBase $ApiBase -BucketId $bucketId -ClientName 'aw-rus-collector-guard' -BucketType 'aw.rus.collector.guard' -Hostname $Hostname)) {
|
|
return $false
|
|
}
|
|
|
|
$event = @{
|
|
timestamp = (Get-Date).ToUniversalTime().ToString('o')
|
|
duration = 0
|
|
data = $State
|
|
}
|
|
|
|
try {
|
|
Invoke-AwJson -Method 'POST' -Uri "$ApiBase/buckets/$bucketId/heartbeat?pulsetime=$PulseSeconds" -Body $event | Out-Null
|
|
return $true
|
|
}
|
|
catch {
|
|
return $false
|
|
}
|
|
}
|
|
|
|
function Read-GuardRuntime {
|
|
param([string]$Path)
|
|
|
|
if (-not (Test-Path -LiteralPath $Path)) {
|
|
return [pscustomobject]@{ restartHistory = @{}; lastAction = @{}; quarantine = @{} }
|
|
}
|
|
try {
|
|
$state = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json
|
|
if ($null -eq $state.restartHistory) { $state | Add-Member -NotePropertyName restartHistory -NotePropertyValue @{} }
|
|
if ($null -eq $state.lastAction) { $state | Add-Member -NotePropertyName lastAction -NotePropertyValue @{} }
|
|
if ($null -eq $state.quarantine) { $state | Add-Member -NotePropertyName quarantine -NotePropertyValue @{} }
|
|
return $state
|
|
}
|
|
catch {
|
|
return [pscustomobject]@{ restartHistory = @{}; lastAction = @{}; quarantine = @{} }
|
|
}
|
|
}
|
|
|
|
function Write-GuardRuntime {
|
|
param(
|
|
[string]$Path,
|
|
[object]$Runtime
|
|
)
|
|
|
|
$directory = Split-Path -Path $Path -Parent
|
|
if ($directory -and -not (Test-Path -LiteralPath $directory)) {
|
|
New-Item -Path $directory -ItemType Directory -Force | Out-Null
|
|
}
|
|
$Runtime | ConvertTo-Json -Depth 16 | Set-Content -LiteralPath $Path -Encoding UTF8
|
|
}
|
|
|
|
function Get-RuntimeMapValue {
|
|
param(
|
|
[object]$Map,
|
|
[string]$Key
|
|
)
|
|
|
|
if ($null -eq $Map) {
|
|
return $null
|
|
}
|
|
if ($Map -is [hashtable] -and $Map.ContainsKey($Key)) {
|
|
return $Map[$Key]
|
|
}
|
|
$propertyNames = @($Map.PSObject.Properties | ForEach-Object { $_.Name })
|
|
if ($propertyNames -contains $Key) {
|
|
return $Map.$Key
|
|
}
|
|
return $null
|
|
}
|
|
|
|
function Set-RuntimeMapValue {
|
|
param(
|
|
[object]$Map,
|
|
[string]$Key,
|
|
[object]$Value
|
|
)
|
|
|
|
if ($Map -is [hashtable]) {
|
|
$Map[$Key] = $Value
|
|
return
|
|
}
|
|
$propertyNames = @($Map.PSObject.Properties | ForEach-Object { $_.Name })
|
|
if ($propertyNames -contains $Key) {
|
|
$Map.$Key = $Value
|
|
}
|
|
else {
|
|
$Map | Add-Member -NotePropertyName $Key -NotePropertyValue $Value -Force
|
|
}
|
|
}
|
|
|
|
function Remove-RuntimeMapValue {
|
|
param(
|
|
[object]$Map,
|
|
[string]$Key
|
|
)
|
|
|
|
if ($null -eq $Map) {
|
|
return
|
|
}
|
|
if ($Map -is [hashtable]) {
|
|
if ($Map.ContainsKey($Key)) {
|
|
$Map.Remove($Key)
|
|
}
|
|
return
|
|
}
|
|
$property = $Map.PSObject.Properties[$Key]
|
|
if ($null -ne $property) {
|
|
$Map.PSObject.Properties.Remove($Key)
|
|
}
|
|
}
|
|
|
|
function Reset-GuardActionBudget {
|
|
param(
|
|
[object]$Runtime,
|
|
[string]$Key
|
|
)
|
|
|
|
Remove-RuntimeMapValue -Map $Runtime.restartHistory -Key $Key
|
|
Remove-RuntimeMapValue -Map $Runtime.lastAction -Key $Key
|
|
Remove-RuntimeMapValue -Map $Runtime.quarantine -Key $Key
|
|
}
|
|
|
|
function Invoke-GuardSelfTest {
|
|
$emptyObject = [pscustomobject]@{}
|
|
if ($null -ne (Get-RuntimeMapValue -Map $emptyObject -Key 'missing')) {
|
|
throw 'empty PSCustomObject should not return a missing runtime-map value'
|
|
}
|
|
Set-RuntimeMapValue -Map $emptyObject -Key 'headless:worktime-session' -Value 123
|
|
if ((Get-RuntimeMapValue -Map $emptyObject -Key 'headless:worktime-session') -ne 123) {
|
|
throw 'failed to set runtime-map value on empty PSCustomObject'
|
|
}
|
|
|
|
$hash = @{}
|
|
Set-RuntimeMapValue -Map $hash -Key 'headless:worktime-session' -Value @(1, 2)
|
|
$hashValue = @(Get-RuntimeMapValue -Map $hash -Key 'headless:worktime-session')
|
|
if ($hashValue.Count -ne 2) {
|
|
throw 'failed to round-trip runtime-map value on hashtable'
|
|
}
|
|
|
|
$runtime = [pscustomobject]@{ restartHistory = [pscustomobject]@{}; lastAction = [pscustomobject]@{}; quarantine = [pscustomobject]@{} }
|
|
$allowed = Test-ActionAllowed -Runtime $runtime -Key 'headless:worktime-session' -CooldownSeconds 1 -WindowSeconds 60 -MaxCount 3
|
|
if (-not $allowed.allowed) {
|
|
throw "expected action to be allowed, got $($allowed.reason)"
|
|
}
|
|
Register-GuardAction -Runtime $runtime -Key 'headless:worktime-session'
|
|
$blocked = Test-ActionAllowed -Runtime $runtime -Key 'headless:worktime-session' -CooldownSeconds 300 -WindowSeconds 60 -MaxCount 3
|
|
if ($blocked.allowed -or $blocked.reason -ne 'cooldown') {
|
|
throw 'expected cooldown after registering guard action'
|
|
}
|
|
$budgetRuntime = [pscustomobject]@{ restartHistory = [pscustomobject]@{}; lastAction = [pscustomobject]@{}; quarantine = [pscustomobject]@{} }
|
|
foreach ($i in 1..3) {
|
|
Register-GuardAction -Runtime $budgetRuntime -Key 'task:test'
|
|
}
|
|
$budgetBlocked = Test-ActionAllowed -Runtime $budgetRuntime -Key 'task:test' -CooldownSeconds 0 -WindowSeconds 600 -MaxCount 3
|
|
if ($budgetBlocked.allowed -or $budgetBlocked.reason -ne 'quarantine') {
|
|
throw 'expected quarantine when restart budget is exhausted'
|
|
}
|
|
Reset-GuardActionBudget -Runtime $budgetRuntime -Key 'task:test'
|
|
$budgetAllowed = Test-ActionAllowed -Runtime $budgetRuntime -Key 'task:test' -CooldownSeconds 0 -WindowSeconds 600 -MaxCount 3
|
|
if (-not $budgetAllowed.allowed) {
|
|
throw 'expected reset action budget to clear quarantine'
|
|
}
|
|
|
|
$oldComputerName = $env:COMPUTERNAME
|
|
try {
|
|
$env:COMPUTERNAME = 'SHARKON2025'
|
|
$sessionRecords = @(
|
|
[pscustomobject]@{ SessionName = 'USER5'; UserName = 'USER5'; SessionId = 2; State = 'Disc'; IsLive = $false },
|
|
[pscustomobject]@{ SessionName = 'console'; UserName = ''; SessionId = 1; State = 'Conn'; IsLive = $true }
|
|
)
|
|
$taskDefs = @(
|
|
[pscustomobject]@{ taskName = 'ActivityWatch Launch [SHARKON2025_user5]'; userId = 'SHARKON2025\user5' }
|
|
)
|
|
if (-not (Test-ActivityWatchUserHasManagedSession -UserId 'SHARKON2025\user5' -SessionRecords $sessionRecords -IncludeDisconnected)) {
|
|
throw 'expected disconnected managed session to match task user'
|
|
}
|
|
if (Test-ActivityWatchUserHasManagedSession -UserId 'SHARKON2025\user5' -SessionRecords $sessionRecords -IncludeLive) {
|
|
throw 'disconnected managed session should not match live-only filter'
|
|
}
|
|
$managed = @(Get-ActivityWatchManagedInteractiveSessions -TaskDefinitions $taskDefs -SessionRecords $sessionRecords -IncludeDisconnected)
|
|
if ($managed.Count -ne 1 -or [int]$managed[0].SessionId -ne 2) {
|
|
throw 'failed to enumerate disconnected managed session'
|
|
}
|
|
}
|
|
finally {
|
|
if ($null -eq $oldComputerName) {
|
|
Remove-Item Env:COMPUTERNAME -ErrorAction SilentlyContinue
|
|
}
|
|
else {
|
|
$env:COMPUTERNAME = $oldComputerName
|
|
}
|
|
}
|
|
|
|
Write-Output 'collector guard self-test OK'
|
|
}
|
|
|
|
function Test-ActionAllowed {
|
|
param(
|
|
[object]$Runtime,
|
|
[string]$Key,
|
|
[int]$CooldownSeconds,
|
|
[int]$WindowSeconds,
|
|
[int]$MaxCount
|
|
)
|
|
|
|
$now = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds()
|
|
$last = Get-RuntimeMapValue -Map $Runtime.lastAction -Key $Key
|
|
if ($null -ne $last -and ($now - [int64]$last) -lt $CooldownSeconds) {
|
|
return [pscustomobject]@{ allowed = $false; reason = 'cooldown' }
|
|
}
|
|
|
|
$history = @(Get-RuntimeMapValue -Map $Runtime.restartHistory -Key $Key)
|
|
$history = @($history | Where-Object { ($now - [int64]$_) -le $WindowSeconds })
|
|
Set-RuntimeMapValue -Map $Runtime.restartHistory -Key $Key -Value @($history)
|
|
if ($history.Count -ge $MaxCount) {
|
|
Set-RuntimeMapValue -Map $Runtime.quarantine -Key $Key -Value @{
|
|
since = (Get-Date).ToUniversalTime().ToString('o')
|
|
reason = 'restart-budget-exhausted'
|
|
count = $history.Count
|
|
}
|
|
return [pscustomobject]@{ allowed = $false; reason = 'quarantine' }
|
|
}
|
|
|
|
Remove-RuntimeMapValue -Map $Runtime.quarantine -Key $Key
|
|
return [pscustomobject]@{ allowed = $true; reason = 'ok' }
|
|
}
|
|
|
|
function Register-GuardAction {
|
|
param(
|
|
[object]$Runtime,
|
|
[string]$Key
|
|
)
|
|
|
|
$now = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds()
|
|
$history = @(Get-RuntimeMapValue -Map $Runtime.restartHistory -Key $Key)
|
|
$history += $now
|
|
Set-RuntimeMapValue -Map $Runtime.restartHistory -Key $Key -Value @($history)
|
|
Set-RuntimeMapValue -Map $Runtime.lastAction -Key $Key -Value $now
|
|
}
|
|
|
|
function Get-CollectorProcessSnapshot {
|
|
param([pscustomobject]$Config)
|
|
|
|
$scriptPaths = [ordered]@{}
|
|
foreach ($name in @('collectorScript', 'endpointCollectorScript', 'fileCollectorScript', 'emailCollectorScript', 'sessionCollectorScript')) {
|
|
if ($Config.paths.PSObject.Properties.Name -contains $name) {
|
|
$value = [string]$Config.paths.$name
|
|
if (-not [string]::IsNullOrWhiteSpace($value)) {
|
|
$scriptPaths[$name] = $value
|
|
}
|
|
}
|
|
}
|
|
|
|
$powershellCollectors = @()
|
|
try {
|
|
$processes = @(Get-CimInstance Win32_Process -ErrorAction SilentlyContinue |
|
|
Where-Object { $_.Name -ieq 'powershell.exe' -or $_.Name -ieq 'pwsh.exe' })
|
|
foreach ($proc in $processes) {
|
|
$commandLine = [string]$proc.CommandLine
|
|
foreach ($entry in $scriptPaths.GetEnumerator()) {
|
|
if ($commandLine -match [Regex]::Escape([string]$entry.Value)) {
|
|
$powershellCollectors += [pscustomobject]@{
|
|
name = [string]$entry.Key
|
|
processId = [int]$proc.ProcessId
|
|
sessionId = [int]$proc.SessionId
|
|
scriptPath = [string]$entry.Value
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
catch {
|
|
}
|
|
|
|
$watchers = @()
|
|
try {
|
|
$watchers = @(Get-Process -Name 'aw-watcher-afk','aw-watcher-window' -ErrorAction SilentlyContinue |
|
|
Select-Object @{Name='name'; Expression={$_.Name}}, @{Name='processId'; Expression={$_.Id}}, @{Name='sessionId'; Expression={$_.SessionId}})
|
|
}
|
|
catch {
|
|
$watchers = @()
|
|
}
|
|
|
|
return [pscustomobject]@{
|
|
watchers = @($watchers)
|
|
collectors = @($powershellCollectors)
|
|
}
|
|
}
|
|
|
|
function Invoke-ExactTaskRun {
|
|
param([string]$TaskName)
|
|
|
|
& schtasks.exe /Run /TN $TaskName | Out-Null
|
|
return ($LASTEXITCODE -eq 0)
|
|
}
|
|
|
|
function Invoke-GuardCycle {
|
|
param(
|
|
[object]$Runtime,
|
|
[string]$RuntimePath,
|
|
[string]$LogPath
|
|
)
|
|
|
|
$config = Read-ActivityWatchDeploymentConfig -Path $ConfigPath
|
|
$stateRoot = if ($config.paths.PSObject.Properties.Name -contains 'stateRoot') { [string]$config.paths.stateRoot } else { Split-Path -Path $ConfigPath -Parent }
|
|
$hostname = if ($config.PSObject.Properties.Name -contains 'awHostname' -and -not [string]::IsNullOrWhiteSpace([string]$config.awHostname)) { [string]$config.awHostname } else { [string]$env:COMPUTERNAME }
|
|
$apiBase = Get-AwApiBase -Config $config
|
|
$configPaths = Get-ActivityWatchRecoveryConfigPaths -PrimaryConfigPath $ConfigPath
|
|
$taskDefs = @(Get-ActivityWatchRecoveryTaskDefinitions -ConfigPaths $configPaths)
|
|
$sessionRecords = @(Get-ActivityWatchSessionRecords)
|
|
$liveSessions = @(Get-ActivityWatchLiveInteractiveSessions -SessionRecords $sessionRecords)
|
|
$managedInteractiveSessions = @(Get-ActivityWatchManagedInteractiveSessions -TaskDefinitions $taskDefs -SessionRecords $sessionRecords -IncludeLive -IncludeDisconnected)
|
|
$processSnapshot = Get-CollectorProcessSnapshot -Config $config
|
|
$liveSessionIds = @($liveSessions | ForEach-Object { [int]$_.SessionId })
|
|
$managedSessionIds = @($managedInteractiveSessions | ForEach-Object { [int]$_.SessionId } | Sort-Object -Unique)
|
|
|
|
$bucketChecks = [ordered]@{}
|
|
foreach ($bucket in @(
|
|
"aw-worktime-sessions_$hostname",
|
|
"aw-watcher-afk_$hostname",
|
|
"aw-watcher-window_$hostname",
|
|
"aw-dlp-endpoint-signals_$hostname"
|
|
)) {
|
|
$bucketChecks[$bucket] = Get-LatestBucketAge -ApiBase $apiBase -BucketId $bucket
|
|
}
|
|
|
|
$actions = New-Object System.Collections.Generic.List[object]
|
|
$problems = New-Object System.Collections.Generic.List[string]
|
|
|
|
$worktimeAge = $bucketChecks["aw-worktime-sessions_$hostname"].ageSeconds
|
|
$sessionCollectorScript = if ($config.paths.PSObject.Properties.Name -contains 'sessionCollectorScript') { [string]$config.paths.sessionCollectorScript } else { Join-Path $stateRoot 'worktime-session-collector.ps1' }
|
|
$sessionCollectorRunning = Test-ActivityWatchCollectorRunningGlobal -ScriptPath $sessionCollectorScript
|
|
$headlessKey = 'headless:worktime-session'
|
|
$needsHeadlessAction = (-not $sessionCollectorRunning -or $null -eq $worktimeAge -or [int]$worktimeAge -gt $HeadlessMaxAgeSeconds)
|
|
if ($needsHeadlessAction) {
|
|
$key = $headlessKey
|
|
$allowed = Test-ActionAllowed -Runtime $Runtime -Key $key -CooldownSeconds $ActionCooldownSeconds -WindowSeconds $RestartWindowSeconds -MaxCount $MaxRestarts
|
|
if ($allowed.allowed) {
|
|
if ($Mode -eq 'enforce') {
|
|
Start-ActivityWatchCollectorScriptGlobalIfNeeded -ScriptPath $sessionCollectorScript -ConfigPath $ConfigPath
|
|
Register-GuardAction -Runtime $Runtime -Key $key
|
|
Write-GuardLog -LogPath $LogPath -Message "started $key"
|
|
$actions.Add([pscustomobject]@{ action = 'start'; target = $key; applied = $true }) | Out-Null
|
|
}
|
|
else {
|
|
$actions.Add([pscustomobject]@{ action = 'start'; target = $key; applied = $false; mode = 'shadow' }) | Out-Null
|
|
}
|
|
}
|
|
else {
|
|
$problems.Add("$key action blocked: $($allowed.reason)") | Out-Null
|
|
}
|
|
}
|
|
else {
|
|
Reset-GuardActionBudget -Runtime $Runtime -Key $headlessKey
|
|
}
|
|
|
|
if ($Mode -eq 'enforce') {
|
|
Stop-ActivityWatchProcessesInNonLiveSessions -SessionRecords $sessionRecords -Config $config -TaskDefinitions $taskDefs -PreserveManagedSessions
|
|
}
|
|
|
|
$interactiveStale = $false
|
|
foreach ($bucket in @("aw-watcher-afk_$hostname", "aw-watcher-window_$hostname", "aw-dlp-endpoint-signals_$hostname")) {
|
|
$age = $bucketChecks[$bucket].ageSeconds
|
|
if ($null -eq $age -or [int]$age -gt $InteractiveMaxAgeSeconds) {
|
|
$interactiveStale = $true
|
|
}
|
|
}
|
|
|
|
$watchersInLiveSessions = @($processSnapshot.watchers | Where-Object { $liveSessionIds -contains [int]$_.sessionId })
|
|
$watchersInManagedSessions = @($processSnapshot.watchers | Where-Object { $managedSessionIds -contains [int]$_.sessionId })
|
|
$liveWatcherMissing = $false
|
|
if ($liveSessions.Count -gt 0) {
|
|
$hasAfk = @($watchersInLiveSessions | Where-Object { [string]$_.name -ieq 'aw-watcher-afk' }).Count -gt 0
|
|
$hasWindow = @($watchersInLiveSessions | Where-Object { [string]$_.name -ieq 'aw-watcher-window' }).Count -gt 0
|
|
$liveWatcherMissing = (-not $hasAfk) -or (-not $hasWindow)
|
|
}
|
|
$managedWatcherMissing = $false
|
|
if ($managedInteractiveSessions.Count -gt 0) {
|
|
$afkEnabled = if ($config.PSObject.Properties.Name -contains 'collectors' -and $config.collectors.PSObject.Properties.Name -contains 'afkEnabled') { [bool]$config.collectors.afkEnabled } else { $true }
|
|
$windowEnabled = if ($config.PSObject.Properties.Name -contains 'collectors' -and $config.collectors.PSObject.Properties.Name -contains 'windowEnabled') { [bool]$config.collectors.windowEnabled } else { $true }
|
|
foreach ($managedSession in @($managedInteractiveSessions)) {
|
|
$sessionId = [int]$managedSession.SessionId
|
|
$sessionWatchers = @($watchersInManagedSessions | Where-Object { [int]$_.sessionId -eq $sessionId })
|
|
$hasManagedAfk = @($sessionWatchers | Where-Object { [string]$_.name -ieq 'aw-watcher-afk' }).Count -gt 0
|
|
$hasManagedWindow = @($sessionWatchers | Where-Object { [string]$_.name -ieq 'aw-watcher-window' }).Count -gt 0
|
|
if (($afkEnabled -and -not $hasManagedAfk) -or ($windowEnabled -and -not $hasManagedWindow)) {
|
|
$managedWatcherMissing = $true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
if ($managedInteractiveSessions.Count -eq 0 -and $liveSessions.Count -gt 0 -and $interactiveStale) {
|
|
$problems.Add('interactive buckets stale but no managed interactive sessions found') | Out-Null
|
|
}
|
|
|
|
$needsInteractiveTaskAction = $managedInteractiveSessions.Count -gt 0 -and (
|
|
$liveWatcherMissing -or
|
|
$managedWatcherMissing -or
|
|
($interactiveStale -and $liveSessions.Count -gt 0)
|
|
)
|
|
|
|
if ($needsInteractiveTaskAction) {
|
|
foreach ($taskDef in $taskDefs) {
|
|
if (-not (Test-ActivityWatchUserHasManagedSession -UserId ([string]$taskDef.userId) -SessionRecords $sessionRecords -IncludeLive -IncludeDisconnected)) {
|
|
continue
|
|
}
|
|
$key = "task:$($taskDef.taskName)"
|
|
$allowed = Test-ActionAllowed -Runtime $Runtime -Key $key -CooldownSeconds $InteractiveActionCooldownSeconds -WindowSeconds $RestartWindowSeconds -MaxCount $MaxRestarts
|
|
if (-not $allowed.allowed) {
|
|
$problems.Add("$key action blocked: $($allowed.reason)") | Out-Null
|
|
continue
|
|
}
|
|
|
|
if ($Mode -eq 'enforce') {
|
|
$ok = Invoke-ExactTaskRun -TaskName ([string]$taskDef.taskName)
|
|
if ($ok) {
|
|
Register-GuardAction -Runtime $Runtime -Key $key
|
|
}
|
|
Write-GuardLog -LogPath $LogPath -Message ("run {0} ok={1}" -f $key, $ok)
|
|
$actions.Add([pscustomobject]@{ action = 'run-task'; target = [string]$taskDef.taskName; applied = $true; ok = $ok }) | Out-Null
|
|
}
|
|
else {
|
|
$actions.Add([pscustomobject]@{ action = 'run-task'; target = [string]$taskDef.taskName; applied = $false; mode = 'shadow' }) | Out-Null
|
|
}
|
|
}
|
|
}
|
|
else {
|
|
foreach ($taskDef in $taskDefs) {
|
|
if (Test-ActivityWatchUserHasManagedSession -UserId ([string]$taskDef.userId) -SessionRecords $sessionRecords -IncludeLive -IncludeDisconnected) {
|
|
Reset-GuardActionBudget -Runtime $Runtime -Key "task:$($taskDef.taskName)"
|
|
}
|
|
}
|
|
}
|
|
|
|
$status = 'ok'
|
|
if ($problems.Count -gt 0) {
|
|
$status = 'warn'
|
|
}
|
|
if ($managedInteractiveSessions.Count -gt 0 -and $interactiveStale -and $Mode -eq 'shadow') {
|
|
$status = 'warn'
|
|
}
|
|
|
|
$sessionState = @(
|
|
foreach ($session in @($sessionRecords)) {
|
|
[pscustomobject]@{
|
|
SessionName = [string]$session.SessionName
|
|
UserName = [string]$session.UserName
|
|
SessionId = [int]$session.SessionId
|
|
State = [string]$session.State
|
|
IsLive = [bool]$session.IsLive
|
|
}
|
|
}
|
|
)
|
|
|
|
$state = @{}
|
|
$state['status'] = $status
|
|
$state['mode'] = $Mode
|
|
$state['host'] = $hostname
|
|
$state['generatedAtUtc'] = (Get-Date).ToUniversalTime().ToString('o')
|
|
$state['pid'] = $PID
|
|
$state['sessions'] = @($sessionState)
|
|
$state['liveSessionCount'] = $liveSessions.Count
|
|
$state['managedSessionCount'] = $managedInteractiveSessions.Count
|
|
$state['managedSessions'] = @($managedInteractiveSessions)
|
|
$bucketState = @{}
|
|
foreach ($key in $bucketChecks.Keys) {
|
|
$bucketState[$key] = $bucketChecks[$key]
|
|
}
|
|
|
|
$state['processes'] = $processSnapshot
|
|
$state['buckets'] = $bucketState
|
|
$state['actions'] = @($actions.ToArray())
|
|
$state['problems'] = @($problems.ToArray())
|
|
$state['quarantine'] = $Runtime.quarantine
|
|
|
|
$statePath = Join-Path $stateRoot 'collector-guard-state.json'
|
|
$state | ConvertTo-Json -Depth 16 | Set-Content -LiteralPath $statePath -Encoding UTF8
|
|
Write-GuardRuntime -Path $RuntimePath -Runtime $Runtime
|
|
[void](Send-GuardHeartbeat -ApiBase $apiBase -Hostname $hostname -State $state -PulseSeconds ([Math]::Max($LoopSeconds * 2, 60)))
|
|
return $state
|
|
}
|
|
|
|
if ($SelfTest) {
|
|
Invoke-GuardSelfTest
|
|
exit 0
|
|
}
|
|
|
|
$initialConfig = Read-ActivityWatchDeploymentConfig -Path $ConfigPath
|
|
$initialStateRoot = if ($initialConfig.paths.PSObject.Properties.Name -contains 'stateRoot') { [string]$initialConfig.paths.stateRoot } else { Split-Path -Path $ConfigPath -Parent }
|
|
$initialLogsRoot = if ($initialConfig.paths.PSObject.Properties.Name -contains 'logsRoot') { [string]$initialConfig.paths.logsRoot } else { Join-Path $initialStateRoot 'logs' }
|
|
$logPath = Join-Path $initialLogsRoot 'collector-guard.log'
|
|
$runtimePath = Join-Path $initialStateRoot 'collector-guard-runtime.json'
|
|
$lockPath = New-GuardLock -StateRoot $initialStateRoot
|
|
if (-not $lockPath) {
|
|
Write-GuardLog -LogPath $logPath -Message 'another collector guard instance is already running'
|
|
exit 0
|
|
}
|
|
|
|
try {
|
|
$runtime = Read-GuardRuntime -Path $runtimePath
|
|
Write-GuardLog -LogPath $logPath -Message "collector guard started mode=$Mode loop=$LoopSeconds once=$($Once.IsPresent)"
|
|
while ($true) {
|
|
try {
|
|
Invoke-GuardCycle -Runtime $runtime -RuntimePath $runtimePath -LogPath $logPath | Out-Null
|
|
}
|
|
catch {
|
|
Write-GuardLog -LogPath $logPath -Message ("cycle error: {0}; at {1}" -f $_.Exception.Message, $_.ScriptStackTrace)
|
|
}
|
|
|
|
if ($Once) {
|
|
break
|
|
}
|
|
Start-Sleep -Seconds ([Math]::Max($LoopSeconds, 15))
|
|
}
|
|
}
|
|
finally {
|
|
if ($lockPath -and (Test-Path -LiteralPath $lockPath)) {
|
|
Remove-Item -LiteralPath $lockPath -Force -ErrorAction SilentlyContinue
|
|
}
|
|
Write-GuardLog -LogPath $logPath -Message 'collector guard stopped'
|
|
}
|