From 230a9c6936b63b663d848bf9e16c27e005c76a81 Mon Sep 17 00:00:00 2001 From: igor04091968 Date: Fri, 8 May 2026 10:54:18 +0300 Subject: [PATCH] fix(windows): harden worktime session collector encoding and query parsing --- windows/worktime-session-collector.ps1 | 243 +++++++++++++++---------- 1 file changed, 150 insertions(+), 93 deletions(-) diff --git a/windows/worktime-session-collector.ps1 b/windows/worktime-session-collector.ps1 index 65e0b36..ba72ca7 100644 --- a/windows/worktime-session-collector.ps1 +++ b/windows/worktime-session-collector.ps1 @@ -4,17 +4,66 @@ param( [int]$PollSeconds = 30 ) +# 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 = 'Stop' +$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 "Конфигурация не найдена: $Path" + throw "Config not found: $Path" + } + try { + $bytes = [System.IO.File]::ReadAllBytes($Path) + $text = Decode-Bytes-Auto -Bytes $bytes + return $text | ConvertFrom-Json -ErrorAction Stop + } + catch { + throw "Failed to read config: $Path - $($_.Exception.Message)" } - - Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json } function Invoke-AwJsonPost { @@ -22,9 +71,15 @@ function Invoke-AwJsonPost { [Parameter(Mandatory = $true)][string]$Uri, [Parameter(Mandatory = $true)][string]$Json ) - - $bytes = [Text.Encoding]::UTF8.GetBytes($Json) - Invoke-RestMethod -Method Post -Uri $Uri -ContentType 'application/json; charset=utf-8' -Body $bytes | Out-Null + 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 { @@ -33,132 +88,134 @@ function Ensure-Bucket { [Parameter(Mandatory = $true)][string]$BucketId, [Parameter(Mandatory = $true)][string]$HostnameValue ) + try { Invoke-RestMethod -Method Get -Uri "$ApiBase/buckets/$BucketId" -ErrorAction Stop | Out-Null; return } catch { Write-Verbose "Bucket not found, creating: $BucketId" } - try { - Invoke-RestMethod -Method Get -Uri "$ApiBase/buckets/$BucketId" | Out-Null - return - } - catch { - } - - $body = @{ - client = 'aw-worktime-session-collector' - type = 'aw.worktime.session' - hostname = $HostnameValue - } | ConvertTo-Json -Compress - - try { - Invoke-AwJsonPost -Uri "$ApiBase/buckets/$BucketId" -Json $body - } - catch { - Invoke-RestMethod -Method Get -Uri "$ApiBase/buckets/$BucketId" | Out-Null + $body = @{ client='aw-worktime-session-collector'; type='aw.worktime.session'; 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 Get-SessionRecords { +function Run-QueryUser { + $tries = @(@{File='quser';Args=''},@{File='query';Args='user'}) + 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) } + $proc.WaitForExit() + $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 } - try { - $lines = quser 2>$null - if (-not $lines) { - return @() + $startIndex = 0 + if ($Lines.Count -gt 0 -and $Lines[0] -match '\b(USERNAME|Имя|Имя пользователя|Имя_пользователя)\b') { $startIndex = 1 } + + for ($i = $startIndex; $i -lt $Lines.Count; $i++) { + $line = $Lines[$i].Trim() + if (-not $line) { continue } + + $m = [regex]::Match($line, '^\s*(?\S+)\s+(?\S+)?\s+(?\d+)\s+(?\S+)', [System.Text.RegularExpressions.RegexOptions]::None) + if ($m.Success) { + $user = $m.Groups['user'].Value; $sess = $m.Groups['sess'].Value; $id = [int]$m.Groups['id'].Value; $state = $m.Groups['state'].Value + } + else { + $parts = $line -split '\s+' + if ($parts.Count -lt 4) { continue } + $user = $parts[0] + if ($parts[1] -match '^\d+$') { $sess = ''; $id = [int]$parts[1]; $state = $parts[2] } else { $sess = $parts[1]; $id = [int]$parts[2]; $state = $parts[3] } } - foreach ($line in ($lines | Select-Object -Skip 1)) { - $clean = ($line -replace '^\s*>?', '').Trim() - if (-not $clean) { - continue - } - - $parts = $clean -split '\s+' - if ($parts.Count -lt 4) { - continue - } - - $sessionName = '' - $sessionIdIndex = 2 - if ($parts[1] -match '^\d+$') { - $sessionIdIndex = 1 - } - else { - $sessionName = $parts[1] - } - - $sessionId = 0 - if ($parts[$sessionIdIndex] -match '^\d+$') { - $sessionId = [int]$parts[$sessionIdIndex] - } - - $records += [pscustomobject]@{ - username = $parts[0] - sessionName = $sessionName - sessionId = $sessionId - state = $parts[$sessionIdIndex + 1] - } - } + $records += [pscustomobject]@{ username=$user; sessionName=$sess; sessionId=$id; state=$state } } - catch { - } - return $records } function Test-SessionIsActive { - param([AllowNull()][string]$State) - if ([string]::IsNullOrWhiteSpace($State)) { return $false } + param([string]$State) + if (-not $State) { return $false } $s = $State.Trim().ToLowerInvariant() - return ($s -eq 'active') -or ($s -like 'актив*') + return ($s -match 'active') -or ($s -match 'актив') } +# Main $cfg = Get-Config -Path $ConfigPath -$hostValue = if ($Hostname) { $Hostname } elseif ($cfg.PSObject.Properties.Name -contains 'awHostname' -and -not [string]::IsNullOrWhiteSpace([string]$cfg.awHostname)) { [string]$cfg.awHostname } else { [string]$env:COMPUTERNAME } -$apiBase = '{0}://{1}:{2}/api/0' -f [string]$cfg.server.scheme, [string]$cfg.server.host, [string]$cfg.server.port +$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 $pulse = 120 -$sleepSec = if ($PollSeconds -gt 0) { - $PollSeconds -} -elseif ($cfg.collector -and $cfg.collector.pollSeconds) { - [int]$cfg.collector.pollSeconds -} -else { - 30 -} +$sleepSec = if ($PollSeconds -gt 0) { $PollSeconds } elseif ($cfg.collector -and $cfg.collector.pollSeconds) { [int]$cfg.collector.pollSeconds } else { 30 } Ensure-Bucket -ApiBase $apiBase -BucketId $bucketId -HostnameValue $hostValue while ($true) { $now = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') - $records = Get-SessionRecords + 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) { - $records = @([pscustomobject]@{ - username = $env:USERNAME - sessionName = '' - sessionId = (Get-Process -Id $PID).SessionId - state = 'Unknown' - }) + $records = @([pscustomobject]@{ username=$env:USERNAME; sessionName=''; sessionId=(Get-Process -Id $PID).SessionId; state='Unknown' }) } foreach ($rec in $records) { - $payload = @{ + $payloadObj = [PSCustomObject]@{ timestamp = $now duration = 0 - data = @{ + data = [PSCustomObject]@{ username = [string]$rec.username - userId = "$($env:USERDOMAIN)\$($rec.username)" + userId = "${env:USERDOMAIN}\$($rec.username)" sessionId = [int]$rec.sessionId sessionName = [string]$rec.sessionName state = [string]$rec.state - active = (Test-SessionIsActive -State ([string]$rec.state)) + active = Test-SessionIsActive -State ([string]$rec.state) hostname = $hostValue source = 'worktime-session-collector' } - } | ConvertTo-Json -Depth 6 -Compress + } + + $payload = $payloadObj | ConvertTo-Json -Depth 6 -Compress try { - Invoke-AwJsonPost -Uri "$apiBase/buckets/$bucketId/heartbeat?pulsetime=$pulse" -Json $payload + $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)" } }