diff --git a/ansible/deploy_aw_windows.yml b/ansible/deploy_aw_windows.yml index fa6d572..9fae046 100644 --- a/ansible/deploy_aw_windows.yml +++ b/ansible/deploy_aw_windows.yml @@ -31,8 +31,8 @@ aw_windows_policy_engine_host: "{{ aw_windows_server_host }}" aw_windows_policy_engine_port: 5601 aw_windows_policy_engine_scheme: "http" - aw_windows_afk_enabled: true - aw_windows_window_enabled: true + aw_windows_afk_enabled_default: true + aw_windows_window_enabled_default: true aw_windows_file_ops_enabled: true aw_windows_local_agent_logs_enabled: false aw_windows_incident_capture_enabled: true @@ -50,7 +50,7 @@ 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_window_enabled: true + aw_windows_api_smoke_check_window_enabled_default: true aw_windows_api_smoke_check_window_bucket: "" aw_windows_api_smoke_check_min_events: 1 aw_windows_fail_on_validation_error: true @@ -72,6 +72,12 @@ - aw_windows_state_root is defined fail_msg: "Не заданы обязательные переменные Windows-развёртывания." + - name: Нормализовать effective флаги collector'ов и smoke-check + ansible.builtin.set_fact: + aw_windows_afk_enabled_effective: "{{ (aw_windows_afk_enabled | default(aw_windows_afk_enabled_default)) | bool }}" + aw_windows_window_enabled_effective: "{{ (aw_windows_window_enabled | default(aw_windows_window_enabled_default)) | bool }}" + aw_windows_api_smoke_check_window_enabled_effective: "{{ (aw_windows_api_smoke_check_window_enabled | default(aw_windows_api_smoke_check_window_enabled_default)) | bool }}" + - name: Создать каталоги развёртывания ansible.windows.win_file: path: "{{ item }}" @@ -97,6 +103,7 @@ - deploy-domain-users.ps1 - deploy-ensemble.ps1 - hardening-recovery.ps1 + - rebuild-worktime-tasks.ps1 - validate-deployment.ps1 - web-category-rules.example.json - dlp-policy.example.json @@ -155,8 +162,8 @@ UserListPath = "{{ aw_windows_deploy_root }}\windows\users.txt" InstallRoot = "{{ aw_windows_install_root }}" StateRoot = "{{ aw_windows_state_root }}" - AfkEnabled = {{ '$true' if (aw_windows_afk_enabled | bool) else '$false' }} - WindowEnabled = {{ '$true' if (aw_windows_window_enabled | bool) else '$false' }} + AfkEnabled = {{ '$true' if (aw_windows_afk_enabled_effective | bool) else '$false' }} + WindowEnabled = {{ '$true' if (aw_windows_window_enabled_effective | bool) else '$false' }} FileOpsEnabled = {{ '$true' if (aw_windows_file_ops_enabled | bool) else '$false' }} LocalAgentLogsEnabled = {{ '$true' if (aw_windows_local_agent_logs_enabled | bool) else '$false' }} IncidentCaptureEnabled = {{ '$true' if (aw_windows_incident_capture_enabled | bool) else '$false' }} @@ -225,18 +232,20 @@ ForEach-Object { $key = Get-CollectorKey -CommandLine $_.CommandLine if ($key) { + $groupKey = if ($key -eq 'worktime') { 'worktime::global' } else { '{0}::{1}' -f $key, ([int]$_.SessionId) } [pscustomobject]@{ ProcessId = [int]$_.ProcessId SessionId = [int]$_.SessionId CreationDate = $_.CreationDate CollectorKey = $key + GroupKey = $groupKey } } } | Where-Object { $_ -ne $null } - # Keep only one process per (collector, session): newest survives, older duplicates are stopped. - foreach ($group in ($collectorProcs | Group-Object CollectorKey, SessionId)) { + # Keep only one process per collector scope: worktime collector is global, others stay per-session. + foreach ($group in ($collectorProcs | Group-Object GroupKey)) { $ordered = @($group.Group | Sort-Object CreationDate -Descending) if ($ordered.Count -le 1) { continue } foreach ($dup in $ordered | Select-Object -Skip 1) { @@ -271,7 +280,7 @@ - name: Вычислить AW AFK smoke-check bucket when: - aw_windows_api_smoke_check_enabled | bool - - aw_windows_afk_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: >- @@ -284,8 +293,8 @@ - name: Вычислить AW Window smoke-check bucket when: - aw_windows_api_smoke_check_enabled | bool - - aw_windows_api_smoke_check_window_enabled | bool - - aw_windows_window_enabled | bool + - aw_windows_api_smoke_check_window_enabled_effective | bool + - aw_windows_window_enabled_effective | bool - aw_windows_hostname_result.stdout is defined ansible.builtin.set_fact: aw_windows_api_smoke_check_window_bucket_effective: >- @@ -298,7 +307,7 @@ - name: Выполнить AW API smoke-check (AFK bucket должен получать события) when: - aw_windows_api_smoke_check_enabled | bool - - aw_windows_afk_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 @@ -318,8 +327,8 @@ - name: Выполнить AW API smoke-check (Window bucket должен получать события) when: - aw_windows_api_smoke_check_enabled | bool - - aw_windows_api_smoke_check_window_enabled | bool - - aw_windows_window_enabled | bool + - aw_windows_api_smoke_check_window_enabled_effective | bool + - aw_windows_window_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_window_bucket_effective }}/events?limit={{ aw_windows_api_smoke_check_limit }}" method: GET diff --git a/ansible/group_vars/aw_windows.yml b/ansible/group_vars/aw_windows.yml index 4c62728..433b886 100644 --- a/ansible/group_vars/aw_windows.yml +++ b/ansible/group_vars/aw_windows.yml @@ -32,8 +32,8 @@ aw_windows_policy_engine_host: "{{ aw_windows_server_host }}" aw_windows_policy_engine_port: 5601 aw_windows_policy_engine_scheme: "http" -aw_windows_afk_enabled: true -aw_windows_window_enabled: true +aw_windows_afk_enabled: false +aw_windows_window_enabled: false aw_windows_file_ops_enabled: true aw_windows_email_collector_enabled: true aw_windows_email_collector_mode: "both" diff --git a/aw-server/aw-worktime-api.py b/aw-server/aw-worktime-api.py index 0e0c0e2..f47e4a9 100644 --- a/aw-server/aw-worktime-api.py +++ b/aw-server/aw-worktime-api.py @@ -42,7 +42,8 @@ def _is_active_sample(data: dict) -> bool: except Exception: sid = -1 user = str(data.get("username") or "").strip() - if sid > 0 and user and (not _is_machine_user(user)): + session_name = str(data.get("sessionName") or "").strip().lower() + if sid > 0 and user and (not _is_machine_user(user)) and (session_name.startswith("rdp-") or session_name == "console"): return True return False diff --git a/aw-server/aw-worktime-autoheal.sh b/aw-server/aw-worktime-autoheal.sh index aea6fdd..c464186 100644 --- a/aw-server/aw-worktime-autoheal.sh +++ b/aw-server/aw-worktime-autoheal.sh @@ -112,7 +112,8 @@ def is_active(d): try: sid=int(d.get("sessionId")) except: sid=-1 u=str(d.get("username","")).strip().lower() - if sid>0 and u and (not u.endswith("$")): return True + sn=str(d.get("sessionName","")).strip().lower() + if sid>0 and u and (not u.endswith("$")) and (sn.startswith("rdp-") or sn=="console"): return True return False rows=req("GET",f"/api/0/buckets/{sb}/events?limit=12000") or [] diff --git a/aw-server/aw-worktime-ui-bridge.py b/aw-server/aw-worktime-ui-bridge.py index cabd932..ae8edc5 100644 --- a/aw-server/aw-worktime-ui-bridge.py +++ b/aw-server/aw-worktime-ui-bridge.py @@ -96,7 +96,8 @@ def _is_session_active(row_data): except Exception: sid = -1 user = str(row_data.get("username", "")).strip().lower() - if sid > 0 and user and (not user.endswith("$")): + session_name = str(row_data.get("sessionName", "")).strip().lower() + if sid > 0 and user and (not user.endswith("$")) and (session_name.startswith("rdp-") or session_name == "console"): return True return False @@ -111,7 +112,11 @@ def transform(events): ts = e.get("timestamp") if not ts: continue - grouped.setdefault(ts, []).append(e) + try: + normalized_ts = to_iso_utc(parse_iso_utc(ts).replace(microsecond=0).isoformat()) + except Exception: + normalized_ts = ts + grouped.setdefault(normalized_ts, []).append(e) ordered_ts = sorted(grouped.keys()) parsed_ts = {} diff --git a/windows/ActivityWatch.Windows.Common.psm1 b/windows/ActivityWatch.Windows.Common.psm1 index 360aca7..769102c 100755 --- a/windows/ActivityWatch.Windows.Common.psm1 +++ b/windows/ActivityWatch.Windows.Common.psm1 @@ -873,7 +873,6 @@ try { if (`$fileOpsEnabled) { Start-CollectorScriptIfNeeded -ScriptPath `$fileCollectorScript -ConfigPath `$ConfigPath -PowerShellExe `$powershellExe -SessionId `$sessionId } - Start-CollectorScriptIfNeeded -ScriptPath `$sessionCollectorScript -ConfigPath `$ConfigPath -PowerShellExe `$powershellExe -SessionId `$sessionId if (`$emailEnabled -and (Test-Path -LiteralPath `$emailCollectorScript)) { Start-CollectorScriptIfNeeded -ScriptPath `$emailCollectorScript -ConfigPath `$ConfigPath -PowerShellExe `$powershellExe -SessionId `$sessionId } @@ -1010,6 +1009,44 @@ function Start-TaskIfNotRunning { } } +function Test-CollectorRunningGlobal { + param([string]`$ScriptPath) + if ([string]::IsNullOrWhiteSpace(`$ScriptPath)) { + return `$false + } + + return [bool]@( + Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | + Where-Object { + (`$_.Name -ieq 'powershell.exe' -or `$_.Name -ieq 'pwsh.exe') -and + `$_.CommandLine -match [Regex]::Escape(`$ScriptPath) + } + ).Count +} + +function Start-CollectorScriptGlobalIfNeeded { + param( + [string]`$ScriptPath, + [string]`$ConfigPath + ) + + if ([string]::IsNullOrWhiteSpace(`$ScriptPath)) { + return + } + + if (-not (Test-Path -LiteralPath `$ScriptPath)) { + return + } + + if (Test-CollectorRunningGlobal -ScriptPath `$ScriptPath) { + return + } + + `$powershellExe = Join-Path `$env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' + `$argumentList = @('-NoProfile', '-WindowStyle', 'Hidden', '-ExecutionPolicy', 'Bypass', '-File', `$ScriptPath, '-ConfigPath', `$ConfigPath) + Start-Process -FilePath `$powershellExe -ArgumentList `$argumentList -WindowStyle Hidden +} + `$recoveryLockPath = New-RecoveryLock -PrimaryConfigPath `$ConfigPath if (-not `$recoveryLockPath) { return @@ -1020,11 +1057,14 @@ try { `$sleepSeconds = 180 try { `$configPaths = Get-RecoveryConfigPaths -PrimaryConfigPath `$ConfigPath + `$config = Get-DeploymentConfig -Path `$ConfigPath + `$stateRoot = [string]`$config.paths.stateRoot + `$sessionCollectorScript = if (`$config.paths.PSObject.Properties.Name -contains 'sessionCollectorScript') { [string]`$config.paths.sessionCollectorScript } else { Join-Path `$stateRoot 'worktime-session-collector.ps1' } + Start-CollectorScriptGlobalIfNeeded -ScriptPath `$sessionCollectorScript -ConfigPath `$ConfigPath foreach (`$taskName in Get-RecoveryTaskNames -ConfigPaths `$configPaths) { Start-TaskIfNotRunning -TaskName `$taskName } - `$config = Get-DeploymentConfig -Path `$ConfigPath if (`$config -and `$config.recovery -and `$config.recovery.intervalSeconds) { `$sleepSeconds = [Math]::Max([int]`$config.recovery.intervalSeconds, 30) } diff --git a/windows/rebuild-worktime-tasks.ps1 b/windows/rebuild-worktime-tasks.ps1 new file mode 100644 index 0000000..dbdc6aa --- /dev/null +++ b/windows/rebuild-worktime-tasks.ps1 @@ -0,0 +1,66 @@ +[CmdletBinding()] +param( + [string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json', + [string]$ModulePath = 'C:\Program Files\AWatch-rus\windows\ActivityWatch.Windows.Common.psm1' +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +Import-Module $ModulePath -Force + +$config = Read-ActivityWatchDeploymentConfig -Path $ConfigPath +$launchScript = [string]$config.paths.launchScript +$recoveryScript = [string]$config.paths.recoveryScript +$sessionCollectorScript = if ($config.paths.PSObject.Properties.Name -contains 'sessionCollectorScript') { + [string]$config.paths.sessionCollectorScript +} else { + Join-Path ([string]$config.paths.stateRoot) 'worktime-session-collector.ps1' +} +$taskDefinitions = @($config.userTasks) +$recoveryTaskName = [string]$config.recovery.taskName + +$collectorProcs = @( + Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | + Where-Object { + ($_.Name -ieq 'powershell.exe' -or $_.Name -ieq 'pwsh.exe') -and + $_.CommandLine -and + $_.CommandLine -match [Regex]::Escape($sessionCollectorScript) + } +) + +foreach ($proc in $collectorProcs) { + try { + Stop-Process -Id ([int]$proc.ProcessId) -Force -ErrorAction Stop + } + catch { + } +} + +Start-Sleep -Seconds 2 + +Write-ActivityWatchLaunchScript -Path $launchScript -ConfigPath $ConfigPath +Write-ActivityWatchRecoveryScript -Path $recoveryScript -ConfigPath $ConfigPath +Register-ActivityWatchUserTasks -TaskDefinitions $taskDefinitions -LaunchScriptPath $launchScript -ConfigPath $ConfigPath +Register-ActivityWatchRecoveryTask -TaskName $recoveryTaskName -RecoveryScriptPath $recoveryScript -ConfigPath $ConfigPath +Start-ActivityWatchTasks -TaskDefinitions $taskDefinitions -RecoveryTaskName $recoveryTaskName + +Start-Sleep -Seconds 3 + +$running = @( + Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | + Where-Object { + ($_.Name -ieq 'powershell.exe' -or $_.Name -ieq 'pwsh.exe') -and + $_.CommandLine -and + $_.CommandLine -match [Regex]::Escape($sessionCollectorScript) + } | + Select-Object Name, ProcessId, SessionId, CommandLine +) + +[pscustomobject]@{ + rebuiltAtUtc = (Get-Date).ToUniversalTime().ToString('o') + launchScript = $launchScript + recoveryScript = $recoveryScript + sessionCollectorScript = $sessionCollectorScript + runningSessionCollectors = @($running) +} diff --git a/windows/worktime-session-collector.ps1 b/windows/worktime-session-collector.ps1 index b7e654b..7db2292 100644 --- a/windows/worktime-session-collector.ps1 +++ b/windows/worktime-session-collector.ps1 @@ -158,20 +158,32 @@ function Parse-SessionLines { 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].Trim() + $line = ($Lines[$i] -replace '^\s*>', '').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 + $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 { - $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] } + continue } + if ($id -lt 0) { continue } + $records += [pscustomobject]@{ username=$user; sessionName=$sess; sessionId=$id; state=$state } } return $records