param( [string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json', [string]$Hostname, [int]$PollSeconds = 0 ) # Force UTF-8 for console I/O try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 } catch {} try { [Console]::InputEncoding = [System.Text.Encoding]::UTF8 } catch {} Set-StrictMode -Version Latest $ErrorActionPreference = 'Continue' function Decode-Bytes-Auto { param([byte[]]$Bytes) if (-not $Bytes) { return '' } $candidates = @() # Try strict UTF8 first (detect invalid sequences) try { $utf8Strict = New-Object System.Text.UTF8Encoding($false,$true) $txt = $utf8Strict.GetString($Bytes) $candidates += @{enc='utf8'; text=$txt} } catch { # invalid UTF8 sequences; ignore } # Try CP866 and CP1251 try { $cp866 = [System.Text.Encoding]::GetEncoding(866); $txt866 = $cp866.GetString($Bytes); $candidates += @{enc='cp866'; text=$txt866} } catch {} try { $cp1251 = [System.Text.Encoding]::GetEncoding(1251); $txt1251 = $cp1251.GetString($Bytes); $candidates += @{enc='cp1251'; text=$txt1251} } catch {} # If nothing decoded yet, fallback to UTF8 permissive if ($candidates.Count -eq 0) { try { $txt = [System.Text.Encoding]::UTF8.GetString($Bytes); return $txt } catch { return '' } } # Score decodings by count of Cyrillic letters; prefer highest $best = $null; $bestScore = -1 foreach ($c in $candidates) { $t = $c.text if (-not $t) { continue } $score = 0 try { $score = ([regex]::Matches($t,'\p{IsCyrillic}')).Count } catch { $score = 0 } if ($score -gt $bestScore) { $best = $c; $bestScore = $score } } if ($best -ne $null) { return $best.text } # Final fallback: first candidate text return $candidates[0].text } function Get-Config { param([string]$Path) if (-not (Test-Path -LiteralPath $Path)) { throw "Config not found: $Path" } try { $bytes = [System.IO.File]::ReadAllBytes($Path) # Config is JSON. Prefer deterministic BOM-based decoding over heuristics. if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) { $text = [System.Text.Encoding]::UTF8.GetString($bytes) } elseif ($bytes.Length -ge 2 -and $bytes[0] -eq 0xFF -and $bytes[1] -eq 0xFE) { $text = [System.Text.Encoding]::Unicode.GetString($bytes) } else { $text = [System.Text.Encoding]::UTF8.GetString($bytes) } $text = $text -replace '^\uFEFF', '' return $text | ConvertFrom-Json -ErrorAction Stop } catch { throw "Failed to read config: $Path - $($_.Exception.Message)" } } function Invoke-AwJsonPost { param( [Parameter(Mandatory = $true)][string]$Uri, [Parameter(Mandatory = $true)][string]$Json ) try { $bytes = [System.Text.Encoding]::UTF8.GetBytes($Json) Invoke-RestMethod -Method Post -Uri $Uri -ContentType 'application/json; charset=utf-8' -Body $bytes -ErrorAction Stop | Out-Null return $true } catch { Write-Verbose "POST error: $($_.Exception.Message)" return $false } } function Ensure-Bucket { param( [Parameter(Mandatory = $true)][string]$ApiBase, [Parameter(Mandatory = $true)][string]$BucketId, [Parameter(Mandatory = $true)][string]$HostnameValue, [string]$ClientName = 'aw-worktime-session-collector', [string]$BucketType = 'aw.worktime.session' ) try { Invoke-RestMethod -Method Get -Uri "$ApiBase/buckets/$BucketId" -ErrorAction Stop | Out-Null; return } catch { Write-Verbose "Bucket not found, creating: $BucketId" } $body = @{ client=$ClientName; type=$BucketType; hostname=$HostnameValue } | ConvertTo-Json -Compress $attempts = 0 while ($attempts -lt 3) { $attempts++ $ok = Invoke-AwJsonPost -Uri "$ApiBase/buckets/$BucketId" -Json $body if ($ok) { return } Start-Sleep -Seconds (2 * $attempts) } try { Invoke-RestMethod -Method Get -Uri "$ApiBase/buckets/$BucketId" -ErrorAction Stop | Out-Null } catch { Write-Verbose "Ensure-Bucket final check failed: $BucketId" } } function Run-QueryUser { $tries = @( @{File='cmd.exe';Args='/c query user'}, @{File='cmd.exe';Args='/c quser'}, @{File='query.exe';Args='user'}, @{File='quser.exe';Args=''} ) foreach ($t in $tries) { try { $psi = New-Object System.Diagnostics.ProcessStartInfo $psi.FileName = $t.File if ($t.Args) { $psi.Arguments = $t.Args } $psi.RedirectStandardOutput = $true $psi.RedirectStandardError = $true $psi.UseShellExecute = $false $psi.CreateNoWindow = $true $proc = [System.Diagnostics.Process]::Start($psi) $stream = $proc.StandardOutput.BaseStream $ms = New-Object System.IO.MemoryStream $buffer = New-Object byte[] 4096 while (($read = $stream.Read($buffer,0,$buffer.Length)) -gt 0) { $ms.Write($buffer,0,$read) } if (-not $proc.WaitForExit(8000)) { try { $proc.Kill() } catch {} continue } $bytes = $ms.ToArray() $text = Decode-Bytes-Auto -Bytes $bytes if ($text -and $text.Trim()) { return ($text -split "\r?\n") | Where-Object { $_ -ne '' } } } catch { # try next } } return @() } function Parse-SessionLines { param([string[]]$Lines) $records = @() if (-not $Lines) { return $records } $startIndex = 0 # NOTE: Keep this script ASCII-only to stay compatible with Windows PowerShell 5 # when the file is UTF-8 without BOM. Avoid Cyrillic literals in regex patterns. if ($Lines.Count -gt 0 -and $Lines[0] -match '\b(USERNAME|UserName|USER)\b') { $startIndex = 1 } for ($i = $startIndex; $i -lt $Lines.Count; $i++) { $line = ($Lines[$i] -replace '^\s*>', '').Trim() if (-not $line) { continue } $parts = $line -split '\s+' if ($parts.Count -lt 3) { continue } $user = $parts[0] $sess = '' $id = -1 $state = '' if ($parts.Count -ge 4 -and $parts[1] -match '^\d+$') { $sess = '' $id = [int]$parts[1] $state = [string]$parts[2] } elseif ($parts.Count -ge 4 -and $parts[2] -match '^\d+$') { $sess = [string]$parts[1] $id = [int]$parts[2] $state = [string]$parts[3] } else { continue } if ($id -lt 0) { continue } $records += [pscustomobject]@{ username=$user; sessionName=$sess; sessionId=$id; state=$state } } return $records } function Test-SessionIsActive { param([string]$State) if (-not $State) { return $false } $s = $State.Trim().ToLowerInvariant() # Match English "active" and Russian "актив*" without embedding Cyrillic. # "актив" = \u0430\u043A\u0442\u0438\u0432 return ($s -match 'active') -or ($s -match '\u0430\u043a\u0442\u0438\u0432') } function Get-CanonicalUserId { param( [pscustomobject]$Config, [string]$HostnameValue, [string]$Username ) $normalizedUser = [string]$Username if ([string]::IsNullOrWhiteSpace($normalizedUser)) { return '' } if ($Config -and $Config.PSObject.Properties.Name -contains 'userTasks' -and $Config.userTasks) { foreach ($task in @($Config.userTasks)) { try { $taskUserId = [string]$task.userId if ([string]::IsNullOrWhiteSpace($taskUserId)) { continue } $parts = $taskUserId -split '\\', 2 if ($parts.Count -eq 2 -and $parts[1].Equals($normalizedUser, [System.StringComparison]::OrdinalIgnoreCase)) { return $taskUserId } } catch { } } } return "$HostnameValue\$normalizedUser" } function Get-SessionEventsBucketId { param( [pscustomobject]$Config, [string]$HostnameValue ) $prefix = 'aw-session-events' if ( $Config -and $Config.PSObject.Properties.Name -contains 'sessionEvents' -and $Config.sessionEvents -and $Config.sessionEvents.PSObject.Properties.Name -contains 'bucketPrefix' -and -not [string]::IsNullOrWhiteSpace([string]$Config.sessionEvents.bucketPrefix) ) { $prefix = [string]$Config.sessionEvents.bucketPrefix } return ('{0}_{1}' -f $prefix, $HostnameValue) } function Test-SessionProcessEventsEnabled { param([pscustomobject]$Config) if ( $Config -and $Config.PSObject.Properties.Name -contains 'sessionEvents' -and $Config.sessionEvents -and $Config.sessionEvents.PSObject.Properties.Name -contains 'processEventsEnabled' ) { return [bool]$Config.sessionEvents.processEventsEnabled } return $true } function Get-ProcessStatePath { param([pscustomobject]$Config) $stateRoot = '' if ($Config -and $Config.PSObject.Properties.Name -contains 'paths' -and $Config.paths) { if ($Config.paths.PSObject.Properties.Name -contains 'stateRoot') { $stateRoot = [string]$Config.paths.stateRoot } } if ([string]::IsNullOrWhiteSpace($stateRoot)) { $stateRoot = 'C:\ProgramData\AWatch-rus' } return (Join-Path $stateRoot 'session-process-state.json') } function Load-ProcessState { param([string]$Path) $map = @{} try { if (Test-Path -LiteralPath $Path) { $raw = Get-Content -LiteralPath $Path -Raw -ErrorAction Stop if (-not [string]::IsNullOrWhiteSpace($raw)) { $obj = $raw | ConvertFrom-Json -ErrorAction Stop foreach ($item in @($obj.processes)) { if (-not $item) { continue } $key = [string]$item.key if ([string]::IsNullOrWhiteSpace($key)) { continue } $map[$key] = $item } } } } catch { Write-Verbose "Process state load error: $($_.Exception.Message)" } return $map } function Save-ProcessState { param( [string]$Path, [hashtable]$Map ) try { $dir = Split-Path -Path $Path -Parent if ($dir -and -not (Test-Path -LiteralPath $dir)) { New-Item -Path $dir -ItemType Directory -Force | Out-Null } $items = @() foreach ($entry in $Map.GetEnumerator()) { $value = $entry.Value if ($null -eq $value) { continue } $items += [pscustomobject]@{ key = [string]$entry.Key processId = [int]$value.processId sessionId = [int]$value.sessionId username = [string]$value.username userId = [string]$value.userId state = [string]$value.state processName = [string]$value.processName commandLine = [string]$value.commandLine createdAt = [string]$value.createdAt hostname = [string]$value.hostname } } $payload = [pscustomobject]@{ processes = $items } $payload | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $Path -Encoding UTF8 } catch { Write-Verbose "Process state save error: $($_.Exception.Message)" } } function Test-ExcludedSessionProcess { param( [string]$Name, [string]$CommandLine ) $n = [string]$Name if ([string]::IsNullOrWhiteSpace($n)) { return $true } if ($n -match '^(Idle|System|Registry|svchost|services|lsass|winlogon|csrss|fontdrvhost|dwm|taskhostw|sihost|explorer)\.exe$') { return $true } if ($n -match '^(aw-watcher-afk|aw-watcher-window|conhost)\.exe$') { return $true } return $false } function Get-SessionProcessSnapshot { param( [pscustomobject]$Config, [string]$HostnameValue, [object[]]$SessionRecords ) $bySession = @{} foreach ($rec in @($SessionRecords)) { if ($null -eq $rec) { continue } $sid = [int]$rec.sessionId $bySession[$sid] = [pscustomobject]@{ username = [string]$rec.username userId = Get-CanonicalUserId -Config $Config -HostnameValue $HostnameValue -Username ([string]$rec.username) state = [string]$rec.state } } $snapshot = @{} if ($bySession.Count -eq 0) { return $snapshot } try { $procs = Get-Process -ErrorAction Stop | Where-Object { $bySession.ContainsKey([int]$_.SessionId) } } catch { Write-Verbose "Process snapshot error: $($_.Exception.Message)" return $snapshot } foreach ($proc in @($procs)) { try { $sid = [int]$proc.SessionId } catch { continue } if (-not $bySession.ContainsKey($sid)) { continue } $name = [string]$proc.ProcessName if ($name -and $name -notmatch '\.exe$') { $name = "$name.exe" } $commandLine = '' if (Test-ExcludedSessionProcess -Name $name -CommandLine $commandLine) { continue } $createdAt = '' try { if ($proc.StartTime) { $createdAt = $proc.StartTime.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') } } catch { $createdAt = '' } if ([string]::IsNullOrWhiteSpace($createdAt)) { $createdAt = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') } $key = ('{0}|{1}|{2}' -f $sid, [int]$proc.Id, $createdAt) $sessionMeta = $bySession[$sid] $snapshot[$key] = [pscustomobject]@{ processId = [int]$proc.Id sessionId = $sid username = [string]$sessionMeta.username userId = [string]$sessionMeta.userId state = [string]$sessionMeta.state processName = $name commandLine = $commandLine createdAt = $createdAt hostname = $HostnameValue } } return $snapshot } function Publish-SessionProcessEvents { param( [string]$ApiBase, [string]$BucketId, [hashtable]$Previous, [hashtable]$Current ) foreach ($entry in $Current.GetEnumerator()) { if ($Previous.ContainsKey($entry.Key)) { continue } $item = $entry.Value $payload = [pscustomobject]@{ timestamp = [string]$item.createdAt duration = 0 data = [pscustomobject]@{ eventType = 'process_start' username = [string]$item.username userId = [string]$item.userId sessionId = [int]$item.sessionId state = [string]$item.state processId = [int]$item.processId processName = [string]$item.processName commandLine = [string]$item.commandLine createdAt = [string]$item.createdAt hostname = [string]$item.hostname source = 'worktime-session-collector' } } | ConvertTo-Json -Depth 6 -Compress try { [void](Invoke-AwJsonPost -Uri "$ApiBase/buckets/$BucketId/heartbeat?pulsetime=1" -Json $payload) } catch { Write-Verbose "Process start publish error: $($_.Exception.Message)" } } $nowUtc = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') foreach ($entry in $Previous.GetEnumerator()) { if ($Current.ContainsKey($entry.Key)) { continue } $item = $entry.Value $payload = [pscustomobject]@{ timestamp = $nowUtc duration = 0 data = [pscustomobject]@{ eventType = 'process_stop' username = [string]$item.username userId = [string]$item.userId sessionId = [int]$item.sessionId state = [string]$item.state processId = [int]$item.processId processName = [string]$item.processName commandLine = [string]$item.commandLine createdAt = [string]$item.createdAt hostname = [string]$item.hostname source = 'worktime-session-collector' } } | ConvertTo-Json -Depth 6 -Compress try { [void](Invoke-AwJsonPost -Uri "$ApiBase/buckets/$BucketId/heartbeat?pulsetime=1" -Json $payload) } catch { Write-Verbose "Process stop publish error: $($_.Exception.Message)" } } } # Main $cfg = Get-Config -Path $ConfigPath $hostValue = if ($Hostname -and $Hostname.Trim()) { $Hostname.Trim() } elseif ($cfg -and $cfg.PSObject.Properties.Name -contains 'awHostname' -and -not [string]::IsNullOrWhiteSpace([string]$cfg.awHostname)) { [string]$cfg.awHostname } elseif ($cfg -and $cfg.awHostname) { [string]$cfg.awHostname } else { [string]$env:COMPUTERNAME } try { $apiBase = '{0}://{1}:{2}/api/0' -f [string]$cfg.server.scheme, [string]$cfg.server.host, [string]$cfg.server.port } catch { throw 'Invalid server configuration in config file.' } $bucketId = 'aw-worktime-sessions_' + $hostValue $sessionEventsBucketId = Get-SessionEventsBucketId -Config $cfg -HostnameValue $hostValue $processEventsEnabled = Test-SessionProcessEventsEnabled -Config $cfg $processStatePath = Get-ProcessStatePath -Config $cfg $sleepSec = if ($PollSeconds -gt 0) { $PollSeconds } elseif ($cfg.collector -and $cfg.collector.pollSeconds) { [int]$cfg.collector.pollSeconds } else { 30 } $pulse = [Math]::Max($sleepSec * 3, 30) Ensure-Bucket -ApiBase $apiBase -BucketId $bucketId -HostnameValue $hostValue if ($processEventsEnabled) { Ensure-Bucket -ApiBase $apiBase -BucketId $sessionEventsBucketId -HostnameValue $hostValue -ClientName 'aw-session-events' -BucketType 'aw.session.event' $previousProcessState = Load-ProcessState -Path $processStatePath } else { $previousProcessState = @{} } while ($true) { $now = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') try { $lines = Run-QueryUser $records = Parse-SessionLines -Lines $lines } catch { Write-Verbose "Session parse error: $($_.Exception.Message)" $records = @() } if (-not $records -or $records.Count -eq 0) { # Fallback sample: keep bucket alive even when query user output is unavailable # in non-interactive/session-0 contexts. $records = @( [pscustomobject]@{ username = [string]$env:USERNAME sessionName = '' sessionId = [int](Get-Process -Id $PID).SessionId state = 'Unknown' } ) } if ($processEventsEnabled) { $currentProcessState = Get-SessionProcessSnapshot -Config $cfg -HostnameValue $hostValue -SessionRecords $records Publish-SessionProcessEvents -ApiBase $apiBase -BucketId $sessionEventsBucketId -Previous $previousProcessState -Current $currentProcessState Save-ProcessState -Path $processStatePath -Map $currentProcessState $previousProcessState = $currentProcessState } foreach ($rec in $records) { $canonicalUserId = Get-CanonicalUserId -Config $cfg -HostnameValue $hostValue -Username ([string]$rec.username) $payloadObj = [PSCustomObject]@{ timestamp = $now duration = $sleepSec data = [PSCustomObject]@{ username = [string]$rec.username userId = $canonicalUserId sessionId = [int]$rec.sessionId sessionName = [string]$rec.sessionName state = [string]$rec.state active = Test-SessionIsActive -State ([string]$rec.state) sampleSeconds = $sleepSec pollSeconds = $sleepSec hostname = $hostValue source = 'worktime-session-collector' } } $payload = $payloadObj | ConvertTo-Json -Depth 6 -Compress try { $ok = Invoke-AwJsonPost -Uri "$apiBase/buckets/$bucketId/heartbeat?pulsetime=$pulse" -Json $payload if (-not $ok) { Write-Verbose "Heartbeat not confirmed for user $($rec.username)" } } catch { Write-Verbose "Heartbeat error: $($_.Exception.Message)" } } Start-Sleep -Seconds $sleepSec }