From 9927cdb19562f4da8003c532aa4328699f3cff06 Mon Sep 17 00:00:00 2001 From: igor04091968 Date: Fri, 15 May 2026 08:07:18 +0300 Subject: [PATCH] hardening: strengthen deploy and validation gates --- ansible/deploy_aw_server.yml | 4 + ansible/deploy_aw_windows.yml | 44 ++- ansible/group_vars/all.example.yml | 1 + ansible/group_vars/all.yml | 3 + ansible/post_validate_aw_windows.yml | 20 +- aw-server/aw-server.env.example | 4 + aw-server/health-check.sh | 40 +++ windows/validate-deployment.ps1 | 509 ++++++++++++++++++++++----- 8 files changed, 516 insertions(+), 109 deletions(-) diff --git a/ansible/deploy_aw_server.yml b/ansible/deploy_aw_server.yml index 6785d96..e7e596f 100644 --- a/ansible/deploy_aw_server.yml +++ b/ansible/deploy_aw_server.yml @@ -330,6 +330,10 @@ AW_DLP_INFLUX_TOKEN={{ aw_dlp_influx_token | default('') }} AW_DLP_AW_API_BASE=http://127.0.0.1:5600/api/0 AW_DLP_CASE_API_BASE=http://127.0.0.1:5602/api/0/dlp/cases + AW_EXPECT_START_OF_DAY={{ aw_worktime_start_of_day | default('') }} + AW_EXPECT_ALWAYS_ACTIVE_PATTERN={{ aw_server_always_active_pattern | default('') }} + AW_EXPECT_LANDINGPAGE={{ aw_server_landingpage | default('') }} + AW_HEALTH_STRICT_FILEOPS={{ aw_health_strict_fileops | default(0) }} - name: Создать каталог DLP policy engine ansible.builtin.file: diff --git a/ansible/deploy_aw_windows.yml b/ansible/deploy_aw_windows.yml index 17c8674..c50b6eb 100644 --- a/ansible/deploy_aw_windows.yml +++ b/ansible/deploy_aw_windows.yml @@ -265,6 +265,21 @@ } } + # Force managed collectors/watchers to reload the freshly deployed scripts. + Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | + Where-Object { + ( + ($_.Name -ieq 'powershell.exe' -or $_.Name -ieq 'pwsh.exe') -and + $_.CommandLine -match 'C:\\ProgramData\\AWatch-rus\\' -and + $_.CommandLine -match '(collector|launch-watchers|recovery-loop)\.ps1' + ) -or + ($_.Name -ieq 'aw-watcher-afk.exe') -or + ($_.Name -ieq 'aw-watcher-window.exe') + } | + ForEach-Object { + Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue + } + Start-Sleep -Seconds 2 # Ensure tasks are enabled (some environments keep them disabled, causing "0s" in WebUI). @@ -277,9 +292,15 @@ try { Enable-ScheduledTask -TaskName ([string]$taskDef.launchTaskName) -ErrorAction SilentlyContinue | Out-Null } catch {} } - Start-ScheduledTask -TaskName "{{ aw_windows_recovery_task_name }}" + $recoveryTask = Get-ScheduledTask -TaskName "{{ aw_windows_recovery_task_name }}" -ErrorAction SilentlyContinue + if ($recoveryTask -and $recoveryTask.State -notin @('Running', 'Queued')) { + Start-ScheduledTask -TaskName "{{ aw_windows_recovery_task_name }}" + } foreach ($taskDef in @($config.userTasks)) { - Start-ScheduledTask -TaskName ([string]$taskDef.launchTaskName) -ErrorAction SilentlyContinue + $launchTask = Get-ScheduledTask -TaskName ([string]$taskDef.launchTaskName) -ErrorAction SilentlyContinue + if ($launchTask -and $launchTask.State -notin @('Running', 'Queued')) { + Start-ScheduledTask -TaskName ([string]$taskDef.launchTaskName) -ErrorAction SilentlyContinue + } } - name: Получить Windows hostname для AW smoke-check bucket @@ -289,17 +310,16 @@ register: aw_windows_hostname_result changed_when: false - - name: Вычислить AW AFK smoke-check bucket + - name: Вычислить AW worktime smoke-check bucket when: - aw_windows_api_smoke_check_enabled | bool - - aw_windows_afk_enabled_effective | bool - aw_windows_hostname_result.stdout is defined ansible.builtin.set_fact: aw_windows_api_smoke_check_bucket_effective: >- {{ aw_windows_api_smoke_check_bucket if (aw_windows_api_smoke_check_bucket | default('') | string | length) > 0 - else 'aw-watcher-afk_' ~ (aw_windows_hostname_result.stdout | trim) + else 'aw-worktime-sessions_' ~ (aw_windows_hostname_result.stdout | trim) }} - name: Вычислить AW Window smoke-check bucket @@ -316,10 +336,9 @@ else 'aw-watcher-window_' ~ (aw_windows_hostname_result.stdout | trim) }} - - name: Выполнить AW API smoke-check (AFK bucket должен получать события) + - name: Выполнить AW API smoke-check (worktime bucket должен получать события) when: - aw_windows_api_smoke_check_enabled | bool - - aw_windows_afk_enabled_effective | bool ansible.builtin.uri: url: "{{ aw_windows_server_scheme }}://{{ aw_windows_server_host }}:{{ aw_windows_server_port }}/api/0/buckets/{{ aw_windows_api_smoke_check_bucket_effective }}/events?limit={{ aw_windows_api_smoke_check_limit }}" method: GET @@ -330,6 +349,12 @@ until: >- (aw_windows_api_smoke_result.status | default(0)) == 200 and ((aw_windows_api_smoke_result.json | default([])) | length) >= aw_windows_api_smoke_check_min_events + and ( + (aw_windows_api_smoke_result.json | default([])) + | selectattr('data.source', 'equalto', 'worktime-session-collector') + | list + | length + ) > 0 retries: 12 delay: 5 ignore_errors: "{{ (not aw_windows_fail_on_validation_error | bool) }}" @@ -363,7 +388,7 @@ $ErrorActionPreference = 'Stop' $result = & "{{ aw_windows_deploy_root }}\windows\validate-deployment.ps1" ` -ConfigPath "{{ aw_windows_state_root }}\deployment-config.json" - $result | ConvertTo-Json -Depth 8 | Out-File -FilePath "{{ aw_windows_validation_remote_path }}" -Encoding utf8 + $result | ConvertTo-Json -Depth 12 | Out-File -FilePath "{{ aw_windows_validation_remote_path }}" -Encoding utf8 return $result - name: Создать локальную директорию для отчётов валидации @@ -386,7 +411,8 @@ with open('{{ aw_windows_validation_local_dir }}/{{ inventory_hostname }}-aw_validate_ansible.json', 'r', encoding='utf-8-sig') as f: data = json.load(f) if not data.get('overallOk', False): - print(f"Validation failed for {{ inventory_hostname }}: {data.get('summary', 'Unknown error')}") + failed = ", ".join(data.get("summary", {}).get("failedSections", [])) or "unknown" + print(f"Validation failed for {{ inventory_hostname }}: {failed}") sys.exit(1) PY delegate_to: localhost diff --git a/ansible/group_vars/all.example.yml b/ansible/group_vars/all.example.yml index 57a2dcd..bdac96e 100644 --- a/ansible/group_vars/all.example.yml +++ b/ansible/group_vars/all.example.yml @@ -50,6 +50,7 @@ aw_worktime_to: "17:00" aw_worktime_start_of_day: "{{ aw_worktime_from }}" aw_server_always_active_pattern: "aw-watcher-window" aw_server_landingpage: "/activity/SHARKON2025/view/" +aw_health_strict_fileops: 0 aw_dlp_policy_engine_enabled: true aw_dlp_policy_engine_bind_host: "0.0.0.0" diff --git a/ansible/group_vars/all.yml b/ansible/group_vars/all.yml index ba5da2b..cf62c70 100644 --- a/ansible/group_vars/all.yml +++ b/ansible/group_vars/all.yml @@ -58,3 +58,6 @@ aw_server_post_deploy_health_check_enabled: true aw_worktime_from: "00:00" aw_worktime_to: "17:00" aw_worktime_start_of_day: "{{ aw_worktime_from }}" +aw_server_always_active_pattern: "aw-watcher-window" +aw_server_landingpage: "/activity/SHARKON2025/view/" +aw_health_strict_fileops: 0 diff --git a/ansible/post_validate_aw_windows.yml b/ansible/post_validate_aw_windows.yml index 8fc4e7c..232456d 100644 --- a/ansible/post_validate_aw_windows.yml +++ b/ansible/post_validate_aw_windows.yml @@ -18,16 +18,21 @@ aw_windows_api_smoke_check_enabled: true aw_windows_api_smoke_check_bucket: "" aw_windows_api_smoke_check_limit: 10 + aw_windows_api_smoke_check_wait_seconds: 20 tasks: - - name: Принудительно запустить ActivityWatch recovery и launch tasks + - name: Аккуратно запустить ActivityWatch recovery и launch tasks, если они не в Running when: aw_windows_force_task_restart | bool ansible.windows.win_powershell: script: | $ErrorActionPreference = 'Stop' - Start-ScheduledTask -TaskName "{{ aw_windows_recovery_task_name }}" + $recovery = Get-ScheduledTask -TaskName "{{ aw_windows_recovery_task_name }}" -ErrorAction SilentlyContinue + if ($recovery -and $recovery.State -notin @('Running', 'Queued')) { + Start-ScheduledTask -TaskName "{{ aw_windows_recovery_task_name }}" + } Get-ScheduledTask | Where-Object TaskName -like "{{ aw_windows_launch_task_pattern }}" | + Where-Object State -notin @('Running', 'Queued') | ForEach-Object { Start-ScheduledTask -TaskName $_.TaskName } - name: Получить Windows hostname для AW smoke-check bucket @@ -43,10 +48,15 @@ {{ aw_windows_api_smoke_check_bucket if (aw_windows_api_smoke_check_bucket | default('') | string | length) > 0 - else 'aw-watcher-afk_' ~ (aw_windows_hostname_result.stdout | trim) + else 'aw-worktime-sessions_' ~ (aw_windows_hostname_result.stdout | trim) }} - - name: Дождаться свежих AFK событий на AW server + - name: Дождаться короткого окна стабилизации после перезапуска задач + when: aw_windows_api_smoke_check_enabled | bool + ansible.builtin.pause: + seconds: "{{ aw_windows_api_smoke_check_wait_seconds | int }}" + + - name: Дождаться свежих worktime-событий на AW server when: aw_windows_api_smoke_check_enabled | bool delegate_to: localhost ansible.builtin.uri: @@ -60,7 +70,7 @@ (aw_windows_api_smoke.json | length) > 0 and ( aw_windows_api_smoke.json - | selectattr('data.status', 'equalto', 'not-afk') + | selectattr('data.source', 'equalto', 'worktime-session-collector') | list | length ) > 0 diff --git a/aw-server/aw-server.env.example b/aw-server/aw-server.env.example index 5b113e8..14fc306 100755 --- a/aw-server/aw-server.env.example +++ b/aw-server/aw-server.env.example @@ -33,6 +33,10 @@ AW_LOG_TO_FILE=true # Health Check Configuration AW_HEALTH_CHECK_ENABLED=true AW_HEALTH_CHECK_INTERVAL=60 +AW_EXPECT_START_OF_DAY=00:00 +AW_EXPECT_ALWAYS_ACTIVE_PATTERN=aw-watcher-window +AW_EXPECT_LANDINGPAGE=/activity/SHARKON2025/view/ +AW_HEALTH_STRICT_FILEOPS=0 # Integration Test Configuration AW_INTEGRATION_TEST_ENABLED=false diff --git a/aw-server/health-check.sh b/aw-server/health-check.sh index a12ddf8..76042f4 100644 --- a/aw-server/health-check.sh +++ b/aw-server/health-check.sh @@ -8,6 +8,11 @@ SERVICES=("activitywatch-server" "aw-worktime-api" "aw-worktime-ui-bridge") UNHEALTHY_SERVICES=() WARNINGS=() +if [[ -f /etc/activitywatch/aw-server.env ]]; then + # shellcheck disable=SC1091 + source /etc/activitywatch/aw-server.env +fi + check_service() { local service=$1 if [[ "$service" == "aw-worktime-ui-bridge" ]]; then @@ -39,6 +44,38 @@ check_api_endpoint() { fi } +read_setting_value() { + local key=$1 + curl -fsS --max-time 10 "http://127.0.0.1:5600/api/0/settings/${key}" 2>/dev/null | \ + python3 -c 'import json,sys; print(json.load(sys.stdin))' +} + +check_expected_setting() { + local key=$1 + local expected=$2 + local label=${3:-$1} + + if [[ -z "$expected" ]]; then + echo "⚠ expected value for ${label} is not configured, skipping drift check" + WARNINGS+=("${key}-expected-missing") + return + fi + + local actual + if ! actual="$(read_setting_value "$key" 2>/dev/null)"; then + echo "✗ failed to read setting ${label}" + UNHEALTHY_SERVICES+=("setting-${key}") + return + fi + + if [[ "$actual" == "$expected" ]]; then + echo "✓ ${label} matches expected value (${expected})" + else + echo "✗ ${label} drift detected: actual='${actual}' expected='${expected}'" + UNHEALTHY_SERVICES+=("setting-${key}") + fi +} + check_dlp_transport_freshness() { local dlp_health="${DLP_HEALTH_BIN:-/usr/local/bin/dlp-health-check}" local result @@ -92,6 +129,9 @@ echo check_api_endpoint "http://127.0.0.1:5600/api/0/info" "activitywatch-server" check_api_endpoint "http://127.0.0.1:5610/reports/worktime/today" "aw-worktime-api" check_dlp_transport_freshness "http://127.0.0.1:5600/api/0" "900" "${AW_HEALTH_STRICT_FILEOPS:-0}" +check_expected_setting "startOfDay" "${AW_EXPECT_START_OF_DAY:-}" "startOfDay" +check_expected_setting "always_active_pattern" "${AW_EXPECT_ALWAYS_ACTIVE_PATTERN:-}" "always_active_pattern" +check_expected_setting "landingpage" "${AW_EXPECT_LANDINGPAGE:-}" "landingpage" echo diff --git a/windows/validate-deployment.ps1 b/windows/validate-deployment.ps1 index 67c9414..1273a86 100644 --- a/windows/validate-deployment.ps1 +++ b/windows/validate-deployment.ps1 @@ -22,74 +22,19 @@ $policyPath = if ($config.paths.PSObject.Properties.Name -contains 'policyPath') $policyClientScript = if ($config.paths.PSObject.Properties.Name -contains 'policyClientScript') { [string]$config.paths.policyClientScript } else { Join-Path $stateRoot 'dlp-policy-client.ps1' } $launchScript = [string]$config.paths.launchScript $recoveryScript = [string]$config.paths.recoveryScript +$awHostname = if ($config.PSObject.Properties.Name -contains 'awHostname' -and -not [string]::IsNullOrWhiteSpace([string]$config.awHostname)) { [string]$config.awHostname } else { [string]$env:COMPUTERNAME } +$serverUrl = '{0}://{1}:{2}' -f [string]$config.server.scheme, [string]$config.server.host, [int]$config.server.port +$apiBase = "$serverUrl/api/0" +$pollSeconds = if ($config.PSObject.Properties.Name -contains 'collector' -and $config.collector.PSObject.Properties.Name -contains 'pollSeconds') { [int]$config.collector.pollSeconds } else { 5 } +$pulseSeconds = if ($config.PSObject.Properties.Name -contains 'collector' -and $config.collector.PSObject.Properties.Name -contains 'pulseSeconds') { [int]$config.collector.pulseSeconds } else { [Math]::Max($pollSeconds * 3, 30) } +$freshnessSeconds = [Math]::Max($pollSeconds * 3, 30) +$sessionFreshnessSeconds = [Math]::Max($pollSeconds * 4, 45) +$transportStaleSeconds = [Math]::Max($pollSeconds * 12, 180) +$queueMaxDepth = 1000 $afkExpected = if ($config.PSObject.Properties.Name -contains 'collectors' -and $config.collectors.PSObject.Properties.Name -contains 'afkEnabled') { [bool]$config.collectors.afkEnabled } else { $true } $windowExpected = if ($config.PSObject.Properties.Name -contains 'collectors' -and $config.collectors.PSObject.Properties.Name -contains 'windowEnabled') { [bool]$config.collectors.windowEnabled } else { $true } $fileOpsExpected = if ($config.PSObject.Properties.Name -contains 'collectors' -and $config.collectors.PSObject.Properties.Name -contains 'fileOpsEnabled') { [bool]$config.collectors.fileOpsEnabled } else { $true } -$printServiceOperationalEnabled = $false -try { - $printServiceLog = Get-WinEvent -ListLog 'Microsoft-Windows-PrintService/Operational' -ErrorAction Stop - $printServiceOperationalEnabled = [bool]$printServiceLog.IsEnabled -} -catch { -} -$printJobTitlePolicyEnabled = $false -try { - $printPolicy = Get-ItemProperty -LiteralPath 'HKLM:\Software\Policies\Microsoft\Windows NT\Printers' -Name 'ShowJobTitleInEventLogs' -ErrorAction Stop - $printJobTitlePolicyEnabled = ([int]$printPolicy.ShowJobTitleInEventLogs -eq 1) -} -catch { -} -$requiredFiles = @( - $collectorScript, - $endpointCollectorScript, - $sessionCollectorScript, - $evtxExportScript, - $rulesPath, - $policyPath, - $policyClientScript, - $launchScript, - $recoveryScript, - $ConfigPath -) -if ($fileOpsExpected) { - $requiredFiles += $fileCollectorScript -} -if ($afkExpected) { - $requiredFiles += (Join-Path $installRoot 'aw-watcher-afk\aw-watcher-afk.exe') -} -if ($windowExpected) { - $requiredFiles += (Join-Path $installRoot 'aw-watcher-window\aw-watcher-window.exe') -} - -$missingFiles = @( - $requiredFiles | - Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) } | - Where-Object { -not (Test-Path -LiteralPath $_) } -) - -$processNames = @() -if ($afkExpected) { $processNames += 'aw-watcher-afk' } -if ($windowExpected) { $processNames += 'aw-watcher-window' } -$runningProcesses = @() -if ($processNames.Count -gt 0) { - $runningProcesses = Get-Process -Name $processNames -ErrorAction SilentlyContinue | Select-Object Name, Id, SessionId -} -$sessionCollectorProcesses = @( - Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | - Where-Object { - ($_.Name -ieq 'powershell.exe' -or $_.Name -ieq 'pwsh.exe') -and - $_.CommandLine -match [Regex]::Escape($sessionCollectorScript) - } | - Select-Object Name, ProcessId, SessionId, CommandLine -) - -$taskNames = @() -if ($config.userTasks) { - $taskNames += @($config.userTasks | ForEach-Object { [string]$_.launchTaskName }) -} -$taskNames += [string]$config.recovery.taskName -$taskNames = $taskNames | Sort-Object -Unique function Get-LoggedOnUsers { $users = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) @@ -122,6 +67,7 @@ function Test-UserHasSession { [string]$UserId, [string[]]$LoggedOnUsers ) + if ([string]::IsNullOrWhiteSpace($UserId)) { return $false } $candidateIds = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) [void]$candidateIds.Add($UserId) @@ -140,42 +86,380 @@ function Test-UserHasSession { return $false } +function Get-CollectorProcesses { + param( + [Parameter(Mandatory = $true)] + [string]$ScriptPath + ) + + return @( + Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | + Where-Object { + ($_.Name -ieq 'powershell.exe' -or $_.Name -ieq 'pwsh.exe') -and + $_.CommandLine -and + $_.CommandLine -match [Regex]::Escape($ScriptPath) + } | + Select-Object @{ Name = 'Name'; Expression = { $_.Name } }, @{ Name = 'Id'; Expression = { [int]$_.ProcessId } }, @{ Name = 'SessionId'; Expression = { [int]$_.SessionId } }, @{ Name = 'CommandLine'; Expression = { [string]$_.CommandLine } } + ) +} + +function Get-DuplicateProcessGroups { + param( + [object[]]$Processes, + [bool]$PerSession = $true + ) + + if (-not $Processes -or @($Processes).Count -eq 0) { return @() } + $groups = if ($PerSession) { + $Processes | Group-Object -Property Name, SessionId + } + else { + $Processes | Group-Object -Property Name + } + + return @( + $groups | + Where-Object { $_.Count -gt 1 } | + ForEach-Object { + [pscustomobject]@{ + name = [string]$_.Name + count = [int]$_.Count + members = @($_.Group | Select-Object Name, Id, SessionId, CommandLine) + } + } + ) +} + +function Convert-ToUtcDate { + param($Value) + + if ($null -eq $Value) { return $null } + try { + return ([DateTimeOffset]::Parse([string]$Value)).UtcDateTime + } + catch { + return $null + } +} + +function Get-BucketHealth { + param( + [Parameter(Mandatory = $true)] + [string]$BucketId, + [Parameter(Mandatory = $true)] + [int]$MaxAgeSeconds, + [bool]$Required = $true, + [bool]$RequireFreshEvent = $true + ) + + $events = @() + $queryOk = $false + $errorMessage = $null + try { + $response = Invoke-RestMethod -Method Get -Uri "$apiBase/buckets/$BucketId/events?limit=25" -TimeoutSec 15 -DisableKeepAlive -ErrorAction Stop + $events = @($response) + $queryOk = $true + } + catch { + $errorMessage = $_.Exception.Message + } + + $latestTimestampUtc = $null + $ageSeconds = $null + if ($events.Count -gt 0) { + $latestTimestampUtc = @( + $events | + ForEach-Object { Convert-ToUtcDate $_.timestamp } | + Where-Object { $null -ne $_ } | + Sort-Object -Descending + ) | Select-Object -First 1 + if ($null -ne $latestTimestampUtc) { + $ageSeconds = [int][Math]::Floor(((Get-Date).ToUniversalTime() - $latestTimestampUtc).TotalSeconds) + } + } + + $hasFreshEvent = ($null -ne $ageSeconds -and $ageSeconds -le $MaxAgeSeconds) + $hasAnyEvent = ($events.Count -gt 0) + $ok = if (-not $Required) { $true } elseif ($RequireFreshEvent) { $queryOk -and $hasFreshEvent } else { $queryOk -and $hasAnyEvent } + + return [pscustomobject]@{ + bucketId = $BucketId + required = [bool]$Required + requireFreshEvent = [bool]$RequireFreshEvent + maxAgeSeconds = [int]$MaxAgeSeconds + queryOk = [bool]$queryOk + latestTimestampUtc = if ($null -ne $latestTimestampUtc) { $latestTimestampUtc.ToString('o') } else { $null } + ageSeconds = if ($null -ne $ageSeconds) { [int]$ageSeconds } else { $null } + count = [int]$events.Count + ok = [bool]$ok + error = $errorMessage + } +} + +function Get-TransportQueueHealth { + param( + [Parameter(Mandatory = $true)] + [string]$Name, + [Parameter(Mandatory = $true)] + [string]$QueuePath, + [Parameter(Mandatory = $true)] + [string]$LockPath, + [Parameter(Mandatory = $true)] + [int]$StaleAfterSeconds, + [Parameter(Mandatory = $true)] + [int]$MaxDepth, + [int]$ActiveProcessCount = 0, + [bool]$Required = $true + ) + + $queueExists = Test-Path -LiteralPath $QueuePath + $depth = 0 + $sizeBytes = 0 + $ageSeconds = $null + $lastWriteUtc = $null + if ($queueExists) { + $item = Get-Item -LiteralPath $QueuePath -ErrorAction SilentlyContinue + if ($item) { + $sizeBytes = [int64]$item.Length + $lastWriteUtc = $item.LastWriteTimeUtc + $ageSeconds = [int][Math]::Floor(((Get-Date).ToUniversalTime() - $lastWriteUtc).TotalSeconds) + } + try { + $depth = [int]((Get-Content -LiteralPath $QueuePath -ErrorAction SilentlyContinue | Measure-Object).Count) + } + catch { + $depth = 0 + } + } + + $lockExists = Test-Path -LiteralPath $LockPath + $lockHeld = $false + if ($lockExists) { + try { + $lockHandle = [System.IO.File]::Open($LockPath, [System.IO.FileMode]::Open, [System.IO.FileAccess]::ReadWrite, [System.IO.FileShare]::None) + $lockHandle.Dispose() + } + catch { + $lockHeld = $true + } + } + + $staleQueue = ($depth -gt 0 -and $null -ne $ageSeconds -and $ageSeconds -gt $StaleAfterSeconds -and -not $lockHeld) + $orphanedQueue = ($depth -gt 0 -and $ActiveProcessCount -le 0 -and $null -ne $ageSeconds -and $ageSeconds -gt $StaleAfterSeconds) + $oversizedQueue = ($depth -gt $MaxDepth) + $ok = if (-not $Required) { $true } else { -not ($staleQueue -or $orphanedQueue -or $oversizedQueue) } + + return [pscustomobject]@{ + name = $Name + required = [bool]$Required + queuePath = $QueuePath + queueExists = [bool]$queueExists + depth = [int]$depth + sizeBytes = [int64]$sizeBytes + lastWriteUtc = if ($null -ne $lastWriteUtc) { $lastWriteUtc.ToString('o') } else { $null } + ageSeconds = if ($null -ne $ageSeconds) { [int]$ageSeconds } else { $null } + lockPath = $LockPath + lockExists = [bool]$lockExists + lockHeld = [bool]$lockHeld + activeProcessCount = [int]$ActiveProcessCount + staleAfterSeconds = [int]$StaleAfterSeconds + maxDepth = [int]$MaxDepth + staleQueue = [bool]$staleQueue + orphanedQueue = [bool]$orphanedQueue + oversizedQueue = [bool]$oversizedQueue + ok = [bool]$ok + } +} + +function Get-TaskSnapshot { + param( + [Parameter(Mandatory = $true)] + [string[]]$TaskNames + ) + + return @( + foreach ($taskName in @($TaskNames | Sort-Object -Unique)) { + $task = Get-ScheduledTask -ErrorAction SilentlyContinue | Where-Object { $_.TaskName -eq $taskName } | Select-Object -First 1 + if ($null -eq $task) { + [pscustomobject]@{ + taskName = $taskName + present = $false + enabled = $false + state = 'Отсутствует' + lastResult = $null + ok = $false + } + continue + } + + $taskInfo = $null + try { + $taskInfo = Get-ScheduledTaskInfo -TaskName $task.TaskName -TaskPath $task.TaskPath -ErrorAction Stop + } + catch { + } + + $enabled = $true + try { + if ($task.Settings.PSObject.Properties.Name -contains 'Enabled') { + $enabled = [bool]$task.Settings.Enabled + } + } + catch { + } + + [pscustomobject]@{ + taskName = [string]$task.TaskName + present = $true + enabled = [bool]$enabled + state = [string]$task.State + lastResult = if ($taskInfo) { [int64]$taskInfo.LastTaskResult } else { $null } + ok = [bool]($enabled) + } + } + ) +} + +$requiredFiles = @( + $collectorScript, + $endpointCollectorScript, + $sessionCollectorScript, + $evtxExportScript, + $rulesPath, + $policyPath, + $policyClientScript, + $launchScript, + $recoveryScript, + $ConfigPath +) +if ($fileOpsExpected) { + $requiredFiles += $fileCollectorScript +} +if ($afkExpected) { + $requiredFiles += (Join-Path $installRoot 'aw-watcher-afk\aw-watcher-afk.exe') +} +if ($windowExpected) { + $requiredFiles += (Join-Path $installRoot 'aw-watcher-window\aw-watcher-window.exe') +} + +$missingFiles = @( + $requiredFiles | + Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) } | + Where-Object { -not (Test-Path -LiteralPath $_) } +) + +$runningWatchers = @() +$expectedWatcherNames = @() +if ($afkExpected) { $expectedWatcherNames += 'aw-watcher-afk' } +if ($windowExpected) { $expectedWatcherNames += 'aw-watcher-window' } +if ($expectedWatcherNames.Count -gt 0) { + $runningWatchers = @(Get-Process -Name $expectedWatcherNames -ErrorAction SilentlyContinue | Select-Object Name, Id, SessionId) +} + +$sessionCollectorProcesses = @(Get-CollectorProcesses -ScriptPath $sessionCollectorScript) +$endpointCollectorProcesses = @(Get-CollectorProcesses -ScriptPath $endpointCollectorScript) +$fileCollectorProcesses = if ($fileOpsExpected) { @(Get-CollectorProcesses -ScriptPath $fileCollectorScript) } else { @() } +$browserCollectorProcesses = @(Get-CollectorProcesses -ScriptPath $collectorScript) + $loggedOnUsers = Get-LoggedOnUsers $sessionBoundUsers = @( @($config.userTasks) | Where-Object { Test-UserHasSession -UserId ([string]$_.userId) -LoggedOnUsers $loggedOnUsers } | ForEach-Object { [string]$_.userId } ) +$sessionScopedExpectedCount = [int]$sessionBoundUsers.Count +$sessionScopedCollectorsRequired = ($sessionScopedExpectedCount -gt 0) -$tasks = @( - foreach ($taskName in $taskNames) { - $task = Get-ScheduledTask -ErrorAction SilentlyContinue | Where-Object { $_.TaskName -eq $taskName } | Select-Object -First 1 - if ($task) { - [pscustomobject]@{ - taskName = $task.TaskName - state = [string]$task.State - present = $true - } - } - else { - [pscustomobject]@{ - taskName = $taskName - state = 'Отсутствует' - present = $false - } - } +$taskNames = @() +if ($config.userTasks) { + $taskNames += @($config.userTasks | ForEach-Object { [string]$_.launchTaskName }) +} +$taskNames += [string]$config.recovery.taskName +$tasks = @(Get-TaskSnapshot -TaskNames $taskNames) + +$watcherDuplicates = @(Get-DuplicateProcessGroups -Processes $runningWatchers -PerSession $true) +$sessionCollectorDuplicates = @(Get-DuplicateProcessGroups -Processes $sessionCollectorProcesses -PerSession $false) +$endpointCollectorDuplicates = @(Get-DuplicateProcessGroups -Processes $endpointCollectorProcesses -PerSession $true) +$fileCollectorDuplicates = @(Get-DuplicateProcessGroups -Processes $fileCollectorProcesses -PerSession $true) +$browserCollectorDuplicates = @(Get-DuplicateProcessGroups -Processes $browserCollectorProcesses -PerSession $true) + +$watcherByName = @{} +foreach ($watcher in $runningWatchers) { + if (-not $watcherByName.ContainsKey([string]$watcher.Name)) { + $watcherByName[[string]$watcher.Name] = 0 } -) + $watcherByName[[string]$watcher.Name]++ +} + +$bucketChecks = @( + Get-BucketHealth -BucketId ('aw-worktime-sessions_' + $awHostname) -MaxAgeSeconds $sessionFreshnessSeconds -Required $true -RequireFreshEvent $true +) +if ($sessionScopedCollectorsRequired -and $afkExpected) { + $bucketChecks += Get-BucketHealth -BucketId ('aw-watcher-afk_' + $awHostname) -MaxAgeSeconds $freshnessSeconds -Required $true -RequireFreshEvent $false +} +if ($sessionScopedCollectorsRequired -and $windowExpected) { + $bucketChecks += Get-BucketHealth -BucketId ('aw-watcher-window_' + $awHostname) -MaxAgeSeconds $freshnessSeconds -Required $true -RequireFreshEvent $false +} +if ($sessionScopedCollectorsRequired) { + $bucketChecks += Get-BucketHealth -BucketId ('aw-dlp-endpoint-signals_' + $awHostname) -MaxAgeSeconds $freshnessSeconds -Required $true -RequireFreshEvent $true +} +if ($sessionScopedCollectorsRequired -and $fileOpsExpected) { + $bucketChecks += Get-BucketHealth -BucketId ('aw-file-operations_' + $awHostname) -MaxAgeSeconds $transportStaleSeconds -Required $false -RequireFreshEvent $true +} + +$queueChecks = @( + Get-TransportQueueHealth -Name 'endpoint' -QueuePath (Join-Path $stateRoot 'dlp-endpoint-signals-queue.jsonl') -LockPath (Join-Path $stateRoot 'dlp-endpoint-signals-queue.lock') -StaleAfterSeconds $transportStaleSeconds -MaxDepth $queueMaxDepth -ActiveProcessCount @($endpointCollectorProcesses).Count -Required $sessionScopedCollectorsRequired +) +if ($fileOpsExpected) { + $queueChecks += Get-TransportQueueHealth -Name 'fileops' -QueuePath (Join-Path $stateRoot 'file-operations-queue.jsonl') -LockPath (Join-Path $stateRoot 'file-operations-queue.lock') -StaleAfterSeconds $transportStaleSeconds -MaxDepth $queueMaxDepth -ActiveProcessCount @($fileCollectorProcesses).Count -Required $sessionScopedCollectorsRequired +} + +$printServiceOperationalEnabled = $false +try { + $printServiceLog = Get-WinEvent -ListLog 'Microsoft-Windows-PrintService/Operational' -ErrorAction Stop + $printServiceOperationalEnabled = [bool]$printServiceLog.IsEnabled +} +catch { +} + +$printJobTitlePolicyEnabled = $false +try { + $printPolicy = Get-ItemProperty -LiteralPath 'HKLM:\Software\Policies\Microsoft\Windows NT\Printers' -Name 'ShowJobTitleInEventLogs' -ErrorAction Stop + $printJobTitlePolicyEnabled = ([int]$printPolicy.ShowJobTitleInEventLogs -eq 1) +} +catch { +} + +$watcherCountsOk = $true +if ($sessionScopedCollectorsRequired) { + if ($afkExpected) { + $watcherCountsOk = $watcherCountsOk -and (($watcherByName['aw-watcher-afk'] | ForEach-Object { [int]$_ }) -ge $sessionScopedExpectedCount) + } + if ($windowExpected) { + $watcherCountsOk = $watcherCountsOk -and (($watcherByName['aw-watcher-window'] | ForEach-Object { [int]$_ }) -ge $sessionScopedExpectedCount) + } +} + +$endpointProcessOk = if (-not $sessionScopedCollectorsRequired) { $true } else { (@($endpointCollectorProcesses).Count -ge $sessionScopedExpectedCount) } +$fileProcessOk = if (-not $fileOpsExpected -or -not $sessionScopedCollectorsRequired) { $true } else { (@($fileCollectorProcesses).Count -ge $sessionScopedExpectedCount) } +$browserProcessOk = if (-not $sessionScopedCollectorsRequired) { $true } else { (@($browserCollectorProcesses).Count -ge $sessionScopedExpectedCount) } +$sessionCollectorOk = (@($sessionCollectorProcesses).Count -eq 1) -$serverUrl = '{0}://{1}:{2}' -f [string]$config.server.scheme, [string]$config.server.host, [int]$config.server.port -$uniqueRunningProcessNames = @($runningProcesses | Select-Object -ExpandProperty Name -Unique) -$sessionBoundCollectorsExpected = ($sessionBoundUsers.Count -gt 0) $result = [ordered]@{ generatedAtUtc = (Get-Date).ToUniversalTime().ToString('o') configPath = $ConfigPath serverUrl = $serverUrl + apiBase = $apiBase + awHostname = $awHostname installRoot = $installRoot stateRoot = $stateRoot + timing = [ordered]@{ + pollSeconds = [int]$pollSeconds + pulseSeconds = [int]$pulseSeconds + freshnessSeconds = [int]$freshnessSeconds + sessionFreshnessSeconds = [int]$sessionFreshnessSeconds + transportStaleSeconds = [int]$transportStaleSeconds + } files = [ordered]@{ required = $requiredFiles missing = $missingFiles @@ -183,22 +467,42 @@ $result = [ordered]@{ } tasks = [ordered]@{ list = $tasks - ok = [bool]($tasks.Count -gt 0 -and -not ($tasks | Where-Object { -not $_.present })) + ok = [bool]($tasks.Count -gt 0 -and -not ($tasks | Where-Object { -not $_.present -or -not $_.enabled })) } processes = [ordered]@{ - expected = $processNames sessionBoundUsers = $sessionBoundUsers - list = @($runningProcesses) + sessionScopedExpectedCount = [int]$sessionScopedExpectedCount + watchers = @($runningWatchers) + watcherDuplicates = @($watcherDuplicates) sessionCollectors = @($sessionCollectorProcesses) + sessionCollectorDuplicates = @($sessionCollectorDuplicates) + browserCollectors = @($browserCollectorProcesses) + browserCollectorDuplicates = @($browserCollectorDuplicates) + endpointCollectors = @($endpointCollectorProcesses) + endpointCollectorDuplicates = @($endpointCollectorDuplicates) + fileCollectors = @($fileCollectorProcesses) + fileCollectorDuplicates = @($fileCollectorDuplicates) ok = [bool]( - ( - (-not $sessionBoundCollectorsExpected) -or - ($processNames.Count -eq 0) -or - ($uniqueRunningProcessNames.Count -ge $processNames.Count) - ) -and - ($sessionCollectorProcesses.Count -ge 1) + $watcherCountsOk -and + $sessionCollectorOk -and + $browserProcessOk -and + $endpointProcessOk -and + $fileProcessOk -and + ($watcherDuplicates.Count -eq 0) -and + ($sessionCollectorDuplicates.Count -eq 0) -and + ($browserCollectorDuplicates.Count -eq 0) -and + ($endpointCollectorDuplicates.Count -eq 0) -and + ($fileCollectorDuplicates.Count -eq 0) ) } + buckets = [ordered]@{ + list = @($bucketChecks) + ok = [bool](-not ($bucketChecks | Where-Object { -not $_.ok })) + } + queues = [ordered]@{ + list = @($queueChecks) + ok = [bool](-not ($queueChecks | Where-Object { -not $_.ok })) + } printTelemetry = [ordered]@{ operationalLogEnabled = $printServiceOperationalEnabled jobTitlePolicyEnabled = $printJobTitlePolicyEnabled @@ -218,6 +522,21 @@ $result = [ordered]@{ } } -$result.overallOk = [bool]($result.files.ok -and $result.tasks.ok -and $result.processes.ok -and $result.printTelemetry.ok -and $result.forensics.ok) +$result.summary = [ordered]@{ + failedSections = @( + 'files', 'tasks', 'processes', 'buckets', 'queues', 'printTelemetry', 'forensics' | + Where-Object { -not [bool]$result.$_.ok } + ) +} + +$result.overallOk = [bool]( + $result.files.ok -and + $result.tasks.ok -and + $result.processes.ok -and + $result.buckets.ok -and + $result.queues.ok -and + $result.printTelemetry.ok -and + $result.forensics.ok +) $result