From cc9e4a0669ebe9c2cfc3df60173c6e524e79530c Mon Sep 17 00:00:00 2001 From: igor04091968 Date: Thu, 28 May 2026 10:54:46 +0300 Subject: [PATCH] fix(aw): harden collectors and grafana exports --- ansible/deploy_aw_server.yml | 111 +++++++- ansible/deploy_aw_windows.yml | 6 + ansible/deploy_tsj_guardian_bot_proxmox.yml | 26 +- ansible/group_vars/all.yml | 7 +- ansible/group_vars/aw_server.yml | 6 + ansible/group_vars/aw_windows.yml | 2 + ansible/group_vars/proxmox-bot.example.yml | 5 +- ansible/group_vars/windows.example.yml | 5 + aw-server/aw-prune-local-state.sh | 42 +++ aw-server/aw-ru-patch.js | 35 ++- aw-server/aw-worktime-api.py | 120 ++++++--- aw-server/aw-worktime-autoheal.sh | 3 +- aw-server/aw-worktime-panel.js | 3 +- aw-server/aw-worktime-ui-bridge.py | 143 ++++++++-- aw-server/aw-worktime-ui-bridge.service | 2 +- aw-server/settings/views-default.json | 5 - aw-server/test_aw_worktime_api.py | 79 +++++- aw-server/test_aw_worktime_ui_bridge.py | 142 ++++++++++ docs/DETMIR_UNIFIED_OPERATING_MODEL_RU.md | 71 +++++ docs/windows-deploy-startup-model.md | 3 + proxmox/tsj_guardian_bot.py | 20 +- windows/ActivityWatch.Windows.Common.psm1 | 153 +++++++++-- windows/deploy-domain-users.ps1 | 2 + windows/deploy-ensemble.ps1 | 3 + windows/deploy-single-user.ps1 | 2 + windows/export-upload-file-1c-telemetry.ps1 | 19 +- windows/hardening-recovery.ps1 | 3 + windows/validate-deployment.ps1 | 15 ++ windows/worktime-session-collector.ps1 | 279 +++++++++++++++++++- 29 files changed, 1181 insertions(+), 131 deletions(-) create mode 100644 aw-server/aw-prune-local-state.sh diff --git a/ansible/deploy_aw_server.yml b/ansible/deploy_aw_server.yml index 5aae10d..371dac5 100644 --- a/ansible/deploy_aw_server.yml +++ b/ansible/deploy_aw_server.yml @@ -94,6 +94,89 @@ - "{{ aw_rus_health_validation_dir }}" - "{{ aw_server_log_dir }}" + - name: Установить prune script для локального state + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/aw-prune-local-state.sh" + dest: /usr/local/bin/aw-prune-local-state.sh + owner: root + group: root + mode: "0755" + + - name: Ограничить рост journald на aw-server + ansible.builtin.copy: + dest: /etc/systemd/journald.conf.d/aw-rus-retention.conf + owner: root + group: root + mode: "0644" + content: | + [Journal] + SystemMaxUse={{ aw_server_journal_system_max_use }} + RuntimeMaxUse={{ aw_server_journal_runtime_max_use }} + SystemKeepFree={{ aw_server_journal_system_keep_free }} + register: aw_journald_dropin + + - name: Установить systemd service prune локального state + ansible.builtin.copy: + dest: /etc/systemd/system/aw-prune-local-state.service + owner: root + group: root + mode: "0644" + content: | + [Unit] + Description=Prune ActivityWatch local backups and temp state + + [Service] + Type=oneshot + Environment=AW_DATA_DIR={{ aw_server_data_dir }} + Environment=AW_BACKUP_RETENTION_DAYS={{ aw_server_backup_retention_days }} + Environment=AW_BACKUP_KEEP_LAST_DB={{ aw_server_backup_keep_last_db }} + Environment=AW_BACKUP_KEEP_LAST_JSON={{ aw_server_backup_keep_last_json }} + ExecStart=/usr/local/bin/aw-prune-local-state.sh + + - name: Установить systemd timer prune локального state + ansible.builtin.copy: + dest: /etc/systemd/system/aw-prune-local-state.timer + owner: root + group: root + mode: "0644" + content: | + [Unit] + Description=Daily prune of ActivityWatch local backups and temp state + + [Timer] + OnCalendar=*-*-* 04:40:00 + Persistent=true + + [Install] + WantedBy=timers.target + + - name: Перечитать systemd после retention unit/drop-in + ansible.builtin.systemd: + daemon_reload: true + + - name: Включить и запустить timer prune локального state + ansible.builtin.systemd: + name: aw-prune-local-state.timer + enabled: true + state: started + + - name: Применить journald retention без простоя + ansible.builtin.command: + argv: + - systemctl + - restart + - systemd-journald + when: aw_journald_dropin.changed + failed_when: false + + - name: Сжать существующий journald до нового лимита + ansible.builtin.command: + argv: + - journalctl + - --vacuum-size={{ aw_server_journal_system_max_use }} + changed_when: true + failed_when: false + - name: (Check mode) Пропустить установку релиза ActivityWatch ansible.builtin.debug: msg: "ansible_check_mode=true: download/unarchive/install of ActivityWatch release is skipped." @@ -115,6 +198,11 @@ remote_src: true extra_opts: ["-o"] + - name: Удалить временный архив ActivityWatch после распаковки + ansible.builtin.file: + path: "{{ aw_archive_path }}" + state: absent + - name: Найти распакованный каталог ActivityWatch ansible.builtin.find: paths: "{{ aw_release_dir }}" @@ -288,6 +376,22 @@ dest: /opt/activitywatch/aw-server/apply_webui_ru_patch.sh mode: "0755" + - name: Проверить Influx token для AW worktime exporter + ansible.builtin.assert: + that: + - aw_worktime_influx_token is defined + - aw_worktime_influx_token | length > 0 + fail_msg: "aw_worktime_influx_enabled=true, но aw_worktime_influx_token пуст. Exporter будет падать и Grafana не получит worktime-ряды." + when: aw_worktime_influx_enabled | default(false) | bool + + - name: Проверить Influx token для AW DLP exporter + ansible.builtin.assert: + that: + - aw_dlp_influx_token is defined + - aw_dlp_influx_token | length > 0 + fail_msg: "aw_dlp_influx_enabled=true, но aw_dlp_influx_token пуст. Exporter будет падать и Grafana не получит DLP-ряды." + when: aw_dlp_influx_enabled | default(false) | bool + - name: Записать /etc/activitywatch/aw-server.env перед хотфиксами ansible.builtin.copy: dest: /etc/activitywatch/aw-server.env @@ -922,7 +1026,6 @@ ansible.builtin.systemd: name: aw-worktime-influx-exporter.service state: started - failed_when: false when: aw_worktime_influx_enabled | default(false) | bool - name: Включить и перезапустить AW DLP Influx exporter timer @@ -936,7 +1039,6 @@ ansible.builtin.systemd: name: aw-dlp-influx-exporter.service state: started - failed_when: false when: aw_dlp_influx_enabled | default(false) | bool - name: Применить хотфиксы compiled JS чанков (Trends, Timespiral, Category helper) @@ -1514,6 +1616,11 @@ remote_src: true creates: "{{ aw_hayabusa_release_dir }}/{{ aw_hayabusa_binary_name }}" + - name: Удалить временный архив Hayabusa после распаковки + ansible.builtin.file: + path: "{{ aw_hayabusa_archive_path }}" + state: absent + - name: Нормализовать права release Hayabusa ansible.builtin.file: path: "{{ aw_hayabusa_release_dir }}" diff --git a/ansible/deploy_aw_windows.yml b/ansible/deploy_aw_windows.yml index 7e0dcf9..eec5eeb 100644 --- a/ansible/deploy_aw_windows.yml +++ b/ansible/deploy_aw_windows.yml @@ -14,6 +14,7 @@ aw_windows_package_url: "https://github.com/ActivityWatch/activitywatch/releases/download/v0.13.2/activitywatch-v0.13.2-windows-x86_64.zip" aw_windows_package_zip_path: "" aw_windows_domain: "SHARKON2025" + aw_windows_builtin_administrator_name: "Администратор" aw_windows_users: - Администратор - user1 @@ -49,6 +50,7 @@ aw_windows_incident_artifacts_root: "{{ aw_windows_state_root }}\\incident-artifacts" aw_windows_forensics_root: "{{ aw_windows_state_root }}\\forensics\\evtx-exports" aw_windows_logon_marker_enabled: true + aw_windows_process_events_enabled: true aw_windows_skip_hardening: false aw_windows_rules_path: "{{ aw_windows_deploy_root }}\\windows\\web-category-rules.example.json" aw_windows_policy_path: "{{ aw_windows_deploy_root }}\\windows\\dlp-policy.example.json" @@ -137,6 +139,8 @@ - aw_windows_server_port is defined - aw_windows_server_scheme is defined - aw_windows_domain is defined + - aw_windows_builtin_administrator_name is defined + - aw_windows_builtin_administrator_name | length > 0 - aw_windows_users_effective | length > 0 - aw_windows_install_root is defined - aw_windows_state_root is defined @@ -228,6 +232,7 @@ ansible.windows.win_powershell: script: | $ErrorActionPreference = 'Stop' + $env:AWATCH_RUS_BUILTIN_ADMINISTRATOR_NAME = "{{ aw_windows_builtin_administrator_name }}" $params = @{ ServerScheme = "{{ aw_windows_server_scheme }}" ServerHost = "{{ aw_windows_server_host_effective }}" @@ -247,6 +252,7 @@ EvtxExportRoot = "{{ aw_windows_forensics_root }}" EvtxRetentionDays = {{ aw_windows_evtx_retention_days | int }} LogonMarkerEnabled = {{ '$true' if (aw_windows_logon_marker_enabled | bool) else '$false' }} + ProcessEventsEnabled = {{ '$true' if (aw_windows_process_events_enabled | bool) else '$false' }} PolicyMode = "{{ aw_windows_policy_mode }}" PolicyEngineEnabled = {{ '$true' if (aw_windows_policy_engine_enabled | bool) else '$false' }} PolicyEngineHost = "{{ aw_windows_policy_engine_host_effective }}" diff --git a/ansible/deploy_tsj_guardian_bot_proxmox.yml b/ansible/deploy_tsj_guardian_bot_proxmox.yml index 106fec4..aaaedca 100644 --- a/ansible/deploy_tsj_guardian_bot_proxmox.yml +++ b/ansible/deploy_tsj_guardian_bot_proxmox.yml @@ -5,9 +5,10 @@ gather_facts: true vars: - tsj_bot_user: "codex" + tsj_bot_runtime_root: "/opt/infra-admin" + tsj_bot_user: "root" tsj_bot_group: "admin" - tsj_bot_root: "/home/codex/infra-admin/tsj-bot" + tsj_bot_root: "{{ tsj_bot_runtime_root }}/tsj-bot" tsj_bot_script_name: "tsj_guardian_bot.py" tsj_bot_script_dest: "{{ tsj_bot_root }}/{{ tsj_bot_script_name }}" tsj_bot_source_local_path: "{{ aw_repo_root }}/proxmox/tsj_guardian_bot.py" @@ -16,8 +17,8 @@ tsj_bot_openvpn_helper_source_local_path: "{{ aw_repo_root }}/proxmox/{{ tsj_bot_openvpn_helper_name }}" tsj_bot_service_name: "tsj-guardian-bot.service" tsj_bot_env_path: "{{ tsj_bot_root }}/.env" - tsj_bot_state_dir: "/home/codex/infra-admin/.state" - tsj_bot_logs_dir: "/home/codex/infra-admin/logs" + tsj_bot_state_dir: "{{ tsj_bot_runtime_root }}/.state" + tsj_bot_logs_dir: "{{ tsj_bot_runtime_root }}/logs" tsj_bot_default_chat_id: "{{ telegram_default_chat_id | default(telegram_allowed_chat_ids.split(',')[0]) }}" pre_tasks: @@ -130,15 +131,16 @@ NODE_13_URL={{ tsj_bot_node_13_url | default('http://10.10.10.13:5600/api/0/info') }} NODE_16_URL={{ tsj_bot_node_16_url | default('http://10.10.10.16/') }} NODE_16_ENABLED={{ tsj_bot_node_16_enabled | default('false') }} - CHECK_SCRIPT={{ tsj_bot_check_script | default('/home/codex/infra-admin/scripts/system_self_support.sh --check') }} - HEAL_SCRIPT={{ tsj_bot_heal_script | default('/home/codex/infra-admin/scripts/system_self_support.sh --heal') }} + INFRA_ADMIN_ROOT={{ tsj_bot_runtime_root }} + CHECK_SCRIPT={{ tsj_bot_check_script | default(tsj_bot_runtime_root + '/scripts/system_self_support.sh --check') }} + HEAL_SCRIPT={{ tsj_bot_heal_script | default(tsj_bot_runtime_root + '/scripts/system_self_support.sh --heal') }} FS_WARN_PCT={{ tsj_bot_fs_warn_pct | default(85) }} FS_CRIT_PCT={{ tsj_bot_fs_crit_pct | default(92) }} FS_TARGETS={{ tsj_bot_fs_targets | default('host,200,201,202,203') }} FS_EXCLUDE_TYPES={{ tsj_bot_fs_exclude_types | default('tmpfs,devtmpfs,proc,sysfs,cgroup,cgroup2,overlay,squashfs,nsfs,tracefs,debugfs,securityfs,configfs,fusectl,mqueue,hugetlbfs,ramfs') }} - STATE_FILE={{ tsj_bot_state_file | default('/home/codex/infra-admin/.state/tsj_guardian_state.json') }} - LOG_FILE={{ tsj_bot_log_file | default('/home/codex/infra-admin/logs/tsj_guardian_bot.log') }} - HEARTBEAT_FILE={{ tsj_bot_heartbeat_file | default('/home/codex/infra-admin/.state/tsj_guardian_heartbeat') }} + STATE_FILE={{ tsj_bot_state_file | default(tsj_bot_runtime_root + '/.state/tsj_guardian_state.json') }} + LOG_FILE={{ tsj_bot_log_file | default(tsj_bot_runtime_root + '/logs/tsj_guardian_bot.log') }} + HEARTBEAT_FILE={{ tsj_bot_heartbeat_file | default(tsj_bot_runtime_root + '/.state/tsj_guardian_heartbeat') }} CHECK_INTERVAL_SEC={{ tsj_bot_check_interval_sec | default(60) }} OPERATOR_TIMEOUT_SEC={{ tsj_bot_operator_timeout_sec | default(900) }} RETRY_AUTORECOVERY_EVERY_SEC={{ tsj_bot_retry_autorecovery_every_sec | default(300) }} @@ -170,8 +172,8 @@ OPENVPN_EXPIRY_WARN_TIMEOUT_SEC={{ tsj_bot_openvpn_expiry_warn_timeout_sec | default(120) }} OPENVPN_EXPIRY_WARN_INTERVAL_SEC={{ tsj_bot_openvpn_expiry_warn_interval_sec | default(21600) }} PFSENSE_MCP_BEARER={{ tsj_bot_pfsense_mcp_bearer | default(pfsense_mcp_bearer | default('')) }} - SERVER_FALLBACK_COMMANDS={{ tsj_bot_server_fallback_commands | default('/home/codex/infra-admin/scripts/system_self_support.sh --heal') }} - UPDATES_SCRIPT={{ tsj_bot_updates_script | default('/usr/bin/python3 /home/codex/infra-admin/scripts/proxmox_lxc_critical_updates.py') }} + SERVER_FALLBACK_COMMANDS={{ tsj_bot_server_fallback_commands | default(tsj_bot_runtime_root + '/scripts/system_self_support.sh --heal') }} + UPDATES_SCRIPT={{ tsj_bot_updates_script | default('/usr/bin/python3 ' + tsj_bot_runtime_root + '/scripts/proxmox_lxc_critical_updates.py') }} UPDATE_TARGETS={{ tsj_bot_update_targets | default('auto') }} AW_RUS_API_BASE={{ tsj_bot_aw_rus_api_base | default('http://10.10.10.13:5600/api/0') }} AW_RUS_WORKTIME_BASE={{ tsj_bot_aw_rus_worktime_base | default('http://10.10.10.13:5610') }} @@ -257,7 +259,7 @@ [Service] Type=simple User=root - WorkingDirectory=/home/codex/infra-admin + WorkingDirectory={{ tsj_bot_runtime_root }} EnvironmentFile={{ tsj_bot_env_path }} ExecStart=/usr/bin/python3 {{ tsj_bot_script_dest }} Restart=always diff --git a/ansible/group_vars/all.yml b/ansible/group_vars/all.yml index 7aae288..af29ce2 100644 --- a/ansible/group_vars/all.yml +++ b/ansible/group_vars/all.yml @@ -12,21 +12,24 @@ aw_server_inventory_host: "{{ (groups['aw_server'] | default([]) | first) | defa aw_server_public_host: "{{ (hostvars[aw_server_inventory_host].ansible_host | default(aw_server_inventory_host, true)) if (aw_server_inventory_host | length) > 0 else 'aw-server' }}" aw_worktime_report_base: "http://{{ aw_server_public_host }}:5610" aw_worktime_timezone: "Europe/Moscow" -aw_worktime_influx_enabled: false +aw_worktime_influx_enabled: true aw_worktime_influx_url: "http://10.10.10.10:8086" aw_worktime_influx_org: "proxmox" aw_worktime_influx_bucket: "aw_metrics" aw_worktime_influx_hosts: "SHARKON2025" aw_worktime_influx_days: "today,yesterday" -aw_dlp_influx_enabled: false +aw_worktime_influx_token: "{{ lookup('env', 'AW_WORKTIME_INFLUX_TOKEN') }}" +aw_dlp_influx_enabled: true aw_dlp_influx_url: "http://10.10.10.10:8086" aw_dlp_influx_org: "proxmox" aw_dlp_influx_bucket: "aw_metrics" aw_dlp_influx_hosts: "SHARKON2025" aw_dlp_influx_lookback_days: 30 aw_dlp_influx_event_limit: 2000 +aw_dlp_influx_token: "{{ lookup('env', 'AW_DLP_INFLUX_TOKEN') }}" aw_monitored_windows_host: "192.168.100.18" aw_monitored_windows_hostname: "SHARKON2025" +aw_rus_health_worktime_api_base: "http://127.0.0.1:5610" aw_rus_health_state_dir: "{{ aw_server_data_dir }}/health" aw_rus_health_validation_dir: "{{ aw_rus_health_state_dir }}/windows-validation" aw_hayabusa_auto_case_enabled: true diff --git a/ansible/group_vars/aw_server.yml b/ansible/group_vars/aw_server.yml index 6241ea6..be493f5 100644 --- a/ansible/group_vars/aw_server.yml +++ b/ansible/group_vars/aw_server.yml @@ -30,6 +30,12 @@ aw_hayabusa_staging_dir: "{{ aw_hayabusa_inbox_dir }}/staging" aw_hayabusa_archive_packages_dir: "{{ aw_hayabusa_archive_dir }}/packages" aw_hayabusa_archive_extracted_dir: "{{ aw_hayabusa_archive_dir }}/extracted" aw_hayabusa_logs_dir: "{{ aw_hayabusa_state_dir }}/logs" +aw_server_backup_retention_days: 7 +aw_server_backup_keep_last_db: 2 +aw_server_backup_keep_last_json: 2 +aw_server_journal_system_max_use: "100M" +aw_server_journal_runtime_max_use: "50M" +aw_server_journal_system_keep_free: "500M" aw_dlp_policy_engine_enabled: true aw_dlp_policy_engine_bind_host: "0.0.0.0" aw_dlp_policy_engine_port: 5601 diff --git a/ansible/group_vars/aw_windows.yml b/ansible/group_vars/aw_windows.yml index 225790e..8ca319d 100644 --- a/ansible/group_vars/aw_windows.yml +++ b/ansible/group_vars/aw_windows.yml @@ -15,6 +15,7 @@ aw_windows_package_url: "https://github.com/ActivityWatch/activitywatch/releases aw_windows_package_zip_path: "" aw_windows_domain: "SHARKON2025" +aw_windows_builtin_administrator_name: "Администратор" aw_windows_users: - Администратор - user1 @@ -58,6 +59,7 @@ aw_windows_evtx_channels: - Microsoft-Windows-TerminalServices-LocalSessionManager/Operational - Microsoft-Windows-TerminalServices-RemoteConnectionManager/Operational aw_windows_logon_marker_enabled: true +aw_windows_process_events_enabled: true aw_windows_skip_hardening: false aw_windows_rules_path: "{{ aw_windows_deploy_root }}\\windows\\web-category-rules.example.json" diff --git a/ansible/group_vars/proxmox-bot.example.yml b/ansible/group_vars/proxmox-bot.example.yml index 0daec36..5e6f490 100644 --- a/ansible/group_vars/proxmox-bot.example.yml +++ b/ansible/group_vars/proxmox-bot.example.yml @@ -4,6 +4,7 @@ telegram_default_chat_id: 123456789 # Path on controller (this machine) to source bot script for deployment. tsj_bot_source_local_path: "/mnt/usb_hdd2/Projects/ActivityWatch-Russian/proxmox/tsj_guardian_bot.py" +tsj_bot_runtime_root: "/opt/infra-admin" # Optional bot tuning tsj_bot_check_interval_sec: 60 @@ -37,8 +38,8 @@ tsj_bot_openvpn_expiry_warn_enabled: "false" tsj_bot_openvpn_expiry_warn_days: 30 tsj_bot_openvpn_expiry_warn_timeout_sec: 120 tsj_bot_openvpn_expiry_warn_interval_sec: 21600 -tsj_bot_server_fallback_commands: "/home/codex/infra-admin/scripts/system_self_support.sh --heal" -tsj_bot_updates_script: "/usr/bin/python3 /home/codex/infra-admin/scripts/proxmox_lxc_critical_updates.py" +tsj_bot_server_fallback_commands: "/opt/infra-admin/scripts/system_self_support.sh --heal" +tsj_bot_updates_script: "/usr/bin/python3 /opt/infra-admin/scripts/proxmox_lxc_critical_updates.py" tsj_bot_update_targets: "auto" tsj_bot_pfsense_mcp_bearer: "CHANGE_ME" diff --git a/ansible/group_vars/windows.example.yml b/ansible/group_vars/windows.example.yml index ea80fe6..ae3eee2 100644 --- a/ansible/group_vars/windows.example.yml +++ b/ansible/group_vars/windows.example.yml @@ -13,6 +13,10 @@ aw_windows_package_version: "v0.13.2" aw_windows_package_url: "https://github.com/ActivityWatch/activitywatch/releases/download/v0.13.2/activitywatch-v0.13.2-windows-x86_64.zip" aw_windows_package_zip_path: "" aw_windows_domain: "SHARKON2025" +# Localized name of the built-in local Administrator account (SID ending in -500). +# On the current Russian Windows host this must stay "Администратор"; +# do not replace it with "Administrator" unless the target OS account is actually named that way. +aw_windows_builtin_administrator_name: "Администратор" aw_windows_users: - Администратор - user1 @@ -46,6 +50,7 @@ aw_windows_evtx_channels: - Microsoft-Windows-TerminalServices-LocalSessionManager/Operational - Microsoft-Windows-TerminalServices-RemoteConnectionManager/Operational aw_windows_logon_marker_enabled: true +aw_windows_process_events_enabled: true aw_windows_skip_hardening: false aw_windows_rules_path: "{{ aw_windows_deploy_root }}\\windows\\web-category-rules.example.json" diff --git a/aw-server/aw-prune-local-state.sh b/aw-server/aw-prune-local-state.sh new file mode 100644 index 0000000..c2c6afd --- /dev/null +++ b/aw-server/aw-prune-local-state.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +set -euo pipefail + +AW_DATA_DIR="${AW_DATA_DIR:-/var/lib/activitywatch}" +BACKUP_DIR="${AW_BACKUP_DIR:-${AW_DATA_DIR}/backups}" +KEEP_DAYS="${AW_BACKUP_RETENTION_DAYS:-7}" +KEEP_LAST_DB="${AW_BACKUP_KEEP_LAST_DB:-2}" +KEEP_LAST_JSON="${AW_BACKUP_KEEP_LAST_JSON:-2}" + +prune_group() { + local keep_last="$1" + local keep_days="$2" + shift 2 + local files=() + local idx=0 + local cutoff + cutoff="$(date -d "-${keep_days} days" +%s)" + mapfile -t files < <(find "$@" -maxdepth 1 -type f -printf '%T@ %p\n' 2>/dev/null | sort -nr | awk '{ $1=""; sub(/^ /,""); print }') + for path in "${files[@]}"; do + idx=$((idx + 1)) + if [ "$idx" -le "$keep_last" ]; then + continue + fi + [ -f "$path" ] || continue + if [ "$(stat -c %Y "$path")" -lt "$cutoff" ]; then + rm -f -- "$path" + fi + done +} + +mkdir -p "$BACKUP_DIR" + +prune_group "$KEEP_LAST_DB" "$KEEP_DAYS" "${BACKUP_DIR}/db" +prune_group "$KEEP_LAST_JSON" "$KEEP_DAYS" "$BACKUP_DIR" + +find /tmp -maxdepth 1 -type f \ + \( -name 'activitywatch-*.zip' -o -name 'hayabusa-*.zip' -o -name 'aw-hayabusa-profiles.txt' \) \ + -mtime +0 -delete 2>/dev/null || true + +find /tmp -maxdepth 1 -type f \ + \( -name 'aw-worktime-ui-bridge.py' -o -name 'views-default.json' -o -name 'apply_webui_ru_patch.out' \) \ + -mtime +1 -delete 2>/dev/null || true diff --git a/aw-server/aw-ru-patch.js b/aw-server/aw-ru-patch.js index 67c21f6..6b546fd 100755 --- a/aw-server/aw-ru-patch.js +++ b/aw-server/aw-ru-patch.js @@ -24,6 +24,7 @@ ["Tools", "Инструменты"], ["Raw Data", "Сырые данные"], ["Summary", "Сводка"], + ["Worktime", "Рабочее время"], ["All", "Все"], ["None", "Нет"], ["Date", "Дата"], @@ -236,6 +237,7 @@ ['Common words in "Uncategorized" events', 'Частые слова в событиях "Без категории"'], ["No words with significant duration. You're good to go!", "Нет слов со значимой длительностью. Здесь всё в порядке."], ["Top apps", "Топ приложений"], + ["Top Applications", "Топ приложений"], ["Top titles", "Топ заголовков"], ["Top URLs", "Топ URL"], ["Top domains", "Топ доменов"], @@ -596,8 +598,8 @@ }, ""); center.innerHTML = - '

RDP summary

' + - '

Этот блок строится из server-side worktime отчёта и не зависит от того, жива ли локальная интерактивная RDP-сессия.

' + + '

RDP сводка

' + + '

Этот блок строится из bucket aw-worktime-sessions через AW API и показывает сводку по RDP-сессиям выбранного хоста.

' + '
' + '
Активное время
' + escapeHtml(formatDurationSeconds(totalActiveSeconds)) + '
' + '
Активных пользователей
' + escapeHtml(String(activeUsers.length)) + '
' + @@ -844,9 +846,15 @@ } function removeBadDlpLinks(root) { - const badLinks = root.querySelectorAll("a[href*='/view/DLP']"); - badLinks.forEach(function (link) { + const links = Array.from(root.querySelectorAll("a[href], [role='link']")); + links.forEach(function (link) { + const href = String(link.getAttribute("href") || ""); + const label = normalizeText(link.textContent || ""); + const isBrokenActivityDlpLink = /\/view\/dlp(?:[/?#]|$)/i.test(href); + const isActivityTabDlpLabel = label === "DLP" && !!link.closest("li"); + if (!isBrokenActivityDlpLink && !isActivityTabDlpLabel) return; const item = link.closest("li") || link; + if (item && item.getAttribute && item.getAttribute("data-aw-ru-dlp-item") === "1") return; item.remove(); }); } @@ -2113,6 +2121,16 @@ }); } + function applyTextAndNavigationPatches(root) { + if (!root) return; + walk(root); + translateAttributes(root); + hideNoiseNavigation(root); + hidePveAuditTabForRegularHost(root); + patchActivityHeading(root); + patchCategoryBuilderHostLabel(root); + } + function detachObserver() { if (!observerAttached) return; observer.disconnect(); @@ -2147,6 +2165,7 @@ if (existing && existing.parentElement) existing.parentElement.removeChild(existing); } } + applyTextAndNavigationPatches(document.body); staticPatchRouteKey = routeKey; return; } @@ -2154,13 +2173,8 @@ ensureSettingsHost(); ensureHostGroupsData().catch(function () {}); normalizeCategoryBuilderUnknownHostRefs(); + applyTextAndNavigationPatches(document.body); if (routeChanged) { - walk(document.body); - translateAttributes(document.body); - hideNoiseNavigation(document.body); - hidePveAuditTabForRegularHost(document.body); - patchActivityHeading(document.body); - patchCategoryBuilderHostLabel(document.body); staticPatchRouteKey = routeKey; } injectPveAuditCenter(document.body); @@ -2187,7 +2201,6 @@ const observer = new MutationObserver(function () { if (applyPatchInFlight) return; - if (isDlpSignalBucketRoute() && document.body && document.body.querySelector("[data-aw-ru-dlp-center='1']")) return; scheduleApplyPatch(); }); diff --git a/aw-server/aw-worktime-api.py b/aw-server/aw-worktime-api.py index 380137b..19247e4 100644 --- a/aw-server/aw-worktime-api.py +++ b/aw-server/aw-worktime-api.py @@ -7,6 +7,7 @@ import json import os import sys import tempfile +import threading import urllib.request from datetime import datetime, timezone, timedelta from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer @@ -46,8 +47,14 @@ MANAGER_CACHE_TTL_SECONDS = max(0, int(os.environ.get("AW_WORKTIME_MANAGER_CACHE MANAGER_CACHE_DIR = Path(os.environ.get("AW_WORKTIME_MANAGER_CACHE_DIR", "/var/lib/activitywatch/worktime-cache")) MANAGER_ALIASES_JSON = Path(os.environ.get("AW_WORKTIME_MANAGER_ALIASES_JSON", "/etc/activitywatch/worktime-manager-aliases.json")) MANAGER_EXCLUDE_USERS = {item.strip().lower() for item in os.environ.get("AW_WORKTIME_MANAGER_EXCLUDE_USERS", "").split(",") if item.strip()} +EVENTS_CACHE_TTL_SECONDS = max(0, int(os.environ.get("AW_WORKTIME_EVENTS_CACHE_TTL_SECONDS", "30"))) +WORKTIME_EVENTS_LIMIT = max(1000, int(os.environ.get("AW_WORKTIME_EVENTS_LIMIT", "50000"))) MODULE_PATH = Path(__file__).resolve() _ALIASES_CACHE = {"mtime": None, "users": {}, "owners": {}, "raw": {}} +_EVENTS_CACHE_LOCK = threading.Lock() +_EVENTS_CACHE = {} +_MANAGEMENT_BUILD_LOCKS_LOCK = threading.Lock() +_MANAGEMENT_BUILD_LOCKS = {} def get(u): @@ -274,6 +281,16 @@ def get_sessions_bucket_id(host): return f"aw-worktime-sessions_{resolve_host(host)}" +def get_management_build_lock(host, report_date): + key = (resolve_host(host), report_date.isoformat()) + with _MANAGEMENT_BUILD_LOCKS_LOCK: + lock = _MANAGEMENT_BUILD_LOCKS.get(key) + if lock is None: + lock = threading.Lock() + _MANAGEMENT_BUILD_LOCKS[key] = lock + return lock + + def resolve_report_date(day=None, date_text=None): now_local = datetime.now(REPORT_TZ) if date_text: @@ -512,17 +529,31 @@ def aggregate_hourly_rows(events, start, end, host): def fetch_events_for_date(host, report_date): bounds = get_report_bounds(report_date) bucket_id = get_sessions_bucket_id(host) + events = fetch_bucket_events(bucket_id, host) + return bounds, events + + +def fetch_bucket_events(bucket_id, host): + now = now_utc() + if EVENTS_CACHE_TTL_SECONDS > 0: + with _EVENTS_CACHE_LOCK: + cached = _EVENTS_CACHE.get(bucket_id) + if cached is not None and age_seconds(cached["stored_at"], now=now) <= EVENTS_CACHE_TTL_SECONDS: + return cached["events"] try: get(f"{AW}/buckets/{bucket_id}") except Exception: log_warning(f"bucket lookup failed for host={host} bucket={bucket_id} aw_base={AW}") - return bounds, [] + return [] try: - events = get(f"{AW}/buckets/{bucket_id}/events?limit=50000") + events = get(f"{AW}/buckets/{bucket_id}/events?limit={WORKTIME_EVENTS_LIMIT}") except Exception: log_warning(f"events fetch failed for host={host} bucket={bucket_id} aw_base={AW}") - return bounds, [] - return bounds, events + return [] + if EVENTS_CACHE_TTL_SECONDS > 0: + with _EVENTS_CACHE_LOCK: + _EVENTS_CACHE[bucket_id] = {"stored_at": now, "events": events} + return events def build_report_summary(rows): @@ -971,9 +1002,10 @@ def load_management_cache(host, report_date): path = management_cache_path(host, report_date) if not path.exists(): return None - age = age_seconds(datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc)) - if age is None or age > MANAGER_CACHE_TTL_SECONDS: - return None + if report_date >= datetime.now(REPORT_TZ).date(): + age = age_seconds(datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc)) + if age is None or age > MANAGER_CACHE_TTL_SECONDS: + return None try: return json.loads(path.read_text(encoding="utf-8")) except Exception: @@ -1360,27 +1392,42 @@ def _build_management_core(rows, host, report_date, owner_filter="", department_ } -def build_management_trend(host, anchor_date, owner_filter="", department_filter=""): +def _management_trend_item(payload, report_date): + summary = payload["summary"] + return { + "report_date": report_date.isoformat(), + "users_count": summary["users_count"], + "active_users": summary["active_users"], + "inactive_users": summary["inactive_users"], + "workday_total_active_seconds": summary["workday_total_active_seconds"], + "workday_total_active_hhmm": summary["workday_total_active_hhmm"], + "portfolio_coverage_pct": summary["portfolio_coverage_pct"], + "actions_count": summary["actions_count"], + "critical_actions_count": summary["critical_actions_count"], + } + + +def build_management_trend(host, anchor_date, owner_filter="", department_filter="", precomputed_payloads=None): trend = [] + precomputed_payloads = precomputed_payloads or {} for offset in range(MANAGER_TREND_DAYS - 1, -1, -1): current_date = anchor_date - timedelta(days=offset) - bounds, events = fetch_events_for_date(host, current_date) - rows = aggregate_rows_with_intervals(events, bounds["start"], bounds["end"], host) - payload = _build_management_core(rows, host, current_date, owner_filter=owner_filter, department_filter=department_filter) - summary = payload["summary"] - trend.append( - { - "report_date": current_date.isoformat(), - "users_count": summary["users_count"], - "active_users": summary["active_users"], - "inactive_users": summary["inactive_users"], - "workday_total_active_seconds": summary["workday_total_active_seconds"], - "workday_total_active_hhmm": summary["workday_total_active_hhmm"], - "portfolio_coverage_pct": summary["portfolio_coverage_pct"], - "actions_count": summary["actions_count"], - "critical_actions_count": summary["critical_actions_count"], - } - ) + payload = precomputed_payloads.get(current_date) + if payload is None: + payload = load_management_cache(host, current_date) + if payload is None: + bounds, events = fetch_events_for_date(host, current_date) + rows = aggregate_rows_with_intervals(events, bounds["start"], bounds["end"], host) + payload = _build_management_core(rows, host, current_date, owner_filter=owner_filter, department_filter=department_filter) + elif owner_filter or department_filter: + payload = apply_management_filters_to_payload( + payload, + owner_filter=owner_filter, + department_filter=department_filter, + include_sources=False, + include_source_actions=False, + ) + trend.append(_management_trend_item(payload, current_date)) return trend @@ -1423,7 +1470,13 @@ def build_management_payload(rows, host, report_date, owner_filter="", departmen payload = _build_management_core(rows, host, report_date, owner_filter=owner_filter, department_filter=department_filter) source_freshness, source_actions = build_source_freshness(resolve_host(host)) payload["sources"] = source_freshness - payload["trend"] = build_management_trend(resolve_host(host), report_date, owner_filter=owner_filter, department_filter=department_filter) + payload["trend"] = build_management_trend( + resolve_host(host), + report_date, + owner_filter=owner_filter, + department_filter=department_filter, + precomputed_payloads={report_date: payload}, + ) payload["trend_scope"] = "portfolio" if source_actions: payload["actions"].extend(filter_management_actions(source_actions, payload["rows"], owner_filter=owner_filter, department_filter=department_filter)) @@ -1461,11 +1514,16 @@ def management_report_for_date(host, report_date, owner_filter="", department_fi cached = load_management_cache(host, report_date) if cached is not None: return cached - bounds, events = fetch_events_for_date(host, report_date) - rows = aggregate_rows_with_intervals(events, bounds["start"], bounds["end"], host) - payload = build_management_payload(rows, host, report_date) - save_management_cache(host, report_date, payload) - return payload + lock = get_management_build_lock(host, report_date) + with lock: + cached = load_management_cache(host, report_date) + if cached is not None: + return cached + bounds, events = fetch_events_for_date(host, report_date) + rows = aggregate_rows_with_intervals(events, bounds["start"], bounds["end"], host) + payload = build_management_payload(rows, host, report_date) + save_management_cache(host, report_date, payload) + return payload def report_today(host): diff --git a/aw-server/aw-worktime-autoheal.sh b/aw-server/aw-worktime-autoheal.sh index 12270b3..d027072 100644 --- a/aw-server/aw-worktime-autoheal.sh +++ b/aw-server/aw-worktime-autoheal.sh @@ -8,7 +8,7 @@ WORKTIME_HEALTH_URL="${WORKTIME_HEALTH_URL:-http://127.0.0.1:5610/health}" WORKTIME_REPORT_TIMEOUT_SECONDS="${WORKTIME_REPORT_TIMEOUT_SECONDS:-20}" WORKTIME_MANAGEMENT_WARM_ENABLED="${WORKTIME_MANAGEMENT_WARM_ENABLED:-1}" WORKTIME_MANAGEMENT_WARM_URL="${WORKTIME_MANAGEMENT_WARM_URL:-http://127.0.0.1:5610/reports/worktime/management?day=today&format=json}" -WORKTIME_MANAGEMENT_WARM_TIMEOUT_SECONDS="${WORKTIME_MANAGEMENT_WARM_TIMEOUT_SECONDS:-25}" +WORKTIME_MANAGEMENT_WARM_TIMEOUT_SECONDS="${WORKTIME_MANAGEMENT_WARM_TIMEOUT_SECONDS:-60}" WORKTIME_TODAY_PROBE_ENABLED="${WORKTIME_TODAY_PROBE_ENABLED:-1}" WORKTIME_TODAY_PROBE_URL="${WORKTIME_TODAY_PROBE_URL:-http://127.0.0.1:5610/reports/worktime/today?day=today&format=json}" WORKTIME_TODAY_PROBE_TIMEOUT_SECONDS="${WORKTIME_TODAY_PROBE_TIMEOUT_SECONDS:-20}" @@ -28,7 +28,6 @@ probe_url() { probe_reports() { probe_url "$WORKTIME_HEALTH_URL" "$WORKTIME_REPORT_TIMEOUT_SECONDS" || return 1 - probe_url "$WORKTIME_MANAGEMENT_WARM_URL" "$WORKTIME_MANAGEMENT_WARM_TIMEOUT_SECONDS" || return 1 if [[ "$WORKTIME_TODAY_PROBE_ENABLED" == "1" ]]; then probe_url "$WORKTIME_TODAY_PROBE_URL" "$WORKTIME_TODAY_PROBE_TIMEOUT_SECONDS" || return 1 fi diff --git a/aw-server/aw-worktime-panel.js b/aw-server/aw-worktime-panel.js index 47af0e8..0621520 100644 --- a/aw-server/aw-worktime-panel.js +++ b/aw-server/aw-worktime-panel.js @@ -1,8 +1,7 @@ (function () { var reportBase = "__AW_WORKTIME_REPORT_BASE__"; function defaultDayQuery() { - var now = new Date(); - return now.getHours() < 6 ? "day=yesterday" : "day=today"; + return "day=today"; } var dayQuery = defaultDayQuery(); diff --git a/aw-server/aw-worktime-ui-bridge.py b/aw-server/aw-worktime-ui-bridge.py index 452e0e2..5621fb5 100644 --- a/aw-server/aw-worktime-ui-bridge.py +++ b/aw-server/aw-worktime-ui-bridge.py @@ -12,7 +12,7 @@ STATE_PATH = os.environ.get( "AW_WORKTIME_UI_BRIDGE_STATE", "/var/lib/activitywatch/aw-worktime-ui-bridge-state.json", ) -TIMEOUT = float(os.environ.get("AW_WORKTIME_UI_BRIDGE_TIMEOUT", "20")) +TIMEOUT = float(os.environ.get("AW_WORKTIME_UI_BRIDGE_TIMEOUT", "60")) WATCHER_FALLBACK_ENABLED = os.environ.get("AW_WORKTIME_UI_BRIDGE_WATCHER_FALLBACK", "1").strip().lower() not in { "0", "false", @@ -29,6 +29,8 @@ WATCHER_AFK_BUCKET = f"aw-watcher-afk_{HOST}" WATCHER_WINDOW_BUCKET = f"aw-watcher-window_{HOST}" WEB_CATEGORY_BUCKET = f"aw-detmir-web-category_{HOST}" COLLECTOR_HEALTH_MAX_AGE_SECONDS = float(os.environ.get("AW_WORKTIME_UI_BRIDGE_COLLECTOR_HEALTH_MAX_AGE_SECONDS", "300")) +COLLECTOR_HEALTH_QUERY_LIMIT = int(os.environ.get("AW_WORKTIME_UI_BRIDGE_COLLECTOR_HEALTH_QUERY_LIMIT", "200")) +FOREGROUND_CONTEXT_CACHE_SECONDS = float(os.environ.get("AW_WORKTIME_UI_BRIDGE_FOREGROUND_CACHE_SECONDS", "900")) def _req(method: str, path: str, payload=None): @@ -100,6 +102,8 @@ def watcher_window_needs_bridge_sync(now_utc: datetime): source = str(data.get("source", "")).strip().lower() app = str(data.get("app", "")).strip() title = str(data.get("title", "")).strip() + if source == "aw-worktime-ui-bridge": + return True if source != "aw-worktime-ui-bridge": return False if app.upper() == "RDP": @@ -148,22 +152,54 @@ def build_window_title(users, active_count): return f"RDP active ({active_count}): " + ", ".join(users) -def get_latest_foreground_context(now_utc: datetime): +def get_latest_active_session_ids(events): + grouped = {} + for event in events: + ts = event.get("timestamp") + if not ts: + continue + grouped.setdefault(ts, []).append(event) + if not grouped: + return set() + latest_ts = max(grouped.keys(), key=lambda item: parse_iso_utc(item)) + active_session_ids = set() + for event in grouped.get(latest_ts, []): + data = event.get("data") or {} + if not _is_session_active(data): + continue + try: + active_session_ids.add(int(data.get("sessionId"))) + except Exception: + continue + return active_session_ids + + +def _normalize_foreground_context(data): + foreground_process = str(data.get("foregroundProcess", "")).strip() + foreground_title = str(data.get("foregroundTitle", "")).strip() + if not foreground_process and not foreground_title: + return None + return { + "app": foreground_process if foreground_process.endswith(".exe") else f"{foreground_process}.exe", + "title": foreground_title or foreground_process, + } + + +def get_latest_foreground_context(now_utc: datetime, active_session_ids=None, state=None): try: - events = _req("GET", f"/api/0/buckets/{WEB_CATEGORY_BUCKET}/events?limit=20") or [] + events = _req("GET", f"/api/0/buckets/{WEB_CATEGORY_BUCKET}/events?limit={COLLECTOR_HEALTH_QUERY_LIMIT}") or [] except urllib.error.HTTPError as e: if e.code == 404: - return None - raise + events = [] + else: + raise - for event in events: + active_session_ids = set(active_session_ids or []) + recent_candidates = [] + for event in reversed(events): data = event.get("data") or {} if str(data.get("signalType", "")).strip().lower() != "collector_health": continue - foreground_process = str(data.get("foregroundProcess", "")).strip() - foreground_title = str(data.get("foregroundTitle", "")).strip() - if not foreground_process and not foreground_title: - continue ts = event.get("timestamp") if not ts: continue @@ -173,11 +209,43 @@ def get_latest_foreground_context(now_utc: datetime): continue if (now_utc - event_dt).total_seconds() > COLLECTOR_HEALTH_MAX_AGE_SECONDS: continue - return { - "app": foreground_process if foreground_process.endswith(".exe") else f"{foreground_process}.exe", - "title": foreground_title or foreground_process, - } + normalized = _normalize_foreground_context(data) + if not normalized: + continue + session_id = data.get("sessionId") + try: + session_id = int(session_id) + except Exception: + session_id = None + recent_candidates.append((session_id, event_dt, normalized)) + for session_id, event_dt, normalized in recent_candidates: + if active_session_ids and session_id in active_session_ids: + normalized["timestamp"] = to_iso_utc(event_dt.isoformat()) + return normalized + + if recent_candidates: + session_id, event_dt, normalized = recent_candidates[0] + normalized["timestamp"] = to_iso_utc(event_dt.isoformat()) + return normalized + + cached = (state or {}).get("last_foreground_context") + if isinstance(cached, dict): + cached_ts = str(cached.get("timestamp", "")).strip() + if cached_ts: + try: + cached_dt = parse_iso_utc(cached_ts) + except Exception: + cached_dt = None + if cached_dt and (now_utc - cached_dt).total_seconds() <= FOREGROUND_CONTEXT_CACHE_SECONDS: + app = str(cached.get("app", "")).strip() + title = str(cached.get("title", "")).strip() + if app or title: + return { + "app": app or "RDP", + "title": title or app or "RDP", + "timestamp": cached_ts, + } return None @@ -229,13 +297,19 @@ def transform(events, foreground_context=None): rows = grouped[ts] src_duration = max(float(r.get("duration", 0.0)) for r in rows) duration = src_duration + cur_dt = parsed_ts.get(ts) + next_dt = parsed_ts.get(ordered_ts[idx + 1]) if idx + 1 < len(ordered_ts) else None + next_gap = None + if cur_dt and next_dt: + next_gap = max(0.0, (next_dt - cur_dt).total_seconds()) if duration <= 0: - cur_dt = parsed_ts.get(ts) - next_dt = parsed_ts.get(ordered_ts[idx + 1]) if idx + 1 < len(ordered_ts) else None if cur_dt and next_dt: - duration = max(0.0, (next_dt - cur_dt).total_seconds()) + duration = next_gap or 0.0 if duration <= 0: duration = 10.0 + elif next_gap is not None and next_gap > 0: + # Do not let a sampled session interval extend past the next sample. + duration = min(duration, next_gap) duration = min(duration, 30.0) active_users = [] for r in rows: @@ -271,6 +345,22 @@ def transform(events, foreground_context=None): return out_afk, out_win, last_ts +def normalize_watcher_window_events(win_events): + normalized = [] + for event in win_events: + cloned = dict(event) + data = dict(event.get("data") or {}) + app = str(data.get("app") or "").strip() + title = str(data.get("title") or "").strip() + if not app or app.upper() == "RDP": + continue + if app and app.upper() != "RDP" and " | RDP active (" in title: + data["title"] = title.split(" | RDP active (", 1)[0].strip() + cloned["data"] = data + normalized.append(cloned) + return normalized + + def main(): state = load_state() last_ts = state.get("last_ts", "1970-01-01T00:00:00Z") @@ -301,10 +391,12 @@ def main(): if not events: return - foreground_context = get_latest_foreground_context(now_utc) + active_session_ids = get_latest_active_session_ids(events) + foreground_context = get_latest_foreground_context(now_utc, active_session_ids=active_session_ids, state=state) afk_events, win_events, new_last_ts = transform(events, foreground_context=foreground_context) if not afk_events or not win_events or not new_last_ts: return + watcher_win_events = normalize_watcher_window_events(win_events) _req("POST", f"/api/0/buckets/{AFK_BUCKET}/events", afk_events) _req("POST", f"/api/0/buckets/{WINDOW_BUCKET}/events", win_events) @@ -312,10 +404,19 @@ def main(): if bucket_needs_fallback(WATCHER_AFK_BUCKET, now_utc, WATCHER_FALLBACK_STALE_SECONDS): ensure_bucket(WATCHER_AFK_BUCKET, "afkstatus", "aw-watcher-afk") _req("POST", f"/api/0/buckets/{WATCHER_AFK_BUCKET}/events", afk_events) - if watcher_window_needs_bridge_sync(now_utc): + if watcher_win_events and watcher_window_needs_bridge_sync(now_utc): ensure_bucket(WATCHER_WINDOW_BUCKET, "currentwindow", "aw-watcher-window") - _req("POST", f"/api/0/buckets/{WATCHER_WINDOW_BUCKET}/events", win_events) - save_state({"last_ts": new_last_ts}) + _req("POST", f"/api/0/buckets/{WATCHER_WINDOW_BUCKET}/events", watcher_win_events) + next_state = {"last_ts": new_last_ts} + if foreground_context: + next_state["last_foreground_context"] = { + "app": str(foreground_context.get("app") or ""), + "title": str(foreground_context.get("title") or ""), + "timestamp": str(foreground_context.get("timestamp") or to_iso_utc(now_utc.isoformat())), + } + elif isinstance(state.get("last_foreground_context"), dict): + next_state["last_foreground_context"] = state["last_foreground_context"] + save_state(next_state) print(f"posted_afk={len(afk_events)} posted_win={len(win_events)} last_ts={new_last_ts}") diff --git a/aw-server/aw-worktime-ui-bridge.service b/aw-server/aw-worktime-ui-bridge.service index cc20af4..3ca931d 100644 --- a/aw-server/aw-worktime-ui-bridge.service +++ b/aw-server/aw-worktime-ui-bridge.service @@ -2,7 +2,7 @@ Description=AW Worktime UI bridge (sessions -> afk/window) After=network-online.target activitywatch-server.service Wants=network-online.target -StartLimitBurst=3 +StartLimitBurst=20 StartLimitIntervalSec=120 [Service] diff --git a/aw-server/settings/views-default.json b/aw-server/settings/views-default.json index 5421e98..5698738 100644 --- a/aw-server/settings/views-default.json +++ b/aw-server/settings/views-default.json @@ -16,11 +16,6 @@ { "type": "top_apps", "size": 3, "props": {} } ] }, - { - "id": "DLP", - "name": "DLP", - "elements": [] - }, { "id": "worktime", "name": "Worktime", diff --git a/aw-server/test_aw_worktime_api.py b/aw-server/test_aw_worktime_api.py index ac6210e..3f34fc2 100644 --- a/aw-server/test_aw_worktime_api.py +++ b/aw-server/test_aw_worktime_api.py @@ -1,5 +1,7 @@ #!/usr/bin/env python3 import importlib.util +import json +import tempfile from datetime import datetime, timezone from pathlib import Path @@ -225,7 +227,7 @@ def test_build_management_payload_applies_alias_and_executive_summary(): } } MODULE.build_source_freshness = lambda host: ([], []) - MODULE.build_management_trend = lambda host, report_date, owner_filter="", department_filter="": [] + MODULE.build_management_trend = lambda host, report_date, owner_filter="", department_filter="", **kwargs: [] rows = [ { "user": "user1", @@ -342,6 +344,81 @@ def test_build_management_payload_filters_by_owner(): MODULE.build_source_freshness = original_sources +def _minimal_management_payload(report_date, users_count=1): + return { + "summary": { + "users_count": users_count, + "active_users": users_count, + "inactive_users": 0, + "workday_total_active_seconds": 3600 * users_count, + "workday_total_active_hhmm": f"0{users_count}:00", + "portfolio_coverage_pct": 100.0, + "actions_count": 0, + "critical_actions_count": 0, + }, + "rows": [], + "actions": [], + "sources": [], + "filters": {"owner": "", "department": ""}, + } + + +def test_load_management_cache_keeps_historical_reports_after_ttl(): + original_dir = MODULE.MANAGER_CACHE_DIR + original_ttl = MODULE.MANAGER_CACHE_TTL_SECONDS + try: + with tempfile.TemporaryDirectory() as tmp: + MODULE.MANAGER_CACHE_DIR = Path(tmp) + MODULE.MANAGER_CACHE_TTL_SECONDS = 1 + report_date = datetime(2026, 5, 14, tzinfo=timezone.utc).date() + payload = _minimal_management_payload(report_date) + path = MODULE.management_cache_path("SHARKON2025", report_date) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload), encoding="utf-8") + loaded = MODULE.load_management_cache("SHARKON2025", report_date) + assert loaded["summary"]["users_count"] == 1 + finally: + MODULE.MANAGER_CACHE_DIR = original_dir + MODULE.MANAGER_CACHE_TTL_SECONDS = original_ttl + + +def test_build_management_trend_reuses_precomputed_anchor_payload(): + original_trend_days = MODULE.MANAGER_TREND_DAYS + original_load = MODULE.load_management_cache + original_fetch = MODULE.fetch_events_for_date + original_aggregate = MODULE.aggregate_rows_with_intervals + fetch_dates = [] + try: + MODULE.MANAGER_TREND_DAYS = 3 + anchor = datetime(2026, 5, 14, tzinfo=timezone.utc).date() + + def fake_load(host, report_date): + if report_date < anchor: + return _minimal_management_payload(report_date) + return None + + def fake_fetch(host, report_date): + fetch_dates.append(report_date) + return MODULE.get_report_bounds(report_date), [] + + MODULE.load_management_cache = fake_load + MODULE.fetch_events_for_date = fake_fetch + MODULE.aggregate_rows_with_intervals = lambda events, start, end, host: [] + trend = MODULE.build_management_trend( + "SHARKON2025", + anchor, + precomputed_payloads={anchor: _minimal_management_payload(anchor, users_count=2)}, + ) + assert [item["report_date"] for item in trend] == ["2026-05-12", "2026-05-13", "2026-05-14"] + assert trend[-1]["users_count"] == 2 + assert fetch_dates == [] + finally: + MODULE.MANAGER_TREND_DAYS = original_trend_days + MODULE.load_management_cache = original_load + MODULE.fetch_events_for_date = original_fetch + MODULE.aggregate_rows_with_intervals = original_aggregate + + def test_render_management_html_contains_action_queue(): payload = { "generated_at_utc": "2026-05-14T12:00:00Z", diff --git a/aw-server/test_aw_worktime_ui_bridge.py b/aw-server/test_aw_worktime_ui_bridge.py index 2bde2b6..3becf60 100644 --- a/aw-server/test_aw_worktime_ui_bridge.py +++ b/aw-server/test_aw_worktime_ui_bridge.py @@ -47,6 +47,79 @@ class WorktimeUiBridgeTests(unittest.TestCase): self.assertEqual(ctx["app"], "totalcmd.exe") self.assertEqual(ctx["title"], "Total Commander 6.01 - HARVEST") + def test_get_latest_active_session_ids_uses_latest_timestamp_group(self): + events = [ + { + "timestamp": "2026-05-27T07:59:25Z", + "data": {"sessionId": 2, "state": "Активно", "username": "администратор", "sessionName": "console"}, + }, + { + "timestamp": "2026-05-27T07:59:30Z", + "data": {"sessionId": 2, "state": "Активно", "username": "администратор", "sessionName": "console"}, + }, + { + "timestamp": "2026-05-27T07:59:30Z", + "data": {"sessionId": 3, "state": "Активно", "username": "user5", "sessionName": "rdp-tcp#0"}, + }, + { + "timestamp": "2026-05-27T07:59:30Z", + "data": {"sessionId": 4, "state": "Диск", "username": "user1", "sessionName": ""}, + }, + ] + self.assertEqual(MODULE.get_latest_active_session_ids(events), {2, 3}) + + def test_get_latest_foreground_context_prefers_active_session(self): + now = datetime(2026, 5, 27, 8, 0, 0, tzinfo=timezone.utc) + recent_events = [ + { + "timestamp": "2026-05-27T07:59:40Z", + "data": { + "signalType": "collector_health", + "foregroundProcess": "explorer", + "foregroundTitle": "Explorer", + "sessionId": 9, + }, + }, + { + "timestamp": "2026-05-27T07:59:45Z", + "data": { + "signalType": "collector_health", + "foregroundProcess": "totalcmd", + "foregroundTitle": "Total Commander 6.01 - HARVEST", + "sessionId": 2, + }, + }, + ] + with mock.patch.object(MODULE, "_req", return_value=recent_events): + ctx = MODULE.get_latest_foreground_context(now, active_session_ids={2}) + self.assertEqual(ctx["app"], "totalcmd.exe") + self.assertEqual(ctx["title"], "Total Commander 6.01 - HARVEST") + + def test_get_latest_foreground_context_uses_cached_context_when_recent_events_are_blank(self): + now = datetime(2026, 5, 27, 8, 5, 0, tzinfo=timezone.utc) + recent_events = [ + { + "timestamp": "2026-05-27T08:04:50Z", + "data": { + "signalType": "collector_health", + "foregroundProcess": "", + "foregroundTitle": "", + "sessionId": 3, + }, + } + ] + state = { + "last_foreground_context": { + "app": "totalcmd.exe", + "title": "Total Commander 6.01 - HARVEST", + "timestamp": "2026-05-27T08:03:30Z", + } + } + with mock.patch.object(MODULE, "_req", return_value=recent_events): + ctx = MODULE.get_latest_foreground_context(now, active_session_ids={2, 3}, state=state) + self.assertEqual(ctx["app"], "totalcmd.exe") + self.assertEqual(ctx["title"], "Total Commander 6.01 - HARVEST") + def test_transform_uses_foreground_context_for_active_sessions(self): events = [ { @@ -69,6 +142,61 @@ class WorktimeUiBridgeTests(unittest.TestCase): self.assertEqual(win_events[0]["data"]["title"], "Total Commander 6.01 - HARVEST") self.assertEqual(last_ts, "2026-05-27T07:59:30Z") + def test_transform_caps_duration_at_next_timestamp_gap(self): + events = [ + { + "timestamp": "2026-05-27T07:59:30Z", + "duration": 5, + "data": { + "username": "user5", + "state": "Активно", + "sessionId": 3, + "sessionName": "rdp-tcp#0", + }, + }, + { + "timestamp": "2026-05-27T07:59:31Z", + "duration": 5, + "data": { + "username": "user5", + "state": "Активно", + "sessionId": 3, + "sessionName": "rdp-tcp#0", + }, + }, + ] + _, win_events, _ = MODULE.transform( + events, + foreground_context={"app": "totalcmd.exe", "title": "Total Commander 6.01 - HARVEST"}, + ) + self.assertEqual(win_events[0]["duration"], 1.0) + self.assertEqual(win_events[1]["duration"], 5) + + def test_normalize_watcher_window_events_strips_rdp_suffix_for_real_apps(self): + events = [ + { + "timestamp": "2026-05-27T08:10:00Z", + "duration": 5, + "data": { + "app": "totalcmd.exe", + "title": "Total Commander 6.01 - HARVEST | RDP active (2): user5, администратор", + "source": "aw-worktime-ui-bridge", + }, + }, + { + "timestamp": "2026-05-27T08:10:05Z", + "duration": 5, + "data": { + "app": "RDP", + "title": "RDP active (2): user5, администратор", + "source": "aw-worktime-ui-bridge", + }, + }, + ] + normalized = MODULE.normalize_watcher_window_events(events) + self.assertEqual(normalized[0]["data"]["title"], "Total Commander 6.01 - HARVEST") + self.assertEqual(len(normalized), 1) + def test_watcher_window_needs_bridge_sync_for_generic_bridge_rdp(self): now = datetime(2026, 5, 27, 8, 5, 0, tzinfo=timezone.utc) latest_event = { @@ -83,6 +211,20 @@ class WorktimeUiBridgeTests(unittest.TestCase): mock.patch.object(MODULE, "get_latest_bucket_event_ts", return_value=datetime(2026, 5, 27, 8, 4, 40, tzinfo=timezone.utc)): self.assertTrue(MODULE.watcher_window_needs_bridge_sync(now)) + def test_watcher_window_needs_bridge_sync_for_non_generic_bridge_event(self): + now = datetime(2026, 5, 27, 8, 5, 0, tzinfo=timezone.utc) + latest_event = { + "timestamp": "2026-05-27T08:04:40Z", + "data": { + "app": "totalcmd.exe", + "title": "Total Commander 6.01 - HARVEST", + "source": "aw-worktime-ui-bridge", + }, + } + with mock.patch.object(MODULE, "get_latest_bucket_event", return_value=latest_event), \ + mock.patch.object(MODULE, "get_latest_bucket_event_ts", return_value=datetime(2026, 5, 27, 8, 4, 40, tzinfo=timezone.utc)): + self.assertTrue(MODULE.watcher_window_needs_bridge_sync(now)) + def test_watcher_window_does_not_override_real_watcher_stream(self): now = datetime(2026, 5, 27, 8, 5, 0, tzinfo=timezone.utc) latest_event = { diff --git a/docs/DETMIR_UNIFIED_OPERATING_MODEL_RU.md b/docs/DETMIR_UNIFIED_OPERATING_MODEL_RU.md index 132d57f..2135a69 100644 --- a/docs/DETMIR_UNIFIED_OPERATING_MODEL_RU.md +++ b/docs/DETMIR_UNIFIED_OPERATING_MODEL_RU.md @@ -2,6 +2,8 @@ Дата фиксации: `2026-05-24` +Последнее runtime-уточнение: `2026-05-28` + Этот файл предназначен как единая рабочая опора по `DetMir`: что именно входит в систему, где это живет, каким инструментарием проект надо планировать и сопровождать, и какой операционный контур считать промышленным. Если старые документы расходятся с этим файлом по адресам или runtime-ролям, для текущей эксплуатации приоритет у этого файла. @@ -43,6 +45,75 @@ - операторский и gateway-контур должен считаться `10.10.10.2`; - Windows production-host для `DetMir` сейчас `192.168.100.18`, а не старые упоминания `192.168.100.21`. +### 2.1 Runtime snapshot после полной проверки 2026-05-28 + +Проверка выполнялась как production-contour test, а не только как HTTP ping. +Покрыты: + +- `AW-rus` API/WebUI на `10.10.10.13:5600`; +- worktime/management API на `10.10.10.13:5610`; +- Windows/RDP host `192.168.100.18` через WinRM/SSH/Scheduled Tasks; +- `1C/file analytics` backend на `10.10.10.2:8710`; +- Proxmox/nginx gateway на `10.10.10.2`; +- Grafana на `10.10.10.11:3000`; +- browser smoke через Playwright по operator-facing страницам. + +Фактический результат после стабилизации: + +| Проверка | Результат | +|---|---| +| `./check-aw-full.sh` | `FRESH=8 STALE=0 DEAD=0` | +| `aw-rus-healthd.py --json` | `ok=13 warn=0 fail=0` | +| `dlp-health-check --json` | `ok=20 warn=0 fail=0` | +| `systemctl --failed` на `10.10.10.13` | `0 loaded units listed` | +| Playwright browser smoke | `14/14` страниц открылись | +| Grafana authenticated API/UI smoke | login OK, `19` dashboards в `/api/search`, все ключевые `1C File`/`DetMir` dashboards открылись | +| Grafana datasource health | `OK` для `clickhouse-1c`, `InfluxDB-AW`, `loki`, Proxmox/pfSense Influx datasources | +| Python unit tests по AW server/worktime/DLP/exporters | `36 passed` | +| `proxmox.test_tsj_guardian_bot` | `25 tests OK` | +| Windows `ActivityWatch Recovery` | `Last Result: 0` | + +Ключевые runtime-факты на момент фиксации: + +- `activitywatch-server`, `aw-worktime-api`, `aw-worktime-ui-bridge.timer`, `aw-worktime-autoheal.timer` активны; +- свежие buckets: `aw-watcher-afk_*`, `aw-watcher-window_*`, `aw-worktime-sessions_*`, `aw-dlp-endpoint-signals_*`; +- `aw-dlp-incidents_*`, `aw-dlp-review_*`, `aw-dlp-rules_*` могут быть event-driven и не обязаны двигаться каждую минуту; +- Grafana dashboards без авторизации корректно редиректят на login, это не считается отказом; с сохраненной admin-учеткой проверены фактические страницы и datasource health; +- gateway `/go/file1c-brief`, `/go/file1c-actions`, `/go/aw-ui` ведет на рабочие внутренние surface. + +### 2.2 Стабилизация management report, bridge и recovery от 2026-05-28 + +До стабилизации слабые места были такими: + +- холодный `management report` на `:5610` занимал примерно `38-58s`; +- `aw-worktime-autoheal` мог считать тяжелый management warm частью health-check и перезапускать `aw-worktime-api`; +- `aw-worktime-ui-bridge.service` периодически ловил `start-limit-hit`, хотя затем восстанавливался; +- Windows task `ActivityWatch Recovery` оставался с `Last Result: 1`, несмотря на зеленый основной сбор данных. + +Что изменено: + +| Компонент | Файл | Решение | +|---|---|---| +| Management report API | `aw-server/aw-worktime-api.py` | Добавлены in-process events cache, build lock на `(host, report_date)`, переиспользование уже построенного payload для trend и чтение historical cache без TTL. | +| Autoheal | `aw-server/aw-worktime-autoheal.sh` | Management warm больше не является обязательным health probe; timeout warm увеличен до `60s`. | +| Worktime UI bridge | `aw-server/aw-worktime-ui-bridge.service` | `StartLimitBurst` поднят до `20`, чтобы штатные timer-запуски не переводили unit в `start-limit-hit`. | +| Windows recovery | `windows/ActivityWatch.Windows.Common.psm1` | Усилен hidden wrapper, добавлен fallback через `schtasks.exe`, recovery task выбирает live interactive user, если SYSTEM path на хосте проблемный. | + +Измеренный эффект: + +| Сценарий | До | После | +|---|---:|---:| +| Cold/cold-ish management JSON | `38-58s` | около `11s` на сервере | +| Повторный management JSON из cache | нестабильно | около `0.006s` на сервере | +| Внешний первый request | до `58s` | около `18.9s` | +| Внешний повторный request | нестабильно | около `0.215s` | + +Операционное ограничение: + +- полный `hardening-recovery.ps1` на Windows host может упираться в CIM/ScheduledTasks `message filter`; +- для текущего production recovery закреплен рабочий путь через `schtasks.exe` и live interactive admin principal; +- не запускать полный hardening-прогон без причины, если buckets свежие и `ActivityWatch Recovery` уже `Last Result: 0`. + ## 3. Полный функциональный состав DetMir ### 3.1 Ядро AW-rus diff --git a/docs/windows-deploy-startup-model.md b/docs/windows-deploy-startup-model.md index 3de5c74..8ec7c61 100644 --- a/docs/windows-deploy-startup-model.md +++ b/docs/windows-deploy-startup-model.md @@ -25,6 +25,8 @@ Collector ownership in this model: - `file-operations-collector.ps1`: user-session path - `dlp-endpoint-signals-collector.ps1`: user-session path - `worktime-session-collector.ps1`: single global process under recovery path + - publishes session presence and `process_start` / `process_stop` events for all visible user sessions, including `Disc` + - this is session/process telemetry, not a replacement for per-user foreground window watchers ### 2. Standalone service installer @@ -39,6 +41,7 @@ Collector ownership in this model: - `dlp-endpoint-signals-collector.ps1`: allowed - `file-operations-collector.ps1`: allowed - `worktime-session-collector.ps1`: allowed + - still provides session/process telemetry, but not interactive foreground-window truth - `browser-domains-native-collector.ps1`: not reliable in Session 0 - `email-outbound-collector.ps1`: not reliable in Session 0 - `aw-watcher-afk` / `aw-watcher-window`: not a standalone Session 0 primitive diff --git a/proxmox/tsj_guardian_bot.py b/proxmox/tsj_guardian_bot.py index 58c6942..0fd2ead 100644 --- a/proxmox/tsj_guardian_bot.py +++ b/proxmox/tsj_guardian_bot.py @@ -331,6 +331,7 @@ class TSJGuardianBot: BTN_AW_DFIR_LEGACY = "Hayabusa DFIR" BTN_AI_CHAT_ALIASES = ("AI чат", "Чат с поддержкой", "Техподдержка", "Тех поддержка") BTN_OVPN_CERTS_ALIASES = ("OpenVPN certs", "OpenVPN cert", "OpenVPN серты", "OpenVPN сертификат") + INFRA_ADMIN_ROOT = "/opt/infra-admin" PFSENSE_ENV_PATH = "/home/igor/.config/tsj-bot/pfsense.env.readonly" PFSENSE_INVENTORY_PATH = "/home/igor/.config/tsj-bot/inventory.md" @@ -358,8 +359,9 @@ class TSJGuardianBot: self.allowed_chats = {int(x.strip()) for x in chats_raw.split(",") if x.strip()} self.default_chat_id = int(os.getenv("TELEGRAM_DEFAULT_CHAT_ID", str(min(self.allowed_chats)))) + self.infra_admin_root = os.getenv("INFRA_ADMIN_ROOT", self.INFRA_ADMIN_ROOT).strip() or self.INFRA_ADMIN_ROOT self.check_script = os.getenv( - "CHECK_SCRIPT", "/home/codex/infra-admin/scripts/system_self_support.sh --check" + "CHECK_SCRIPT", f"{self.infra_admin_root}/scripts/system_self_support.sh --check" ) self.aw_rus_api_base = os.getenv("AW_RUS_API_BASE", "http://10.10.10.13:5600/api/0").strip() self.aw_rus_worktime_base = os.getenv("AW_RUS_WORKTIME_BASE", "http://10.10.10.13:5610").strip() @@ -411,16 +413,16 @@ class TSJGuardianBot: r"C:\ProgramData\AWatch-rus\email-outbound-collector.ps1", ).strip() or r"C:\ProgramData\AWatch-rus\email-outbound-collector.ps1" self.heal_script = os.getenv( - "HEAL_SCRIPT", "/home/codex/infra-admin/scripts/system_self_support.sh --heal" + "HEAL_SCRIPT", f"{self.infra_admin_root}/scripts/system_self_support.sh --heal" ) self.state_file = os.getenv( - "STATE_FILE", "/home/codex/infra-admin/.state/tsj_guardian_state.json" + "STATE_FILE", f"{self.infra_admin_root}/.state/tsj_guardian_state.json" ) self.log_file = os.getenv( - "LOG_FILE", "/home/codex/infra-admin/logs/tsj_guardian_bot.log" + "LOG_FILE", f"{self.infra_admin_root}/logs/tsj_guardian_bot.log" ) self.heartbeat_file = os.getenv( - "HEARTBEAT_FILE", "/home/codex/infra-admin/.state/tsj_guardian_heartbeat" + "HEARTBEAT_FILE", f"{self.infra_admin_root}/.state/tsj_guardian_heartbeat" ) self.check_interval = env_int("CHECK_INTERVAL_SEC", 60) self.operator_timeout = env_int("OPERATOR_TIMEOUT_SEC", 900) # 15 min @@ -475,23 +477,23 @@ class TSJGuardianBot: self.server_fallback_commands = [ x.strip() for x in os.getenv( "SERVER_FALLBACK_COMMANDS", - "/home/codex/infra-admin/scripts/system_self_support.sh --heal" + f"{self.infra_admin_root}/scripts/system_self_support.sh --heal" ).split(";;") if x.strip() ] self.updates_script = os.getenv( "UPDATES_SCRIPT", - "/usr/bin/python3 /home/codex/infra-admin/scripts/proxmox_lxc_critical_updates.py", + f"/usr/bin/python3 {self.infra_admin_root}/scripts/proxmox_lxc_critical_updates.py", ) self.updates_status_file = Path( os.getenv( "UPDATES_STATUS_FILE", - "/home/codex/infra-admin/.state/proxmox_lxc_critical_updates.json", + f"{self.infra_admin_root}/.state/proxmox_lxc_critical_updates.json", ) ) self.updates_rollback_file = Path( os.getenv( "UPDATES_ROLLBACK_FILE", - "/home/codex/infra-admin/.state/proxmox_lxc_pending_rollback.json", + f"{self.infra_admin_root}/.state/proxmox_lxc_pending_rollback.json", ) ) self.proxmox_selection_ttl_sec = env_int("PROXMOX_SELECTION_TTL_SEC", 900) diff --git a/windows/ActivityWatch.Windows.Common.psm1 b/windows/ActivityWatch.Windows.Common.psm1 index 3446905..3c68781 100755 --- a/windows/ActivityWatch.Windows.Common.psm1 +++ b/windows/ActivityWatch.Windows.Common.psm1 @@ -295,6 +295,11 @@ function Get-ActivityWatchBuiltInAdministratorName { return $script:ActivityWatchBuiltInAdministratorName } + if (-not [string]::IsNullOrWhiteSpace($env:AWATCH_RUS_BUILTIN_ADMINISTRATOR_NAME)) { + $script:ActivityWatchBuiltInAdministratorName = [string]$env:AWATCH_RUS_BUILTIN_ADMINISTRATOR_NAME + return $script:ActivityWatchBuiltInAdministratorName + } + try { $account = Get-CimInstance Win32_UserAccount -Filter "LocalAccount=True" -ErrorAction Stop | Where-Object { [string]$_.SID -match '-500$' } | @@ -307,6 +312,11 @@ function Get-ActivityWatchBuiltInAdministratorName { catch { } + if ([string]$env:COMPUTERNAME -ieq 'SHARKON2025') { + $script:ActivityWatchBuiltInAdministratorName = 'Администратор' + return $script:ActivityWatchBuiltInAdministratorName + } + $script:ActivityWatchBuiltInAdministratorName = 'Administrator' return $script:ActivityWatchBuiltInAdministratorName } @@ -389,11 +399,11 @@ function Normalize-ActivityWatchUsers { } } - $normalized = $collected | + $normalized = @($collected | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | ForEach-Object { Normalize-ActivityWatchUserId -UserId $_ -Domain $Domain } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | - Sort-Object -Unique + Sort-Object -Unique) if (-not $normalized -or $normalized.Count -eq 0) { throw 'Не удалось определить целевых пользователей. Укажите -Users или -UserListPath.' @@ -767,6 +777,7 @@ function New-ActivityWatchDeploymentConfig { [int]$EvtxRetentionDays = 14, [string[]]$EvtxChannels = @(), [bool]$LogonMarkerEnabled = $true, + [bool]$ProcessEventsEnabled = $true, [Parameter(Mandatory = $true)] [string]$LaunchScriptPath, [Parameter(Mandatory = $true)] @@ -888,8 +899,9 @@ function New-ActivityWatchDeploymentConfig { } } sessionEvents = [pscustomobject]@{ - logonEnabled = $LogonMarkerEnabled - bucketPrefix = 'aw-session-events' + logonEnabled = $LogonMarkerEnabled + processEventsEnabled = $ProcessEventsEnabled + bucketPrefix = 'aw-session-events' } recovery = [pscustomobject]@{ intervalSeconds = $RecoveryIntervalSeconds @@ -964,7 +976,9 @@ Set-StrictMode -Version Latest [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12 Add-Type -AssemblyName System.Net.Http -`$script:MaxCollectorPowerShellProcesses = 24 +`$script:MaxCollectorPowerShellProcesses = 48 +`$script:CollectorProcessSnapshotLoaded = `$false +`$script:CollectorProcessSnapshot = @() function Get-DeploymentConfig { param([string]`$Path) @@ -980,6 +994,41 @@ function Test-ProcessInSession { return [bool](Get-Process -Name `$Name -ErrorAction SilentlyContinue | Where-Object { `$_.SessionId -eq `$SessionId } | Select-Object -First 1) } +function Get-CollectorProcessSnapshot { + if (`$script:CollectorProcessSnapshotLoaded) { + return @(`$script:CollectorProcessSnapshot) + } + + `$script:CollectorProcessSnapshotLoaded = `$true + `$script:CollectorProcessSnapshot = @() + `$job = `$null + try { + `$job = Start-Job -ScriptBlock { + Get-CimInstance Win32_Process -Filter "Name = 'powershell.exe' OR Name = 'pwsh.exe'" -ErrorAction SilentlyContinue | + Where-Object { + `$_.CommandLine -match 'AWatch-rus' -and + `$_.CommandLine -match '\.ps1' + } | + Select-Object ProcessId, SessionId, CommandLine + } + + if (Wait-Job -Job `$job -Timeout 4) { + `$script:CollectorProcessSnapshot = @(Receive-Job -Job `$job -ErrorAction SilentlyContinue) + } + } + catch { + `$script:CollectorProcessSnapshot = @() + } + finally { + if (`$job) { + Stop-Job -Job `$job -ErrorAction SilentlyContinue | Out-Null + Remove-Job -Job `$job -Force -ErrorAction SilentlyContinue | Out-Null + } + } + + return @(`$script:CollectorProcessSnapshot) +} + function Test-CollectorRunning { param( [string]`$ScriptPath, @@ -987,9 +1036,8 @@ function Test-CollectorRunning { ) `$escapedCollector = [Regex]::Escape(`$ScriptPath) - `$processes = Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | + `$processes = Get-CollectorProcessSnapshot | Where-Object { - (`$_.Name -ieq 'powershell.exe' -or `$_.Name -ieq 'pwsh.exe') -and `$_.SessionId -eq `$SessionId -and `$_.CommandLine -match `$escapedCollector } @@ -998,14 +1046,7 @@ function Test-CollectorRunning { } function Get-CollectorPowerShellProcessCount { - `$processes = Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | - Where-Object { - (`$_.Name -ieq 'powershell.exe' -or `$_.Name -ieq 'pwsh.exe') -and - `$_.CommandLine -match 'AWatch-rus' -and - `$_.CommandLine -match '\.ps1' - } - - return @(`$processes).Count + return @(Get-CollectorProcessSnapshot).Count } function New-LaunchLock { @@ -1908,8 +1949,15 @@ function Write-ActivityWatchHiddenPowerShellWrapper { $escapedConfigPath = $ConfigPath.Replace('"', '""') $content = @" +On Error Resume Next Set shell = CreateObject("WScript.Shell") -shell.Run """$escapedPowerShellExe"" -NoProfile -ExecutionPolicy Bypass -File ""$escapedScriptPath"" -ConfigPath ""$escapedConfigPath""", 0, False +q = Chr(34) +command = q & "$escapedPowerShellExe" & q & " -NoProfile -ExecutionPolicy Bypass -File " & q & "$escapedScriptPath" & q & " -ConfigPath " & q & "$escapedConfigPath" & q +shell.Run command, 0, False +If Err.Number <> 0 Then + WScript.Quit 1 +End If +WScript.Quit 0 "@ Set-Content -LiteralPath $Path -Value $content -Encoding ASCII @@ -1927,7 +1975,7 @@ function Remove-LegacyActivityWatchEntries { ) foreach ($taskName in $legacyTaskNames) { - Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue + Remove-ActivityWatchScheduledTask -TaskName $taskName } $legacyTaskPatterns = @( @@ -1943,7 +1991,15 @@ function Remove-LegacyActivityWatchEntries { 'ActivityWatch File1C Upload' ) - foreach ($task in @(Get-ScheduledTask -ErrorAction SilentlyContinue)) { + $scheduledTasks = @() + try { + $scheduledTasks = @(Get-ScheduledTask -ErrorAction Stop) + } + catch { + $scheduledTasks = @() + } + + foreach ($task in $scheduledTasks) { $taskName = [string]$task.TaskName if ([string]::IsNullOrWhiteSpace($taskName) -or $managedTaskNames -contains $taskName -or $taskName -like 'ActivityWatch Launch *') { continue @@ -1985,11 +2041,28 @@ function Remove-ActivityWatchScheduledTask { [string]$TaskName ) - Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue + try { + Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction Stop + } + catch { + } + & cmd.exe /c "schtasks /Delete /TN `"$TaskName`" /F >nul 2>&1" | Out-Null + if ($LASTEXITCODE -eq 0) { + return + } for ($attempt = 0; $attempt -lt 10; $attempt++) { - $task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue + $task = $null + try { + $task = Get-ScheduledTask -TaskName $TaskName -ErrorAction Stop + } + catch { + & cmd.exe /c "schtasks /Query /TN `"$TaskName`" >nul 2>&1" | Out-Null + if ($LASTEXITCODE -ne 0) { + return + } + } if (-not $task) { return } @@ -2138,11 +2211,45 @@ function Register-ActivityWatchRecoveryTask { $launcherPath = Get-ActivityWatchHiddenLauncherPath -ScriptPath $RecoveryScriptPath Write-ActivityWatchHiddenPowerShellWrapper -Path $launcherPath -ScriptPath $RecoveryScriptPath -ConfigPath $ConfigPath $action = New-ScheduledTaskAction -Execute $wscriptExe -Argument "//B //NoLogo `"$launcherPath`"" - $trigger = New-ScheduledTaskTrigger -AtStartup - $principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest + $sessionRecords = @() + try { + $sessionRecords = @(Get-ActivityWatchSessionRecords) + } + catch { + $sessionRecords = @() + } + $liveSession = @(Get-ActivityWatchLiveInteractiveSessions -SessionRecords $sessionRecords) | Select-Object -First 1 + $interactiveUserId = $null + if ($liveSession -and -not [string]::IsNullOrWhiteSpace([string]$liveSession.UserName)) { + $rawUser = [string]$liveSession.UserName + $interactiveUserId = if ($rawUser -match '^[^\\]+\\') { $rawUser } else { ('{0}\{1}' -f $env:COMPUTERNAME, $rawUser) } + } + + if ($interactiveUserId) { + $trigger = New-ScheduledTaskTrigger -AtLogOn -User $interactiveUserId + $principal = New-ScheduledTaskPrincipal -UserId $interactiveUserId -LogonType Interactive -RunLevel Highest + } + else { + $trigger = New-ScheduledTaskTrigger -AtStartup + $principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest + } $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -StartWhenAvailable -Hidden -MultipleInstances IgnoreNew -ExecutionTimeLimit (New-TimeSpan -Hours 0) - Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger -Principal $principal -Settings $settings | Out-Null + try { + Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger -Principal $principal -Settings $settings -ErrorAction Stop | Out-Null + } + catch { + $taskCommand = ('"{0}" {1}' -f $wscriptExe, $action.Arguments) + if ($interactiveUserId) { + & schtasks.exe /Create /TN $TaskName /SC ONLOGON /RU $interactiveUserId /IT /RL HIGHEST /F /TR $taskCommand | Out-Null + } + else { + & schtasks.exe /Create /TN $TaskName /SC ONSTART /RU SYSTEM /RL HIGHEST /F /TR $taskCommand | Out-Null + } + if ($LASTEXITCODE -ne 0) { + throw + } + } } function Register-ActivityWatchHayabusaAutoUploadTask { diff --git a/windows/deploy-domain-users.ps1 b/windows/deploy-domain-users.ps1 index 38bd8e3..c8b66f7 100755 --- a/windows/deploy-domain-users.ps1 +++ b/windows/deploy-domain-users.ps1 @@ -27,6 +27,7 @@ param( [int]$EvtxRetentionDays = 14, [string[]]$EvtxChannels = @(), [bool]$LogonMarkerEnabled = $true, + [bool]$ProcessEventsEnabled = $true, [string]$AwHostname, [string]$CustomRulesPath, [string]$CustomPolicyPath, @@ -140,6 +141,7 @@ $config = New-ActivityWatchDeploymentConfig ` -EvtxRetentionDays $EvtxRetentionDays ` -EvtxChannels $EvtxChannels ` -LogonMarkerEnabled $LogonMarkerEnabled ` + -ProcessEventsEnabled $ProcessEventsEnabled ` -AwHostname $AwHostname ` -PolicyMode $PolicyMode ` -PolicyEngineEnabled $PolicyEngineEnabled ` diff --git a/windows/deploy-ensemble.ps1 b/windows/deploy-ensemble.ps1 index a58eb2f..cbcf689 100644 --- a/windows/deploy-ensemble.ps1 +++ b/windows/deploy-ensemble.ps1 @@ -27,6 +27,7 @@ param( [int]$EvtxRetentionDays = 14, [string[]]$EvtxChannels = @(), [bool]$LogonMarkerEnabled = $true, + [bool]$ProcessEventsEnabled = $true, [string]$AwHostname, [string]$CustomRulesPath, [string]$CustomPolicyPath, @@ -99,6 +100,7 @@ if (-not (Test-Path -LiteralPath $deployScript)) { -EvtxRetentionDays $EvtxRetentionDays ` -EvtxChannels $EvtxChannels ` -LogonMarkerEnabled $LogonMarkerEnabled ` + -ProcessEventsEnabled $ProcessEventsEnabled ` -AwHostname $AwHostname ` -CustomRulesPath $CustomRulesPath ` -CustomPolicyPath $CustomPolicyPath ` @@ -145,6 +147,7 @@ if (-not $SkipHardening) { -EvtxRetentionDays $EvtxRetentionDays ` -EvtxChannels $EvtxChannels ` -LogonMarkerEnabled $LogonMarkerEnabled ` + -ProcessEventsEnabled $ProcessEventsEnabled ` -AwHostname $AwHostname ` -CustomRulesPath $CustomRulesPath ` -CustomPolicyPath $CustomPolicyPath ` diff --git a/windows/deploy-single-user.ps1 b/windows/deploy-single-user.ps1 index c970c13..25b7221 100755 --- a/windows/deploy-single-user.ps1 +++ b/windows/deploy-single-user.ps1 @@ -25,6 +25,7 @@ param( [int]$EvtxRetentionDays = 14, [string[]]$EvtxChannels = @(), [bool]$LogonMarkerEnabled = $true, + [bool]$ProcessEventsEnabled = $true, [string]$AwHostname, [string]$CustomRulesPath, [string]$CustomPolicyPath @@ -102,6 +103,7 @@ $config = New-ActivityWatchDeploymentConfig ` -EvtxRetentionDays $EvtxRetentionDays ` -EvtxChannels $EvtxChannels ` -LogonMarkerEnabled $LogonMarkerEnabled ` + -ProcessEventsEnabled $ProcessEventsEnabled ` -AwHostname $AwHostname ` -LaunchScriptPath $launchScriptPath ` -RecoveryScriptPath $recoveryScriptPath ` diff --git a/windows/export-upload-file-1c-telemetry.ps1 b/windows/export-upload-file-1c-telemetry.ps1 index 9cb393b..1d39d3c 100644 --- a/windows/export-upload-file-1c-telemetry.ps1 +++ b/windows/export-upload-file-1c-telemetry.ps1 @@ -170,10 +170,19 @@ function Get-1CFileInfobases { } function Get-HostSample { - $os = Get-CimInstance Win32_OperatingSystem - $cpuSample = Get-CimInstance Win32_Processor -ErrorAction SilentlyContinue | - Measure-Object -Property LoadPercentage -Average - $cpu = if ($cpuSample.Count -gt 0 -and $null -ne $cpuSample.Average) { [double]$cpuSample.Average } else { 0 } + $cpu = 0.0 + $ramPct = 0.0 + try { + Add-Type -AssemblyName Microsoft.VisualBasic -ErrorAction Stop + $computerInfo = New-Object Microsoft.VisualBasic.Devices.ComputerInfo + $totalMemory = [double]$computerInfo.TotalPhysicalMemory + $availableMemory = [double]$computerInfo.AvailablePhysicalMemory + if ($totalMemory -gt 0) { + $ramPct = (($totalMemory - $availableMemory) / $totalMemory) * 100 + } + } catch { + Write-RunLog ("warning: host memory sample fallback reason={0}" -f $_.Exception.Message) + } $disk = Get-PSDrive -Name E -ErrorAction SilentlyContinue $rdp = (quser 2>$null | Select-Object -Skip 1 | Measure-Object).Count @@ -181,7 +190,7 @@ function Get-HostSample { ts = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') host = $env:COMPUTERNAME cpu_pct = [math]::Round($cpu, 2) - ram_pct = [math]::Round((($os.TotalVisibleMemorySize - $os.FreePhysicalMemory) / $os.TotalVisibleMemorySize) * 100, 2) + ram_pct = [math]::Round($ramPct, 2) disk_free_gb = if ($disk) { [math]::Round($disk.Free / 1GB, 2) } else { 0 } disk_latency_ms = 0 smb_errors = 0 diff --git a/windows/hardening-recovery.ps1 b/windows/hardening-recovery.ps1 index 65a3187..b28b76c 100755 --- a/windows/hardening-recovery.ps1 +++ b/windows/hardening-recovery.ps1 @@ -24,6 +24,7 @@ param( [int]$EvtxRetentionDays, [string[]]$EvtxChannels, [bool]$LogonMarkerEnabled, + [bool]$ProcessEventsEnabled, [string]$AwHostname, [string]$CustomRulesPath, [string]$CustomPolicyPath, @@ -93,6 +94,7 @@ $effectiveEvtxExportRoot = if ($PSBoundParameters.ContainsKey('EvtxExportRoot') $effectiveEvtxRetentionDays = if ($PSBoundParameters.ContainsKey('EvtxRetentionDays')) { [int]$EvtxRetentionDays } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'forensics' -and $existingConfig.forensics.PSObject.Properties.Name -contains 'retentionDays') { [int]$existingConfig.forensics.retentionDays } else { 14 } $effectiveEvtxChannels = if ($PSBoundParameters.ContainsKey('EvtxChannels')) { @($EvtxChannels) } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'forensics' -and $existingConfig.forensics.PSObject.Properties.Name -contains 'evtxChannels') { @($existingConfig.forensics.evtxChannels) } else { @() } $effectiveLogonMarkerEnabled = if ($PSBoundParameters.ContainsKey('LogonMarkerEnabled')) { [bool]$LogonMarkerEnabled } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'sessionEvents' -and $existingConfig.sessionEvents.PSObject.Properties.Name -contains 'logonEnabled') { [bool]$existingConfig.sessionEvents.logonEnabled } else { $true } +$effectiveProcessEventsEnabled = if ($PSBoundParameters.ContainsKey('ProcessEventsEnabled')) { [bool]$ProcessEventsEnabled } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'sessionEvents' -and $existingConfig.sessionEvents.PSObject.Properties.Name -contains 'processEventsEnabled') { [bool]$existingConfig.sessionEvents.processEventsEnabled } else { $true } $effectiveAwHostname = if ($PSBoundParameters.ContainsKey('AwHostname') -and -not [string]::IsNullOrWhiteSpace($AwHostname)) { [string]$AwHostname } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'awHostname' -and -not [string]::IsNullOrWhiteSpace([string]$existingConfig.awHostname)) { [string]$existingConfig.awHostname } else { [string]$env:COMPUTERNAME } $effectiveVersion = if ($Version) { $Version } elseif ($existingConfig) { [string]$existingConfig.package.version } else { 'v0.13.2' } $effectivePolicyMode = if ($PSBoundParameters.ContainsKey('PolicyMode') -and $PolicyMode) { [string]$PolicyMode } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'policyEngine' -and $existingConfig.policyEngine.PSObject.Properties.Name -contains 'mode') { [string]$existingConfig.policyEngine.mode } else { 'local' } @@ -202,6 +204,7 @@ $config = New-ActivityWatchDeploymentConfig ` -EvtxRetentionDays $effectiveEvtxRetentionDays ` -EvtxChannels $effectiveEvtxChannels ` -LogonMarkerEnabled $effectiveLogonMarkerEnabled ` + -ProcessEventsEnabled $effectiveProcessEventsEnabled ` -AwHostname $effectiveAwHostname ` -PolicyMode $effectivePolicyMode ` -PolicyEngineEnabled $effectivePolicyEngineEnabled ` diff --git a/windows/validate-deployment.ps1 b/windows/validate-deployment.ps1 index 8b704f5..237bf69 100644 --- a/windows/validate-deployment.ps1 +++ b/windows/validate-deployment.ps1 @@ -36,6 +36,15 @@ $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 } +$sessionEventsConfig = if ($config.PSObject.Properties.Name -contains 'sessionEvents') { $config.sessionEvents } else { $null } +$sessionLogonEnabled = if ($sessionEventsConfig -and $sessionEventsConfig.PSObject.Properties.Name -contains 'logonEnabled') { [bool]$sessionEventsConfig.logonEnabled } else { $false } +$sessionProcessEventsEnabled = if ($sessionEventsConfig -and $sessionEventsConfig.PSObject.Properties.Name -contains 'processEventsEnabled') { [bool]$sessionEventsConfig.processEventsEnabled } else { $true } +$sessionEventsBucketId = if ($sessionEventsConfig -and $sessionEventsConfig.PSObject.Properties.Name -contains 'bucketPrefix' -and -not [string]::IsNullOrWhiteSpace([string]$sessionEventsConfig.bucketPrefix)) { + ('{0}_{1}' -f [string]$sessionEventsConfig.bucketPrefix, $awHostname) +} +else { + 'aw-session-events_' + $awHostname +} function Get-LoggedOnUsers { param( @@ -535,6 +544,12 @@ $result = [ordered]@{ jobTitlePolicyEnabled = $printJobTitlePolicyEnabled ok = [bool]($printServiceOperationalEnabled -and $printJobTitlePolicyEnabled) } + sessionEvents = [ordered]@{ + bucketId = $sessionEventsBucketId + logonEnabled = [bool]$sessionLogonEnabled + processEventsEnabled = [bool]$sessionProcessEventsEnabled + ok = $true + } forensics = [ordered]@{ evtxExportRoot = if ($config.PSObject.Properties.Name -contains 'forensics' -and $config.forensics.PSObject.Properties.Name -contains 'evtxExportRoot') { [string]$config.forensics.evtxExportRoot } else { $null } retentionDays = if ($config.PSObject.Properties.Name -contains 'forensics' -and $config.forensics.PSObject.Properties.Name -contains 'retentionDays') { [int]$config.forensics.retentionDays } else { $null } diff --git a/windows/worktime-session-collector.ps1 b/windows/worktime-session-collector.ps1 index 84fbd04..a551627 100644 --- a/windows/worktime-session-collector.ps1 +++ b/windows/worktime-session-collector.ps1 @@ -94,11 +94,13 @@ function Ensure-Bucket { param( [Parameter(Mandatory = $true)][string]$ApiBase, [Parameter(Mandatory = $true)][string]$BucketId, - [Parameter(Mandatory = $true)][string]$HostnameValue + [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='aw-worktime-session-collector'; type='aw.worktime.session'; hostname=$HostnameValue } | ConvertTo-Json -Compress + $body = @{ client=$ClientName; type=$BucketType; hostname=$HostnameValue } | ConvertTo-Json -Compress $attempts = 0 while ($attempts -lt 3) { $attempts++ @@ -230,16 +232,282 @@ function Get-CanonicalUserId { 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') @@ -265,6 +533,13 @@ while ($true) { ) } + 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]@{