fix(worktime): isolate global session collector and reset RDP bridge flow

This commit is contained in:
igor04091968
2026-05-13 08:18:47 +03:00
parent b69f4d83cc
commit f6ee903b10
8 changed files with 163 additions and 29 deletions
+22 -13
View File
@@ -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
+2 -2
View File
@@ -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"
+2 -1
View File
@@ -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
+2 -1
View File
@@ -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 []
+7 -2
View File
@@ -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 = {}
+42 -2
View File
@@ -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)
}
+66
View File
@@ -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)
}
+20 -8
View File
@@ -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*(?<user>\S+)\s+(?<sess>\S+)?\s+(?<id>\d+)\s+(?<state>\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