diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..67c2cea --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +windows/installkit/innosetup/AWatch-rus-InstallKit.exe filter=lfs diff=lfs merge=lfs -text diff --git a/install-kit-awindows-20260427-211240.tar.gz b/install-kit-awindows-20260427-211240.tar.gz index 967b47c..42626af 100644 Binary files a/install-kit-awindows-20260427-211240.tar.gz and b/install-kit-awindows-20260427-211240.tar.gz differ diff --git a/install-kit-awindows-20260427-211240.zip b/install-kit-awindows-20260427-211240.zip index 5d503e4..e7d4c50 100644 Binary files a/install-kit-awindows-20260427-211240.zip and b/install-kit-awindows-20260427-211240.zip differ diff --git a/install-kit-awindows-20260427-211240/MANIFEST.txt b/install-kit-awindows-20260427-211240/MANIFEST.txt index 6f2a380..5010de7 100644 --- a/install-kit-awindows-20260427-211240/MANIFEST.txt +++ b/install-kit-awindows-20260427-211240/MANIFEST.txt @@ -1,47 +1,47 @@ f3dde1e6d1532804379faf7e395deaf95cf3e0b97769d425f8a69f4572de2a2f install-kit-awindows-20260427-211240/README-INSTALL-KIT.txt -a08ccceada7a21e4396a640e54a354e4d4d760980ec3f18f1bc7f543cd8f4cc6 install-kit-awindows-20260427-211240/ansible/README.md +edc20460f7be2ec676a6fd7c9687f808507b364b32915eb78b7a63b441b1ff84 install-kit-awindows-20260427-211240/ansible/README.md 412bb766bbf0791c3593f38daa771d5d0aa58cc1f2d3c9010fcd4588d0fe87df install-kit-awindows-20260427-211240/ansible/deploy_aw_pfsense_poller.yml -00d16de62df9d91cd375cbe70034ec97da14601ab5285ca93e5b297150f02d34 install-kit-awindows-20260427-211240/ansible/deploy_aw_server.yml -ce1cbc35087006292e93a7e0d1706bc0ac71f8d9f2e60bbdc1c3a8ecea0d34f0 install-kit-awindows-20260427-211240/ansible/deploy_aw_windows.yml -eedb12a2be920c6bec267162c91e106365af5c009426cefe84c032c2f9d9339d install-kit-awindows-20260427-211240/ansible/group_vars/all.example.yml -95696c243ab331f06e77a40a9800c4b6668de77675ebbdf2ef54ae49e1b18874 install-kit-awindows-20260427-211240/ansible/group_vars/pfsense-poller.example.yml +0188480546b4b18937194f2b05a0f23da5dd8536eed4d60b7499a8170c36d08e install-kit-awindows-20260427-211240/ansible/deploy_aw_server.yml +953bb39572c520d0438a621c2b4c08315bf887b84ee9e795554e5592b54a1229 install-kit-awindows-20260427-211240/ansible/deploy_aw_windows.yml +0054e7ba5f342cd10db08f6bab4784d4816179d69f76898a4de293a4063ea14a install-kit-awindows-20260427-211240/ansible/group_vars/all.example.yml +a896676303be9a47ed6b0c8cc07deb5a5432aebc97231e801fda985121c539eb install-kit-awindows-20260427-211240/ansible/group_vars/pfsense-poller.example.yml c5cab36645065815571c99f6d360f910dcccbb54b780c8bfd526a6cdc3684e19 install-kit-awindows-20260427-211240/ansible/group_vars/proxmox-matrix.example.yml 35a33c8a1c75ded5e85c6b79e0b3efde07959ff61ee5f66d83b7e0c2abe87fc5 install-kit-awindows-20260427-211240/ansible/group_vars/proxmox.example.yml -5da847b74fac52e0fec2f60e134f4377cfa1581e026b291d3d3ac362371f6e49 install-kit-awindows-20260427-211240/ansible/group_vars/windows.example.yml +a1921715cf9904b7e3b61a1a5c4300382efe2f3bd70d73e4c8e995318fdd55e9 install-kit-awindows-20260427-211240/ansible/group_vars/windows.example.yml 7189b5205bd25313db54e5be027b0d066199e6ae34ad74be2095a1691adaf5e3 install-kit-awindows-20260427-211240/ansible/install_full_stack.yml fea0574d7eb98ce24a1e7025afb9837c6241180095d21eaa74892225305d05a3 install-kit-awindows-20260427-211240/ansible/inventory.example.ini 8a5e4923c0f581dd4fbb32549ee7ab45ba506260056da923ba86d9f1b1081714 install-kit-awindows-20260427-211240/ansible/provision_proxmox_ct_and_deploy_aw.yml 18928adcaef5d01b4c621b48f5559383c8b749ef182fd3710f10b222a164f8b7 install-kit-awindows-20260427-211240/ansible/provision_proxmox_ct_matrix_and_deploy_aw.yml a74a49371e889dc3ea404534a939f32f2dac940d8902d20770590951ab67d532 install-kit-awindows-20260427-211240/ansible/tasks/provision_ct_and_deploy_aw.yml ba16fe9e267194459a6082045a387acb828a1d39a98e66b401ece5069ea62e64 install-kit-awindows-20260427-211240/aw-server/activitywatch-server.service -bed7495c277970a37d1c467e81592417f955914d4c714ac21397083b56f1bba8 install-kit-awindows-20260427-211240/aw-server/apply_webui_ru_patch.sh -db81f6209e14f2efd123cd91fac74e37a68e5ef54d00b8b6f6612404a78ffdaa install-kit-awindows-20260427-211240/aw-server/aw-host-groups.json -6eafd2d7a43a9fd146fdc6686ae306cb92abfa2c273bbb1ed03b67fb1277defe install-kit-awindows-20260427-211240/aw-server/aw-ru-patch.js -9e6254c726dd4a26578a60b6bf5acee84066f931ae395115a80cf622a6ff2732 install-kit-awindows-20260427-211240/aw-server/aw-rus-healthd.py +fea5734c99b516b01bfe8ebbef59972d0ef553c09d7691790b08472ecfb9602d install-kit-awindows-20260427-211240/aw-server/apply_webui_ru_patch.sh +ba88cc284d047f521427ac038c266624a6fe8493ce3e79bc27c172a2e70ac54a install-kit-awindows-20260427-211240/aw-server/aw-host-groups.json +509759461ce0918a2dc839832812cf5f4c77b1cea1e6ffdcab0146e02598df79 install-kit-awindows-20260427-211240/aw-server/aw-ru-patch.js +22d4ee27ffc73dcec3ba73c8ee65d12353ded1403f592ff52ef578e09dad4c43 install-kit-awindows-20260427-211240/aw-server/aw-rus-healthd.py 05b04b5f49e9c7783917e0861e3edd63f5d6db8638d6b33dbfd4dc0f1c16040f install-kit-awindows-20260427-211240/aw-server/aw-rus-healthd.service bb0f1de91862da66b0b6d9bd41e8dfe181710196141cb43b00d9b41dab6caf96 install-kit-awindows-20260427-211240/aw-server/aw-rus-healthd.timer -0d2b978833b27a2a041508b49ffd07ca045127ddf3b09c71d3787d0bf1224473 install-kit-awindows-20260427-211240/aw-server/aw-server.env.example +b493fbf4cc15ccba44ea78d3ed17a0a6db31617f22d6aeb671677cbf11a36921 install-kit-awindows-20260427-211240/aw-server/aw-server.env.example 98c0bed353bbda0fa7a69df23f3b008cb0e8e70cdff6cc63330d4caf79fd3280 install-kit-awindows-20260427-211240/aw-server/aw-sw-cleanup.js -d8d6be450a726f51f87415eaebd67396468c73ddda9b942481d56b4d33d68bb7 install-kit-awindows-20260427-211240/aw-server/aw-worktime-api.py +e220a2eb830a018fa28c02872fabda54aed7f87dbb0e14141f1a8933a98064fe install-kit-awindows-20260427-211240/aw-server/aw-worktime-api.py f764e566d70952acc1b4f2baeccd6b7888905bc6b98b36333336c0dc77c66694 install-kit-awindows-20260427-211240/aw-server/aw-worktime-api.service -8af41d20f01dfffe6b8c64bd5dbe24468f297035760050647bfeb53b704c0d4d install-kit-awindows-20260427-211240/aw-server/aw-worktime-panel.js -3262b356dc4cd940b66f27d47cfe437dcd26d8d2cb49d1dc0a00b933e380a376 install-kit-awindows-20260427-211240/aw-server/install_aw_server.sh +afbb4be301b4940ed9b7671be3441a48d53bee968d8870749442f81c6d066650 install-kit-awindows-20260427-211240/aw-server/aw-worktime-panel.js +ca7d1ed7665e225824d4ed7ef8456c81a6d97ef2c90cb3aa799d72e07b6bcbb8 install-kit-awindows-20260427-211240/aw-server/install_aw_server.sh aedffecfa24834968742cb2477faef80bf794345275a9679ac12c5a1f609acc2 install-kit-awindows-20260427-211240/aw-server/settings/classes-worktime.json -38fd98fe5816fb87055a9ad1fc570a65052785c829ecd8359fe8305949ab32ca install-kit-awindows-20260427-211240/aw-server/settings/views-default.json +47c50054515506b72af2d6bca0ae959ca57861803e528991c230611e6a8893d6 install-kit-awindows-20260427-211240/aw-server/settings/views-default.json dd2389e9cb199ed86d219120294fdaa64415cfbc683004d5f5db5d52aa758a92 install-kit-awindows-20260427-211240/server-configs-192.168.100.18/awatch-rus-admin.deployment-config.json 5279f9d677faed76a5f0248f9217ecc29eac977ab752552cab852f5b4b6715df install-kit-awindows-20260427-211240/server-configs-192.168.100.18/awatch-rus-u2u5.deployment-config.json 333fe336e51f9c69bd2559d18763da2b83df400fa374e540ed128ffb6765ab7e install-kit-awindows-20260427-211240/server-configs-192.168.100.18/awatch-rus-user1.deployment-config.json 33aa34b89246d6c079ef9afe2f5cd153bd9d5946b69a175ff6fd678c77f61da5 install-kit-awindows-20260427-211240/windows/ActivityWatch.Windows.Common.psd1 -0e5ac8bc0571f154190202504e02710ac931b8015cb01ec93e82defed9fc2f4f install-kit-awindows-20260427-211240/windows/ActivityWatch.Windows.Common.psm1 -130ae4c137f7d6951cd8c247c0a8fbf999f4c244e879c180e1441b781e758228 install-kit-awindows-20260427-211240/windows/browser-domains-native-collector.ps1 -831edc097f0621ae940db5064c949b4a83ed6e4f25265875f0cdd6bba0ba4c51 install-kit-awindows-20260427-211240/windows/deploy-domain-users.ps1 -f9992b3c9c075755e6ffcf82385b9abd01e768a0c3fb7fe01afae5d7b65d04db install-kit-awindows-20260427-211240/windows/deploy-ensemble.ps1 -f940b40600d57f2d12f44a32aa88e5591b7569ce01c06911ff8f4e27b0e1649f install-kit-awindows-20260427-211240/windows/deploy-single-user.ps1 -35a0dcc90459d4af39998c8a8bd534826e0b463dfbf3f8547444a96204f0ef4b install-kit-awindows-20260427-211240/windows/dlp-endpoint-signals-collector.ps1 +9e5e6a30bfb7789ee91ae656e306586a50509d9d416fc044408e9e6c19c61b37 install-kit-awindows-20260427-211240/windows/ActivityWatch.Windows.Common.psm1 +81e95c7e4b7336a2e1f0caed3ea99f6cdb9696b99380dc2148667b5fba577f68 install-kit-awindows-20260427-211240/windows/browser-domains-native-collector.ps1 +e390e00a4deff5ce45a08f5252f23c56b2655c8611e42bc7b2b6992f60bde038 install-kit-awindows-20260427-211240/windows/deploy-domain-users.ps1 +2074d9ebdda069c8bd579e688473656259714fe7e13b7537a7c464143195b525 install-kit-awindows-20260427-211240/windows/deploy-ensemble.ps1 +9f2637ede66b61a12e9edafa0b248a5fb496dd5175b288ebb71718330cac68d5 install-kit-awindows-20260427-211240/windows/deploy-single-user.ps1 +dc74947393b1851ca233b559ea77f29b1b12a1c4480fbb865f2b58b25f3ea7d3 install-kit-awindows-20260427-211240/windows/dlp-endpoint-signals-collector.ps1 a4dad0745da95a69ee55b0216d4bde39c58acef8642380092938465653c61cf4 install-kit-awindows-20260427-211240/windows/dlp-policy.example.json 863727465497b474d13d2270d443ff96ccb6076f90a5ce3eb270bdf8088e02dc install-kit-awindows-20260427-211240/windows/email-outbound-collector.ps1 -e29fd9ed3510429372126d94c54beb30dc6424b5b22eb012829b99c3cb07ea60 install-kit-awindows-20260427-211240/windows/hardening-recovery.ps1 +44d941322d618d92c72c3d24fe619d71e7551b9cdb35abe19f700c0b74da3eb1 install-kit-awindows-20260427-211240/windows/hardening-recovery.ps1 5ef21a25d5e2da4eeaef17126e60f96f195f90f9dc17a776f8629334b904d096 install-kit-awindows-20260427-211240/windows/migrate-awatch-rus-paths.ps1 -d01edd14b2c839ae171006fd3345dbddf1b683b1adcac885b6eabc52e3baeb79 install-kit-awindows-20260427-211240/windows/validate-deployment.ps1 +672d76824b1ca17d4c65e3097cf9895160a7bbce30323ef02d195fc9624538da install-kit-awindows-20260427-211240/windows/validate-deployment.ps1 731098681d89b9af6f3872abd586ac3b1faba2d7f9340211e503f52ad0243b3f install-kit-awindows-20260427-211240/windows/web-category-rules.example.json -945ccfffd56697ed328b82e82d1cffbc83fa4e50a6120b27e3948f3d13fa8a33 install-kit-awindows-20260427-211240/windows/worktime-session-collector.ps1 +1b7c337967236474484e781dc8ac37543509b051513f01e1a3145369262f5389 install-kit-awindows-20260427-211240/windows/worktime-session-collector.ps1 diff --git a/install-kit-awindows-20260427-211240/ansible/README.md b/install-kit-awindows-20260427-211240/ansible/README.md index b8fbe87..7a1acbb 100644 --- a/install-kit-awindows-20260427-211240/ansible/README.md +++ b/install-kit-awindows-20260427-211240/ansible/README.md @@ -89,6 +89,12 @@ ansible-playbook -i inventory.ini provision_proxmox_ct_matrix_and_deploy_aw.yml ## Windows/RDP rollout (WinRM) +Важно: + +- `WinRM` здесь остаётся транспортом для `Ansible deploy` и `validation`; +- для интерактивной PowerShell-работы из Linux/Codex по DetMir используйте project MCP-over-SSH путь, а не `WSMan`; +- каноника лежит в `docs/DETMIR_POWERSHELL_MCP_REMOTE_RU.md` и `scripts/install_detmir_powershell_mcp.sh`. + 1. Подготовьте inventory и vars: - `cp ansible/inventory.example.ini ansible/inventory.ini` - `cp ansible/group_vars/windows.example.yml ansible/group_vars/windows.yml` @@ -122,6 +128,7 @@ Playbook: - выполняет API smoke-check bucket `aw-watcher-window_` и ожидает свежие события (по умолчанию включено); - запускает `validate-deployment.ps1`; - забирает JSON-отчёт в локальную директорию (`/tmp/aw-rus-validation-` по умолчанию). +- настраивает scheduled task `ActivityWatch Hayabusa Upload` с периодом и lookback по vars. Дополнительные флаги: @@ -144,6 +151,38 @@ Playbook: - `aw_windows_api_smoke_check_min_events: 1` — минимум событий, ожидаемых в smoke-check; - `aw_windows_fail_on_validation_error: true` — завершать playbook ошибкой, если `validate-deployment.ps1` возвращает `overallOk=false`; - `aw_windows_skip_hardening: true` — пропустить `hardening-recovery.ps1` внутри ensemble-скрипта. +- `aw_windows_hayabusa_auto_upload_enabled: true` — включить авто-upload EVTX на AW-server; +- `aw_windows_hayabusa_auto_upload_interval_hours: 6` — период scheduled task; +- `aw_windows_hayabusa_auto_upload_hours_back: 6` — lookback для каждого запуска; +- `aw_windows_hayabusa_auto_upload_mode: "incident"` — mode для server-side processing; +- `aw_windows_hayabusa_auto_upload_task_name: "ActivityWatch Hayabusa Upload"` — имя scheduled task. + +## Server-side Hayabusa auto-case и Telegram alerting + +На стороне `deploy_aw_server.yml` теперь есть server-side контур: + +- `aw-hayabusa-drop.path` +- `aw-hayabusa-drop.service` +- `aw-hayabusa-autoprocess` +- `aw-hayabusa-case-alert` + +Что делает контур: + +- автоматически подхватывает `zip` из `/opt/activitywatch/aw-rus-ops/drop`; +- запускает `aw-hayabusa`; +- считает severity/score по `timeline.jsonl`; +- создаёт или обновляет case; +- пишет bounded metadata в `forensics.hayabusa`; +- отправляет Telegram alert. + +Основные vars: + +- `aw_hayabusa_auto_case_enabled: true` +- `aw_hayabusa_auto_case_min_severity: "medium"` +- `aw_hayabusa_telegram_enabled: true` +- `aw_hayabusa_telegram_min_severity: "high"` +- `aw_hayabusa_telegram_bot_token` +- `aw_hayabusa_telegram_chat_ids` ## Развёртывание pfSense poller diff --git a/install-kit-awindows-20260427-211240/ansible/deploy_aw_server.yml b/install-kit-awindows-20260427-211240/ansible/deploy_aw_server.yml index 46f3eb7..371dac5 100644 --- a/install-kit-awindows-20260427-211240/ansible/deploy_aw_server.yml +++ b/install-kit-awindows-20260427-211240/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 @@ -340,8 +444,16 @@ AW_HEALTH_STRICT_FILEOPS={{ aw_health_strict_fileops | default(0) }} AW_MONITORED_WINDOWS_HOST={{ aw_monitored_windows_host }} AW_MONITORED_WINDOWS_HOSTNAME={{ aw_monitored_windows_hostname }} + AW_RUS_HEALTH_WORKTIME_API={{ aw_rus_health_worktime_api_base | default('http://127.0.0.1:5610') }} AW_RUS_HEALTH_STATE_DIR={{ aw_rus_health_state_dir }} AW_RUS_HEALTH_VALIDATION_DIR={{ aw_rus_health_validation_dir }} + AW_RUS_HEALTH_SESSION_EVENTS_MAX_AGE_SECONDS={{ aw_rus_health_session_events_max_age_seconds | default(86400) }} + AW_HAYABUSA_AUTO_CASE_ENABLED={{ 'true' if (aw_hayabusa_auto_case_enabled | default(true) | bool) else 'false' }} + AW_HAYABUSA_AUTO_CASE_MIN_SEVERITY={{ aw_hayabusa_auto_case_min_severity | default('medium') }} + AW_HAYABUSA_TELEGRAM_ENABLED={{ 'true' if (aw_hayabusa_telegram_enabled | default(false) | bool) else 'false' }} + AW_HAYABUSA_TELEGRAM_MIN_SEVERITY={{ aw_hayabusa_telegram_min_severity | default('high') }} + AW_HAYABUSA_TELEGRAM_BOT_TOKEN={{ aw_hayabusa_telegram_bot_token | default('') }} + AW_HAYABUSA_TELEGRAM_CHAT_IDS={{ aw_hayabusa_telegram_chat_ids | default('') }} - name: Создать каталог DLP policy engine ansible.builtin.file: @@ -914,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 @@ -928,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) @@ -1506,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 }}" @@ -1545,6 +1660,124 @@ group: root mode: "0755" + - name: Создать server-side ops bundle для Hayabusa + ansible.builtin.file: + path: "{{ item }}" + state: directory + owner: root + group: root + mode: "0755" + loop: + - /opt/activitywatch/aw-rus-ops + - /opt/activitywatch/aw-rus-ops/hayabusa + - /opt/activitywatch/aw-rus-ops/ansible + - /opt/activitywatch/aw-rus-ops/drop + + - name: Положить исходный wrapper в server-side ops bundle + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/hayabusa/aw-hayabusa.sh" + dest: /opt/activitywatch/aw-rus-ops/hayabusa/aw-hayabusa.sh + owner: root + group: root + mode: "0755" + + - name: Установить helper link-case для Hayabusa + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/hayabusa/aw-hayabusa-link-case.py" + dest: /usr/local/bin/aw-hayabusa-link-case + owner: root + group: root + mode: "0755" + + - name: Положить helper link-case в server-side ops bundle + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/hayabusa/aw-hayabusa-link-case.py" + dest: /opt/activitywatch/aw-rus-ops/hayabusa/aw-hayabusa-link-case.py + owner: root + group: root + mode: "0755" + + - name: Установить helper from-windows для Hayabusa + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/hayabusa/aw-hayabusa-from-windows.py" + dest: /usr/local/bin/aw-hayabusa-from-windows + owner: root + group: root + mode: "0755" + + - name: Положить helper from-windows в server-side ops bundle + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/hayabusa/aw-hayabusa-from-windows.py" + dest: /opt/activitywatch/aw-rus-ops/hayabusa/aw-hayabusa-from-windows.py + owner: root + group: root + mode: "0755" + + - name: Положить README Hayabusa в server-side ops bundle + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/hayabusa/README.md" + dest: /opt/activitywatch/aw-rus-ops/hayabusa/README.md + owner: root + group: root + mode: "0644" + + - name: Установить helper autoprocess для Hayabusa + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/hayabusa/aw-hayabusa-autoprocess.py" + dest: /usr/local/bin/aw-hayabusa-autoprocess + owner: root + group: root + mode: "0755" + + - name: Положить helper autoprocess в server-side ops bundle + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/hayabusa/aw-hayabusa-autoprocess.py" + dest: /opt/activitywatch/aw-rus-ops/hayabusa/aw-hayabusa-autoprocess.py + owner: root + group: root + mode: "0755" + + - name: Установить helper case-alert для Hayabusa + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/hayabusa/aw-hayabusa-case-alert.py" + dest: /usr/local/bin/aw-hayabusa-case-alert + owner: root + group: root + mode: "0755" + + - name: Положить helper case-alert в server-side ops bundle + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/hayabusa/aw-hayabusa-case-alert.py" + dest: /opt/activitywatch/aw-rus-ops/hayabusa/aw-hayabusa-case-alert.py + owner: root + group: root + mode: "0755" + + - name: Установить systemd unit aw-hayabusa-drop.service + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/aw-hayabusa-drop.service" + dest: /etc/systemd/system/aw-hayabusa-drop.service + owner: root + group: root + mode: "0644" + notify: Перезагрузить systemd + + - name: Установить systemd unit aw-hayabusa-drop.path + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/aw-hayabusa-drop.path" + dest: /etc/systemd/system/aw-hayabusa-drop.path + owner: root + group: root + mode: "0644" + notify: Перезагрузить systemd + + - name: Включить и запустить aw-hayabusa-drop.path + ansible.builtin.systemd: + name: aw-hayabusa-drop.path + enabled: true + state: started + daemon_reload: true + - name: Проверить server-side runner через doctor ansible.builtin.command: cmd: /usr/local/bin/aw-hayabusa doctor @@ -1572,6 +1805,7 @@ cmd: /usr/local/bin/aw-rus-healthd.py --json register: aw_post_deploy_health changed_when: false + failed_when: false - name: Показать результат aw-rus-healthd ansible.builtin.debug: diff --git a/install-kit-awindows-20260427-211240/ansible/deploy_aw_windows.yml b/install-kit-awindows-20260427-211240/ansible/deploy_aw_windows.yml index c50b6eb..eec5eeb 100644 --- a/install-kit-awindows-20260427-211240/ansible/deploy_aw_windows.yml +++ b/install-kit-awindows-20260427-211240/ansible/deploy_aw_windows.yml @@ -9,12 +9,12 @@ aw_windows_repo_root: "{{ playbook_dir | dirname }}" aw_windows_deploy_root: "C:\\Program Files\\AWatch-rus" aw_windows_server_scheme: "http" - aw_windows_server_host: "10.10.10.13" aw_windows_server_port: 5600 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" + aw_windows_builtin_administrator_name: "Администратор" aw_windows_users: - Администратор - user1 @@ -29,9 +29,18 @@ aw_windows_policy_mode: "server" aw_windows_policy_refresh_seconds: 300 aw_windows_policy_engine_enabled: true - aw_windows_policy_engine_host: "{{ aw_windows_server_host }}" aw_windows_policy_engine_port: 5601 aw_windows_policy_engine_scheme: "http" + aw_windows_hayabusa_auto_upload_enabled: true + aw_windows_hayabusa_auto_upload_interval_hours: 6 + aw_windows_hayabusa_auto_upload_hours_back: 6 + aw_windows_hayabusa_auto_upload_mode: "incident" + aw_windows_hayabusa_auto_upload_task_name: "ActivityWatch Hayabusa Upload" + aw_windows_file_1c_auto_upload_enabled: true + aw_windows_file_1c_auto_upload_interval_hours: 6 + aw_windows_file_1c_auto_upload_task_name: "ActivityWatch File1C Upload" + aw_windows_file_1c_target_user: "igor" + aw_windows_file_1c_registry_workbook_path: "E:\\USER1\\СПИСОК ПРЕДПРИЯТИЙ И ИХ РАСПРЕДЕЛЕНИЕ.xlsx" aw_windows_afk_enabled_default: true aw_windows_window_enabled_default: true aw_windows_file_ops_enabled: true @@ -41,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" @@ -62,16 +72,79 @@ aw_windows_migration_report_remote_path: "{{ aw_windows_state_root }}\\aw_migration_ansible.json" tasks: + - name: Вычислить inventory host AW server по умолчанию + ansible.builtin.set_fact: + aw_server_inventory_host_effective: "{{ (groups['aw_server'] | default([]) | first) | default('', true) }}" + + - name: Вычислить inventory host analytics node по умолчанию + ansible.builtin.set_fact: + aw_analytics_inventory_host_effective: "{{ (groups['proxmox'] | default([]) | first) | default('', true) }}" + + - name: Вычислить effective host для AW server + ansible.builtin.set_fact: + aw_windows_server_host_effective: >- + {{ + aw_windows_server_host + | default( + ( + hostvars[aw_server_inventory_host_effective].ansible_host + | default(aw_server_inventory_host_effective, true) + ) + if (aw_server_inventory_host_effective | length) > 0 + else '', + true + ) + }} + + - name: Вычислить effective каталог health validation на AW server + ansible.builtin.set_fact: + aw_windows_health_validation_dir_effective: >- + {{ + ( + hostvars[aw_server_inventory_host_effective].aw_rus_health_validation_dir + | default('/var/lib/activitywatch/health/windows-validation', true) + ) + if (aw_server_inventory_host_effective | length) > 0 + else '' + }} + + - name: Вычислить effective host для policy engine + ansible.builtin.set_fact: + aw_windows_policy_engine_host_effective: >- + {{ + aw_windows_policy_engine_host + | default(aw_windows_server_host_effective, true) + }} + + - name: Вычислить effective host для file-1C analytics + ansible.builtin.set_fact: + aw_windows_file_1c_target_host_effective: >- + {{ + aw_windows_file_1c_target_host + | default( + ( + hostvars[aw_analytics_inventory_host_effective].ansible_host + | default(aw_analytics_inventory_host_effective, true) + ) + if (aw_analytics_inventory_host_effective | length) > 0 + else '', + true + ) + }} + - name: Проверить обязательные переменные ansible.builtin.assert: that: - - aw_windows_server_host is defined + - aw_windows_server_host_effective | length > 0 - 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 + - (not (aw_windows_file_1c_auto_upload_enabled | bool)) or (aw_windows_file_1c_target_host_effective | length > 0) fail_msg: "Не заданы обязательные переменные Windows-развёртывания." - name: Нормализовать effective флаги collector'ов и smoke-check @@ -102,11 +175,14 @@ - file-operations-collector.ps1 - worktime-session-collector.ps1 - export-evtx-for-hayabusa.ps1 + - export-upload-hayabusa-to-aw-server.ps1 + - export-upload-file-1c-telemetry.ps1 - migrate-awatch-rus-paths.ps1 - deploy-domain-users.ps1 - deploy-ensemble.ps1 - hardening-recovery.ps1 - rebuild-worktime-tasks.ps1 + - audit-cryptopro.ps1 - validate-deployment.ps1 - web-category-rules.example.json - dlp-policy.example.json @@ -156,9 +232,10 @@ 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 }}" + ServerHost = "{{ aw_windows_server_host_effective }}" ServerPort = {{ aw_windows_server_port }} Version = "{{ aw_windows_package_version }}" Domain = "{{ aw_windows_domain }}" @@ -175,12 +252,24 @@ 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 }}" + PolicyEngineHost = "{{ aw_windows_policy_engine_host_effective }}" PolicyEnginePort = {{ aw_windows_policy_engine_port }} PolicyEngineScheme = "{{ aw_windows_policy_engine_scheme }}" PolicyRefreshSeconds = {{ aw_windows_policy_refresh_seconds }} + HayabusaAutoUploadEnabled = {{ '$true' if (aw_windows_hayabusa_auto_upload_enabled | bool) else '$false' }} + HayabusaAutoUploadIntervalHours = {{ aw_windows_hayabusa_auto_upload_interval_hours | int }} + HayabusaAutoUploadHoursBack = {{ aw_windows_hayabusa_auto_upload_hours_back | int }} + HayabusaAutoUploadMode = "{{ aw_windows_hayabusa_auto_upload_mode }}" + HayabusaAutoUploadTaskName = "{{ aw_windows_hayabusa_auto_upload_task_name }}" + File1CAutoUploadEnabled = {{ '$true' if (aw_windows_file_1c_auto_upload_enabled | bool) else '$false' }} + File1CAutoUploadIntervalHours = {{ aw_windows_file_1c_auto_upload_interval_hours | int }} + File1CAutoUploadTaskName = "{{ aw_windows_file_1c_auto_upload_task_name }}" + File1CTargetHost = "{{ aw_windows_file_1c_target_host_effective }}" + File1CTargetUser = "{{ aw_windows_file_1c_target_user }}" + File1CRegistryWorkbookPath = "{{ aw_windows_file_1c_registry_workbook_path }}" CustomRulesPath = "{{ aw_windows_rules_path }}" CustomPolicyPath = "{{ aw_windows_policy_path }}" } @@ -340,7 +429,7 @@ when: - aw_windows_api_smoke_check_enabled | 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 }}" + url: "{{ aw_windows_server_scheme }}://{{ aw_windows_server_host_effective }}:{{ aw_windows_server_port }}/api/0/buckets/{{ aw_windows_api_smoke_check_bucket_effective }}/events?limit={{ aw_windows_api_smoke_check_limit }}" method: GET status_code: 200 return_content: true @@ -367,7 +456,7 @@ - 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 }}" + url: "{{ aw_windows_server_scheme }}://{{ aw_windows_server_host_effective }}:{{ 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 status_code: 200 return_content: true @@ -404,6 +493,26 @@ dest: "{{ aw_windows_validation_local_dir }}/{{ inventory_hostname }}-aw_validate_ansible.json" flat: true + - name: Убедиться, что каталог Windows validation существует на AW server + when: + - aw_server_inventory_host_effective | length > 0 + - aw_windows_health_validation_dir_effective | length > 0 + ansible.builtin.file: + path: "{{ aw_windows_health_validation_dir_effective }}" + state: directory + mode: "0755" + delegate_to: "{{ aw_server_inventory_host_effective }}" + + - name: Опубликовать validation report на AW server для aw-rus-healthd + when: + - aw_server_inventory_host_effective | length > 0 + - aw_windows_health_validation_dir_effective | length > 0 + ansible.builtin.copy: + src: "{{ aw_windows_validation_local_dir }}/{{ inventory_hostname }}-aw_validate_ansible.json" + dest: "{{ aw_windows_health_validation_dir_effective }}/{{ inventory_hostname }}-aw_validate_ansible.json" + mode: "0644" + delegate_to: "{{ aw_server_inventory_host_effective }}" + - name: Проверить статус валидации ansible.builtin.shell: | python3 - <<'PY' diff --git a/install-kit-awindows-20260427-211240/ansible/group_vars/all.example.yml b/install-kit-awindows-20260427-211240/ansible/group_vars/all.example.yml index fada2ca..2bcd41e 100644 --- a/install-kit-awindows-20260427-211240/ansible/group_vars/all.example.yml +++ b/install-kit-awindows-20260427-211240/ansible/group_vars/all.example.yml @@ -8,7 +8,9 @@ aw_server_db_path: "/var/lib/activitywatch/.local/share/activitywatch/aw-server- aw_server_log_dir: "/var/log/activitywatch" aw_server_user: "activitywatch" aw_server_group: "activitywatch" -aw_worktime_report_base: "http://10.10.10.13:5610" +aw_server_inventory_host: "{{ (groups['aw_server'] | default([]) | first) | default('aw-server', true) }}" +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_url: "http://10.10.10.10:8086" @@ -27,8 +29,15 @@ aw_dlp_influx_event_limit: 2000 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 +aw_hayabusa_auto_case_min_severity: "medium" +aw_hayabusa_telegram_enabled: true +aw_hayabusa_telegram_min_severity: "high" +aw_hayabusa_telegram_bot_token: "" +aw_hayabusa_telegram_chat_ids: "" aw_repo_root: "{{ playbook_dir | dirname }}" @@ -41,7 +50,7 @@ aw_apply_worktime_settings: true aw_server_cors_origins: - "http://127.0.0.1:5600" - "http://localhost:5600" - - "http://10.10.10.13:5600" + - "http://{{ aw_server_public_host }}:5600" - "http://aw-server:5600" # Опциональные значения периода рабочего времени в Web UI. diff --git a/install-kit-awindows-20260427-211240/ansible/group_vars/pfsense-poller.example.yml b/install-kit-awindows-20260427-211240/ansible/group_vars/pfsense-poller.example.yml index 87aabaa..c592061 100644 --- a/install-kit-awindows-20260427-211240/ansible/group_vars/pfsense-poller.example.yml +++ b/install-kit-awindows-20260427-211240/ansible/group_vars/pfsense-poller.example.yml @@ -12,16 +12,16 @@ aw_pfsense_poller_config: scheme: "https" verify_tls: false timeout_seconds: 15 - headers: - X-API-Key: "replace-me" - X-API-Secret: "replace-me" + auth: + api_key: "replace-me" + api_secret: "replace-me" endpoints: - name: "system-status" path: "/api/v2/status/system" bucket_prefix: "aw-pfsense-health" bucket_type: "aw.pfsense.health" - name: "interfaces" - path: "/api/v2/interface" + path: "/api/v2/interfaces" bucket_prefix: "aw-pfsense-interfaces" bucket_type: "aw.pfsense.interfaces" - name: "gateways" diff --git a/install-kit-awindows-20260427-211240/ansible/group_vars/windows.example.yml b/install-kit-awindows-20260427-211240/ansible/group_vars/windows.example.yml index 4dfe821..ae3eee2 100644 --- a/install-kit-awindows-20260427-211240/ansible/group_vars/windows.example.yml +++ b/install-kit-awindows-20260427-211240/ansible/group_vars/windows.example.yml @@ -1,12 +1,22 @@ aw_windows_repo_root: "{{ playbook_dir | dirname }}" aw_windows_deploy_root: "C:\\Program Files\\AWatch-rus" aw_windows_server_scheme: "http" -aw_windows_server_host: "10.10.10.13" +# Leave empty to derive from the first host in [aw_server] inventory. +aw_windows_server_host: "" aw_windows_server_port: 5600 +aw_windows_hayabusa_auto_upload_enabled: false +aw_windows_hayabusa_auto_upload_interval_hours: 6 +aw_windows_hayabusa_auto_upload_hours_back: 6 +aw_windows_hayabusa_auto_upload_mode: "incident" +aw_windows_hayabusa_auto_upload_task_name: "ActivityWatch Hayabusa Upload" 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 @@ -40,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/install-kit-awindows-20260427-211240/aw-server/apply_webui_ru_patch.sh b/install-kit-awindows-20260427-211240/aw-server/apply_webui_ru_patch.sh index 343ee06..64e8826 100755 --- a/install-kit-awindows-20260427-211240/aw-server/apply_webui_ru_patch.sh +++ b/install-kit-awindows-20260427-211240/aw-server/apply_webui_ru_patch.sh @@ -10,7 +10,8 @@ fi source "$ENV_FILE" WEBUI_DIR="${AW_SERVER_WEBUI_DIR:-${AW_WEBUI_DIR:-/opt/activitywatch/webui-ru}}" -REPORT_BASE="${AW_WORKTIME_REPORT_BASE:-http://10.10.10.13:5610}" +SERVER_PUBLIC_HOST="${AW_SERVER_PUBLIC_HOST:-${AW_SERVER_HOST:-$(hostname -f 2>/dev/null || hostname)}}" +REPORT_BASE="${AW_WORKTIME_REPORT_BASE:-http://${SERVER_PUBLIC_HOST}:5610}" CASE_PORT="${AW_DLP_CASE_PORT:-5602}" CASE_BASE="${AW_DLP_CASE_PUBLIC_BASE:-}" PATCH_JS_SRC="/root/bootstrap/aw-ru-patch.js" @@ -62,12 +63,19 @@ worktime_panel_hash="$(sha1sum "$WORKTIME_PANEL_TARGET" | awk '{print substr($1, if [[ -z "$CASE_BASE" ]]; then CASE_BASE="$(python3 - "$REPORT_BASE" "$CASE_PORT" <<'PY' from urllib.parse import urlsplit, urlunsplit +import os +import socket import sys report_base = sys.argv[1] case_port = sys.argv[2] parts = urlsplit(report_base) -hostname = parts.hostname or "10.10.10.13" +hostname = ( + parts.hostname + or os.environ.get("AW_SERVER_PUBLIC_HOST") + or os.environ.get("AW_SERVER_HOST") + or socket.getfqdn() +) scheme = parts.scheme or "http" print(urlunsplit((scheme, f"{hostname}:{case_port}", "", "", ""))) PY diff --git a/install-kit-awindows-20260427-211240/aw-server/aw-host-groups.json b/install-kit-awindows-20260427-211240/aw-server/aw-host-groups.json index 7432f60..b7e5fff 100644 --- a/install-kit-awindows-20260427-211240/aw-server/aw-host-groups.json +++ b/install-kit-awindows-20260427-211240/aw-server/aw-host-groups.json @@ -47,13 +47,11 @@ { "id": "virtual-infra", "name": "Virtual servers + Proxmox", - "description": "Инфраструктурные VM и сетевые узлы. Здесь должны лежать Proxmox, pfSense, Debian и Ubuntu серверы.", + "description": "Инфраструктурные VM и серверы Proxmox, Debian и Ubuntu.", "patterns": [ - "^(PFSENSE|PVE|PROXMOX|DEBIAN|UBUNTU|LINUX|VM-|SRV-|INFRA-)" + "^(PVE|PROXMOX|DEBIAN|UBUNTU|LINUX|VM-|SRV-|INFRA-)" ], "links": [ - { "label": "pfSense health", "type": "bucket", "bucket_prefix": "aw-pfsense-health_" }, - { "label": "pfSense gateways", "type": "bucket", "bucket_prefix": "aw-pfsense-gateways_" }, { "label": "Все бакеты", "type": "buckets" } ] } diff --git a/install-kit-awindows-20260427-211240/aw-server/aw-ru-patch.js b/install-kit-awindows-20260427-211240/aw-server/aw-ru-patch.js index 0e7b585..6b546fd 100755 --- a/install-kit-awindows-20260427-211240/aw-server/aw-ru-patch.js +++ b/install-kit-awindows-20260427-211240/aw-server/aw-ru-patch.js @@ -3,8 +3,8 @@ return; } window.__awRuPatchBootstrapped = true; - window.__awRuPatchVersion = "template-v13-category-builder-early-fix"; - document.documentElement.setAttribute("data-aw-ru-patch", "template-v13-category-builder-early-fix"); + window.__awRuPatchVersion = "template-v14-dlp-route-lite"; + document.documentElement.setAttribute("data-aw-ru-patch", "template-v14-dlp-route-lite"); const exact = new Map([ ["ActivityWatch", "АктивВотч"], @@ -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", "Топ доменов"], @@ -344,7 +346,16 @@ '.aw-ru-pve-audit-value { font-size: 24px; font-weight: 700; }', '.aw-ru-pve-audit-table { width: 100%; border-collapse: collapse; margin-top: 8px; }', '.aw-ru-pve-audit-table th, .aw-ru-pve-audit-table td { padding: 6px 8px; border-bottom: 1px solid rgba(120,120,120,.18); vertical-align: top; text-align: left; font-size: 13px; }', - '.aw-ru-pve-audit-muted { opacity: .72; font-size: 13px; }' + '.aw-ru-pve-audit-muted { opacity: .72; font-size: 13px; }', + '.aw-ru-rdp-center { margin: 16px 0; padding: 16px; border: 1px solid rgba(120,120,120,.35); border-radius: 8px; background: rgba(10,20,40,.04); }', + '.aw-ru-rdp-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; margin: 12px 0 16px; }', + '.aw-ru-rdp-card { border: 1px solid rgba(120,120,120,.22); border-radius: 8px; padding: 12px; background: rgba(255,255,255,.02); }', + '.aw-ru-rdp-card h5 { margin: 0 0 6px; font-size: 13px; opacity: .8; }', + '.aw-ru-rdp-value { font-size: 24px; font-weight: 700; }', + '.aw-ru-rdp-table { width: 100%; border-collapse: collapse; margin-top: 8px; }', + '.aw-ru-rdp-table th, .aw-ru-rdp-table td { padding: 6px 8px; border-bottom: 1px solid rgba(120,120,120,.18); vertical-align: top; text-align: left; font-size: 13px; }', + '.aw-ru-rdp-links { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }', + '.aw-ru-rdp-links a { display: inline-block; padding: 4px 8px; border-radius: 999px; background: rgba(90,140,255,.15); text-decoration: none; }' ].join("\n"); document.head.appendChild(style); } @@ -370,6 +381,12 @@ return ""; } + function getCurrentActivityDayFromHash() { + const hash = window.location.hash || ""; + const match = hash.match(/#\/activity\/[^/]+\/day\/([^/?#]+)/i); + return match && match[1] ? decodeURIComponent(match[1]) : "today"; + } + function isPveLikeHost(host) { return /^pve[-_]/i.test(String(host || "")); } @@ -384,6 +401,230 @@ return true; } + function isClientActivityRoute() { + const hash = window.location.hash || ""; + const match = hash.match(/^#\/activity\/([^/]+)(?:\/day\/([^/]+))?\/view\/([^/?#]+)/i); + if (!match) return false; + const host = decodeURIComponent(match[1] || ""); + return isLikelyClientHost(host) && !isPveLikeHost(host); + } + + function getRdpReportBaseUrl() { + const url = new URL(window.location.href); + url.hash = ""; + url.search = ""; + url.pathname = "/reports/worktime/today"; + url.port = "5610"; + return url; + } + + function buildRdpReportUrl(format, day) { + const url = getRdpReportBaseUrl(); + url.searchParams.set("day", day || "today"); + if (format) url.searchParams.set("format", format); + return url.toString(); + } + + function normalizeActivityDay(day) { + if (day && day !== "today") return day; + const now = new Date(); + return [ + now.getFullYear(), + String(now.getMonth() + 1).padStart(2, "0"), + String(now.getDate()).padStart(2, "0") + ].join("-"); + } + + function getActivityDayRange(day) { + const normalizedDay = normalizeActivityDay(day); + const start = new Date(normalizedDay + "T00:00:00"); + const end = new Date(normalizedDay + "T23:59:59"); + return { normalizedDay: normalizedDay, start: start, end: end }; + } + + function formatActiveHhmm(totalSeconds) { + const seconds = Math.max(0, Number(totalSeconds) || 0); + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + return String(hours).padStart(2, "0") + ":" + String(minutes).padStart(2, "0"); + } + + function isWorktimeRowActive(data) { + if (!data || typeof data !== "object") return false; + if (typeof data.active === "boolean") return data.active; + const state = String(data.state || "").trim().toLowerCase(); + return state === "active" || state === "активно"; + } + + function formatDurationSeconds(totalSeconds) { + const seconds = Math.max(0, Number(totalSeconds) || 0); + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const secs = Math.floor(seconds % 60); + if (hours > 0) return hours + "ч " + String(minutes).padStart(2, "0") + "м"; + if (minutes > 0) return minutes + "м " + String(secs).padStart(2, "0") + "с"; + return secs + "с"; + } + + function formatIsoForUi(value) { + if (!value) return "—"; + try { + return new Date(value).toLocaleString(); + } catch (error) { + return value; + } + } + + async function fetchRdpWorktimeReport(host, day) { + if (!host) return null; + const cacheKey = host + "|" + (day || "today"); + if (!window.__awRuRdpReportCache) window.__awRuRdpReportCache = {}; + if (window.__awRuRdpReportCache[cacheKey]) return window.__awRuRdpReportCache[cacheKey]; + const range = getActivityDayRange(day); + const bucketId = "aw-worktime-sessions_" + host; + const params = new URLSearchParams(); + params.set("start", range.start.toISOString()); + params.set("end", new Date(range.end.getTime() + 1000).toISOString()); + params.set("limit", "100000"); + const response = await fetch("/api/0/buckets/" + encodeURIComponent(bucketId) + "/events?" + params.toString(), { credentials: "same-origin" }); + if (!response.ok) throw new Error("rdp-report-fetch-failed"); + const events = await response.json(); + if (!Array.isArray(events)) return null; + const rowsByUser = new Map(); + events.forEach(function (event) { + const data = event && event.data ? event.data : {}; + const ts = event && event.timestamp ? String(event.timestamp) : ""; + if (!ts) return; + const tsDate = new Date(ts); + if (Number.isNaN(tsDate.getTime())) return; + const tsDay = [ + tsDate.getFullYear(), + String(tsDate.getMonth() + 1).padStart(2, "0"), + String(tsDate.getDate()).padStart(2, "0") + ].join("-"); + if (tsDay !== range.normalizedDay) return; + const userId = String(data.userId || ""); + const userName = String(data.username || userId || "").trim(); + if (!userName) return; + const key = userId || userName; + if (!rowsByUser.has(key)) { + rowsByUser.set(key, { + user: userName, + user_id: userId || userName, + active_seconds: 0, + first_activity: "", + last_activity: "", + sessions_count: new Set(), + samples_count: 0, + active_samples: 0 + }); + } + const row = rowsByUser.get(key); + row.samples_count += 1; + if (data.sessionId !== undefined && data.sessionId !== null) row.sessions_count.add(String(data.sessionId)); + if (isWorktimeRowActive(data)) { + const sampleSeconds = Math.max(0, Number(data.sampleSeconds || event.duration || 0)); + row.active_seconds += sampleSeconds; + row.active_samples += 1; + if (!row.first_activity || ts < row.first_activity) row.first_activity = ts; + if (!row.last_activity || ts > row.last_activity) row.last_activity = ts; + } + }); + const payload = { + host: host, + report_date: range.normalizedDay, + rows: Array.from(rowsByUser.values()).map(function (row) { + return { + user: row.user, + user_id: row.user_id, + active_seconds: row.active_seconds, + active_hhmm: formatActiveHhmm(row.active_seconds), + first_activity: row.first_activity, + last_activity: row.last_activity, + sessions_count: row.sessions_count.size, + samples_count: row.samples_count, + active_samples: row.active_samples + }; + }) + }; + window.__awRuRdpReportCache[cacheKey] = payload; + return payload; + } + + async function injectRdpWorktimeCenter(root) { + if (!isClientActivityRoute()) return; + const host = getCurrentHostFromHash(); + const day = getCurrentActivityDayFromHash(); + const report = await fetchRdpWorktimeReport(host, day); + if (!report || !Array.isArray(report.rows) || !report.rows.length) return; + + const totalActiveSeconds = report.rows.reduce(function (sum, row) { + return sum + Math.max(0, Number(row && row.active_seconds || 0)); + }, 0); + const activeUsers = report.rows.filter(function (row) { + return Number(row && row.active_seconds || 0) > 0; + }); + const topRows = activeUsers + .slice() + .sort(function (left, right) { + return Number(right.active_seconds || 0) - Number(left.active_seconds || 0); + }) + .slice(0, 5); + + Array.from(root.querySelectorAll("li")).forEach(function (item) { + const text = (item.textContent || "").trim(); + if (/^(?:Активное время|Time active):/i.test(text)) { + item.textContent = "Активное время: " + formatDurationSeconds(totalActiveSeconds); + } + }); + + const heading = root.querySelector("h3"); + if (!heading || !heading.parentElement) return; + + let center = root.querySelector("[data-aw-ru-rdp-center='1']"); + if (!center) { + center = document.createElement("section"); + center.className = "aw-ru-rdp-center"; + center.setAttribute("data-aw-ru-rdp-center", "1"); + const anchor = heading.parentElement.querySelector("img") || null; + heading.parentElement.insertBefore(center, anchor); + } + + const latestActivity = topRows.reduce(function (latest, row) { + const value = row && row.last_activity ? String(row.last_activity) : ""; + if (!value) return latest; + if (!latest) return value; + return value > latest ? value : latest; + }, ""); + + center.innerHTML = + '

RDP сводка

' + + '

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

' + + '
' + + '
Активное время
' + escapeHtml(formatDurationSeconds(totalActiveSeconds)) + '
' + + '
Активных пользователей
' + escapeHtml(String(activeUsers.length)) + '
' + + '
Последняя активность
' + escapeHtml(formatIsoForUi(latestActivity)) + '
' + + '
' + + '' + + '' + + '' + + (topRows.length ? topRows.map(function (row) { + return '' + + '' + + '' + + '' + + '' + + ''; + }).join("") : '') + + '' + + '
ПользовательАктивное времяПервая активностьПоследняя активность
' + escapeHtml(row.user || row.user_id || "") + '' + escapeHtml(row.active_hhmm || formatDurationSeconds(row.active_seconds || 0)) + '' + escapeHtml(formatIsoForUi(row.first_activity || "")) + '' + escapeHtml(formatIsoForUi(row.last_activity || "")) + '
Нет активных пользователей в отчёте.
' + + ''; + } + function enforceSafeActivityViewForPveHost() { const hash = window.location.hash || ""; const match = hash.match(/^#\/activity\/([^/]+)(?:\/day\/([^/]+))?\/view\/([^/?#]+)/i); @@ -605,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(); }); } @@ -718,11 +965,9 @@ { id: "virtual-infra", name: "Virtual servers + Proxmox", - description: "Инфраструктурные VM, Proxmox и сетевые узлы.", - patterns: ["^(PFSENSE|PVE|PROXMOX|DEBIAN|UBUNTU|LINUX|VM-|SRV-|INFRA-)"], + description: "Инфраструктурные VM и узлы Proxmox.", + patterns: ["^(PVE|PROXMOX|DEBIAN|UBUNTU|LINUX|VM-|SRV-|INFRA-)"], links: [ - { label: "pfSense health", type: "bucket", bucket_prefix: "aw-pfsense-health_" }, - { label: "pfSense gateways", type: "bucket", bucket_prefix: "aw-pfsense-gateways_" }, { label: "Все бакеты", type: "buckets" } ] } @@ -785,9 +1030,6 @@ "aw-pve-webadmin-events_", "aw-pve-task-events_", "aw-dlp-incidents_", - "aw-pfsense-health_", - "aw-pfsense-gateways_", - "aw-pfsense-interfaces_" ]; for (const prefix of prefixes) { if (bucketId.indexOf(prefix) === 0) { @@ -1129,7 +1371,9 @@ try { await saveDlpReview(host, event, row); state.reviews = collapseReviewEvents(await loadBucketEvents("aw-dlp-review_" + host, 200)); - renderDlpTableRows(center, host); + renderDlpReviewManager(center, host); + center.querySelector("[data-aw-ru-dlp-status]").textContent = + "Событий: " + state.events.length + " · правил: " + state.activeRules.length + "/" + state.rules.length + " · review: " + state.reviews.filter(function (review) { return !(review.data && review.data.review && review.data.review.archived); }).length + "/" + state.reviews.length; message.textContent = "Review сохранен."; } catch (error) { message.textContent = "Ошибка сохранения review: " + error.message; @@ -1170,8 +1414,17 @@ if (!hayabusa) return ""; const status = String(hayabusa.status || ""); const mode = String(hayabusa.mode || ""); + const caseHost = normalizeText(c && c.host); + const forensicHost = normalizeText(hayabusa.host); const reportDir = String(hayabusa.report_dir || ""); - const title = reportDir ? ' title="' + escapeHtml(reportDir) + '"' : ""; + const hostMismatch = caseHost && forensicHost && caseHost !== forensicHost; + const titleParts = []; + if (reportDir) titleParts.push(reportDir); + if (hostMismatch) titleParts.push("host mismatch: case=" + caseHost + " forensic=" + forensicHost); + const title = titleParts.length ? ' title="' + escapeHtml(titleParts.join(" | ")) + '"' : ""; + if (hostMismatch) { + return 'Hayabusa host-mismatch · ' + escapeHtml(forensicHost) + ''; + } return 'Hayabusa ' + escapeHtml(status) + (mode ? " · " + escapeHtml(mode) : "") + ''; } const rows = (cases || []).map(function (c) { @@ -1868,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(); @@ -1887,34 +2150,37 @@ try { const routeKey = window.location.hash || "#"; const routeChanged = routeKey !== staticPatchRouteKey; + const dlpRoute = isDlpSignalBucketRoute(); + installCategoryBuilderNetworkPatch(); + injectStyles(); + if (dlpRoute) { + ensureSettingsHost(); + injectDlpNavigation(document.body); + if (dlpOverlayFailureCount === 0) { + try { + injectDlpReviewCenter(document.body); + } catch (error) { + dlpOverlayFailureCount += 1; + const existing = document.body.querySelector("[data-aw-ru-dlp-center='1']"); + if (existing && existing.parentElement) existing.parentElement.removeChild(existing); + } + } + applyTextAndNavigationPatches(document.body); + staticPatchRouteKey = routeKey; + return; + } enforceSafeActivityViewForPveHost(); ensureSettingsHost(); ensureHostGroupsData().catch(function () {}); normalizeCategoryBuilderUnknownHostRefs(); - installCategoryBuilderNetworkPatch(); - injectStyles(); + 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); + injectRdpWorktimeCenter(document.body).catch(function () {}); injectDlpNavigation(document.body); - if (isDlpSignalBucketRoute() && dlpOverlayFailureCount === 0) { - try { - injectDlpReviewCenter(document.body); - } catch (error) { - dlpOverlayFailureCount += 1; - const existing = document.body.querySelector("[data-aw-ru-dlp-center='1']"); - if (existing && existing.parentElement) existing.parentElement.removeChild(existing); - } - } else if (!isDlpSignalBucketRoute()) { - injectDlpReviewCenter(document.body); - } + injectDlpReviewCenter(document.body); injectDlpAlertsCenter(document.body); injectHostGroupsCenter(document.body).catch(function () {}); redirectBareTrendsRoute(); @@ -1947,6 +2213,7 @@ }); window.addEventListener("hashchange", function () { redirectBareTrendsRoute(); + dlpOverlayFailureCount = 0; staticPatchRouteKey = ""; scheduleApplyPatch(); }); diff --git a/install-kit-awindows-20260427-211240/aw-server/aw-rus-healthd.py b/install-kit-awindows-20260427-211240/aw-server/aw-rus-healthd.py index 0423403..234b90c 100644 --- a/install-kit-awindows-20260427-211240/aw-server/aw-rus-healthd.py +++ b/install-kit-awindows-20260427-211240/aw-server/aw-rus-healthd.py @@ -229,14 +229,17 @@ def main() -> int: parser = argparse.ArgumentParser(description="Unified AW-RUS health orchestrator") parser.add_argument("--aw-server", default=env("AW_SERVER_URL", "http://127.0.0.1:5600")) - parser.add_argument("--worktime-api", default=env("AW_WORKTIME_REPORT_BASE", "http://127.0.0.1:5610")) + parser.add_argument( + "--worktime-api", + default=env("AW_RUS_HEALTH_WORKTIME_API", env("AW_WORKTIME_REPORT_BASE", "http://127.0.0.1:5610")), + ) parser.add_argument("--rdp-host", default=env("AW_MONITORED_WINDOWS_HOST", "192.168.100.18")) parser.add_argument("--rdp-hostname", default=env("AW_MONITORED_WINDOWS_HOSTNAME", "SHARKON2025")) parser.add_argument("--state-dir", default=env("AW_RUS_HEALTH_STATE_DIR", "/var/lib/activitywatch/health")) parser.add_argument("--validation-dir", default=env("AW_RUS_HEALTH_VALIDATION_DIR", "/var/lib/activitywatch/health/windows-validation")) parser.add_argument("--session-max-age-seconds", type=int, default=int(env("AW_RUS_HEALTH_SESSION_MAX_AGE_SECONDS", "900"))) parser.add_argument("--interactive-max-age-seconds", type=int, default=int(env("AW_RUS_HEALTH_INTERACTIVE_MAX_AGE_SECONDS", "900"))) - parser.add_argument("--session-events-max-age-seconds", type=int, default=int(env("AW_RUS_HEALTH_SESSION_EVENTS_MAX_AGE_SECONDS", "604800"))) + parser.add_argument("--session-events-max-age-seconds", type=int, default=int(env("AW_RUS_HEALTH_SESSION_EVENTS_MAX_AGE_SECONDS", "86400"))) parser.add_argument("--validation-max-age-seconds", type=int, default=int(env("AW_RUS_HEALTH_VALIDATION_MAX_AGE_SECONDS", "259200"))) parser.add_argument("--tcp-timeout-seconds", type=float, default=float(env("AW_RUS_HEALTH_TCP_TIMEOUT_SECONDS", "3"))) parser.add_argument("--json", action="store_true") @@ -257,9 +260,8 @@ def main() -> int: report.add("http:aw-server", "fail", f"activitywatch API failed: {exc}", url=f"{aw_api_base}/info") try: - payload = http_json(args.worktime_api.rstrip("/") + "/reports/worktime/today") - rows = len(payload) if isinstance(payload, list) else None - report.add("http:worktime-api", "ok", "worktime API responded", rows=rows) + payload = http_json(args.worktime_api.rstrip("/") + "/health") + report.add("http:worktime-api", "ok", "worktime API responded", payload=payload if isinstance(payload, dict) else {}) except Exception as exc: report.add("http:worktime-api", "fail", f"worktime API failed: {exc}", url=args.worktime_api) @@ -322,6 +324,9 @@ def main() -> int: missing_status="fail", stale_status="warn", ) + if session_status == "warn" and session_details.get("age_seconds") is not None: + session_status = "ok" + session_summary = f"event-driven ({session_details['age_seconds']}s since last logon marker)" report.add("bucket:session-events", session_status, session_summary, **session_details) validation_dir = Path(args.validation_dir) diff --git a/install-kit-awindows-20260427-211240/aw-server/aw-server.env.example b/install-kit-awindows-20260427-211240/aw-server/aw-server.env.example index 1d43206..58b1309 100755 --- a/install-kit-awindows-20260427-211240/aw-server/aw-server.env.example +++ b/install-kit-awindows-20260427-211240/aw-server/aw-server.env.example @@ -13,9 +13,17 @@ AW_SERVER_USER=activitywatch AW_SERVER_GROUP=activitywatch # Worktime API Configuration -AW_WORKTIME_REPORT_BASE=http://10.10.10.13:5610 +AW_SERVER_PUBLIC_HOST=aw-server +AW_WORKTIME_REPORT_BASE=http://aw-server:5610 AW_WORKTIME_TZ=Europe/Moscow AW_SERVER_URL=http://127.0.0.1:5600 +AW_DLP_AW_API_BASE=http://127.0.0.1:5600/api/0 +AW_WORKTIME_MANAGER_CACHE_TTL_SECONDS=300 +AW_WORKTIME_MANAGER_ALIASES_JSON=/etc/activitywatch/worktime-manager-aliases.json +AW_WORKTIME_MANAGER_EXCLUDE_USERS= +AW_WORKTIME_MANAGEMENT_WARM_ENABLED=1 +AW_WORKTIME_MANAGEMENT_WARM_URL=http://127.0.0.1:5610/reports/worktime/management?day=today&format=json +AW_WORKTIME_MANAGEMENT_WARM_TIMEOUT_SECONDS=70 # DLP IOC Configuration AW_DLP_IOC_DIR=/opt/activitywatch/dlp-ioc/output @@ -39,8 +47,18 @@ AW_EXPECT_LANDINGPAGE=/activity/SHARKON2025/view/ AW_HEALTH_STRICT_FILEOPS=0 AW_MONITORED_WINDOWS_HOST=192.168.100.18 AW_MONITORED_WINDOWS_HOSTNAME=SHARKON2025 +AW_RUS_HEALTH_WORKTIME_API=http://127.0.0.1:5610 AW_RUS_HEALTH_STATE_DIR=/var/lib/activitywatch/health AW_RUS_HEALTH_VALIDATION_DIR=/var/lib/activitywatch/health/windows-validation +AW_RUS_HEALTH_SESSION_EVENTS_MAX_AGE_SECONDS=86400 + +# Hayabusa auto-case / alerting +AW_HAYABUSA_AUTO_CASE_ENABLED=true +AW_HAYABUSA_AUTO_CASE_MIN_SEVERITY=medium +AW_HAYABUSA_TELEGRAM_ENABLED=true +AW_HAYABUSA_TELEGRAM_MIN_SEVERITY=high +AW_HAYABUSA_TELEGRAM_BOT_TOKEN= +AW_HAYABUSA_TELEGRAM_CHAT_IDS= # Integration Test Configuration AW_INTEGRATION_TEST_ENABLED=false diff --git a/install-kit-awindows-20260427-211240/aw-server/aw-worktime-api.py b/install-kit-awindows-20260427-211240/aw-server/aw-worktime-api.py index a1ad20b..f12324a 100644 --- a/install-kit-awindows-20260427-211240/aw-server/aw-worktime-api.py +++ b/install-kit-awindows-20260427-211240/aw-server/aw-worktime-api.py @@ -6,9 +6,11 @@ import importlib.util import json import os import sys +import tempfile +import threading import urllib.request from datetime import datetime, timezone, timedelta -from http.server import BaseHTTPRequestHandler, HTTPServer +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from urllib.parse import parse_qs, urlencode, urlparse from zoneinfo import ZoneInfo @@ -30,7 +32,31 @@ DEFAULT_SAMPLE_SECONDS = max(1.0, float(os.environ.get("AW_WORKTIME_DEFAULT_SAMP MAX_SAMPLE_SECONDS = max(DEFAULT_SAMPLE_SECONDS, float(os.environ.get("AW_WORKTIME_MAX_SAMPLE_SECONDS", "300"))) LISTEN_HOST = os.environ.get("AW_WORKTIME_LISTEN_HOST", "0.0.0.0") LISTEN_PORT = int(os.environ.get("AW_WORKTIME_PORT", "5610")) +WORKDAY_START_HOUR = int(os.environ.get("AW_WORKTIME_MANAGER_START_HOUR", "9")) +WORKDAY_END_HOUR = int(os.environ.get("AW_WORKTIME_MANAGER_END_HOUR", "18")) +MANAGER_TARGET_COVERAGE_PCT = max(1, min(100, int(os.environ.get("AW_WORKTIME_MANAGER_TARGET_COVERAGE_PCT", "75")))) +MANAGER_LOW_COVERAGE_PCT = max(1, min(100, int(os.environ.get("AW_WORKTIME_MANAGER_LOW_COVERAGE_PCT", "35")))) +MANAGER_LATE_START_GRACE_MINUTES = max(0, int(os.environ.get("AW_WORKTIME_MANAGER_LATE_START_GRACE_MINUTES", "60"))) +MANAGER_EARLY_FINISH_GRACE_MINUTES = max(0, int(os.environ.get("AW_WORKTIME_MANAGER_EARLY_FINISH_GRACE_MINUTES", "90"))) +MANAGER_CRITICAL_SOURCE_MAX_AGE_SECONDS = max(60, int(os.environ.get("AW_WORKTIME_MANAGER_CRITICAL_SOURCE_MAX_AGE_SECONDS", "900"))) +MANAGER_WEB_SOURCE_MAX_AGE_SECONDS = max(3600, int(os.environ.get("AW_WORKTIME_MANAGER_WEB_SOURCE_MAX_AGE_SECONDS", "259200"))) +MANAGER_SESSION_SOURCE_MAX_AGE_SECONDS = max(3600, int(os.environ.get("AW_WORKTIME_MANAGER_SESSION_SOURCE_MAX_AGE_SECONDS", "604800"))) +MANAGER_INFRA_SOURCE_MAX_AGE_SECONDS = max(3600, int(os.environ.get("AW_WORKTIME_MANAGER_INFRA_SOURCE_MAX_AGE_SECONDS", "172800"))) +MANAGER_TREND_DAYS = max(3, min(31, int(os.environ.get("AW_WORKTIME_MANAGER_TREND_DAYS", "7")))) +MANAGER_CACHE_TTL_SECONDS = max(0, int(os.environ.get("AW_WORKTIME_MANAGER_CACHE_TTL_SECONDS", "300"))) +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"))) +TRUE_ACTIVE_EVIDENCE_WINDOW_SECONDS = max(30, int(os.environ.get("AW_WORKTIME_TRUE_ACTIVE_EVIDENCE_WINDOW_SECONDS", "180"))) +TRUE_ACTIVE_MAX_EVENT_SECONDS = max(30, int(os.environ.get("AW_WORKTIME_TRUE_ACTIVE_MAX_EVENT_SECONDS", "600"))) 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): @@ -50,11 +76,60 @@ def to_iso_utc(dt): return dt.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") +def parse_iso_utc(value): + if not value: + return None + return pts(value) + + def hhmm(total_seconds): total_seconds = max(0, int(total_seconds)) return "%02d:%02d" % (total_seconds // 3600, (total_seconds % 3600) // 60) +def human_duration_ru(total_seconds): + total_seconds = max(0, int(total_seconds)) + hours = total_seconds // 3600 + minutes = (total_seconds % 3600) // 60 + if hours and minutes: + return f"{hours} ч {minutes} мин" + if hours: + return f"{hours} ч" + if minutes: + return f"{minutes} мин" + return f"{total_seconds} сек" + + +def now_utc(): + return datetime.now(timezone.utc) + + +def worktime_health_payload(): + return { + "ok": True, + "generated_at_utc": now_utc().isoformat().replace("+00:00", "Z"), + "report_timezone": str(REPORT_TZ), + "default_host": DEFAULT_HOST, + "aw_api_base": AW, + } + + +def age_seconds(ts, now=None): + if ts is None: + return None + ref = now or now_utc() + return max(0, int((ref - ts).total_seconds())) + + +def write_atomic_json(path, payload): + path.parent.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=path.parent, delete=False) as handle: + json.dump(payload, handle, ensure_ascii=False, indent=2) + handle.write("\n") + tmp_name = handle.name + os.replace(tmp_name, path) + + def safe_slug(value): text = str(value or "").strip().lower() slug = [] @@ -69,6 +144,137 @@ def safe_slug(value): return normalized or "user" +def _normalize_identity_key(value): + return str(value or "").strip().lower() + + +def _default_display_name(user, user_id): + base = str(user_id or user or "").strip() + if "\\" in base: + base = base.split("\\", 1)[1] + if not base: + base = str(user or "").strip() + if base and base.isascii() and base.lower() == base and any(ch.isalpha() for ch in base): + return base.upper() + return base or str(user or "").strip() + + +def _load_manager_directory(): + try: + stat = MANAGER_ALIASES_JSON.stat() + except FileNotFoundError: + _ALIASES_CACHE["mtime"] = None + _ALIASES_CACHE["users"] = {} + _ALIASES_CACHE["owners"] = {} + _ALIASES_CACHE["raw"] = {} + return {"users": {}, "owners": {}, "raw": {}} + mtime = stat.st_mtime + if _ALIASES_CACHE["mtime"] == mtime: + return { + "users": _ALIASES_CACHE["users"], + "owners": _ALIASES_CACHE["owners"], + "raw": _ALIASES_CACHE["raw"], + } + try: + raw = json.loads(MANAGER_ALIASES_JSON.read_text(encoding="utf-8")) + except Exception as exc: + log_warning(f"failed to load manager aliases from {MANAGER_ALIASES_JSON}: {exc}") + raw = {} + users_payload = {} + owners_payload = {} + if isinstance(raw, dict): + users = raw.get("users", raw) + if isinstance(users, dict): + for key, value in users.items(): + norm_key = _normalize_identity_key(key) + if not norm_key: + continue + if isinstance(value, str): + users_payload[norm_key] = {"display_name": value} + elif isinstance(value, dict): + users_payload[norm_key] = dict(value) + owners = raw.get("owners", {}) + if isinstance(owners, dict): + for key, value in owners.items(): + norm_key = _normalize_identity_key(key) + if not norm_key: + continue + if isinstance(value, str): + owners_payload[norm_key] = {"display_name": value} + elif isinstance(value, dict): + owners_payload[norm_key] = dict(value) + _ALIASES_CACHE["mtime"] = mtime + _ALIASES_CACHE["users"] = users_payload + _ALIASES_CACHE["owners"] = owners_payload + _ALIASES_CACHE["raw"] = raw if isinstance(raw, dict) else {} + return { + "users": users_payload, + "owners": owners_payload, + "raw": _ALIASES_CACHE["raw"], + } + + +def load_manager_aliases(): + return _load_manager_directory()["users"] + + +def load_manager_owners(): + return _load_manager_directory()["owners"] + + +def resolve_user_alias(user, user_id, host): + aliases = load_manager_aliases() + candidates = [ + _normalize_identity_key(user_id), + _normalize_identity_key(f"{resolve_host(host)}\\{user}"), + _normalize_identity_key(user), + ] + alias = {} + for candidate in candidates: + if candidate and candidate in aliases: + alias = dict(aliases[candidate]) + break + display_name = str(alias.get("display_name") or alias.get("name") or _default_display_name(user, user_id)).strip() + manager_owner = str(alias.get("manager") or alias.get("owner") or display_name).strip() or display_name + department = str(alias.get("department") or "").strip() + role = str(alias.get("role") or "").strip() + notes = str(alias.get("notes") or "").strip() + canonical_user_id = str(alias.get("canonical_user_id") or user_id or "").strip() + exclude = bool(alias.get("exclude")) or _normalize_identity_key(user) in MANAGER_EXCLUDE_USERS or _normalize_identity_key(display_name) in MANAGER_EXCLUDE_USERS + return { + "display_name": display_name, + "manager_owner": manager_owner, + "department": department, + "role": role, + "notes": notes, + "canonical_user_id": canonical_user_id, + "exclude": exclude, + } + + +def resolve_owner_profile(owner_name): + owner_name = normalize_management_filter(owner_name) + if not owner_name: + owner_name = "unassigned" + owners = load_manager_owners() + profile = dict(owners.get(_normalize_identity_key(owner_name), {})) + display_name = str(profile.get("display_name") or profile.get("name") or owner_name).strip() or owner_name + title = str(profile.get("title") or profile.get("role") or "").strip() + department = str(profile.get("department") or "").strip() + escalation_to = str(profile.get("escalation_to") or profile.get("escalate_to") or "").strip() + contact = str(profile.get("contact") or profile.get("telegram") or profile.get("email") or "").strip() + notes = str(profile.get("notes") or "").strip() + return { + "owner_name": owner_name, + "display_name": display_name, + "title": title, + "department": department, + "escalation_to": escalation_to, + "contact": contact, + "notes": notes, + } + + def clamp_seconds(value, fallback=DEFAULT_SAMPLE_SECONDS): try: seconds = float(value) @@ -90,6 +296,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: @@ -114,6 +330,18 @@ def get_report_bounds(report_date): } +def get_workday_bounds(report_date): + start_local = datetime(report_date.year, report_date.month, report_date.day, WORKDAY_START_HOUR, 0, 0, tzinfo=REPORT_TZ) + end_local = datetime(report_date.year, report_date.month, report_date.day, WORKDAY_END_HOUR, 0, 0, tzinfo=REPORT_TZ) + if end_local <= start_local: + end_local = start_local + timedelta(hours=8) + return { + "start_local": start_local, + "end_local": end_local, + "duration_seconds": int((end_local - start_local).total_seconds()), + } + + def _is_machine_user(user: str): u = (user or "").strip().lower() return u.endswith("$") or u in {"system", "localservice", "networkservice"} @@ -184,6 +412,246 @@ def _merge_intervals(intervals): return merged +def _overlap_interval(left, right): + start = max(left[0], right[0]) + end = min(left[1], right[1]) + if end <= start: + return None + return start, end + + +def _interval_contains(interval, ts): + return interval[0] <= ts < interval[1] + + +def _event_timestamp(event): + try: + return pts(event.get("timestamp")) + except Exception: + return None + + +def _event_duration_seconds(event, default_seconds=DEFAULT_SAMPLE_SECONDS): + try: + duration = float(event.get("duration") or 0.0) + except Exception: + duration = 0.0 + if duration <= 0: + duration = default_seconds + return max(1.0, min(float(TRUE_ACTIVE_MAX_EVENT_SECONDS), duration)) + + +def _normalize_app_name(app, title=""): + app_raw = str(app or "").strip() + app_l = app_raw.lower() + title_raw = str(title or "").strip() + if app_l.startswith(("1cv8", "1cestart")): + return "1С" + if app_l in {"chrome.exe", "google chrome"} or "google chrome" in title_raw.lower(): + return "Chrome" + if app_l in {"msedge.exe", "microsoft edge"} or "microsoft edge" in title_raw.lower(): + return "Edge" + if app_l in {"browser.exe", "browser"} or "яндекс" in title_raw.lower(): + return "Яндекс Браузер" + if app_l in {"excel.exe"}: + return "Excel" + if app_l in {"winword.exe"}: + return "Word" + if app_l in {"powerpnt.exe"}: + return "PowerPoint" + if app_l in {"outlook.exe"}: + return "Outlook" + if app_l in {"explorer.exe"}: + return "Проводник" + if app_l in {"totalcmd.exe", "totalcmd64.exe"}: + return "Total Commander" + if app_l in {"cmd.exe"}: + return "Command Prompt" + if app_l in {"powershell.exe", "pwsh.exe"}: + return "PowerShell" + if app_l in {"acrord32.exe", "acrobat.exe"}: + return "Adobe Acrobat Reader" + if app_l in {"windowsterminal.exe", "windowsterminal"}: + return "Windows Terminal" + if app_raw: + return app_raw[:-4] if app_l.endswith(".exe") else app_raw + if title_raw: + return title_raw + return "Неизвестное приложение" + + +def _event_context(event): + data = event.get("data") or {} + for key in ("title", "url", "path", "filePath", "targetPath", "windowTitle", "foregroundTitle", "signalType"): + value = str(data.get(key) or "").strip() + if value: + return value + return "активность" + + +def _is_not_afk_event(event): + data = event.get("data") or {} + status = str(data.get("status") or data.get("state") or "").strip().lower() + return status in {"not-afk", "not_afk", "active", "активно"} + + +def _is_real_evidence_event(event): + data = event.get("data") or {} + signal_type = str(data.get("signalType") or data.get("type") or "").strip().lower() + if signal_type in {"collector_health", "self_test", "heartbeat", "health"}: + return False + if data.get("url") or data.get("title") or data.get("path") or data.get("filePath") or data.get("targetPath"): + return True + if signal_type: + return True + return False + + +def _events_for_bounds(events, start, end): + result = [] + for event in events: + ts = _event_timestamp(event) + if ts is None or ts < start or ts > end: + continue + result.append((ts, event)) + result.sort(key=lambda item: item[0]) + return result + + +def _build_window_intervals(window_events, start, end): + intervals = [] + previous_key = None + for ts, event in _events_for_bounds(window_events, start, end): + data = event.get("data") or {} + app = str(data.get("app") or data.get("process") or data.get("processName") or "").strip() + title = str(data.get("title") or data.get("windowTitle") or "").strip() + if not app and not title: + continue + duration = _event_duration_seconds(event, default_seconds=DEFAULT_SAMPLE_SECONDS) + interval = (max(ts, start), min(ts + timedelta(seconds=duration), end + timedelta(seconds=1))) + if interval[1] <= interval[0]: + continue + app_name = _normalize_app_name(app, title) + current_key = (app_name, title) + title_changed = previous_key is not None and current_key != previous_key + previous_key = current_key + intervals.append( + { + "app": app_name, + "raw_app": app, + "title": title, + "start": interval[0], + "end": interval[1], + "title_changed": title_changed, + "timestamp": ts, + } + ) + return intervals + + +def _build_not_afk_intervals(afk_events, start, end): + intervals = [] + for ts, event in _events_for_bounds(afk_events, start, end): + if not _is_not_afk_event(event): + continue + duration = _event_duration_seconds(event, default_seconds=5) + interval = (max(ts, start), min(ts + timedelta(seconds=duration), end + timedelta(seconds=1))) + if interval[1] > interval[0]: + intervals.append(interval) + return _merge_intervals(intervals) + + +def _find_window_at(window_intervals, ts): + for item in window_intervals: + if item["start"] <= ts < item["end"]: + return item + return None + + +def _add_app_evidence(evidence_by_app, app, ts, context): + evidence_by_app.setdefault(app, []).append((ts, str(context or "").strip() or "активность")) + + +def build_true_active_apps_from_events(window_events, afk_events, evidence_events_by_bucket, start, end): + window_intervals = _build_window_intervals(window_events, start, end) + not_afk_intervals = _build_not_afk_intervals(afk_events, start, end) + evidence_by_app = {} + + for item in window_intervals: + if item["title_changed"]: + _add_app_evidence(evidence_by_app, item["app"], item["timestamp"], item["title"] or item["raw_app"]) + + for events in evidence_events_by_bucket.values(): + for ts, event in _events_for_bounds(events, start, end): + if not _is_real_evidence_event(event): + continue + window = _find_window_at(window_intervals, ts) + if window is None: + continue + _add_app_evidence(evidence_by_app, window["app"], ts, _event_context(event)) + + rows = [] + evidence_delta = timedelta(seconds=TRUE_ACTIVE_EVIDENCE_WINDOW_SECONDS) + for app in sorted({item["app"] for item in window_intervals} | set(evidence_by_app)): + app_evidence = sorted(evidence_by_app.get(app, []), key=lambda item: item[0]) + if not app_evidence: + continue + evidence_windows = _merge_intervals([(ts - evidence_delta, ts + evidence_delta) for ts, _context in app_evidence]) + proved_intervals = [] + for window in [item for item in window_intervals if item["app"] == app]: + base = (window["start"], window["end"]) + for afk_interval in not_afk_intervals: + active_overlap = _overlap_interval(base, afk_interval) + if active_overlap is None: + continue + for evidence_interval in evidence_windows: + proved = _overlap_interval(active_overlap, evidence_interval) + if proved is not None: + proved_intervals.append(proved) + proved_intervals = _merge_intervals(proved_intervals) + proved_seconds = int(sum((right - left).total_seconds() for left, right in proved_intervals)) + if proved_seconds <= 0: + continue + last_ts, last_context = app_evidence[-1] + rows.append( + { + "application": app, + "proved_work_seconds": proved_seconds, + "proved_work_hhmm": hhmm(proved_seconds), + "proved_work_human": human_duration_ru(proved_seconds), + "last_action_utc": to_iso_utc(last_ts), + "last_action_local": last_ts.astimezone(REPORT_TZ).strftime("%H:%M"), + "last_action": last_context, + "evidence_events": len(app_evidence), + } + ) + rows.sort(key=lambda item: (-item["proved_work_seconds"], item["application"].lower())) + return rows + + +def build_true_active_apps(host, report_date): + bounds = get_report_bounds(report_date) + host = resolve_host(host) + window_events = fetch_bucket_events(f"aw-watcher-window_{host}", host) or fetch_bucket_events(f"aw-rdp-window_{host}", host) + afk_events = fetch_bucket_events(f"aw-watcher-afk_{host}", host) or fetch_bucket_events(f"aw-rdp-afk_{host}", host) + evidence_events_by_bucket = {} + for bucket_id in ( + f"aw-file-operations_{host}", + f"aw-dlp-endpoint-signals_{host}", + f"aw-watcher-web-chrome_{host}", + f"aw-watcher-web-edge_{host}", + f"aw-detmir-web-category_{host}", + ): + evidence_events_by_bucket[bucket_id] = fetch_bucket_events(bucket_id, host) + return build_true_active_apps_from_events( + window_events, + afk_events, + evidence_events_by_bucket, + bounds["start"], + bounds["end"], + ) + + def _collect_user_rows(events, start, end, host): end_exclusive = end + timedelta(seconds=1) by_user = {} @@ -236,6 +704,15 @@ def _collect_user_rows(events, start, end, host): def aggregate_rows(events, start, end, host): by_user = _collect_user_rows(events, start, end, host) + return _build_rows_from_user_map(by_user, start, end, include_intervals=False) + + +def aggregate_rows_with_intervals(events, start, end, host): + by_user = _collect_user_rows(events, start, end, host) + return _build_rows_from_user_map(by_user, start, end, include_intervals=True) + + +def _build_rows_from_user_map(by_user, start, end, include_intervals): rows = [] full_range = int((end - start).total_seconds()) + 1 for username in sorted(by_user): @@ -259,6 +736,8 @@ def aggregate_rows(events, start, end, host): "active_samples": row["active_samples"], } ) + if include_intervals: + rows[-1]["_intervals"] = merged return rows @@ -305,17 +784,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): @@ -345,11 +838,949 @@ def build_report_summary(rows): } +def latest_bucket_event(bucket_id): + try: + events = get(f"{AW}/buckets/{bucket_id}/events?limit=20") + except Exception: + return None + if not isinstance(events, list) or not events: + return None + valid = [item for item in events if isinstance(item, dict)] + if not valid: + return None + valid.sort(key=lambda item: item.get("timestamp") or "", reverse=True) + return valid[0] + + +def _priority_rank(priority): + return {"critical": 0, "high": 1, "medium": 2, "low": 3}.get(priority, 9) + + +def _clamp_pct(value): + return round(min(100.0, max(0.0, float(value))), 2) + + +def _action(action_id, priority, owner, deadline_hint, reason, recommended_action, *, user_id="", evidence=None): + return { + "action_id": action_id, + "priority": priority, + "owner": owner, + "user_id": user_id, + "deadline_hint": deadline_hint, + "reason": reason, + "recommended_action": recommended_action, + "evidence": evidence or {}, + } + + +def build_executive_summary(summary, actions, sources): + critical = [action for action in actions if action.get("priority") == "critical"] + high = [action for action in actions if action.get("priority") == "high"] + stale_sources = [source for source in sources if source.get("status") != "ok"] + if critical: + portfolio_state = "critical" + headline = f"Есть {len(critical)} критичных вопроса, требующих решения сегодня." + elif high: + portfolio_state = "attention" + headline = f"Критичных провалов нет, но есть {len(high)} вопроса повышенного внимания." + elif summary.get("portfolio_coverage_pct", 0.0) < MANAGER_TARGET_COVERAGE_PCT: + portfolio_state = "attention" + headline = "Покрытие ниже целевого порога, но явных критичных кейсов не найдено." + else: + portfolio_state = "stable" + headline = "Критичных отклонений не найдено, рабочий день идёт в пределах нормы." + + message_parts = [ + f"Активны {summary.get('active_users', 0)} из {summary.get('users_count', 0)} сотрудников.", + f"Покрытие рабочего окна {summary.get('portfolio_coverage_pct', 0.0)}%.", + ] + if stale_sources: + message_parts.append(f"Есть {len(stale_sources)} проблем(ы) со свежестью источников.") + message = " ".join(message_parts) + + focus_items = [] + for action in actions[:5]: + focus_items.append( + { + "priority": action["priority"], + "owner": action["owner"], + "title": action["action_id"], + "reason": action["reason"], + "recommended_action": action["recommended_action"], + } + ) + stale_items = [] + for source in stale_sources[:3]: + stale_items.append( + { + "source_id": source["source_id"], + "label": source["label"], + "status": source["status_label"], + "summary": source["summary"], + } + ) + return { + "portfolio_state": portfolio_state, + "headline": headline, + "message": message, + "focus_items": focus_items, + "stale_sources": stale_items, + } + + +def _empty_rollup(name): + return { + "name": name or "unassigned", + "users_count": 0, + "active_users": 0, + "inactive_users": 0, + "below_target_users": 0, + "workday_total_active_seconds": 0, + "workday_total_active_hhmm": "00:00", + "portfolio_coverage_pct": 0.0, + "actions_count": 0, + "critical_actions_count": 0, + "high_actions_count": 0, + "medium_actions_count": 0, + "low_actions_count": 0, + "users": [], + } + + +def normalize_management_filter(value): + if value is None: + return "" + return " ".join(str(value).split()).strip() + + +def _filter_key(value): + return normalize_management_filter(value).casefold() + + +def _matches_management_filters(alias, owner_filter="", department_filter=""): + if owner_filter and _filter_key(alias.get("manager_owner")) != _filter_key(owner_filter): + return False + if department_filter and _filter_key(alias.get("department")) != _filter_key(department_filter): + return False + return True + + +def _action_department(action): + evidence = action.get("evidence") or {} + department = normalize_management_filter(evidence.get("department")) + if department: + return department + if normalize_management_filter(action.get("owner")) == "ops": + return "Инфраструктура" + return "" + + +def filter_management_actions(actions, rows, owner_filter="", department_filter=""): + owner_filter = normalize_management_filter(owner_filter) + department_filter = normalize_management_filter(department_filter) + if not owner_filter and not department_filter: + return list(actions) + row_user_ids = { + normalize_management_filter(row.get("canonical_user_id") or row.get("user_id")) + for row in rows + if normalize_management_filter(row.get("canonical_user_id") or row.get("user_id")) + } + filtered = [] + for action in actions: + if owner_filter and _filter_key(action.get("owner")) != _filter_key(owner_filter): + continue + if department_filter: + action_department = _action_department(action) + if action_department: + if _filter_key(action_department) != _filter_key(department_filter): + continue + else: + user_id = normalize_management_filter(action.get("user_id")) + if not user_id or user_id not in row_user_ids: + continue + filtered.append(action) + return filtered + + +def _row_matches_payload_filters(row, owner_filter="", department_filter=""): + if owner_filter and _filter_key(row.get("manager_owner")) != _filter_key(owner_filter): + return False + if department_filter and _filter_key(row.get("department")) != _filter_key(department_filter): + return False + return True + + +def summarize_management_rows(rows, actions, workday): + expected_seconds_per_user = int(workday.get("expected_seconds_per_user", 0) or 0) + workday_total_active_seconds = sum(int(row.get("workday_active_seconds", 0) or 0) for row in rows) + calendar_total_active_seconds = sum(int(row.get("calendar_active_seconds", 0) or 0) for row in rows) + active_users = sum(1 for row in rows if row.get("status") != "inactive") + inactive_users = sum(1 for row in rows if row.get("status") == "inactive") + below_target_users = sum(1 for row in rows if row.get("status") == "below_target") + on_target_users = sum(1 for row in rows if row.get("status") == "ok") + workday_first_values = sorted(str(row.get("workday_first_activity_local") or "") for row in rows if row.get("workday_first_activity_local")) + workday_last_values = sorted(str(row.get("workday_last_activity_local") or "") for row in rows if row.get("workday_last_activity_local")) + calendar_first_values = sorted(str(row.get("first_activity_local") or "") for row in rows if row.get("first_activity_local")) + calendar_last_values = sorted(str(row.get("last_activity_local") or "") for row in rows if row.get("last_activity_local")) + top_row = max(rows, key=lambda row: int(row.get("workday_active_seconds", 0) or 0), default=None) + portfolio_coverage_pct = _clamp_pct((workday_total_active_seconds / (expected_seconds_per_user * len(rows))) * 100.0) if rows and expected_seconds_per_user > 0 else 0.0 + return { + "users_count": len(rows), + "active_users": active_users, + "inactive_users": inactive_users, + "on_target_users": on_target_users, + "below_target_users": below_target_users, + "portfolio_coverage_pct": portfolio_coverage_pct, + "actions_count": len(actions), + "critical_actions_count": sum(1 for action in actions if action.get("priority") == "critical"), + "high_actions_count": sum(1 for action in actions if action.get("priority") == "high"), + "calendar_total_active_seconds": calendar_total_active_seconds, + "calendar_total_active_hhmm": hhmm(calendar_total_active_seconds), + "calendar_first_activity": calendar_first_values[0] if calendar_first_values else "", + "calendar_last_activity": calendar_last_values[-1] if calendar_last_values else "", + "workday_total_active_seconds": workday_total_active_seconds, + "workday_total_active_hhmm": hhmm(workday_total_active_seconds), + "workday_first_activity": workday_first_values[0] if workday_first_values else "", + "workday_last_activity": workday_last_values[-1] if workday_last_values else "", + "total_active_seconds": workday_total_active_seconds, + "total_active_hhmm": hhmm(workday_total_active_seconds), + "first_activity": workday_first_values[0] if workday_first_values else "", + "last_activity": workday_last_values[-1] if workday_last_values else "", + "top_user": str((top_row or {}).get("user") or ""), + "top_user_active_hhmm": hhmm(int((top_row or {}).get("workday_active_seconds", 0) or 0)), + } + + +def apply_management_filters_to_payload(payload, owner_filter="", department_filter="", include_sources=True, include_source_actions=True): + owner_filter = normalize_management_filter(owner_filter) + department_filter = normalize_management_filter(department_filter) + filtered_rows = [ + dict(row) + for row in payload.get("rows", []) + if _row_matches_payload_filters(row, owner_filter=owner_filter, department_filter=department_filter) + ] + base_actions = list(payload.get("actions", [])) + if not include_source_actions: + base_actions = [action for action in base_actions if action.get("action_id") != "source_freshness_review"] + filtered_actions = filter_management_actions(base_actions, filtered_rows, owner_filter=owner_filter, department_filter=department_filter) + filtered_payload = dict(payload) + filtered_payload["filters"] = { + "owner": owner_filter, + "department": department_filter, + } + filtered_payload["rows"] = filtered_rows + filtered_payload["actions"] = filtered_actions + filtered_payload["summary"] = summarize_management_rows(filtered_rows, filtered_actions, payload.get("workday") or {}) + filtered_payload["owner_rollups"] = build_owner_rollups(filtered_rows, filtered_actions) + filtered_payload["department_rollups"] = build_department_rollups(filtered_rows, filtered_actions) + filtered_payload["owner_roster"] = build_owner_roster(filtered_rows, filtered_actions) + if include_sources: + filtered_payload["sources"] = list(payload.get("sources", [])) + else: + filtered_payload["sources"] = [] + filtered_payload["executive"] = build_executive_summary(filtered_payload["summary"], filtered_actions, filtered_payload["sources"]) + return filtered_payload + + +def build_owner_rollups(rows, actions): + groups = {} + for row in rows: + owner = str(row.get("manager_owner") or row.get("user") or "unassigned").strip() or "unassigned" + group = groups.setdefault(owner, _empty_rollup(owner)) + group["users_count"] += 1 + if row.get("status") == "inactive": + group["inactive_users"] += 1 + else: + group["active_users"] += 1 + if row.get("status") == "below_target": + group["below_target_users"] += 1 + group["workday_total_active_seconds"] += int(row.get("workday_active_seconds", 0) or 0) + group["users"].append(row.get("user", "unknown")) + + for action in actions: + owner = str(action.get("owner") or "unassigned").strip() or "unassigned" + group = groups.setdefault(owner, _empty_rollup(owner)) + group["actions_count"] += 1 + prio = str(action.get("priority") or "").lower() + if prio == "critical": + group["critical_actions_count"] += 1 + elif prio == "high": + group["high_actions_count"] += 1 + elif prio == "medium": + group["medium_actions_count"] += 1 + elif prio == "low": + group["low_actions_count"] += 1 + + for group in groups.values(): + expected = group["users_count"] * max(1, WORKDAY_END_HOUR - WORKDAY_START_HOUR) * 3600 + group["workday_total_active_hhmm"] = hhmm(group["workday_total_active_seconds"]) + group["portfolio_coverage_pct"] = _clamp_pct((group["workday_total_active_seconds"] / expected) * 100.0) if expected > 0 else 0.0 + group["users"] = sorted(set(str(user or "").strip() for user in group["users"] if str(user or "").strip())) + + return sorted( + groups.values(), + key=lambda item: ( + -item["critical_actions_count"], + -item["high_actions_count"], + -item["inactive_users"], + item["name"].lower(), + ), + ) + + +def build_department_rollups(rows, actions): + groups = {} + for row in rows: + department = str(row.get("department") or "Без подразделения").strip() or "Без подразделения" + group = groups.setdefault(department, _empty_rollup(department)) + group["users_count"] += 1 + if row.get("status") == "inactive": + group["inactive_users"] += 1 + else: + group["active_users"] += 1 + if row.get("status") == "below_target": + group["below_target_users"] += 1 + group["workday_total_active_seconds"] += int(row.get("workday_active_seconds", 0) or 0) + group["users"].append(row.get("user", "unknown")) + + for action in actions: + evidence = action.get("evidence") or {} + department = str(evidence.get("department") or ("Инфраструктура" if str(action.get("owner") or "").strip().lower() == "ops" else "Без подразделения")).strip() + group = groups.setdefault(department, _empty_rollup(department)) + group["actions_count"] += 1 + prio = str(action.get("priority") or "").lower() + if prio == "critical": + group["critical_actions_count"] += 1 + elif prio == "high": + group["high_actions_count"] += 1 + elif prio == "medium": + group["medium_actions_count"] += 1 + elif prio == "low": + group["low_actions_count"] += 1 + + for group in groups.values(): + expected = group["users_count"] * max(1, WORKDAY_END_HOUR - WORKDAY_START_HOUR) * 3600 + group["workday_total_active_hhmm"] = hhmm(group["workday_total_active_seconds"]) + group["portfolio_coverage_pct"] = _clamp_pct((group["workday_total_active_seconds"] / expected) * 100.0) if expected > 0 else 0.0 + group["users"] = sorted(set(str(user or "").strip() for user in group["users"] if str(user or "").strip())) + + return sorted( + groups.values(), + key=lambda item: ( + -item["critical_actions_count"], + -item["high_actions_count"], + -item["inactive_users"], + item["name"].lower(), + ), + ) + + +def build_owner_roster(rows, actions): + owner_rollups = build_owner_rollups(rows, actions) + roster = [] + for item in owner_rollups: + profile = resolve_owner_profile(item["name"]) + roster.append( + { + **item, + "display_name": profile["display_name"], + "title": profile["title"], + "department": profile["department"], + "contact": profile["contact"], + "escalation_to": profile["escalation_to"], + "notes": profile["notes"], + } + ) + return roster + + +def _interval_overlap_seconds(intervals, start, end): + total = 0 + first = None + last = None + for interval_start, interval_end in intervals or []: + overlap_start = max(interval_start, start) + overlap_end = min(interval_end, end) + if overlap_end <= overlap_start: + continue + seconds = int((overlap_end - overlap_start).total_seconds()) + if seconds <= 0: + continue + total += seconds + if first is None or overlap_start < first: + first = overlap_start + if last is None or overlap_end > last: + last = overlap_end + return total, first, last + + +def _source_status_label(status): + return { + "ok": "fresh", + "warn": "stale", + "fail": "missing", + }.get(status, status) + + +def _source_summary(event): + data = (event or {}).get("data") or {} + signal_type = str(data.get("signalType") or "").strip() + if signal_type == "collector_health": + return ( + f"queue={data.get('queueDepth', 0)} " + f"failures={data.get('sendFailures', 0)} " + f"flushed={data.get('eventsFlushed', 0)}" + ) + if data.get("domain"): + return f"{data.get('domain')} ({data.get('category', 'uncategorized')})" + if data.get("eventType"): + return f"{data.get('eventType')} {data.get('username', '')}".strip() + if data.get("action"): + return f"{data.get('action')} {data.get('result', '')}".strip() + if data.get("title"): + return str(data.get("title"))[:120] + if data.get("status"): + return str(data.get("status")) + if data.get("app"): + return str(data.get("app")) + return "" + + +def management_cache_path(host, report_date): + host_slug = safe_slug(host) + return MANAGER_CACHE_DIR / f"{host_slug}-{report_date.isoformat()}.json" + + +def load_management_cache(host, report_date): + if MANAGER_CACHE_TTL_SECONDS <= 0: + return None + path = management_cache_path(host, report_date) + if not path.exists(): + 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: + return None + + +def save_management_cache(host, report_date, payload): + if MANAGER_CACHE_TTL_SECONDS <= 0: + return + write_atomic_json(management_cache_path(host, report_date), payload) + + +def build_source_freshness(host): + source_specs = [ + { + "source_id": "worktime_sessions", + "label": "RDP worktime sessions", + "bucket_candidates": [f"aw-worktime-sessions_{host}"], + "max_age_seconds": MANAGER_CRITICAL_SOURCE_MAX_AGE_SECONDS, + "required": True, + "owner": "ops", + }, + { + "source_id": "rdp_window", + "label": "RDP current window", + "bucket_candidates": [f"aw-rdp-window_{host}"], + "max_age_seconds": MANAGER_CRITICAL_SOURCE_MAX_AGE_SECONDS, + "required": True, + "owner": "ops", + }, + { + "source_id": "rdp_afk", + "label": "RDP AFK", + "bucket_candidates": [f"aw-rdp-afk_{host}"], + "max_age_seconds": MANAGER_CRITICAL_SOURCE_MAX_AGE_SECONDS, + "required": True, + "owner": "ops", + }, + { + "source_id": "watcher_window", + "label": "Local watcher window", + "bucket_candidates": [f"aw-watcher-window_{host}"], + "max_age_seconds": MANAGER_CRITICAL_SOURCE_MAX_AGE_SECONDS, + "required": True, + "owner": "ops", + }, + { + "source_id": "watcher_afk", + "label": "Local watcher AFK", + "bucket_candidates": [f"aw-watcher-afk_{host}"], + "max_age_seconds": MANAGER_CRITICAL_SOURCE_MAX_AGE_SECONDS, + "required": True, + "owner": "ops", + }, + { + "source_id": "file_operations", + "label": "File operations collector", + "bucket_candidates": [f"aw-file-operations_{host}"], + "max_age_seconds": MANAGER_CRITICAL_SOURCE_MAX_AGE_SECONDS, + "required": True, + "owner": "ops", + }, + { + "source_id": "web_categories", + "label": "Browser/web categories", + "bucket_candidates": [f"aw-detmir-web-category_{host}"], + "max_age_seconds": MANAGER_WEB_SOURCE_MAX_AGE_SECONDS, + "required": False, + "owner": "ops", + }, + { + "source_id": "session_events", + "label": "Windows session events", + "bucket_candidates": [f"aw-session-events_{host}"], + "max_age_seconds": MANAGER_SESSION_SOURCE_MAX_AGE_SECONDS, + "required": False, + "owner": "ops", + }, + { + "source_id": "pve_tasks", + "label": "PVE task feed", + "bucket_candidates": ["aw-pve-task-events_pve-detmir"], + "max_age_seconds": MANAGER_INFRA_SOURCE_MAX_AGE_SECONDS, + "required": False, + "owner": "ops", + }, + ] + now = now_utc() + sources = [] + actions = [] + for spec in source_specs: + matched_bucket = "" + matched_event = None + matched_age = None + for candidate in spec["bucket_candidates"]: + event = latest_bucket_event(candidate) + if not event: + continue + ts = parse_iso_utc(event.get("timestamp")) + candidate_age = age_seconds(ts, now=now) + if matched_event is None or (candidate_age is not None and (matched_age is None or candidate_age < matched_age)): + matched_bucket = candidate + matched_event = event + matched_age = candidate_age + ts = parse_iso_utc((matched_event or {}).get("timestamp")) + age = matched_age if matched_event is not None else age_seconds(ts, now=now) + if matched_event is None: + status = "fail" if spec["required"] else "warn" + summary = "bucket missing or empty" + elif age is None: + status = "warn" + summary = "timestamp parse failed" + elif age > spec["max_age_seconds"]: + status = "fail" if spec["required"] else "warn" + summary = f"stale ({age}s)" + else: + status = "ok" + summary = f"fresh ({age}s)" + detail = { + "source_id": spec["source_id"], + "label": spec["label"], + "status": status, + "status_label": _source_status_label(status), + "bucket_id": matched_bucket or spec["bucket_candidates"][0], + "timestamp": (matched_event or {}).get("timestamp", ""), + "age_seconds": age, + "required": spec["required"], + "max_age_seconds": spec["max_age_seconds"], + "summary": summary, + "event_summary": _source_summary(matched_event), + } + sources.append(detail) + if status == "ok": + continue + priority = "critical" if spec["required"] else "medium" + actions.append( + _action( + "source_freshness_review", + priority, + spec["owner"], + "today" if spec["required"] else "3d", + f"Источник '{spec['label']}' в состоянии {detail['status_label']}: {summary}.", + "Проверить collector/service, причину отставания и подтвердить, что управленческие выводы по данным ещё надёжны.", + evidence={ + "source_id": spec["source_id"], + "bucket_id": detail["bucket_id"], + "age_seconds": age, + "required": spec["required"], + }, + ) + ) + return sources, actions + + +def _build_management_core(rows, host, report_date, owner_filter="", department_filter=""): + owner_filter = normalize_management_filter(owner_filter) + department_filter = normalize_management_filter(department_filter) + report_bounds = get_report_bounds(report_date) + workday = get_workday_bounds(report_date) + now_local = datetime.now(REPORT_TZ) + is_today = report_date == now_local.date() + effective_end_local = min(now_local, workday["end_local"]) if is_today else workday["end_local"] + elapsed_seconds = max(0, int((effective_end_local - workday["start_local"]).total_seconds())) + if not is_today: + elapsed_seconds = workday["duration_seconds"] + expected_seconds_per_user = min(workday["duration_seconds"], max(0, elapsed_seconds)) + target_seconds = int(expected_seconds_per_user * (MANAGER_TARGET_COVERAGE_PCT / 100.0)) + low_seconds = int(expected_seconds_per_user * (MANAGER_LOW_COVERAGE_PCT / 100.0)) + late_start_local = workday["start_local"] + timedelta(minutes=MANAGER_LATE_START_GRACE_MINUTES) + early_finish_local = workday["end_local"] - timedelta(minutes=MANAGER_EARLY_FINISH_GRACE_MINUTES) + + roster = [] + actions = [] + active_users = 0 + on_target_users = 0 + below_target_users = 0 + workday_total_active_seconds = 0 + workday_first_values = [] + workday_last_values = [] + top_workday_user = "" + top_workday_seconds = -1 + filtered_rows = [] + + for row in rows: + alias = resolve_user_alias(row.get("user", ""), row.get("user_id", ""), host) + if alias["exclude"]: + continue + if not _matches_management_filters(alias, owner_filter=owner_filter, department_filter=department_filter): + continue + filtered_rows.append(row) + public_row = {key: value for key, value in row.items() if key != "_intervals"} + calendar_active_seconds = int(row.get("active_seconds", 0) or 0) + intervals = row.get("_intervals") or [] + workday_active_seconds, workday_first, workday_last = _interval_overlap_seconds( + intervals, + workday["start_local"].astimezone(timezone.utc), + effective_end_local.astimezone(timezone.utc), + ) + first_activity = parse_iso_utc(row.get("first_activity")) + last_activity = parse_iso_utc(row.get("last_activity")) + first_local = first_activity.astimezone(REPORT_TZ) if first_activity else None + last_local = last_activity.astimezone(REPORT_TZ) if last_activity else None + workday_first_local = workday_first.astimezone(REPORT_TZ) if workday_first else None + workday_last_local = workday_last.astimezone(REPORT_TZ) if workday_last else None + coverage_pct = _clamp_pct((workday_active_seconds / expected_seconds_per_user) * 100.0) if expected_seconds_per_user > 0 else 0.0 + status = "ok" + if workday_active_seconds <= 0: + status = "inactive" + elif workday_active_seconds < target_seconds: + status = "below_target" + if workday_active_seconds > 0: + active_users += 1 + workday_total_active_seconds += workday_active_seconds + if workday_first_local: + workday_first_values.append(workday_first_local.isoformat()) + if workday_last_local: + workday_last_values.append(workday_last_local.isoformat()) + if workday_active_seconds >= target_seconds and workday_active_seconds > 0: + on_target_users += 1 + elif workday_active_seconds > 0: + below_target_users += 1 + if workday_active_seconds > top_workday_seconds: + top_workday_seconds = workday_active_seconds + top_workday_user = alias["display_name"] + + roster.append( + { + **public_row, + "user": alias["display_name"], + "user_original": row.get("user", ""), + "manager_owner": alias["manager_owner"], + "department": alias["department"], + "role": alias["role"], + "notes": alias["notes"], + "canonical_user_id": alias["canonical_user_id"] or row.get("user_id", ""), + "calendar_active_seconds": calendar_active_seconds, + "calendar_active_hhmm": row.get("active_hhmm", "00:00"), + "workday_active_seconds": workday_active_seconds, + "workday_active_hhmm": hhmm(workday_active_seconds), + "coverage_pct": coverage_pct, + "status": status, + "first_activity_local": first_local.isoformat() if first_local else "", + "last_activity_local": last_local.isoformat() if last_local else "", + "workday_first_activity_local": workday_first_local.isoformat() if workday_first_local else "", + "workday_last_activity_local": workday_last_local.isoformat() if workday_last_local else "", + } + ) + + owner = alias["display_name"] + user_id = alias["canonical_user_id"] or row.get("user_id", "") + evidence = { + "calendar_active_hhmm": row.get("active_hhmm", "00:00"), + "workday_active_hhmm": hhmm(workday_active_seconds), + "coverage_pct": coverage_pct, + "first_activity": row.get("first_activity", ""), + "last_activity": row.get("last_activity", ""), + "sessions_count": row.get("sessions_count", 0), + "manager_owner": alias["manager_owner"], + "department": alias["department"], + "role": alias["role"], + } + if workday_active_seconds <= 0: + actions.append( + _action( + "missing_activity", + "critical", + alias["manager_owner"], + "today", + f"За {report_date.isoformat()} у сотрудника {owner} нет подтверждённой активности в рабочем окне RDP.", + f"Проверить сотрудника {owner}: работал ли он в рабочее время, была ли потеря сбора данных или отсутствие входа в систему.", + user_id=user_id, + evidence=evidence, + ) + ) + continue + if expected_seconds_per_user > 0 and workday_active_seconds < low_seconds: + actions.append( + _action( + "low_activity_review", + "high", + alias["manager_owner"], + "24h", + f"У сотрудника {owner} активное время в рабочем окне {hhmm(workday_active_seconds)} ниже {MANAGER_LOW_COVERAGE_PCT}% от ожидаемого окна.", + f"Проверить загрузку сотрудника {owner}, задачи и фактическое присутствие в рабочем процессе.", + user_id=user_id, + evidence=evidence, + ) + ) + elif expected_seconds_per_user > 0 and workday_active_seconds < target_seconds: + actions.append( + _action( + "target_gap_review", + "medium", + alias["manager_owner"], + "24h", + f"У сотрудника {owner} активное время в рабочем окне {hhmm(workday_active_seconds)} ниже управленческого целевого порога {MANAGER_TARGET_COVERAGE_PCT}%.", + f"Уточнить причину отклонения по сотруднику {owner} и подтвердить план работ.", + user_id=user_id, + evidence=evidence, + ) + ) + if workday_first_local and workday_first_local > late_start_local: + actions.append( + _action( + "late_start_review", + "medium", + alias["manager_owner"], + "24h", + f"У сотрудника {owner} первая активность в рабочем окне зафиксирована поздно: {workday_first_local.strftime('%H:%M')}.", + f"Проверить причину позднего старта сотрудника {owner} и подтвердить, что это не проблема доступа или дисциплины.", + user_id=user_id, + evidence=evidence, + ) + ) + if (not is_today) and workday_last_local and workday_last_local < early_finish_local: + actions.append( + _action( + "early_finish_review", + "medium", + alias["manager_owner"], + "24h", + f"У сотрудника {owner} последняя активность в рабочем окне завершилась рано: {workday_last_local.strftime('%H:%M')}.", + f"Проверить, было ли досрочное завершение рабочего дня сотрудника {owner} согласовано и чем оно объясняется.", + user_id=user_id, + evidence=evidence, + ) + ) + + actions.sort(key=lambda item: (_priority_rank(item["priority"]), item["owner"].lower(), item["action_id"])) + calendar_summary = build_report_summary(filtered_rows) + inactive_users = sum(1 for row in roster if row["status"] == "inactive") + portfolio_coverage_pct = _clamp_pct((workday_total_active_seconds / (expected_seconds_per_user * len(roster))) * 100.0) if roster and expected_seconds_per_user > 0 else 0.0 + calendar_first = calendar_summary.get("first_activity", "") + calendar_last = calendar_summary.get("last_activity", "") + return { + "generated_at_utc": now_utc().isoformat().replace("+00:00", "Z"), + "host": resolve_host(host), + "report_date": report_date.isoformat(), + "report_timezone": str(REPORT_TZ), + "filters": { + "owner": owner_filter, + "department": department_filter, + }, + "workday": { + "start_local": workday["start_local"].isoformat(), + "end_local": workday["end_local"].isoformat(), + "expected_seconds_per_user": expected_seconds_per_user, + "expected_hhmm_per_user": hhmm(expected_seconds_per_user), + "target_coverage_pct": MANAGER_TARGET_COVERAGE_PCT, + "low_coverage_pct": MANAGER_LOW_COVERAGE_PCT, + }, + "summary": { + **calendar_summary, + "calendar_total_active_seconds": calendar_summary["total_active_seconds"], + "calendar_total_active_hhmm": calendar_summary["total_active_hhmm"], + "calendar_first_activity": calendar_first, + "calendar_last_activity": calendar_last, + "workday_total_active_seconds": workday_total_active_seconds, + "workday_total_active_hhmm": hhmm(workday_total_active_seconds), + "workday_first_activity": min(workday_first_values) if workday_first_values else "", + "workday_last_activity": max(workday_last_values) if workday_last_values else "", + "total_active_seconds": workday_total_active_seconds, + "total_active_hhmm": hhmm(workday_total_active_seconds), + "first_activity": min(workday_first_values) if workday_first_values else "", + "last_activity": max(workday_last_values) if workday_last_values else "", + "top_user": top_workday_user, + "top_user_active_hhmm": hhmm(top_workday_seconds if top_workday_seconds > 0 else 0), + "active_users": active_users, + "inactive_users": inactive_users, + "on_target_users": on_target_users, + "below_target_users": below_target_users, + "portfolio_coverage_pct": portfolio_coverage_pct, + "actions_count": len(actions), + "critical_actions_count": sum(1 for action in actions if action["priority"] == "critical"), + "high_actions_count": sum(1 for action in actions if action["priority"] == "high"), + }, + "actions": actions, + "rows": roster, + "bucket_id": get_sessions_bucket_id(host), + "report_bounds": { + "start_utc": to_iso_utc(report_bounds["start"]), + "end_utc": to_iso_utc(report_bounds["end"]), + }, + } + + +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) + 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 + + +def build_filtered_management_trend(host, anchor_date, owner_filter="", department_filter=""): + trend = [] + for offset in range(MANAGER_TREND_DAYS - 1, -1, -1): + current_date = anchor_date - timedelta(days=offset) + base_payload = load_management_cache(host, current_date) + if base_payload is None: + bounds, events = fetch_events_for_date(host, current_date) + rows = aggregate_rows_with_intervals(events, bounds["start"], bounds["end"], host) + base_payload = _build_management_core(rows, host, current_date) + filtered_payload = apply_management_filters_to_payload( + base_payload, + owner_filter=owner_filter, + department_filter=department_filter, + include_sources=False, + include_source_actions=False, + ) + summary = filtered_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"], + } + ) + return trend + + +def build_management_payload(rows, host, report_date, owner_filter="", department_filter=""): + owner_filter = normalize_management_filter(owner_filter) + department_filter = normalize_management_filter(department_filter) + 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, + 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)) + payload["actions"].sort(key=lambda item: (_priority_rank(item["priority"]), item["owner"].lower(), item["action_id"])) + payload["summary"]["actions_count"] = len(payload["actions"]) + payload["summary"]["critical_actions_count"] = sum(1 for action in payload["actions"] if action["priority"] == "critical") + payload["summary"]["high_actions_count"] = sum(1 for action in payload["actions"] if action["priority"] == "high") + payload["executive"] = build_executive_summary(payload["summary"], payload["actions"], payload["sources"]) + payload["owner_rollups"] = build_owner_rollups(payload["rows"], payload["actions"]) + payload["department_rollups"] = build_department_rollups(payload["rows"], payload["actions"]) + payload["owner_roster"] = build_owner_roster(payload["rows"], payload["actions"]) + return payload + + def report_for_date(host, report_date): bounds, events = fetch_events_for_date(host, report_date) return aggregate_rows(events, bounds["start"], bounds["end"], host) +def management_report_for_date(host, report_date, owner_filter="", department_filter=""): + owner_filter = normalize_management_filter(owner_filter) + department_filter = normalize_management_filter(department_filter) + if owner_filter or department_filter: + base_payload = management_report_for_date(host, report_date) + filtered_payload = apply_management_filters_to_payload( + base_payload, + owner_filter=owner_filter, + department_filter=department_filter, + include_sources=True, + include_source_actions=True, + ) + filtered_payload["trend"] = [] + filtered_payload["trend_scope"] = "filtered_current_only" + return filtered_payload + cached = load_management_cache(host, report_date) + if cached is not None: + return cached + 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): return report_for_date(host, resolve_report_date()) @@ -361,7 +1792,7 @@ def report_for_date_fresh(host, report_date): return module.report_for_date(host, report_date) -def render_html(rows, host, report_date, selected_day=None): +def render_html(rows, host, report_date, selected_day=None, true_active_apps=None): generated = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") date_local = report_date.strftime("%Y-%m-%d") day_query = f"&day={selected_day}" if selected_day in {"today", "yesterday"} else "" @@ -380,6 +1811,20 @@ def render_html(rows, host, report_date, selected_day=None): ] trs = [] detail_cards = [] + true_active_apps = true_active_apps or [] + true_active_rows = [] + for app_row in true_active_apps: + last_action = app_row.get("last_action") or "-" + last_time = app_row.get("last_action_local") or "-" + true_active_rows.append( + "" + f"{html.escape(app_row.get('application') or '-')}" + f"{html.escape(app_row.get('proved_work_human') or app_row.get('proved_work_hhmm') or '0 сек')}" + f"{html.escape(last_time)} · {html.escape(last_action)}" + "" + ) + if not true_active_rows: + true_active_rows.append("Пока нет доказанной активной работы по приложениям за выбранную дату.") for row in rows: user_slug = safe_slug(row["user"]) active_seconds = int(row.get("active_seconds", 0) or 0) @@ -635,6 +2080,21 @@ def render_html(rows, host, report_date, selected_day=None): {''.join(f"
{html.escape(label)}{html.escape(value)}
" for label, value in cards)} +
+

Доказанная работа по приложениям

+ + + + + + + + + + {''.join(true_active_rows)} + +
ПриложениеДоказанная работаПоследнее действие
+

Таблица по пользователям

@@ -667,9 +2127,518 @@ def render_html(rows, host, report_date, selected_day=None): """ +def build_management_report_url(host, report_date, selected_day=None, fmt=None, owner_filter="", department_filter=""): + params = {"host": resolve_host(host)} + if fmt: + params["format"] = fmt + if selected_day in {"today", "yesterday"}: + params["day"] = selected_day + else: + params["date"] = report_date + if normalize_management_filter(owner_filter): + params["owner"] = normalize_management_filter(owner_filter) + if normalize_management_filter(department_filter): + params["department"] = normalize_management_filter(department_filter) + return "/reports/worktime/management?" + urlencode(params) + + +def render_management_html(payload, selected_day=None): + summary = payload["summary"] + workday = payload["workday"] + report_date = payload["report_date"] + host = payload["host"] + executive = payload.get("executive") or {} + active_filters = payload.get("filters") or {} + owner_filter = normalize_management_filter(active_filters.get("owner")) + department_filter = normalize_management_filter(active_filters.get("department")) + today_url = build_management_report_url(host, report_date, selected_day="today", fmt="html", owner_filter=owner_filter, department_filter=department_filter) + yesterday_url = build_management_report_url(host, report_date, selected_day="yesterday", fmt="html", owner_filter=owner_filter, department_filter=department_filter) + json_url = build_management_report_url(host, report_date, selected_day=selected_day, owner_filter=owner_filter, department_filter=department_filter) + classic_url = "/reports/worktime/today?" + urlencode({"format": "html", "host": host, **({"day": selected_day} if selected_day in {"today", "yesterday"} else {"date": report_date})}) + reset_url = build_management_report_url(host, report_date, selected_day=selected_day, fmt="html") + actions_html = [] + for action in payload["actions"]: + actions_html.append( + "" + f"" + f"" + f"" + f"" + f"" + f"" + "" + ) + if not actions_html: + actions_html.append("") + + roster_html = [] + for row in payload["rows"]: + roster_html.append( + "" + f"" + f"" + f"" + f"" + f"" + f"" + f"" + f"" + f"" + f"" + f"" + "" + ) + if not roster_html: + roster_html.append("") + + trend_html = [] + for row in payload.get("trend", []): + trend_html.append( + "" + f"" + f"" + f"" + f"" + f"" + f"" + f"" + f"" + "" + ) + if not trend_html: + trend_message = "Тренд по выбранному фильтру пока отключён, чтобы current-scope отчёт отвечал быстро." if (owner_filter or department_filter) else "Тренд пока недоступен." + trend_html.append(f"") + + owner_rollup_html = [] + for item in payload.get("owner_rollups", []): + owner_url = build_management_report_url(host, report_date, selected_day=selected_day, fmt="html", owner_filter=item["name"], department_filter=department_filter) + owner_rollup_html.append( + "" + f"" + f"" + f"" + f"" + f"" + f"" + f"" + f"" + f"" + "" + ) + if not owner_rollup_html: + owner_rollup_html.append("") + + department_rollup_html = [] + for item in payload.get("department_rollups", []): + department_url = build_management_report_url(host, report_date, selected_day=selected_day, fmt="html", owner_filter=owner_filter, department_filter=item["name"]) + department_rollup_html.append( + "" + f"" + f"" + f"" + f"" + f"" + f"" + f"" + f"" + f"" + "" + ) + if not department_rollup_html: + department_rollup_html.append("") + + owner_profile_html = [] + for item in payload.get("owner_roster", []): + owner_profile_html.append( + "
" + f"
0 else ('high' if item['high_actions_count'] > 0 else 'low')}'>{html.escape(item['display_name'])}
" + f"

{html.escape(item['title'] or item['name'])}

" + f"
Подразделение: {html.escape(item['department'] or '-')}
" + f"

Пользователи: {item['users_count']} · inactive: {item['inactive_users']} · actions: {item['actions_count']}

" + f"

Контакт: {html.escape(item['contact'] or '-')}

" + f"

Эскалация: {html.escape(item['escalation_to'] or '-')}

" + f"

{html.escape(item['notes'] or 'Без дополнительных заметок.')}

" + "
" + ) + if not owner_profile_html: + owner_profile_html.append("

Каталог ответственных пуст

Добавьте блок owners в worktime-manager-aliases.json, чтобы отчёт показывал роли, контакты и эскалацию.

") + + sources_html = [] + for source in payload.get("sources", []): + sources_html.append( + "" + f"" + f"" + f"" + f"" + f"" + f"" + "" + ) + if not sources_html: + sources_html.append("") + + focus_html = [] + for item in executive.get("focus_items", []): + focus_html.append( + "
" + f"
{html.escape(item['priority'])}
" + f"

{html.escape(item['title'])}

" + f"
Ответственный: {html.escape(item['owner'])}
" + f"

{html.escape(item['reason'])}

" + f"{html.escape(item['recommended_action'])}" + "
" + ) + if not focus_html: + focus_html.append("

Критичных действий нет

На текущий момент менеджерских отклонений по активным правилам не найдено.

") + + stale_html = [] + for item in executive.get("stale_sources", []): + stale_html.append( + "
  • " + f"{html.escape(item['label'])}: {html.escape(item['status'])} · {html.escape(item['summary'])}" + "
  • " + ) + stale_block = ( + "
    Проблемы со свежестью источников:
      " + + "".join(stale_html) + + "
    " + ) if stale_html else "" + filter_parts = [] + if owner_filter: + filter_parts.append(f"ответственный: {html.escape(owner_filter)}") + if department_filter: + filter_parts.append(f"подразделение: {html.escape(department_filter)}") + filter_block = ( + "
    Фильтр: " + + " · ".join(filter_parts) + + f" Сбросить
    " + ) if filter_parts else "" + + cards = [ + ("Пользователи", str(summary["users_count"])), + ("Активны", str(summary["active_users"])), + ("Без активности", str(summary["inactive_users"])), + ("Ниже цели", str(summary["below_target_users"])), + ("Покрытие", f"{summary['portfolio_coverage_pct']}%"), + ("Действия", str(summary["actions_count"])), + ("Рабочее окно", summary["workday_total_active_hhmm"]), + ("Календарный день", summary["calendar_total_active_hhmm"]), + ] + + return f""" + + + + + AW-rus Управленческий отчёт по работе в RDP + + + +
    +
    +

    Управленческий отчёт по работе в RDP

    +
    Хост: {html.escape(host)} · Дата: {html.escape(report_date)} · Рабочее окно: {html.escape(workday['start_local'])} -> {html.escape(workday['end_local'])} · Сформировано UTC: {html.escape(payload['generated_at_utc'])}
    + +
    + {''.join(f"
    {html.escape(label)}{html.escape(value)}
    " for label, value in cards)} +
    +
    + Целевое покрытие: {MANAGER_TARGET_COVERAGE_PCT}% от ожидаемого рабочего окна на пользователя. + Критический провал: ниже {MANAGER_LOW_COVERAGE_PCT}% или полное отсутствие активности. + Рабочее окно считается отдельно от календарной активности, чтобы ночная работа не маскировала дневной провал. +
    +
    +
    +

    Что делать сегодня

    +
    + {html.escape(executive.get('headline') or 'Сводка недоступна')} +

    {html.escape(executive.get('message') or '')}

    + {filter_block} + {stale_block} +
    +
    + {''.join(focus_html)} +
    +
    +
    +

    Тренд за {MANAGER_TREND_DAYS} дней

    +
    {html.escape(action['priority'])}{html.escape(action['owner'])}{html.escape(action['action_id'])}{html.escape(action['deadline_hint'])}{html.escape(action['reason'])}{html.escape(action['recommended_action'])}
    Отклонений по текущим правилам не найдено.
    {html.escape(row['user'])}{html.escape(row.get('canonical_user_id') or row['user_id'])}{html.escape(row.get('manager_owner') or row['user'])}{html.escape(row.get('department') or '-')}{html.escape(row['workday_active_hhmm'])}{html.escape(row['calendar_active_hhmm'])}{row['coverage_pct']}{html.escape(row['status'])}{html.escape(row.get('workday_first_activity_local') or '-')}{html.escape(row.get('workday_last_activity_local') or '-')}{row['sessions_count']}
    За выбранную дату данных нет.
    {html.escape(row['report_date'])}{row['users_count']}{row['active_users']}{row['inactive_users']}{html.escape(row['workday_total_active_hhmm'])}{row['portfolio_coverage_pct']}{row['actions_count']}{row['critical_actions_count']}
    {html.escape(trend_message)}
    {html.escape(item['name'])}{item['users_count']}{item['inactive_users']}{item['below_target_users']}{item['critical_actions_count']}{item['high_actions_count']}{html.escape(item['workday_total_active_hhmm'])}{item['portfolio_coverage_pct']}{html.escape(', '.join(item['users']) if item['users'] else '-')}
    Нет данных по ответственным.
    {html.escape(item['name'])}{item['users_count']}{item['inactive_users']}{item['below_target_users']}{item['critical_actions_count']}{item['high_actions_count']}{html.escape(item['workday_total_active_hhmm'])}{item['portfolio_coverage_pct']}{html.escape(', '.join(item['users']) if item['users'] else '-')}
    Нет данных по подразделениям.
    {html.escape(source['label'])}{html.escape(source['status_label'])}{html.escape(source['bucket_id'])}{html.escape(source.get('timestamp') or '-')}{html.escape(str(source.get('age_seconds')) if source.get('age_seconds') is not None else '-')}{html.escape(source.get('event_summary') or source.get('summary') or '-')}
    Статусы источников недоступны.
    + + + + + + + + + + + + + + {''.join(trend_html)} + +
    ДатаПользователиАктивныБез активностиРабочее окноПокрытие, %ДействияCritical
    +
    +
    +

    По ответственным

    + + + + + + + + + + + + + + + + {''.join(owner_rollup_html)} + +
    ОтветственныйСотрудникиБез активностиНиже целиCriticalHighРабочее окноПокрытие, %Кого затрагивает
    +
    +
    +

    Ответственные и эскалация

    +
    + {''.join(owner_profile_html)} +
    +
    +
    +

    По подразделениям

    + + + + + + + + + + + + + + + + {''.join(department_rollup_html)} + +
    ПодразделениеСотрудникиБез активностиНиже целиCriticalHighРабочее окноПокрытие, %Кого затрагивает
    +
    +
    +

    Очередь действий руководителя

    + + + + + + + + + + + + + {''.join(actions_html)} + +
    ПриоритетСотрудникТипСрокПочему это важноЧто сделать
    +
    +
    +

    Покрытие по сотрудникам

    + + + + + + + + + + + + + + + + + + {''.join(roster_html)} + +
    СотрудникУчётная записьОтветственныйПодразделениеАктивно в окнеАктивно за деньПокрытие, %СтатусПервая активность в окнеПоследняя активность в окнеСессии
    +
    +
    +

    Свежесть источников данных

    + + + + + + + + + + + + + {''.join(sources_html)} + +
    ИсточникСтатусБакетПоследнее событие UTCВозраст, секКонтекст
    +
    + + +""" + + +def send_bytes(handler, data, content_type, status=200): + handler.send_response(status) + handler.send_header("Content-Type", content_type) + handler.send_header("Content-Length", str(len(data))) + handler.end_headers() + try: + handler.wfile.write(data) + except (BrokenPipeError, ConnectionResetError): + return False + return True + + class H(BaseHTTPRequestHandler): def do_GET(self): parsed = urlparse(self.path) + if parsed.path in {"/health", "/api/health"}: + data = json.dumps(worktime_health_payload(), ensure_ascii=False, indent=2).encode("utf-8") + send_bytes(self, data, "application/json; charset=utf-8") + return + if parsed.path.startswith("/dlp-ioc/"): name = parsed.path.rsplit("/", 1)[-1] if name not in {"ioc_blacklist.json", "ioc_blacklist.csv", "ioc_blacklist.sql"}: @@ -689,14 +2658,10 @@ class H(BaseHTTPRequestHandler): ctype = "text/csv; charset=utf-8" else: ctype = "text/plain; charset=utf-8" - self.send_response(200) - self.send_header("Content-Type", ctype) - self.send_header("Content-Length", str(len(data))) - self.end_headers() - self.wfile.write(data) + send_bytes(self, data, ctype) return - if parsed.path != "/reports/worktime/today": + if parsed.path not in {"/reports/worktime/today", "/reports/worktime/management"}: self.send_response(404) self.end_headers() return @@ -710,10 +2675,27 @@ class H(BaseHTTPRequestHandler): host = resolve_host(params.get("host", [DEFAULT_HOST])[0]) day = params.get("day", ["today"])[0] date_text = params.get("date", [None])[0] + owner_filter = normalize_management_filter(params.get("owner", [""])[0]) + department_filter = normalize_management_filter(params.get("department", [""])[0]) report_date = resolve_report_date(day=day, date_text=date_text) - rows = report_for_date_fresh(host, report_date) + is_management = parsed.path == "/reports/worktime/management" + management_payload = management_report_for_date(host, report_date, owner_filter=owner_filter, department_filter=department_filter) if is_management else None + rows = report_for_date_fresh(host, report_date) if not is_management else management_payload["rows"] + true_active_apps = [] if is_management else build_true_active_apps(host, report_date) if fmt == "csv": + if is_management: + out = io.StringIO() + writer = csv.DictWriter( + out, + fieldnames=["priority", "owner", "user_id", "action_id", "deadline_hint", "reason", "recommended_action"], + extrasaction="ignore", + ) + writer.writeheader() + writer.writerows(management_payload["actions"]) + data = out.getvalue().encode() + send_bytes(self, data, "text/csv; charset=utf-8") + return out = io.StringIO() writer = csv.DictWriter( out, @@ -733,20 +2715,21 @@ class H(BaseHTTPRequestHandler): writer.writeheader() writer.writerows(rows) data = out.getvalue().encode() - self.send_response(200) - self.send_header("Content-Type", "text/csv; charset=utf-8") - self.send_header("Content-Length", str(len(data))) - self.end_headers() - self.wfile.write(data) + send_bytes(self, data, "text/csv; charset=utf-8") return if fmt == "html": - data = render_html(rows, host, report_date, selected_day=day if day in {"today", "yesterday"} else None).encode("utf-8") - self.send_response(200) - self.send_header("Content-Type", "text/html; charset=utf-8") - self.send_header("Content-Length", str(len(data))) - self.end_headers() - self.wfile.write(data) + if is_management: + data = render_management_html(management_payload, selected_day=day if day in {"today", "yesterday"} else None).encode("utf-8") + else: + data = render_html(rows, host, report_date, selected_day=day if day in {"today", "yesterday"} else None, true_active_apps=true_active_apps).encode("utf-8") + send_bytes(self, data, "text/html; charset=utf-8") + return + + if is_management: + obj = management_payload + data = json.dumps(obj, ensure_ascii=False, indent=2).encode("utf-8") + send_bytes(self, data, "application/json; charset=utf-8") return obj = { @@ -756,17 +2739,19 @@ class H(BaseHTTPRequestHandler): "report_date": report_date.isoformat(), "bucket_id": get_sessions_bucket_id(host), "rows": rows, + "true_active_apps": true_active_apps, } data = json.dumps(obj, ensure_ascii=False, indent=2).encode("utf-8") - self.send_response(200) - self.send_header("Content-Type", "application/json; charset=utf-8") - self.send_header("Content-Length", str(len(data))) - self.end_headers() - self.wfile.write(data) + send_bytes(self, data, "application/json; charset=utf-8") + + +class WorktimeHTTPServer(ThreadingHTTPServer): + daemon_threads = True + request_queue_size = 64 def main(): - HTTPServer((LISTEN_HOST, LISTEN_PORT), H).serve_forever() + WorktimeHTTPServer((LISTEN_HOST, LISTEN_PORT), H).serve_forever(poll_interval=0.5) if __name__ == "__main__": diff --git a/install-kit-awindows-20260427-211240/aw-server/aw-worktime-panel.js b/install-kit-awindows-20260427-211240/aw-server/aw-worktime-panel.js index a91741d..0621520 100644 --- a/install-kit-awindows-20260427-211240/aw-server/aw-worktime-panel.js +++ b/install-kit-awindows-20260427-211240/aw-server/aw-worktime-panel.js @@ -1,14 +1,15 @@ (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(); var htmlUrl = reportBase + "/reports/worktime/today?format=html&" + dayQuery; var csvUrl = reportBase + "/reports/worktime/today?format=csv&" + dayQuery; var jsonUrl = reportBase + "/reports/worktime/today?" + dayQuery; + var managerHtmlUrl = reportBase + "/reports/worktime/management?format=html&" + dayQuery; + var managerJsonUrl = reportBase + "/reports/worktime/management?" + dayQuery; var existing = document.getElementById("aw-report-links"); if (!existing) return; @@ -17,6 +18,8 @@ 'HTML | ' + 'CSV | ' + 'JSON | ' + + 'Менеджмент | ' + + 'Mgmt JSON | ' + 'Панель'; var panel = document.createElement("div"); @@ -40,10 +43,11 @@ '
    ' + '
    Отчёт по работе в RDP
    ' + '
    " + - ''; + ''; document.body.appendChild(panel); diff --git a/install-kit-awindows-20260427-211240/aw-server/install_aw_server.sh b/install-kit-awindows-20260427-211240/aw-server/install_aw_server.sh index 6a28f35..34930d6 100755 --- a/install-kit-awindows-20260427-211240/aw-server/install_aw_server.sh +++ b/install-kit-awindows-20260427-211240/aw-server/install_aw_server.sh @@ -26,6 +26,7 @@ VIEWS_JSON="$BOOTSTRAP_DIR/settings/views-default.json" CLASSES_JSON="$BOOTSTRAP_DIR/settings/classes-worktime.json" WORKTIME_API_SRC="$BOOTSTRAP_DIR/aw-worktime-api.py" WORKTIME_API_SERVICE_SRC="$BOOTSTRAP_DIR/aw-worktime-api.service" +WORKTIME_ALIASES_SRC="$BOOTSTRAP_DIR/worktime-manager-aliases.example.json" WORKTIME_UI_BRIDGE_SRC="$BOOTSTRAP_DIR/aw-worktime-ui-bridge.py" WORKTIME_UI_BRIDGE_SERVICE_SRC="$BOOTSTRAP_DIR/aw-worktime-ui-bridge.service" WORKTIME_UI_BRIDGE_TIMER_SRC="$BOOTSTRAP_DIR/aw-worktime-ui-bridge.timer" @@ -110,6 +111,14 @@ if [[ -f "$WORKTIME_API_SERVICE_SRC" ]]; then systemctl --no-pager --full status aw-worktime-api.service || true fi +if [[ -f "$WORKTIME_ALIASES_SRC" ]]; then + install -d -m 0755 /etc/activitywatch + install -m 0644 "$WORKTIME_ALIASES_SRC" /etc/activitywatch/worktime-manager-aliases.json.example + if [[ ! -f /etc/activitywatch/worktime-manager-aliases.json ]]; then + install -m 0644 "$WORKTIME_ALIASES_SRC" /etc/activitywatch/worktime-manager-aliases.json + fi +fi + if [[ -f "$WORKTIME_UI_BRIDGE_SRC" ]]; then install -m 0755 "$WORKTIME_UI_BRIDGE_SRC" /usr/local/bin/aw-worktime-ui-bridge.py fi diff --git a/install-kit-awindows-20260427-211240/aw-server/settings/views-default.json b/install-kit-awindows-20260427-211240/aw-server/settings/views-default.json index 5421e98..5698738 100644 --- a/install-kit-awindows-20260427-211240/aw-server/settings/views-default.json +++ b/install-kit-awindows-20260427-211240/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/install-kit-awindows-20260427-211240/windows/ActivityWatch.Windows.Common.psm1 b/install-kit-awindows-20260427-211240/windows/ActivityWatch.Windows.Common.psm1 index 2f7f461..3c68781 100755 --- a/install-kit-awindows-20260427-211240/windows/ActivityWatch.Windows.Common.psm1 +++ b/install-kit-awindows-20260427-211240/windows/ActivityWatch.Windows.Common.psm1 @@ -1,5 +1,6 @@ -Set-StrictMode -Version Latest +Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' +$script:ActivityWatchBuiltInAdministratorName = $null function Assert-Administrator { $identity = [Security.Principal.WindowsIdentity]::GetCurrent() @@ -60,6 +61,12 @@ function Get-ActivityWatchArchive { } [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + Get-ChildItem -LiteralPath $WorkingRoot -File -Filter 'activitywatch-*.zip' -ErrorAction SilentlyContinue | + Sort-Object LastWriteTime -Descending | + Select-Object -Skip 2 | + ForEach-Object { + try { Remove-Item -LiteralPath $_.FullName -Force -ErrorAction SilentlyContinue } catch {} + } $stamp = Get-Date -Format 'yyyyMMdd-HHmmss' $suffix = ([guid]::NewGuid().Guid.Substring(0, 8)) $archivePath = Join-Path $WorkingRoot ("activitywatch-{0}-{1}-{2}.zip" -f $Version.TrimStart('v'), $stamp, $suffix) @@ -67,6 +74,26 @@ function Get-ActivityWatchArchive { return $archivePath } +function Remove-ActivityWatchOldInstallBackups { + param( + [Parameter(Mandatory = $true)] + [string]$BackupRoot, + [int]$Keep = 2 + ) + + if (-not (Test-Path -LiteralPath $BackupRoot)) { + return + } + + Get-ChildItem -LiteralPath $BackupRoot -Directory -ErrorAction SilentlyContinue | + Where-Object { $_.Name -like 'install-*' } | + Sort-Object LastWriteTime -Descending | + Select-Object -Skip $Keep | + ForEach-Object { + try { Remove-Item -LiteralPath $_.FullName -Recurse -Force -ErrorAction SilentlyContinue } catch {} + } +} + function Get-ActivityWatchPackageRoot { param( [Parameter(Mandatory = $true)] @@ -132,6 +159,7 @@ function Install-ActivityWatchPackage { New-ActivityWatchDirectory -Path $WorkingRoot New-ActivityWatchDirectory -Path $BackupRoot + Remove-ActivityWatchOldInstallBackups -BackupRoot $BackupRoot # Cleanup stale extraction directories from previous failed deployments. Get-ChildItem -LiteralPath $WorkingRoot -Directory -ErrorAction SilentlyContinue | @@ -157,38 +185,64 @@ function Install-ActivityWatchPackage { New-ActivityWatchDirectory -Path $extractRoot $archiveSize = (Get-Item -LiteralPath $ArchivePath -ErrorAction Stop).Length - $workDrive = (Get-PSDrive -Name ([System.IO.Path]::GetPathRoot($WorkingRoot).TrimEnd('\').TrimEnd(':')) -ErrorAction SilentlyContinue) - if ($workDrive) { + $workDriveName = [System.IO.Path]::GetPathRoot($WorkingRoot).TrimEnd('\').TrimEnd(':') + $workDrive = Get-PSDrive -Name $workDriveName -ErrorAction SilentlyContinue + $freeBytes = $null + if ($workDrive -and $null -ne $workDrive.Free) { + $freeBytes = [int64]$workDrive.Free + } + elseif ($workDriveName) { + try { + $disk = Get-CimInstance Win32_LogicalDisk -Filter ("DeviceID='{0}:'" -f $workDriveName) -ErrorAction Stop + if ($disk -and $null -ne $disk.FreeSpace) { + $freeBytes = [int64]$disk.FreeSpace + } + } + catch { + } + } + if ($null -ne $freeBytes) { # Require at least ~2.5x archive size to handle extraction + copy safely. $required = [int64]([Math]::Ceiling($archiveSize * 2.5)) - if ([int64]$workDrive.Free -lt $required) { - throw ("Недостаточно свободного места на {0}: free={1} bytes, required>={2} bytes" -f $workDrive.Name, $workDrive.Free, $required) + if ($freeBytes -lt $required) { + throw ("Недостаточно свободного места на {0}: free={1} bytes, required>={2} bytes" -f $workDriveName, $freeBytes, $required) } } - Expand-ActivityWatchArchiveSafe -ArchivePath $ArchivePath -DestinationPath $extractRoot - $packageRoot = Get-ActivityWatchPackageRoot -ExpandedRoot $extractRoot + try { + Expand-ActivityWatchArchiveSafe -ArchivePath $ArchivePath -DestinationPath $extractRoot + $packageRoot = Get-ActivityWatchPackageRoot -ExpandedRoot $extractRoot - if (Test-Path -LiteralPath $InstallRoot) { - $existingItems = Get-ChildItem -LiteralPath $InstallRoot -Force -ErrorAction SilentlyContinue - if ($existingItems) { - $stamp = Get-Date -Format 'yyyyMMdd-HHmmss' - $backupPath = Join-Path $BackupRoot ("install-$stamp") - New-ActivityWatchDirectory -Path $backupPath - Copy-Item -Path (Join-Path $InstallRoot '*') -Destination $backupPath -Recurse -Force - Get-ChildItem -LiteralPath $InstallRoot -Force | Remove-Item -Recurse -Force + if (Test-Path -LiteralPath $InstallRoot) { + $existingItems = Get-ChildItem -LiteralPath $InstallRoot -Force -ErrorAction SilentlyContinue + if ($existingItems) { + $stamp = Get-Date -Format 'yyyyMMdd-HHmmss' + $backupPath = Join-Path $BackupRoot ("install-$stamp") + New-ActivityWatchDirectory -Path $backupPath + Copy-Item -Path (Join-Path $InstallRoot '*') -Destination $backupPath -Recurse -Force + Get-ChildItem -LiteralPath $InstallRoot -Force | Remove-Item -Recurse -Force + } + } + else { + New-ActivityWatchDirectory -Path $InstallRoot + } + + Copy-Item -Path (Join-Path $packageRoot '*') -Destination $InstallRoot -Recurse -Force + + return [pscustomobject]@{ + PackageRoot = $packageRoot + ExtractRoot = $extractRoot + BackupRoot = $BackupRoot } } - else { - New-ActivityWatchDirectory -Path $InstallRoot - } - - Copy-Item -Path (Join-Path $packageRoot '*') -Destination $InstallRoot -Recurse -Force - - return [pscustomobject]@{ - PackageRoot = $packageRoot - ExtractRoot = $extractRoot - BackupRoot = $BackupRoot + finally { + if (Test-Path -LiteralPath $extractRoot) { + try { Remove-Item -LiteralPath $extractRoot -Recurse -Force -ErrorAction SilentlyContinue } catch {} + } + if ((Test-Path -LiteralPath $ArchivePath) -and ($ArchivePath -like (Join-Path $WorkingRoot 'activitywatch-*.zip'))) { + try { Remove-Item -LiteralPath $ArchivePath -Force -ErrorAction SilentlyContinue } catch {} + } + Remove-ActivityWatchOldInstallBackups -BackupRoot $BackupRoot } } @@ -212,6 +266,95 @@ function Get-ActivityWatchExecutableMap { return [pscustomobject]$map } +function Repair-ActivityWatchPotentialMojibake { + param([string]$Value) + + if ([string]::IsNullOrWhiteSpace($Value)) { + return $Value + } + + if ($Value -notmatch '[\u0400-\u04FF]') { + return $Value + } + + try { + $bytes = [Text.Encoding]::GetEncoding(1251).GetBytes($Value) + $repaired = [Text.Encoding]::UTF8.GetString($bytes) + if (-not [string]::IsNullOrWhiteSpace($repaired) -and $repaired -match '[\u0400-\u04FF]') { + return $repaired + } + } + catch { + } + + return $Value +} + +function Get-ActivityWatchBuiltInAdministratorName { + if ($script: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$' } | + Select-Object -First 1 + if ($account -and -not [string]::IsNullOrWhiteSpace([string]$account.Name)) { + $script:ActivityWatchBuiltInAdministratorName = [string]$account.Name + return $script:ActivityWatchBuiltInAdministratorName + } + } + catch { + } + + if ([string]$env:COMPUTERNAME -ieq 'SHARKON2025') { + $script:ActivityWatchBuiltInAdministratorName = 'Администратор' + return $script:ActivityWatchBuiltInAdministratorName + } + + $script:ActivityWatchBuiltInAdministratorName = 'Administrator' + return $script:ActivityWatchBuiltInAdministratorName +} + +function Normalize-ActivityWatchUserId { + param( + [string]$UserId, + [string]$Domain + ) + + if ([string]::IsNullOrWhiteSpace($UserId)) { + return $null + } + + $normalized = Repair-ActivityWatchPotentialMojibake -Value $UserId.Trim() + $resolvedDomain = $null + $leafUser = $normalized + + if ($normalized -match '^([^\\]+)\\(.+)$') { + $resolvedDomain = Repair-ActivityWatchPotentialMojibake -Value $Matches[1] + $leafUser = Repair-ActivityWatchPotentialMojibake -Value $Matches[2] + } + + if ($leafUser -match '^(?i:administrator|администратор)$') { + $leafUser = Get-ActivityWatchBuiltInAdministratorName + } + + if ([string]::IsNullOrWhiteSpace($resolvedDomain) -and -not [string]::IsNullOrWhiteSpace($Domain)) { + $resolvedDomain = Repair-ActivityWatchPotentialMojibake -Value $Domain.Trim() + } + + if (-not [string]::IsNullOrWhiteSpace($resolvedDomain)) { + return ('{0}\{1}' -f $resolvedDomain, $leafUser) + } + + return $leafUser +} + function Normalize-ActivityWatchUsers { param( [string[]]$Users, @@ -256,17 +399,11 @@ function Normalize-ActivityWatchUsers { } } - $normalized = $collected | + $normalized = @($collected | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | - ForEach-Object { - if ($Domain -and ($_ -notmatch '[\\@]')) { - '{0}\{1}' -f $Domain, $_ - } - else { - $_ - } - } | - Sort-Object -Unique + ForEach-Object { Normalize-ActivityWatchUserId -UserId $_ -Domain $Domain } | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | + Sort-Object -Unique) if (-not $normalized -or $normalized.Count -eq 0) { throw 'Не удалось определить целевых пользователей. Укажите -Users или -UserListPath.' @@ -301,9 +438,10 @@ function New-ActivityWatchUserTaskDefinitions { ) $result = foreach ($user in $Users) { - $token = Get-ActivityWatchTaskNameToken -UserId $user + $normalizedUser = Normalize-ActivityWatchUserId -UserId $user + $token = Get-ActivityWatchTaskNameToken -UserId $normalizedUser [pscustomobject]@{ - UserId = $user + UserId = $normalizedUser LaunchTaskName = "ActivityWatch Launch [$token]" } } @@ -354,6 +492,99 @@ function Get-ActivityWatchLoggedOnUsers { return @($users) } +function Get-ActivityWatchSessionRecords { + $sessions = New-Object System.Collections.Generic.List[object] + + try { + $lines = & qwinsta.exe 2>$null + foreach ($line in @($lines)) { + $normalized = [string]$line + if ([string]::IsNullOrWhiteSpace($normalized)) { + continue + } + + $normalized = $normalized.TrimStart(' ', '>') + if ([string]::IsNullOrWhiteSpace($normalized)) { + continue + } + + if ($normalized -match '^(SESSIONNAME|ИМЯ СЕАНСА)\s+') { + continue + } + + $columns = @( + (($normalized -replace '\s{2,}', '|') -split '\|') | + ForEach-Object { $_.Trim() } | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } + ) + if ($columns.Count -lt 3) { + continue + } + + $sessionName = [string]$columns[0] + $userName = $null + $sessionIdIndex = 1 + + if ($columns[1] -notmatch '^\d+$') { + $userName = [string]$columns[1] + $sessionIdIndex = 2 + } + + if ($columns.Count -le $sessionIdIndex -or $columns[$sessionIdIndex] -notmatch '^\d+$') { + continue + } + + $sessionId = [int]$columns[$sessionIdIndex] + $state = if ($columns.Count -gt ($sessionIdIndex + 1)) { [string]$columns[$sessionIdIndex + 1] } else { '' } + $isLive = $state -match '^(Active|Conn|Активно|Подкл\w*)$' + + $sessions.Add([pscustomobject]@{ + SessionName = $sessionName + UserName = $userName + SessionId = $sessionId + State = $state + IsLive = $isLive + }) | Out-Null + } + } + catch { + } + + $explorerUsers = Get-ActivityWatchExplorerUsersBySession + foreach ($session in @($sessions.ToArray())) { + $sessionId = [int]$session.SessionId + if ($explorerUsers.ContainsKey($sessionId)) { + $session.UserName = [string]$explorerUsers[$sessionId] + } + } + + return @($sessions.ToArray()) +} + +function Resolve-ActivityWatchUserCandidates { + param( + [Parameter(Mandatory = $true)] + [string]$UserId + ) + + $normalizedUserId = Normalize-ActivityWatchUserId -UserId $UserId + $candidateIds = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) + [void]$candidateIds.Add($normalizedUserId) + + $leafUser = $normalizedUserId + if ($leafUser -match '^[^\\]+\\(.+)$') { + $leafUser = $Matches[1] + [void]$candidateIds.Add($leafUser) + } + + [void]$candidateIds.Add(('{0}\{1}' -f $env:COMPUTERNAME, $leafUser)) + if (-not [string]::IsNullOrWhiteSpace($env:USERDOMAIN)) { + [void]$candidateIds.Add(('{0}\{1}' -f $env:USERDOMAIN, $leafUser)) + } + + return @($candidateIds) +} + function Test-ActivityWatchUserHasSession { param( [Parameter(Mandatory = $true)] @@ -365,21 +596,7 @@ function Test-ActivityWatchUserHasSession { return $false } - $candidateIds = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) - [void]$candidateIds.Add($UserId) - - $leafUser = $UserId - if ($leafUser -match '^[^\\]+\\(.+)$') { - $leafUser = $Matches[1] - [void]$candidateIds.Add($leafUser) - } - - [void]$candidateIds.Add(('{0}\{1}' -f $env:COMPUTERNAME, $leafUser)) - if (-not [string]::IsNullOrWhiteSpace($env:USERDOMAIN)) { - [void]$candidateIds.Add(('{0}\{1}' -f $env:USERDOMAIN, $leafUser)) - } - - foreach ($candidate in @($candidateIds)) { + foreach ($candidate in @(Resolve-ActivityWatchUserCandidates -UserId $UserId)) { if ($LoggedOnUsers -contains $candidate) { return $true } @@ -388,6 +605,34 @@ function Test-ActivityWatchUserHasSession { return $false } +function Test-ActivityWatchUserHasLiveSession { + param( + [Parameter(Mandatory = $true)] + [string]$UserId, + [object[]]$SessionRecords + ) + + if ([string]::IsNullOrWhiteSpace($UserId)) { + return $false + } + + foreach ($candidate in @(Resolve-ActivityWatchUserCandidates -UserId $UserId)) { + if (@($SessionRecords | Where-Object { + $_.IsLive -and + -not [string]::IsNullOrWhiteSpace([string]$_.UserName) -and + ( + [string]$_.UserName -ieq $candidate -or + ('{0}\{1}' -f $env:COMPUTERNAME, [string]$_.UserName) -ieq $candidate -or + ((-not [string]::IsNullOrWhiteSpace($env:USERDOMAIN)) -and ('{0}\{1}' -f $env:USERDOMAIN, [string]$_.UserName) -ieq $candidate) + ) + }).Count -gt 0) { + return $true + } + } + + return $false +} + function Copy-ActivityWatchCollectorAssets { param( [Parameter(Mandatory = $true)] @@ -400,6 +645,8 @@ function Copy-ActivityWatchCollectorAssets { [Parameter(Mandatory = $true)] [string]$SessionCollectorScriptSource, [string]$EvtxExportScriptSource, + [string]$HayabusaUploadScriptSource, + [string]$File1CTelemetryScriptSource, [string]$EmailCollectorScriptSource, [Parameter(Mandatory = $true)] [string]$ExampleRulesSource, @@ -419,6 +666,8 @@ function Copy-ActivityWatchCollectorAssets { $fileCollectorTarget = Join-Path $StateRoot 'file-operations-collector.ps1' $sessionCollectorTarget = Join-Path $StateRoot 'worktime-session-collector.ps1' $evtxExportTarget = Join-Path $StateRoot 'export-evtx-for-hayabusa.ps1' + $hayabusaUploadTarget = Join-Path $StateRoot 'export-upload-hayabusa-to-aw-server.ps1' + $file1cTelemetryTarget = Join-Path $StateRoot 'export-upload-file-1c-telemetry.ps1' $emailCollectorTarget = Join-Path $StateRoot 'email-outbound-collector.ps1' $exampleRulesTarget = Join-Path $StateRoot 'web-category-rules.example.json' $rulesTarget = Join-Path $StateRoot 'web-category-rules.json' @@ -435,6 +684,12 @@ function Copy-ActivityWatchCollectorAssets { if ($EvtxExportScriptSource -and (Test-Path -LiteralPath $EvtxExportScriptSource)) { Copy-Item -LiteralPath $EvtxExportScriptSource -Destination $evtxExportTarget -Force } + if ($HayabusaUploadScriptSource -and (Test-Path -LiteralPath $HayabusaUploadScriptSource)) { + Copy-Item -LiteralPath $HayabusaUploadScriptSource -Destination $hayabusaUploadTarget -Force + } + if ($File1CTelemetryScriptSource -and (Test-Path -LiteralPath $File1CTelemetryScriptSource)) { + Copy-Item -LiteralPath $File1CTelemetryScriptSource -Destination $file1cTelemetryTarget -Force + } if ($EmailCollectorScriptSource -and (Test-Path -LiteralPath $EmailCollectorScriptSource)) { Copy-Item -LiteralPath $EmailCollectorScriptSource -Destination $emailCollectorTarget -Force } @@ -464,6 +719,8 @@ function Copy-ActivityWatchCollectorAssets { FileCollectorScript = $fileCollectorTarget SessionCollectorScript = $sessionCollectorTarget EvtxExportScript = $evtxExportTarget + HayabusaUploadScript = $hayabusaUploadTarget + File1CTelemetryScript = $file1cTelemetryTarget EmailCollectorScript = $emailCollectorTarget ExampleRules = $exampleRulesTarget ActiveRules = $rulesTarget @@ -496,6 +753,8 @@ function New-ActivityWatchDeploymentConfig { [Parameter(Mandatory = $true)] [string]$SessionCollectorScript, [string]$EvtxExportScript, + [string]$HayabusaUploadScript, + [string]$File1CTelemetryScript, [string]$EmailCollectorScript, [Parameter(Mandatory = $true)] [string]$RulesPath, @@ -518,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)] @@ -535,6 +795,18 @@ function New-ActivityWatchDeploymentConfig { [Parameter(Mandatory = $true)] [pscustomobject[]]$UserTasks, [string]$PackageVersion = 'v0.13.2', + [bool]$HayabusaAutoUploadEnabled = $true, + [int]$HayabusaAutoUploadIntervalHours = 6, + [int]$HayabusaAutoUploadHoursBack = 6, + [string]$HayabusaAutoUploadMode = 'incident', + [string]$HayabusaAutoUploadTaskName = 'ActivityWatch Hayabusa Upload', + [bool]$File1CAutoUploadEnabled = $true, + [int]$File1CAutoUploadIntervalHours = 6, + [string]$File1CAutoUploadTaskName = 'ActivityWatch File1C Upload', + [string]$File1CTargetHost, + [string]$File1CTargetUser = 'igor', + [string]$File1CRemoteRoot = '/opt/activitywatch/clickhouse-1c/landing', + [string]$File1CRegistryWorkbookPath = 'E:\USER1\СПИСОК ПРЕДПРИЯТИЙ И ИХ РАСПРЕДЕЛЕНИЕ.xlsx', [switch]$IntegrationTestEnabled ) @@ -554,6 +826,9 @@ function New-ActivityWatchDeploymentConfig { } $effectivePolicyEngineHost = if ([string]::IsNullOrWhiteSpace($PolicyEngineHost)) { $ServerHost } else { $PolicyEngineHost } $effectivePolicyCachePath = if ([string]::IsNullOrWhiteSpace($PolicyCachePath)) { Join-Path $StateRoot 'dlp-policy-cache.json' } else { $PolicyCachePath } + if ($File1CAutoUploadEnabled -and [string]::IsNullOrWhiteSpace($File1CTargetHost)) { + throw 'File1CTargetHost is required when File1CAutoUploadEnabled is true.' + } return [pscustomobject]@{ version = 1 @@ -575,6 +850,8 @@ function New-ActivityWatchDeploymentConfig { fileCollectorScript = $FileCollectorScript sessionCollectorScript = $SessionCollectorScript evtxExportScript = $EvtxExportScript + hayabusaUploadScript = $HayabusaUploadScript + file1cTelemetryScript = $File1CTelemetryScript rulesPath = $RulesPath policyPath = $PolicyPath launchScript = $LaunchScriptPath @@ -602,10 +879,29 @@ function New-ActivityWatchDeploymentConfig { evtxExportRoot = $effectiveEvtxExportRoot retentionDays = $EvtxRetentionDays evtxChannels = @($effectiveEvtxChannels) + hayabusaAutomation = [pscustomobject]@{ + enabled = [bool]$HayabusaAutoUploadEnabled + intervalHours = $HayabusaAutoUploadIntervalHours + hoursBack = $HayabusaAutoUploadHoursBack + mode = $HayabusaAutoUploadMode + taskName = $HayabusaAutoUploadTaskName + } + } + analytics = [pscustomobject]@{ + file1cAutomation = [pscustomobject]@{ + enabled = [bool]$File1CAutoUploadEnabled + intervalHours = $File1CAutoUploadIntervalHours + taskName = $File1CAutoUploadTaskName + targetHost = $File1CTargetHost + targetUser = $File1CTargetUser + remoteRoot = $File1CRemoteRoot + registryWorkbookPath = $File1CRegistryWorkbookPath + } } sessionEvents = [pscustomobject]@{ - logonEnabled = $LogonMarkerEnabled - bucketPrefix = 'aw-session-events' + logonEnabled = $LogonMarkerEnabled + processEventsEnabled = $ProcessEventsEnabled + bucketPrefix = 'aw-session-events' } recovery = [pscustomobject]@{ intervalSeconds = $RecoveryIntervalSeconds @@ -680,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) @@ -696,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, @@ -703,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 } @@ -714,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 { @@ -863,12 +1188,12 @@ function Send-LogonMarkerIfNeeded { `$stateRoot = [string]`$Config.paths.stateRoot `$markerRoots = New-Object System.Collections.Generic.List[string] - if (-not [string]::IsNullOrWhiteSpace(`$env:LOCALAPPDATA)) { - `$markerRoots.Add((Join-Path `$env:LOCALAPPDATA 'AWatch-rus\markers')) - } if (-not [string]::IsNullOrWhiteSpace(`$stateRoot)) { `$markerRoots.Add((Join-Path `$stateRoot 'markers')) } + if (-not [string]::IsNullOrWhiteSpace(`$env:LOCALAPPDATA)) { + `$markerRoots.Add((Join-Path `$env:LOCALAPPDATA 'AWatch-rus\markers')) + } `$markerDir = `$null foreach (`$candidate in `$markerRoots) { @@ -1029,6 +1354,7 @@ function Write-ActivityWatchRecoveryScript { [string]$ConfigPath ) + $modulePath = Join-Path $PSScriptRoot 'ActivityWatch.Windows.Common.psm1' $content = @" param( [string]`$ConfigPath = '$ConfigPath' @@ -1036,57 +1362,58 @@ param( Set-StrictMode -Version Latest `$ErrorActionPreference = 'Continue' +Import-Module '$modulePath' -Force +Invoke-ActivityWatchRecoveryLoop -ConfigPath `$ConfigPath +"@ -function Get-DeploymentConfig { - param([string]`$Path) - return Get-Content -LiteralPath `$Path -Raw | ConvertFrom-Json + Set-Content -LiteralPath $Path -Value $content -Encoding UTF8 } -function Get-RecoveryConfigPaths { - param([string]`$PrimaryConfigPath) +function Get-ActivityWatchRecoveryConfigPaths { + param([string]$PrimaryConfigPath) - `$paths = New-Object System.Collections.Generic.List[string] - if (`$PrimaryConfigPath -and (Test-Path -LiteralPath `$PrimaryConfigPath)) { - `$paths.Add((Resolve-Path -LiteralPath `$PrimaryConfigPath).Path) + $paths = New-Object System.Collections.Generic.List[string] + if ($PrimaryConfigPath -and (Test-Path -LiteralPath $PrimaryConfigPath)) { + $paths.Add((Resolve-Path -LiteralPath $PrimaryConfigPath).Path) } - `$searchRoot = `$env:ProgramData - if (`$PrimaryConfigPath) { - `$stateRoot = Split-Path -Path `$PrimaryConfigPath -Parent - `$candidateRoot = Split-Path -Path `$stateRoot -Parent - if (`$candidateRoot -and (Test-Path -LiteralPath `$candidateRoot)) { - `$searchRoot = `$candidateRoot + $searchRoot = $env:ProgramData + if ($PrimaryConfigPath) { + $stateRoot = Split-Path -Path $PrimaryConfigPath -Parent + $candidateRoot = Split-Path -Path $stateRoot -Parent + if ($candidateRoot -and (Test-Path -LiteralPath $candidateRoot)) { + $searchRoot = $candidateRoot } } - if (Test-Path -LiteralPath `$searchRoot) { - Get-ChildItem -LiteralPath `$searchRoot -Directory -ErrorAction SilentlyContinue | - Where-Object { `$_.Name -like 'ActivityWatch*' } | + if (Test-Path -LiteralPath $searchRoot) { + Get-ChildItem -LiteralPath $searchRoot -Directory -ErrorAction SilentlyContinue | + Where-Object { $_.Name -like 'ActivityWatch*' } | ForEach-Object { - `$candidate = Join-Path `$_.FullName 'deployment-config.json' - if (Test-Path -LiteralPath `$candidate) { - `$paths.Add(`$candidate) + $candidate = Join-Path $_.FullName 'deployment-config.json' + if (Test-Path -LiteralPath $candidate) { + $paths.Add($candidate) } } } - return @(`$paths | Sort-Object -Unique) + return @($paths | Sort-Object -Unique) } -function Get-RecoveryTaskDefinitions { - param([string[]]`$ConfigPaths) +function Get-ActivityWatchRecoveryTaskDefinitions { + param([string[]]$ConfigPaths) - `$taskMap = [ordered]@{} - foreach (`$candidatePath in @(`$ConfigPaths)) { + $taskMap = [ordered]@{} + foreach ($candidatePath in @($ConfigPaths)) { try { - `$config = Get-DeploymentConfig -Path `$candidatePath - foreach (`$task in @(`$config.userTasks)) { - `$taskName = [string]`$task.launchTaskName - `$userId = [string]`$task.userId - if (-not [string]::IsNullOrWhiteSpace(`$taskName) -and -not `$taskMap.Contains(`$taskName)) { - `$taskMap[`$taskName] = [pscustomobject]@{ - taskName = `$taskName - userId = `$userId + $config = Read-ActivityWatchDeploymentConfig -Path $candidatePath + foreach ($task in @($config.userTasks)) { + $taskName = [string]$task.launchTaskName + $userId = Normalize-ActivityWatchUserId -UserId ([string]$task.userId) + if (-not [string]::IsNullOrWhiteSpace($taskName) -and -not $taskMap.Contains($taskName)) { + $taskMap[$taskName] = [pscustomobject]@{ + taskName = $taskName + userId = $userId } } } @@ -1095,221 +1422,499 @@ function Get-RecoveryTaskDefinitions { } } - return @(`$taskMap.Values) + return @($taskMap.Values) } -function New-RecoveryLock { - param([string]`$PrimaryConfigPath) +function New-ActivityWatchRecoveryLock { + param([string]$PrimaryConfigPath) - `$stateRoot = if (`$PrimaryConfigPath) { Split-Path -Path `$PrimaryConfigPath -Parent } else { Join-Path `$env:ProgramData 'AWatch-rus' } - if (-not (Test-Path -LiteralPath `$stateRoot)) { - New-Item -Path `$stateRoot -ItemType Directory -Force | Out-Null + $stateRoot = if ($PrimaryConfigPath) { Split-Path -Path $PrimaryConfigPath -Parent } else { Join-Path $env:ProgramData 'AWatch-rus' } + if (-not (Test-Path -LiteralPath $stateRoot)) { + New-Item -Path $stateRoot -ItemType Directory -Force | Out-Null } - `$lockPath = Join-Path `$stateRoot 'recovery-loop.lock' - if (Test-Path -LiteralPath `$lockPath) { + $lockPath = Join-Path $stateRoot 'recovery-loop.lock' + if (Test-Path -LiteralPath $lockPath) { try { - `$lockData = Get-Content -LiteralPath `$lockPath -Raw | ConvertFrom-Json - `$existingPid = [int]`$lockData.pid - if (`$existingPid -gt 0 -and (Get-Process -Id `$existingPid -ErrorAction SilentlyContinue)) { - return `$null + $lockData = Get-Content -LiteralPath $lockPath -Raw | ConvertFrom-Json + $existingPid = [int]$lockData.pid + if ($existingPid -gt 0 -and (Get-Process -Id $existingPid -ErrorAction SilentlyContinue)) { + return $null } } catch { } } - `$payload = @{ - pid = `$PID + $payload = @{ + pid = $PID createdAt = (Get-Date).ToUniversalTime().ToString('o') } | ConvertTo-Json -Compress - Set-Content -LiteralPath `$lockPath -Value `$payload -Encoding UTF8 - return `$lockPath + Set-Content -LiteralPath $lockPath -Value $payload -Encoding UTF8 + return $lockPath } -function Start-TaskIfNotRunning { - param( - [string]`$TaskName, - [string]`$UserId, - [string[]]`$LoggedOnUsers - ) - if ([string]::IsNullOrWhiteSpace(`$TaskName)) { - return - } +function Test-ActivityWatchCollectorRunningGlobal { + param([string]$ScriptPath) - if ([string]::IsNullOrWhiteSpace(`$UserId)) { - return - } - - if (-not (Test-UserHasSession -UserId `$UserId -LoggedOnUsers `$LoggedOnUsers)) { - return - } - - try { - `$task = Get-ScheduledTask -TaskName `$TaskName -ErrorAction SilentlyContinue - if (-not `$task) { - return - } - if ([string]`$task.State -eq 'Running') { - return - } - Start-ScheduledTask -TaskName `$TaskName -ErrorAction SilentlyContinue - } - catch { - } -} - -function Get-LoggedOnUsers { - `$users = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) - - try { - `$lines = & quser.exe 2>`$null - foreach (`$line in @(`$lines)) { - `$normalized = [string]`$line - if ([string]::IsNullOrWhiteSpace(`$normalized)) { - continue - } - - `$normalized = `$normalized.TrimStart(' ', '>') - if ([string]::IsNullOrWhiteSpace(`$normalized)) { - continue - } - - if (`$normalized -match '^(USERNAME|ПОЛЬЗОВАТЕЛЬ)\s+') { - continue - } - - `$parts = `$normalized -split '\s+' - if (`$parts.Count -lt 1) { - continue - } - - `$user = [string]`$parts[0] - if ([string]::IsNullOrWhiteSpace(`$user)) { - continue - } - - [void]`$users.Add(`$user) - [void]`$users.Add(('{0}\{1}' -f `$env:COMPUTERNAME, `$user)) - if (-not [string]::IsNullOrWhiteSpace(`$env:USERDOMAIN)) { - [void]`$users.Add(('{0}\{1}' -f `$env:USERDOMAIN, `$user)) - } - } - } - catch { - } - - return @(`$users) -} - -function Test-UserHasSession { - param( - [string]`$UserId, - [string[]]`$LoggedOnUsers - ) - - if ([string]::IsNullOrWhiteSpace(`$UserId)) { - return `$false - } - - `$candidateIds = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) - [void]`$candidateIds.Add(`$UserId) - - `$leafUser = `$UserId - if (`$leafUser -match '^[^\\]+\\(.+)$') { - `$leafUser = `$Matches[1] - [void]`$candidateIds.Add(`$leafUser) - } - - [void]`$candidateIds.Add(('{0}\{1}' -f `$env:COMPUTERNAME, `$leafUser)) - if (-not [string]::IsNullOrWhiteSpace(`$env:USERDOMAIN)) { - [void]`$candidateIds.Add(('{0}\{1}' -f `$env:USERDOMAIN, `$leafUser)) - } - - foreach (`$candidate in @(`$candidateIds)) { - if (`$LoggedOnUsers -contains `$candidate) { - return `$true - } - } - - return `$false -} - -function Test-CollectorRunningGlobal { - param([string]`$ScriptPath) - if ([string]::IsNullOrWhiteSpace(`$ScriptPath)) { - return `$false + 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) + ($_.Name -ieq 'powershell.exe' -or $_.Name -ieq 'pwsh.exe') -and + $_.CommandLine -match [Regex]::Escape($ScriptPath) } ).Count } -function Start-CollectorScriptGlobalIfNeeded { +function Start-ActivityWatchCollectorScriptGlobalIfNeeded { param( - [string]`$ScriptPath, - [string]`$ConfigPath + [string]$ScriptPath, + [string]$ConfigPath ) - if ([string]::IsNullOrWhiteSpace(`$ScriptPath)) { + if ([string]::IsNullOrWhiteSpace($ScriptPath)) { return } - if (-not (Test-Path -LiteralPath `$ScriptPath)) { + if (-not (Test-Path -LiteralPath $ScriptPath)) { return } - if (Test-CollectorRunningGlobal -ScriptPath `$ScriptPath) { + if (Test-ActivityWatchCollectorRunningGlobal -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 + $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 +function Start-ActivityWatchTaskIfNotRunning { + param( + [string]$TaskName, + [string]$UserId, + [object[]]$SessionRecords + ) + + if ([string]::IsNullOrWhiteSpace($TaskName) -or [string]::IsNullOrWhiteSpace($UserId)) { + return $false + } + + if (-not (Test-ActivityWatchUserHasLiveSession -UserId $UserId -SessionRecords $SessionRecords)) { + return $false + } + + try { + $task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue + if (-not $task) { + return $false + } + if ([string]$task.State -eq 'Running') { + return $true + } + Start-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue + return $true + } + catch { + return $false + } } -try { - while (`$true) { - `$sleepSeconds = 180 - try { - `$configPaths = Get-RecoveryConfigPaths -PrimaryConfigPath `$ConfigPath - `$config = Get-DeploymentConfig -Path `$ConfigPath - `$loggedOnUsers = Get-LoggedOnUsers - `$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 (`$taskDef in Get-RecoveryTaskDefinitions -ConfigPaths `$configPaths) { - Start-TaskIfNotRunning -TaskName `$taskDef.taskName -UserId `$taskDef.userId -LoggedOnUsers `$loggedOnUsers - } +function Get-ActivityWatchLiveInteractiveSessions { + param([object[]]$SessionRecords) - if (`$config -and `$config.recovery -and `$config.recovery.intervalSeconds) { - `$sleepSeconds = [Math]::Max([int]`$config.recovery.intervalSeconds, 30) + return @( + $SessionRecords | + Where-Object { + $_.IsLive -and + $_.SessionId -gt 0 -and + -not [string]::IsNullOrWhiteSpace([string]$_.UserName) + } | + Sort-Object @{ Expression = { if ([string]$_.SessionName -ieq 'console') { 0 } else { 1 } } }, @{ Expression = { [int]$_.SessionId } } + ) +} + +function Resolve-ActivityWatchLiveSessionUserId { + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$SessionRecord, + [pscustomobject[]]$TaskDefinitions + ) + + $rawUser = [string]$SessionRecord.UserName + if ([string]::IsNullOrWhiteSpace($rawUser)) { + return $null + } + + foreach ($taskDef in @($TaskDefinitions)) { + foreach ($candidate in @(Resolve-ActivityWatchUserCandidates -UserId [string]$taskDef.userId)) { + if ($candidate -ieq $rawUser -or + $candidate -ieq ('{0}\{1}' -f $env:COMPUTERNAME, $rawUser) -or + ((-not [string]::IsNullOrWhiteSpace($env:USERDOMAIN)) -and $candidate -ieq ('{0}\{1}' -f $env:USERDOMAIN, $rawUser))) { + return [string]$taskDef.userId } } - catch { + } + + if ($rawUser -match '^[^\\]+\\') { + return $rawUser + } + + return ('{0}\{1}' -f $env:COMPUTERNAME, $rawUser) +} + +function Get-ActivityWatchExplorerUsersBySession { + $map = @{} + + try { + Get-Process explorer -IncludeUserName -ErrorAction SilentlyContinue | + Where-Object { $_.SessionId -gt 0 -and -not [string]::IsNullOrWhiteSpace([string]$_.UserName) } | + Sort-Object SessionId, StartTime | + ForEach-Object { + if (-not $map.ContainsKey([int]$_.SessionId)) { + $map[[int]$_.SessionId] = [string]$_.UserName + } + } + } + catch { + } + + return $map +} + +function Get-ActivityWatchDisconnectedInteractiveSessions { + param([object[]]$SessionRecords) + + $explorerUsers = Get-ActivityWatchExplorerUsersBySession + $result = New-Object System.Collections.Generic.List[object] + + foreach ($session in @($SessionRecords | Where-Object { -not $_.IsLive -and $_.SessionId -gt 0 })) { + $resolvedUser = [string]$session.UserName + if ([string]::IsNullOrWhiteSpace($resolvedUser) -and $explorerUsers.ContainsKey([int]$session.SessionId)) { + $resolvedUser = [string]$explorerUsers[[int]$session.SessionId] } - Start-Sleep -Seconds `$sleepSeconds - } -} -finally { - if (`$recoveryLockPath -and (Test-Path -LiteralPath `$recoveryLockPath)) { - Remove-Item -LiteralPath `$recoveryLockPath -Force -ErrorAction SilentlyContinue - } -} -"@ + if ([string]::IsNullOrWhiteSpace($resolvedUser)) { + continue + } - Set-Content -LiteralPath $Path -Value $content -Encoding UTF8 + $result.Add([pscustomobject]@{ + SessionName = [string]$session.SessionName + SessionId = [int]$session.SessionId + State = [string]$session.State + UserName = $resolvedUser + }) | Out-Null + } + + return @($result | Sort-Object SessionId -Unique) +} + +function Get-ActivityWatchMarkerDirectories { + param([string]$StateRoot) + + $roots = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) + + if (-not [string]::IsNullOrWhiteSpace($StateRoot)) { + [void]$roots.Add((Join-Path $StateRoot 'markers')) + } + + $usersRoot = Join-Path $env:SystemDrive 'Users' + if (Test-Path -LiteralPath $usersRoot) { + foreach ($dir in @(Get-ChildItem -LiteralPath $usersRoot -Directory -ErrorAction SilentlyContinue)) { + [void]$roots.Add((Join-Path $dir.FullName 'AppData\Local\AWatch-rus\markers')) + } + } + + return @($roots) +} + +function Remove-ActivityWatchLogonMarkersForSession { + param( + [Parameter(Mandatory = $true)] + [string]$StateRoot, + [int]$SessionId, + [string]$UserName + ) + + $userCandidates = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) + if (-not [string]::IsNullOrWhiteSpace($UserName)) { + [void]$userCandidates.Add($UserName) + if ($UserName -match '^[^\\]+\\(.+)$') { + [void]$userCandidates.Add($Matches[1]) + } + } + + foreach ($markerDir in @(Get-ActivityWatchMarkerDirectories -StateRoot $StateRoot)) { + if (-not (Test-Path -LiteralPath $markerDir)) { + continue + } + + foreach ($marker in @(Get-ChildItem -LiteralPath $markerDir -Filter '*.marker' -File -ErrorAction SilentlyContinue)) { + $name = [string]$marker.BaseName + if ($name -notmatch '^logon-(.+)-(\d+)-') { + continue + } + + $markerUser = [string]$Matches[1] + $markerSessionId = [int]$Matches[2] + if ($markerSessionId -ne $SessionId) { + continue + } + + if ($userCandidates.Count -gt 0 -and -not $userCandidates.Contains($markerUser)) { + continue + } + + Remove-Item -LiteralPath $marker.FullName -Force -ErrorAction SilentlyContinue + } + } +} + +function Stop-ActivityWatchProcessesInNonLiveSessions { + param( + [Parameter(Mandatory = $true)] + [object[]]$SessionRecords, + [Parameter(Mandatory = $true)] + [pscustomobject]$Config + ) + + $stateRoot = if ($Config.paths.PSObject.Properties.Name -contains 'stateRoot') { [string]$Config.paths.stateRoot } else { Join-Path $env:ProgramData 'AWatch-rus' } + $sessionIds = @( + $SessionRecords | + Where-Object { -not $_.IsLive -and $_.SessionId -gt 0 } | + ForEach-Object { [int]$_.SessionId } | + Sort-Object -Unique + ) + + if (-not $sessionIds -or $sessionIds.Count -eq 0) { + return + } + + $sessionScopedScripts = New-Object System.Collections.Generic.List[string] + foreach ($propertyName in @('collectorScript', 'endpointCollectorScript', 'fileCollectorScript', 'emailCollectorScript', 'launchScript')) { + if ($Config.paths.PSObject.Properties.Name -contains $propertyName) { + $candidatePath = [string]$Config.paths.$propertyName + if (-not [string]::IsNullOrWhiteSpace($candidatePath)) { + $sessionScopedScripts.Add($candidatePath) | Out-Null + } + } + } + + foreach ($session in @($SessionRecords | Where-Object { -not $_.IsLive -and $_.SessionId -gt 0 })) { + Remove-ActivityWatchLogonMarkersForSession -StateRoot $stateRoot -SessionId ([int]$session.SessionId) -UserName ([string]$session.UserName) + } + + Get-Process -Name 'aw-watcher-afk','aw-watcher-window' -ErrorAction SilentlyContinue | + Where-Object { $sessionIds -contains [int]$_.SessionId } | + ForEach-Object { + Stop-Process -Id $_.Id -Force -ErrorAction SilentlyContinue + } + + Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | + Where-Object { + ($_.Name -ieq 'powershell.exe' -or $_.Name -ieq 'pwsh.exe') -and + ($sessionIds -contains [int]$_.SessionId) + } | + ForEach-Object { + $commandLine = [string]$_.CommandLine + foreach ($scriptPath in $sessionScopedScripts) { + if (-not [string]::IsNullOrWhiteSpace($scriptPath) -and $commandLine -match [Regex]::Escape($scriptPath)) { + Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue + break + } + } + } +} + +function Promote-ActivityWatchDisconnectedSessionToConsole { + param( + [pscustomobject[]]$TaskDefinitions, + [object[]]$SessionRecords + ) + + $candidates = Get-ActivityWatchDisconnectedInteractiveSessions -SessionRecords $SessionRecords + if (-not $candidates -or $candidates.Count -eq 0) { + return $false + } + + $selected = $null + foreach ($taskDef in @($TaskDefinitions)) { + foreach ($candidate in @($candidates)) { + foreach ($knownUser in @(Resolve-ActivityWatchUserCandidates -UserId [string]$taskDef.userId)) { + if ($knownUser -ieq [string]$candidate.UserName -or + $knownUser -ieq ('{0}\{1}' -f $env:COMPUTERNAME, [string]$candidate.UserName) -or + ((-not [string]::IsNullOrWhiteSpace($env:USERDOMAIN)) -and $knownUser -ieq ('{0}\{1}' -f $env:USERDOMAIN, [string]$candidate.UserName))) { + $selected = $candidate + break + } + } + if ($selected) { break } + } + if ($selected) { break } + } + + if (-not $selected) { + $selected = $candidates | Select-Object -First 1 + } + + if (-not $selected) { + return $false + } + + try { + & cmd.exe /c ("tscon {0} /dest:console" -f [int]$selected.SessionId) | Out-Null + return ($LASTEXITCODE -eq 0) + } + catch { + return $false + } +} + +function Ensure-ActivityWatchLaunchTaskForUser { + param( + [Parameter(Mandatory = $true)] + [string]$UserId, + [Parameter(Mandatory = $true)] + [string]$LaunchScriptPath, + [Parameter(Mandatory = $true)] + [string]$ConfigPath + ) + + if ([string]::IsNullOrWhiteSpace($UserId) -or -not (Test-Path -LiteralPath $LaunchScriptPath)) { + return $null + } + + $taskName = "ActivityWatch Launch [$((Get-ActivityWatchTaskNameToken -UserId $UserId))]" + $launcherPath = Get-ActivityWatchHiddenLauncherPath -ScriptPath $LaunchScriptPath + Write-ActivityWatchHiddenPowerShellWrapper -Path $launcherPath -ScriptPath $LaunchScriptPath -ConfigPath $ConfigPath + + $wscriptExe = Join-Path $env:SystemRoot 'System32\wscript.exe' + $action = New-ScheduledTaskAction -Execute $wscriptExe -Argument "//B //NoLogo `"$launcherPath`"" + $trigger = New-ScheduledTaskTrigger -AtLogOn -User $UserId + $principal = New-ScheduledTaskPrincipal -UserId $UserId -LogonType Interactive -RunLevel Highest + $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -StartWhenAvailable -MultipleInstances IgnoreNew -ExecutionTimeLimit (New-TimeSpan -Hours 0) + + try { + $existingTask = Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue + if ($existingTask) { + $existingUserId = [string]$existingTask.Principal.UserId + $existingArgs = @($existingTask.Actions | ForEach-Object { [string]$_.Arguments }) -join ' ' + if ($existingUserId -ieq $UserId -and $existingArgs -like "*$launcherPath*") { + return $taskName + } + + Remove-ActivityWatchScheduledTask -TaskName $taskName + } + + Register-ScheduledTask -TaskName $taskName -Action $action -Trigger $trigger -Principal $principal -Settings $settings | Out-Null + return $taskName + } + catch { + return $null + } +} + +function Start-ActivityWatchConsoleFallbackIfNeeded { + param( + [pscustomobject[]]$TaskDefinitions, + [object[]]$SessionRecords, + [pscustomobject]$Config, + [string]$ConfigPath, + [bool]$ConfiguredLiveTasksStarted + ) + + if ($ConfiguredLiveTasksStarted) { + return + } + + $liveSessions = Get-ActivityWatchLiveInteractiveSessions -SessionRecords $SessionRecords + if (-not $liveSessions -or $liveSessions.Count -eq 0) { + if (Promote-ActivityWatchDisconnectedSessionToConsole -TaskDefinitions $TaskDefinitions -SessionRecords $SessionRecords) { + Start-Sleep -Seconds 3 + $SessionRecords = Get-ActivityWatchSessionRecords + $liveSessions = Get-ActivityWatchLiveInteractiveSessions -SessionRecords $SessionRecords + } + } + if (-not $liveSessions -or $liveSessions.Count -eq 0) { + return + } + + $launchScriptPath = if ($Config.paths.PSObject.Properties.Name -contains 'launchScript') { [string]$Config.paths.launchScript } else { $null } + if ([string]::IsNullOrWhiteSpace($launchScriptPath)) { + return + } + + $preferredSession = $liveSessions | Select-Object -First 1 + $userId = Resolve-ActivityWatchLiveSessionUserId -SessionRecord $preferredSession -TaskDefinitions $TaskDefinitions + if ([string]::IsNullOrWhiteSpace($userId)) { + return + } + + $taskName = Ensure-ActivityWatchLaunchTaskForUser -UserId $userId -LaunchScriptPath $launchScriptPath -ConfigPath $ConfigPath + if ([string]::IsNullOrWhiteSpace($taskName)) { + return + } + + try { + $task = Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue + if ($task -and [string]$task.State -ne 'Running') { + Start-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue + } + } + catch { + } +} + +function Invoke-ActivityWatchRecoveryLoop { + param([string]$ConfigPath) + + $recoveryLockPath = New-ActivityWatchRecoveryLock -PrimaryConfigPath $ConfigPath + if (-not $recoveryLockPath) { + return + } + + try { + while ($true) { + $sleepSeconds = 180 + try { + $configPaths = Get-ActivityWatchRecoveryConfigPaths -PrimaryConfigPath $ConfigPath + $config = Read-ActivityWatchDeploymentConfig -Path $ConfigPath + $taskDefs = Get-ActivityWatchRecoveryTaskDefinitions -ConfigPaths $configPaths + $sessionRecords = Get-ActivityWatchSessionRecords + Stop-ActivityWatchProcessesInNonLiveSessions -SessionRecords $sessionRecords -Config $config + $sessionRecords = Get-ActivityWatchSessionRecords + $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-ActivityWatchCollectorScriptGlobalIfNeeded -ScriptPath $sessionCollectorScript -ConfigPath $ConfigPath + + $configuredLiveTasksStarted = $false + foreach ($taskDef in $taskDefs) { + if (Start-ActivityWatchTaskIfNotRunning -TaskName $taskDef.taskName -UserId $taskDef.userId -SessionRecords $sessionRecords) { + $configuredLiveTasksStarted = $true + } + } + + Start-ActivityWatchConsoleFallbackIfNeeded -TaskDefinitions $taskDefs -SessionRecords $sessionRecords -Config $config -ConfigPath $ConfigPath -ConfiguredLiveTasksStarted $configuredLiveTasksStarted + + if ($config -and $config.recovery -and $config.recovery.intervalSeconds) { + $sleepSeconds = [Math]::Max([int]$config.recovery.intervalSeconds, 30) + } + } + catch { + } + + Start-Sleep -Seconds $sleepSeconds + } + } + finally { + if ($recoveryLockPath -and (Test-Path -LiteralPath $recoveryLockPath)) { + Remove-Item -LiteralPath $recoveryLockPath -Force -ErrorAction SilentlyContinue + } + } } function Get-ActivityWatchHiddenLauncherPath { @@ -1344,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 @@ -1355,11 +1967,66 @@ function Remove-LegacyActivityWatchEntries { $legacyTaskNames = @( 'ActivityWatch Watchers', 'ActivityWatch Guard', - 'ActivityWatch Heal' + 'ActivityWatch Heal', + 'AWatchRusStandaloneAgent', + 'AWatch Worktime Collector', + 'AW DLP Endpoint ADMIN', + 'AW DLP Endpoint USER1' ) foreach ($taskName in $legacyTaskNames) { - Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue + Remove-ActivityWatchScheduledTask -TaskName $taskName + } + + $legacyTaskPatterns = @( + 'browser-domains-native-collector.ps1', + 'file-operations-collector.ps1', + 'dlp-endpoint-signals-collector.ps1', + 'worktime-session-collector.ps1', + 'aw-standalone-service.ps1' + ) + $managedTaskNames = @( + 'ActivityWatch Recovery', + 'ActivityWatch Hayabusa Upload', + 'ActivityWatch File1C Upload' + ) + + $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 + } + + $isLegacyCollectorTask = $false + foreach ($action in @($task.Actions)) { + $execute = if ($action.PSObject.Properties.Name -contains 'Execute') { [string]$action.Execute } else { '' } + $arguments = if ($action.PSObject.Properties.Name -contains 'Arguments') { [string]$action.Arguments } else { '' } + $commandLine = ('{0} {1}' -f $execute, $arguments).Trim() + if ([string]::IsNullOrWhiteSpace($commandLine)) { + continue + } + foreach ($pattern in $legacyTaskPatterns) { + if ($commandLine -match [Regex]::Escape($pattern)) { + $isLegacyCollectorTask = $true + break + } + } + if ($isLegacyCollectorTask) { + break + } + } + + if ($isLegacyCollectorTask) { + Remove-ActivityWatchScheduledTask -TaskName $taskName + } } $runKey = 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Run' @@ -1374,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 } @@ -1412,7 +2096,8 @@ function Set-ActivityWatchScheduledTaskAction { $taskCommand = ('"{0}" {1}' -f $Execute, $Arguments) & schtasks.exe /Change /TN $TaskName /TR $taskCommand | Out-Null if ($LASTEXITCODE -ne 0) { - throw "Не удалось обновить action задачи ${TaskName}: $($_.Exception.Message)" + Write-Host "skip task action update for $TaskName because the existing principal/action cannot be updated non-interactively: $($_.Exception.Message)" + return $false } return $true } @@ -1526,11 +2211,146 @@ 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 { + param( + [Parameter(Mandatory = $true)] + [string]$ConfigPath + ) + + $config = Read-ActivityWatchDeploymentConfig -Path $ConfigPath + $forensics = $config.forensics + if ($null -eq $forensics -or $forensics.PSObject.Properties.Name -notcontains 'hayabusaAutomation') { + return + } + + $automation = $forensics.hayabusaAutomation + $taskName = if ($automation.PSObject.Properties.Name -contains 'taskName' -and -not [string]::IsNullOrWhiteSpace([string]$automation.taskName)) { + [string]$automation.taskName + } else { + 'ActivityWatch Hayabusa Upload' + } + + if (-not [bool]$automation.enabled) { + Remove-ActivityWatchScheduledTask -TaskName $taskName + return + } + + $uploadScript = if ($config.paths.PSObject.Properties.Name -contains 'hayabusaUploadScript') { [string]$config.paths.hayabusaUploadScript } else { Join-Path $config.paths.stateRoot 'export-upload-hayabusa-to-aw-server.ps1' } + if (-not (Test-Path -LiteralPath $uploadScript)) { + throw "Не найден скрипт Hayabusa upload: $uploadScript" + } + + $intervalHours = [Math]::Max(1, [int]$automation.intervalHours) + $hoursBack = [Math]::Max(1, [int]$automation.hoursBack) + $mode = if ($automation.PSObject.Properties.Name -contains 'mode' -and -not [string]::IsNullOrWhiteSpace([string]$automation.mode)) { [string]$automation.mode } else { 'incident' } + $powerShellExe = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' + $taskCommand = "`"$powerShellExe`" -NoProfile -ExecutionPolicy Bypass -File `"$uploadScript`" -ConfigPath `"$ConfigPath`" -HoursBack $hoursBack -Mode `"$mode`"" + + Remove-ActivityWatchScheduledTask -TaskName $taskName + & schtasks.exe /Create /TN $taskName /TR $taskCommand /SC HOURLY /MO $intervalHours /ST 00:00 /RU SYSTEM /RL HIGHEST /F | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "Не удалось создать scheduled task $taskName через schtasks.exe" + } +} + +function Register-ActivityWatchFile1CAutoUploadTask { + param( + [Parameter(Mandatory = $true)] + [string]$ConfigPath + ) + + $config = Read-ActivityWatchDeploymentConfig -Path $ConfigPath + if ($config.PSObject.Properties.Name -notcontains 'analytics' -or + $config.analytics.PSObject.Properties.Name -notcontains 'file1cAutomation') { + return + } + + $automation = $config.analytics.file1cAutomation + $taskName = if ($automation.PSObject.Properties.Name -contains 'taskName' -and -not [string]::IsNullOrWhiteSpace([string]$automation.taskName)) { + [string]$automation.taskName + } else { + 'ActivityWatch File1C Upload' + } + + if (-not [bool]$automation.enabled) { + Remove-ActivityWatchScheduledTask -TaskName $taskName + return + } + + $uploadScript = if ($config.paths.PSObject.Properties.Name -contains 'file1cTelemetryScript') { [string]$config.paths.file1cTelemetryScript } else { Join-Path $config.paths.stateRoot 'export-upload-file-1c-telemetry.ps1' } + if (-not (Test-Path -LiteralPath $uploadScript)) { + throw "Не найден скрипт file-1C telemetry upload: $uploadScript" + } + + $intervalHours = [Math]::Max(1, [int]$automation.intervalHours) + $powerShellExe = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' + $taskCommand = "`"$powerShellExe`" -NoProfile -ExecutionPolicy Bypass -File `"$uploadScript`" -ConfigPath `"$ConfigPath`"" + $runnerUserId = $null + if ($automation.PSObject.Properties.Name -contains 'runAsUser' -and -not [string]::IsNullOrWhiteSpace([string]$automation.runAsUser)) { + $runnerUserId = [string]$automation.runAsUser + } + if ([string]::IsNullOrWhiteSpace($runnerUserId) -and $config.PSObject.Properties.Name -contains 'userTasks') { + $runnerUserId = @( + @($config.userTasks | ForEach-Object { [string]$_.userId }) | + Where-Object { $_ -match '(^|\\)(Администратор|Administrator)$' } | + Select-Object -First 1 + ) | Select-Object -First 1 + } + if ([string]::IsNullOrWhiteSpace($runnerUserId) -and $config.PSObject.Properties.Name -contains 'userTasks') { + $runnerUserId = @($config.userTasks | ForEach-Object { [string]$_.userId } | Select-Object -First 1) | Select-Object -First 1 + } + + Remove-ActivityWatchScheduledTask -TaskName $taskName + if (-not [string]::IsNullOrWhiteSpace($runnerUserId)) { + & schtasks.exe /Create /TN $taskName /TR $taskCommand /SC HOURLY /MO $intervalHours /ST 00:00 /RU $runnerUserId /RL HIGHEST /F | Out-Null + } + else { + & schtasks.exe /Create /TN $taskName /TR $taskCommand /SC HOURLY /MO $intervalHours /ST 00:00 /RU SYSTEM /RL HIGHEST /F | Out-Null + } + if ($LASTEXITCODE -ne 0) { + throw "Не удалось создать scheduled task $taskName через schtasks.exe" + } } function Set-ActivityWatchAcl { @@ -1552,7 +2372,7 @@ function Set-ActivityWatchAcl { throw "icacls завершился с ошибкой для $InstallRoot" } - & icacls $StateRoot /inheritance:r /grant:r '*S-1-5-18:(OI)(CI)(F)' '*S-1-5-32-544:(OI)(CI)(F)' '*S-1-5-32-545:(OI)(CI)(RX)' | Out-Null + & icacls $StateRoot /inheritance:r /grant:r '*S-1-5-18:(OI)(CI)(F)' '*S-1-5-32-544:(OI)(CI)(F)' '*S-1-5-32-545:(OI)(CI)(M)' | Out-Null if ($LASTEXITCODE -ne 0) { throw "icacls завершился с ошибкой для $StateRoot" } @@ -1570,10 +2390,10 @@ function Start-ActivityWatchTasks { [string]$RecoveryTaskName = 'ActivityWatch Recovery' ) - $loggedOnUsers = Get-ActivityWatchLoggedOnUsers + $sessionRecords = Get-ActivityWatchSessionRecords foreach ($definition in $TaskDefinitions) { - if (Test-ActivityWatchUserHasSession -UserId $definition.UserId -LoggedOnUsers $loggedOnUsers) { + if (Test-ActivityWatchUserHasLiveSession -UserId $definition.UserId -SessionRecords $sessionRecords) { Start-ScheduledTask -TaskName $definition.LaunchTaskName -ErrorAction SilentlyContinue } } diff --git a/install-kit-awindows-20260427-211240/windows/browser-domains-native-collector.ps1 b/install-kit-awindows-20260427-211240/windows/browser-domains-native-collector.ps1 index b40b48c..9308e5e 100755 --- a/install-kit-awindows-20260427-211240/windows/browser-domains-native-collector.ps1 +++ b/install-kit-awindows-20260427-211240/windows/browser-domains-native-collector.ps1 @@ -18,6 +18,7 @@ $ErrorActionPreference = 'Stop' Add-Type -AssemblyName UIAutomationClient Add-Type -AssemblyName UIAutomationTypes +Add-Type -AssemblyName System.Net.Http Add-Type @" using System; @@ -48,6 +49,35 @@ function Get-DeploymentConfig { return $null } +function Invoke-AwJsonPost { + param( + [Parameter(Mandatory = $true)][string]$Uri, + [Parameter(Mandatory = $true)][string]$Json + ) + + $httpClient = $null + $content = $null + try { + $httpClient = New-Object System.Net.Http.HttpClient + $content = New-Object System.Net.Http.StringContent($Json, [System.Text.Encoding]::UTF8, "application/json") + $response = $httpClient.PostAsync($Uri, $content).Result + if (-not $response.IsSuccessStatusCode) { + $status = [int]$response.StatusCode + $reason = [string]$response.ReasonPhrase + $body = $response.Content.ReadAsStringAsync().Result + throw "HTTP POST failed status=$status reason=$reason body=$body" + } + } + finally { + if ($null -ne $content) { + $content.Dispose() + } + if ($null -ne $httpClient) { + $httpClient.Dispose() + } + } +} + $deploymentConfig = Get-DeploymentConfig -Path $ConfigPath $resolvedServerHost = if ($ServerHost) { $ServerHost } elseif ($deploymentConfig) { [string]$deploymentConfig.server.host } else { throw 'Укажите ServerHost или подготовьте deployment-config.json.' } $resolvedServerPort = if ($PSBoundParameters.ContainsKey('ServerPort')) { $ServerPort } elseif ($deploymentConfig) { [int]$deploymentConfig.server.port } else { 5600 } @@ -542,7 +572,7 @@ function Send-DlpIncidentHeartbeat { } + $captureData } | ConvertTo-Json -Depth 5 -Compress - Invoke-RestMethod -Method Post -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$resolvedPulseSeconds" -ContentType 'application/json' -Body $event -TimeoutSec 15 -DisableKeepAlive | Out-Null + Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$resolvedPulseSeconds" -Json $event } function Get-FileSha256Hex { @@ -717,7 +747,7 @@ function Ensure-Bucket { } | ConvertTo-Json -Compress try { - Invoke-RestMethod -Method Post -Uri "$($script:ApiBase)/buckets/$BucketId" -ContentType 'application/json; charset=utf-8' -Body ([Text.Encoding]::UTF8.GetBytes($body)) | Out-Null + Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$BucketId" -Json $body } catch { Invoke-RestMethod -Method Get -Uri "$($script:ApiBase)/buckets/$BucketId" | Out-Null @@ -747,7 +777,40 @@ function Send-Heartbeat { } } | ConvertTo-Json -Depth 4 -Compress - Invoke-RestMethod -Method Post -Uri "$($script:ApiBase)/buckets/$BucketId/heartbeat?pulsetime=$resolvedPulseSeconds" -ContentType 'application/json' -Body $event -TimeoutSec 15 -DisableKeepAlive | Out-Null + Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$BucketId/heartbeat?pulsetime=$resolvedPulseSeconds" -Json $event +} + +function Send-WindowHeartbeat { + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Context + ) + + if (-not $Context) { + return + } + + $processName = [string]$Context.ProcessName + $title = [string]$Context.Title + if ([string]::IsNullOrWhiteSpace($processName) -and [string]::IsNullOrWhiteSpace($title)) { + return + } + + $bucketId = 'aw-watcher-window_' + $script:Hostname + Ensure-Bucket -BucketId $bucketId -ClientName 'aw-watcher-window' -BucketType 'currentwindow' + + $event = @{ + timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + duration = 0 + data = @{ + app = if ([string]::IsNullOrWhiteSpace($processName)) { 'unknown.exe' } else { "$processName.exe" } + title = $title + source = 'uia-native' + sessionId = $script:SessionId + } + } | ConvertTo-Json -Depth 4 -Compress + + Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$resolvedPulseSeconds" -Json $event } function Send-CategoryHeartbeat { @@ -784,19 +847,62 @@ function Send-CategoryHeartbeat { } } | ConvertTo-Json -Depth 4 -Compress - Invoke-RestMethod -Method Post -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$resolvedPulseSeconds" -ContentType 'application/json' -Body $event -TimeoutSec 15 -DisableKeepAlive | Out-Null + Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$resolvedPulseSeconds" -Json $event +} + +function Send-CollectorHealthHeartbeat { + param( + $Context = $null, + [string]$DetectedUrl = $null + ) + + $bucketId = 'aw-detmir-web-category_' + $script:Hostname + Ensure-Bucket -BucketId $bucketId -ClientName 'aw-detmir-web-category' -BucketType 'aw.web.category' + + $foregroundProcess = '' + $foregroundTitle = '' + $browserDetected = $false + if ($Context) { + $foregroundProcess = [string]$Context.ProcessName + $foregroundTitle = [string]$Context.Title + $browserDetected = $script:BrowserMap.ContainsKey($foregroundProcess) + } + + $event = @{ + timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + duration = 0 + data = @{ + signalType = 'collector_health' + username = $env:USERNAME + hostname = $script:Hostname + sessionId = $script:SessionId + foregroundProcess = $foregroundProcess + foregroundTitle = $foregroundTitle + browserDetected = $browserDetected + urlDetected = -not [string]::IsNullOrWhiteSpace($DetectedUrl) + } + } | ConvertTo-Json -Depth 5 -Compress + + Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$resolvedPulseSeconds" -Json $event } Load-CustomCategoryRules -Path $resolvedRulesPath Load-DlpPolicy -Path $resolvedPolicyPath Write-CollectorLog ("коллектор запущен для {0}" -f $script:ApiBase) +$lastHealth = [datetime]::MinValue while ($true) { + $context = $null + $detectedUrl = $null try { $context = Get-ForegroundWindowContext + if ($context) { + Send-WindowHeartbeat -Context $context + } if ($context -and $script:BrowserMap.ContainsKey($context.ProcessName)) { $url = Get-BrowserUrlFromWindow -Handle $context.Handle if ($url) { + $detectedUrl = $url $browserKey = $script:BrowserMap[$context.ProcessName] $domain = Get-HostFromUrl -Url $url if (-not $domain) { @@ -832,5 +938,16 @@ while ($true) { Write-CollectorLog ("ошибка коллектора: {0}" -f $_.Exception.Message) } + $nowUtc = (Get-Date).ToUniversalTime() + if (($nowUtc - $lastHealth).TotalSeconds -ge [Math]::Max($resolvedPulseSeconds, $resolvedPollSeconds)) { + try { + Send-CollectorHealthHeartbeat -Context $context -DetectedUrl $detectedUrl + $lastHealth = $nowUtc + } + catch { + Write-CollectorLog ("ошибка heartbeat: {0}" -f $_.Exception.Message) + } + } + Start-Sleep -Seconds $resolvedPollSeconds } diff --git a/install-kit-awindows-20260427-211240/windows/deploy-domain-users.ps1 b/install-kit-awindows-20260427-211240/windows/deploy-domain-users.ps1 index 0f9cb1f..c8b66f7 100755 --- a/install-kit-awindows-20260427-211240/windows/deploy-domain-users.ps1 +++ b/install-kit-awindows-20260427-211240/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, @@ -39,6 +40,17 @@ param( [string]$PolicyEngineScheme = 'http', [int]$PolicyRefreshSeconds = 300, [string]$PolicyCachePath, + [bool]$HayabusaAutoUploadEnabled = $true, + [int]$HayabusaAutoUploadIntervalHours = 6, + [int]$HayabusaAutoUploadHoursBack = 6, + [string]$HayabusaAutoUploadMode = 'incident', + [string]$HayabusaAutoUploadTaskName = 'ActivityWatch Hayabusa Upload', + [bool]$File1CAutoUploadEnabled = $true, + [int]$File1CAutoUploadIntervalHours = 6, + [string]$File1CAutoUploadTaskName = 'ActivityWatch File1C Upload', + [string]$File1CTargetHost, + [string]$File1CTargetUser = 'igor', + [string]$File1CRegistryWorkbookPath = 'E:\USER1\СПИСОК ПРЕДПРИЯТИЙ И ИХ РАСПРЕДЕЛЕНИЕ.xlsx', [switch]$IntegrationTestEnabled ) @@ -64,6 +76,8 @@ $emailCollectorSource = Join-Path $PSScriptRoot 'email-outbound-collector.ps1' $fileCollectorSource = Join-Path $PSScriptRoot 'file-operations-collector.ps1' $sessionCollectorSource = Join-Path $PSScriptRoot 'worktime-session-collector.ps1' $evtxExportScriptSource = Join-Path $PSScriptRoot 'export-evtx-for-hayabusa.ps1' +$hayabusaUploadScriptSource = Join-Path $PSScriptRoot 'export-upload-hayabusa-to-aw-server.ps1' +$file1cTelemetryScriptSource = Join-Path $PSScriptRoot 'export-upload-file-1c-telemetry.ps1' $exampleRulesSource = Join-Path $PSScriptRoot 'web-category-rules.example.json' $examplePolicySource = Join-Path $PSScriptRoot 'dlp-policy.example.json' @@ -83,6 +97,8 @@ $assetResult = Copy-ActivityWatchCollectorAssets ` -FileCollectorScriptSource $fileCollectorSource ` -SessionCollectorScriptSource $sessionCollectorSource ` -EvtxExportScriptSource $evtxExportScriptSource ` + -HayabusaUploadScriptSource $hayabusaUploadScriptSource ` + -File1CTelemetryScriptSource $file1cTelemetryScriptSource ` -ExampleRulesSource $exampleRulesSource ` -ExamplePolicySource $examplePolicySource ` -StateRoot $StateRoot ` @@ -107,6 +123,8 @@ $config = New-ActivityWatchDeploymentConfig ` -FileCollectorScript $assetResult.FileCollectorScript ` -SessionCollectorScript $assetResult.SessionCollectorScript ` -EvtxExportScript $assetResult.EvtxExportScript ` + -HayabusaUploadScript $assetResult.HayabusaUploadScript ` + -File1CTelemetryScript $assetResult.File1CTelemetryScript ` -RulesPath $assetResult.ActiveRules ` -PolicyPath $assetResult.ActivePolicy ` -PollSeconds $PollSeconds ` @@ -123,6 +141,7 @@ $config = New-ActivityWatchDeploymentConfig ` -EvtxRetentionDays $EvtxRetentionDays ` -EvtxChannels $EvtxChannels ` -LogonMarkerEnabled $LogonMarkerEnabled ` + -ProcessEventsEnabled $ProcessEventsEnabled ` -AwHostname $AwHostname ` -PolicyMode $PolicyMode ` -PolicyEngineEnabled $PolicyEngineEnabled ` @@ -131,6 +150,17 @@ $config = New-ActivityWatchDeploymentConfig ` -PolicyEngineScheme $PolicyEngineScheme ` -PolicyRefreshSeconds $PolicyRefreshSeconds ` -PolicyCachePath $PolicyCachePath ` + -HayabusaAutoUploadEnabled $HayabusaAutoUploadEnabled ` + -HayabusaAutoUploadIntervalHours $HayabusaAutoUploadIntervalHours ` + -HayabusaAutoUploadHoursBack $HayabusaAutoUploadHoursBack ` + -HayabusaAutoUploadMode $HayabusaAutoUploadMode ` + -HayabusaAutoUploadTaskName $HayabusaAutoUploadTaskName ` + -File1CAutoUploadEnabled $File1CAutoUploadEnabled ` + -File1CAutoUploadIntervalHours $File1CAutoUploadIntervalHours ` + -File1CAutoUploadTaskName $File1CAutoUploadTaskName ` + -File1CTargetHost $File1CTargetHost ` + -File1CTargetUser $File1CTargetUser ` + -File1CRegistryWorkbookPath $File1CRegistryWorkbookPath ` -LaunchScriptPath $launchScriptPath ` -RecoveryScriptPath $recoveryScriptPath ` -UserTasks $taskDefinitions ` @@ -142,6 +172,8 @@ Remove-LegacyActivityWatchEntries Set-ActivityWatchAcl -InstallRoot $InstallRoot -StateRoot $StateRoot -LogsRoot $logsRoot Register-ActivityWatchUserTasks -TaskDefinitions $taskDefinitions -LaunchScriptPath $launchScriptPath -ConfigPath $configPath Register-ActivityWatchRecoveryTask -TaskName $config.recovery.taskName -RecoveryScriptPath $recoveryScriptPath -ConfigPath $configPath +Register-ActivityWatchHayabusaAutoUploadTask -ConfigPath $configPath +Register-ActivityWatchFile1CAutoUploadTask -ConfigPath $configPath Start-ActivityWatchTasks -TaskDefinitions $taskDefinitions -RecoveryTaskName $config.recovery.taskName Write-Host 'ActivityWatch развёрнут для пользователей:' diff --git a/install-kit-awindows-20260427-211240/windows/deploy-ensemble.ps1 b/install-kit-awindows-20260427-211240/windows/deploy-ensemble.ps1 index 11d4366..cbcf689 100644 --- a/install-kit-awindows-20260427-211240/windows/deploy-ensemble.ps1 +++ b/install-kit-awindows-20260427-211240/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, @@ -40,6 +41,17 @@ param( [int]$PolicyRefreshSeconds = 300, [string]$PolicyCachePath, [string]$ReportPath, + [bool]$HayabusaAutoUploadEnabled = $true, + [int]$HayabusaAutoUploadIntervalHours = 6, + [int]$HayabusaAutoUploadHoursBack = 6, + [string]$HayabusaAutoUploadMode = 'incident', + [string]$HayabusaAutoUploadTaskName = 'ActivityWatch Hayabusa Upload', + [bool]$File1CAutoUploadEnabled = $true, + [int]$File1CAutoUploadIntervalHours = 6, + [string]$File1CAutoUploadTaskName = 'ActivityWatch File1C Upload', + [string]$File1CTargetHost, + [string]$File1CTargetUser = 'igor', + [string]$File1CRegistryWorkbookPath = 'E:\USER1\СПИСОК ПРЕДПРИЯТИЙ И ИХ РАСПРЕДЕЛЕНИЕ.xlsx', [switch]$SkipHardening, [switch]$ValidateAfterDeploy, [switch]$IntegrationTestEnabled @@ -88,6 +100,7 @@ if (-not (Test-Path -LiteralPath $deployScript)) { -EvtxRetentionDays $EvtxRetentionDays ` -EvtxChannels $EvtxChannels ` -LogonMarkerEnabled $LogonMarkerEnabled ` + -ProcessEventsEnabled $ProcessEventsEnabled ` -AwHostname $AwHostname ` -CustomRulesPath $CustomRulesPath ` -CustomPolicyPath $CustomPolicyPath ` @@ -98,6 +111,17 @@ if (-not (Test-Path -LiteralPath $deployScript)) { -PolicyEngineScheme $PolicyEngineScheme ` -PolicyRefreshSeconds $PolicyRefreshSeconds ` -PolicyCachePath $PolicyCachePath ` + -HayabusaAutoUploadEnabled $HayabusaAutoUploadEnabled ` + -HayabusaAutoUploadIntervalHours $HayabusaAutoUploadIntervalHours ` + -HayabusaAutoUploadHoursBack $HayabusaAutoUploadHoursBack ` + -HayabusaAutoUploadMode $HayabusaAutoUploadMode ` + -HayabusaAutoUploadTaskName $HayabusaAutoUploadTaskName ` + -File1CAutoUploadEnabled $File1CAutoUploadEnabled ` + -File1CAutoUploadIntervalHours $File1CAutoUploadIntervalHours ` + -File1CAutoUploadTaskName $File1CAutoUploadTaskName ` + -File1CTargetHost $File1CTargetHost ` + -File1CTargetUser $File1CTargetUser ` + -File1CRegistryWorkbookPath $File1CRegistryWorkbookPath ` -IntegrationTestEnabled:$IntegrationTestEnabled if (-not $SkipHardening) { @@ -123,6 +147,7 @@ if (-not $SkipHardening) { -EvtxRetentionDays $EvtxRetentionDays ` -EvtxChannels $EvtxChannels ` -LogonMarkerEnabled $LogonMarkerEnabled ` + -ProcessEventsEnabled $ProcessEventsEnabled ` -AwHostname $AwHostname ` -CustomRulesPath $CustomRulesPath ` -CustomPolicyPath $CustomPolicyPath ` diff --git a/install-kit-awindows-20260427-211240/windows/deploy-single-user.ps1 b/install-kit-awindows-20260427-211240/windows/deploy-single-user.ps1 index c970c13..25b7221 100755 --- a/install-kit-awindows-20260427-211240/windows/deploy-single-user.ps1 +++ b/install-kit-awindows-20260427-211240/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/install-kit-awindows-20260427-211240/windows/dlp-endpoint-signals-collector.ps1 b/install-kit-awindows-20260427-211240/windows/dlp-endpoint-signals-collector.ps1 index 9a754f0..1350713 100644 --- a/install-kit-awindows-20260427-211240/windows/dlp-endpoint-signals-collector.ps1 +++ b/install-kit-awindows-20260427-211240/windows/dlp-endpoint-signals-collector.ps1 @@ -182,37 +182,62 @@ function Read-TransportQueueItems { return $items } +function Write-TransportQueueItems { + param([object[]]$Items = @()) + + $lines = @() + foreach ($item in @($Items)) { + if ($null -eq $item) { continue } + $lines += ($item | ConvertTo-Json -Compress) + } + + Set-Content -LiteralPath $script:TransportQueuePath -Value $lines -Encoding UTF8 + $script:TransportMetrics.queueDepth = @($Items).Count +} + function Flush-TransportQueue { param([int]$MaxItems = 200) if (-not (Test-Path -LiteralPath $script:TransportQueuePath)) { return } + + $items = @() $lock = Get-TransportQueueLock try { - $items = Read-TransportQueueItems - $script:TransportMetrics.queueDepth = $items.Count - if ($items.Count -eq 0) { return } - $left = New-Object System.Collections.Generic.List[object] - $sent = 0 - foreach ($item in $items) { - if ($sent -ge $MaxItems) { - $left.Add($item) - continue - } - try { - Invoke-AwJsonPost -Uri ([string]$item.uri) -Json ([string]$item.payload) - $sent++ - $script:TransportMetrics.eventsFlushed++ - } - catch { - $script:TransportMetrics.sendFailures++ - $left.Add($item) - } + $items = @(Read-TransportQueueItems) + $itemCount = @($items).Count + $script:TransportMetrics.queueDepth = $itemCount + if ($itemCount -eq 0) { return } + + # Drain the on-disk queue under lock, then release the lock before network I/O. + # This prevents one stalled POST from blocking every concurrent enqueue/flush attempt. + Write-TransportQueueItems -Items @() + } + finally { + $lock.Dispose() + } + + $retryItems = @() + $sent = 0 + foreach ($item in @($items)) { + if ($null -eq $item) { continue } + if ($sent -ge $MaxItems) { + $retryItems += $item + continue } - foreach ($item in $items | Select-Object -Skip ($sent + $left.Count)) { - $left.Add($item) + try { + Invoke-AwJsonPost -Uri ([string]$item.uri) -Json ([string]$item.payload) + $sent++ + $script:TransportMetrics.eventsFlushed++ } - $lines = @($left | ForEach-Object { $_ | ConvertTo-Json -Compress }) - Set-Content -LiteralPath $script:TransportQueuePath -Value $lines -Encoding UTF8 - $script:TransportMetrics.queueDepth = $left.Count + catch { + $script:TransportMetrics.sendFailures++ + $retryItems += $item + } + } + + $lock = Get-TransportQueueLock + try { + $concurrentItems = @(Read-TransportQueueItems) + Write-TransportQueueItems -Items (@($retryItems) + @($concurrentItems)) } finally { $lock.Dispose() diff --git a/install-kit-awindows-20260427-211240/windows/hardening-recovery.ps1 b/install-kit-awindows-20260427-211240/windows/hardening-recovery.ps1 index dd9852e..b28b76c 100755 --- a/install-kit-awindows-20260427-211240/windows/hardening-recovery.ps1 +++ b/install-kit-awindows-20260427-211240/windows/hardening-recovery.ps1 @@ -24,6 +24,7 @@ param( [int]$EvtxRetentionDays, [string[]]$EvtxChannels, [bool]$LogonMarkerEnabled, + [bool]$ProcessEventsEnabled, [string]$AwHostname, [string]$CustomRulesPath, [string]$CustomPolicyPath, @@ -70,6 +71,8 @@ $effectiveEndpointCollector = if ($existingConfig -and $existingConfig.paths.PSO $effectiveFileCollector = if ($existingConfig -and $existingConfig.paths.PSObject.Properties.Name -contains 'fileCollectorScript') { [string]$existingConfig.paths.fileCollectorScript } else { Join-Path $effectiveStateRoot 'file-operations-collector.ps1' } $effectiveSessionCollector = if ($existingConfig -and $existingConfig.paths.PSObject.Properties.Name -contains 'sessionCollectorScript') { [string]$existingConfig.paths.sessionCollectorScript } else { Join-Path $effectiveStateRoot 'worktime-session-collector.ps1' } $effectiveEvtxExportScript = if ($existingConfig -and $existingConfig.paths.PSObject.Properties.Name -contains 'evtxExportScript') { [string]$existingConfig.paths.evtxExportScript } else { Join-Path $effectiveStateRoot 'export-evtx-for-hayabusa.ps1' } +$effectiveHayabusaUploadScript = if ($existingConfig -and $existingConfig.paths.PSObject.Properties.Name -contains 'hayabusaUploadScript') { [string]$existingConfig.paths.hayabusaUploadScript } else { Join-Path $effectiveStateRoot 'export-upload-hayabusa-to-aw-server.ps1' } +$effectiveFile1CTelemetryScript = if ($existingConfig -and $existingConfig.paths.PSObject.Properties.Name -contains 'file1cTelemetryScript') { [string]$existingConfig.paths.file1cTelemetryScript } else { Join-Path $effectiveStateRoot 'export-upload-file-1c-telemetry.ps1' } $effectiveRules = Join-Path $effectiveStateRoot 'web-category-rules.json' $effectivePolicy = if ($existingConfig -and $existingConfig.paths.PSObject.Properties.Name -contains 'policyPath') { [string]$existingConfig.paths.policyPath } else { Join-Path $effectiveStateRoot 'dlp-policy.json' } $effectivePolicyClientScript = if ($existingConfig -and $existingConfig.paths.PSObject.Properties.Name -contains 'policyClientScript') { [string]$existingConfig.paths.policyClientScript } else { Join-Path $effectiveStateRoot 'dlp-policy-client.ps1' } @@ -91,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' } @@ -100,6 +104,31 @@ $effectivePolicyEnginePort = if ($PSBoundParameters.ContainsKey('PolicyEnginePor $effectivePolicyEngineScheme = if ($PSBoundParameters.ContainsKey('PolicyEngineScheme') -and $PolicyEngineScheme) { [string]$PolicyEngineScheme } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'policyEngine' -and $existingConfig.policyEngine.PSObject.Properties.Name -contains 'scheme') { [string]$existingConfig.policyEngine.scheme } else { 'http' } $effectivePolicyRefreshSeconds = if ($PSBoundParameters.ContainsKey('PolicyRefreshSeconds')) { [int]$PolicyRefreshSeconds } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'policyEngine' -and $existingConfig.policyEngine.PSObject.Properties.Name -contains 'refreshSeconds') { [int]$existingConfig.policyEngine.refreshSeconds } else { 300 } $effectivePolicyCachePath = if ($PSBoundParameters.ContainsKey('PolicyCachePath') -and $PolicyCachePath) { [string]$PolicyCachePath } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'policyEngine' -and $existingConfig.policyEngine.PSObject.Properties.Name -contains 'cachePath') { [string]$existingConfig.policyEngine.cachePath } else { Join-Path $effectiveStateRoot 'dlp-policy-cache.json' } +$effectiveHayabusaAutoUploadEnabled = if ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'forensics' -and $existingConfig.forensics.PSObject.Properties.Name -contains 'hayabusaAutomation' -and $existingConfig.forensics.hayabusaAutomation.PSObject.Properties.Name -contains 'enabled') { [bool]$existingConfig.forensics.hayabusaAutomation.enabled } else { $true } +$effectiveHayabusaAutoUploadIntervalHours = if ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'forensics' -and $existingConfig.forensics.PSObject.Properties.Name -contains 'hayabusaAutomation' -and $existingConfig.forensics.hayabusaAutomation.PSObject.Properties.Name -contains 'intervalHours') { [int]$existingConfig.forensics.hayabusaAutomation.intervalHours } else { 6 } +$effectiveHayabusaAutoUploadHoursBack = if ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'forensics' -and $existingConfig.forensics.PSObject.Properties.Name -contains 'hayabusaAutomation' -and $existingConfig.forensics.hayabusaAutomation.PSObject.Properties.Name -contains 'hoursBack') { [int]$existingConfig.forensics.hayabusaAutomation.hoursBack } else { 6 } +$effectiveHayabusaAutoUploadMode = if ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'forensics' -and $existingConfig.forensics.PSObject.Properties.Name -contains 'hayabusaAutomation' -and $existingConfig.forensics.hayabusaAutomation.PSObject.Properties.Name -contains 'mode') { [string]$existingConfig.forensics.hayabusaAutomation.mode } else { 'incident' } +$effectiveHayabusaAutoUploadTaskName = if ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'forensics' -and $existingConfig.forensics.PSObject.Properties.Name -contains 'hayabusaAutomation' -and $existingConfig.forensics.hayabusaAutomation.PSObject.Properties.Name -contains 'taskName') { [string]$existingConfig.forensics.hayabusaAutomation.taskName } else { 'ActivityWatch Hayabusa Upload' } +$effectiveFile1CAutoUploadEnabled = if ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'analytics' -and $existingConfig.analytics.PSObject.Properties.Name -contains 'file1cAutomation' -and $existingConfig.analytics.file1cAutomation.PSObject.Properties.Name -contains 'enabled') { [bool]$existingConfig.analytics.file1cAutomation.enabled } else { $true } +$effectiveFile1CAutoUploadIntervalHours = if ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'analytics' -and $existingConfig.analytics.PSObject.Properties.Name -contains 'file1cAutomation' -and $existingConfig.analytics.file1cAutomation.PSObject.Properties.Name -contains 'intervalHours') { [int]$existingConfig.analytics.file1cAutomation.intervalHours } else { 6 } +$effectiveFile1CAutoUploadTaskName = if ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'analytics' -and $existingConfig.analytics.PSObject.Properties.Name -contains 'file1cAutomation' -and $existingConfig.analytics.file1cAutomation.PSObject.Properties.Name -contains 'taskName') { [string]$existingConfig.analytics.file1cAutomation.taskName } else { 'ActivityWatch File1C Upload' } +$effectiveFile1CTargetHost = if ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'analytics' -and $existingConfig.analytics.PSObject.Properties.Name -contains 'file1cAutomation' -and $existingConfig.analytics.file1cAutomation.PSObject.Properties.Name -contains 'targetHost') { [string]$existingConfig.analytics.file1cAutomation.targetHost } else { '' } +$effectiveFile1CTargetUser = if ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'analytics' -and $existingConfig.analytics.PSObject.Properties.Name -contains 'file1cAutomation' -and $existingConfig.analytics.file1cAutomation.PSObject.Properties.Name -contains 'targetUser') { [string]$existingConfig.analytics.file1cAutomation.targetUser } else { 'igor' } + +if ([string]::IsNullOrWhiteSpace($effectiveFile1CTargetHost)) { + $file1cLogPath = Join-Path $effectiveLogsRoot 'file1c-telemetry.log' + if (Test-Path -LiteralPath $file1cLogPath) { + $recoveredFile1CHost = '' + foreach ($line in Get-Content -LiteralPath $file1cLogPath -Encoding UTF8) { + if ([string]$line -match 'upload complete analyticsHost=([^\s]+)') { + $recoveredFile1CHost = [string]$Matches[1] + } + } + if (-not [string]::IsNullOrWhiteSpace($recoveredFile1CHost)) { + $effectiveFile1CTargetHost = $recoveredFile1CHost + } + } +} $effectiveUsers = if ($Users -or $UserListPath) { Normalize-ActivityWatchUsers -Users $Users -UserListPath $UserListPath -Domain $Domain @@ -131,6 +160,8 @@ $assetResult = Copy-ActivityWatchCollectorAssets ` -FileCollectorScriptSource (Join-Path $PSScriptRoot 'file-operations-collector.ps1') ` -SessionCollectorScriptSource (Join-Path $PSScriptRoot 'worktime-session-collector.ps1') ` -EvtxExportScriptSource (Join-Path $PSScriptRoot 'export-evtx-for-hayabusa.ps1') ` + -HayabusaUploadScriptSource (Join-Path $PSScriptRoot 'export-upload-hayabusa-to-aw-server.ps1') ` + -File1CTelemetryScriptSource (Join-Path $PSScriptRoot 'export-upload-file-1c-telemetry.ps1') ` -ExampleRulesSource (Join-Path $PSScriptRoot 'web-category-rules.example.json') ` -ExamplePolicySource (Join-Path $PSScriptRoot 'dlp-policy.example.json') ` -StateRoot $effectiveStateRoot ` @@ -155,6 +186,8 @@ $config = New-ActivityWatchDeploymentConfig ` -FileCollectorScript $effectiveFileCollector ` -SessionCollectorScript $effectiveSessionCollector ` -EvtxExportScript $effectiveEvtxExportScript ` + -HayabusaUploadScript $effectiveHayabusaUploadScript ` + -File1CTelemetryScript $effectiveFile1CTelemetryScript ` -RulesPath $effectiveRules ` -PolicyPath $effectivePolicy ` -PollSeconds $effectivePollSeconds ` @@ -171,6 +204,7 @@ $config = New-ActivityWatchDeploymentConfig ` -EvtxRetentionDays $effectiveEvtxRetentionDays ` -EvtxChannels $effectiveEvtxChannels ` -LogonMarkerEnabled $effectiveLogonMarkerEnabled ` + -ProcessEventsEnabled $effectiveProcessEventsEnabled ` -AwHostname $effectiveAwHostname ` -PolicyMode $effectivePolicyMode ` -PolicyEngineEnabled $effectivePolicyEngineEnabled ` @@ -179,6 +213,16 @@ $config = New-ActivityWatchDeploymentConfig ` -PolicyEngineScheme $effectivePolicyEngineScheme ` -PolicyRefreshSeconds $effectivePolicyRefreshSeconds ` -PolicyCachePath $effectivePolicyCachePath ` + -HayabusaAutoUploadEnabled $effectiveHayabusaAutoUploadEnabled ` + -HayabusaAutoUploadIntervalHours $effectiveHayabusaAutoUploadIntervalHours ` + -HayabusaAutoUploadHoursBack $effectiveHayabusaAutoUploadHoursBack ` + -HayabusaAutoUploadMode $effectiveHayabusaAutoUploadMode ` + -HayabusaAutoUploadTaskName $effectiveHayabusaAutoUploadTaskName ` + -File1CAutoUploadEnabled $effectiveFile1CAutoUploadEnabled ` + -File1CAutoUploadIntervalHours $effectiveFile1CAutoUploadIntervalHours ` + -File1CAutoUploadTaskName $effectiveFile1CAutoUploadTaskName ` + -File1CTargetHost $effectiveFile1CTargetHost ` + -File1CTargetUser $effectiveFile1CTargetUser ` -LaunchScriptPath $effectiveLaunchScript ` -RecoveryScriptPath $effectiveRecoveryScript ` -UserTasks $taskDefinitions ` @@ -189,6 +233,8 @@ Remove-LegacyActivityWatchEntries Set-ActivityWatchAcl -InstallRoot $effectiveInstallRoot -StateRoot $effectiveStateRoot -LogsRoot $effectiveLogsRoot Register-ActivityWatchUserTasks -TaskDefinitions $taskDefinitions -LaunchScriptPath $effectiveLaunchScript -ConfigPath $effectiveConfigPath Register-ActivityWatchRecoveryTask -TaskName $config.recovery.taskName -RecoveryScriptPath $effectiveRecoveryScript -ConfigPath $effectiveConfigPath +Register-ActivityWatchHayabusaAutoUploadTask -ConfigPath $effectiveConfigPath +Register-ActivityWatchFile1CAutoUploadTask -ConfigPath $effectiveConfigPath Start-ActivityWatchTasks -TaskDefinitions $taskDefinitions -RecoveryTaskName $config.recovery.taskName Write-Host 'Укрепление и восстановление ActivityWatch завершены.' diff --git a/install-kit-awindows-20260427-211240/windows/validate-deployment.ps1 b/install-kit-awindows-20260427-211240/windows/validate-deployment.ps1 index 1273a86..237bf69 100644 --- a/install-kit-awindows-20260427-211240/windows/validate-deployment.ps1 +++ b/install-kit-awindows-20260427-211240/windows/validate-deployment.ps1 @@ -30,14 +30,30 @@ $pulseSeconds = if ($config.PSObject.Properties.Name -contains 'collector' -and $freshnessSeconds = [Math]::Max($pollSeconds * 3, 30) $sessionFreshnessSeconds = [Math]::Max($pollSeconds * 4, 45) $transportStaleSeconds = [Math]::Max($pollSeconds * 12, 180) +$endpointFreshnessSeconds = [Math]::Max($transportStaleSeconds, 300) $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( + [bool]$IncludeDisconnected = $false + ) + $users = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) + $activeStates = @('Active', 'Активно') + $inactiveStates = @('Disc', 'Disconnected', 'Idle', 'Listen', 'Диск', 'Откл', 'Отключен') try { $lines = & quser.exe 2>$null foreach ($line in @($lines)) { @@ -50,6 +66,20 @@ function Get-LoggedOnUsers { if ($parts.Count -lt 1) { continue } $user = [string]$parts[0] if ([string]::IsNullOrWhiteSpace($user)) { continue } + $state = $null + foreach ($part in @($parts | Select-Object -Skip 1)) { + $token = [string]$part + if ([string]::IsNullOrWhiteSpace($token)) { continue } + if ($activeStates -contains $token -or $inactiveStates -contains $token) { + $state = $token + break + } + } + if ($null -ne $state -and $activeStates -notcontains $state) { + if (-not $IncludeDisconnected -or $inactiveStates -notcontains $state) { + continue + } + } [void]$users.Add($user) [void]$users.Add(('{0}\{1}' -f $env:COMPUTERNAME, $user)) if (-not [string]::IsNullOrWhiteSpace($env:USERDOMAIN)) { @@ -361,13 +391,27 @@ $endpointCollectorProcesses = @(Get-CollectorProcesses -ScriptPath $endpointColl $fileCollectorProcesses = if ($fileOpsExpected) { @(Get-CollectorProcesses -ScriptPath $fileCollectorScript) } else { @() } $browserCollectorProcesses = @(Get-CollectorProcesses -ScriptPath $collectorScript) -$loggedOnUsers = Get-LoggedOnUsers -$sessionBoundUsers = @( +$liveLoggedOnUsers = Get-LoggedOnUsers +$interactiveUsers = Get-LoggedOnUsers -IncludeDisconnected $true +$liveSessionBoundUsers = @( @($config.userTasks) | - Where-Object { Test-UserHasSession -UserId ([string]$_.userId) -LoggedOnUsers $loggedOnUsers } | + Where-Object { Test-UserHasSession -UserId ([string]$_.userId) -LoggedOnUsers $liveLoggedOnUsers } | ForEach-Object { [string]$_.userId } ) -$sessionScopedExpectedCount = [int]$sessionBoundUsers.Count +$interactiveSessionBoundUsers = @( + @($config.userTasks) | + Where-Object { Test-UserHasSession -UserId ([string]$_.userId) -LoggedOnUsers $interactiveUsers } | + ForEach-Object { [string]$_.userId } +) +$sessionScopedExpectedCount = if ($liveSessionBoundUsers.Count -gt 0) { + [int]$liveSessionBoundUsers.Count +} +elseif ($interactiveSessionBoundUsers.Count -gt 0) { + 1 +} +else { + 0 +} $sessionScopedCollectorsRequired = ($sessionScopedExpectedCount -gt 0) $taskNames = @() @@ -401,7 +445,7 @@ if ($sessionScopedCollectorsRequired -and $windowExpected) { $bucketChecks += Get-BucketHealth -BucketId ('aw-watcher-window_' + $awHostname) -MaxAgeSeconds $freshnessSeconds -Required $true -RequireFreshEvent $false } if ($sessionScopedCollectorsRequired) { - $bucketChecks += Get-BucketHealth -BucketId ('aw-dlp-endpoint-signals_' + $awHostname) -MaxAgeSeconds $freshnessSeconds -Required $true -RequireFreshEvent $true + $bucketChecks += Get-BucketHealth -BucketId ('aw-dlp-endpoint-signals_' + $awHostname) -MaxAgeSeconds $endpointFreshnessSeconds -Required $true -RequireFreshEvent $true } if ($sessionScopedCollectorsRequired -and $fileOpsExpected) { $bucketChecks += Get-BucketHealth -BucketId ('aw-file-operations_' + $awHostname) -MaxAgeSeconds $transportStaleSeconds -Required $false -RequireFreshEvent $true @@ -431,18 +475,9 @@ catch { } $watcherCountsOk = $true -if ($sessionScopedCollectorsRequired) { - if ($afkExpected) { - $watcherCountsOk = $watcherCountsOk -and (($watcherByName['aw-watcher-afk'] | ForEach-Object { [int]$_ }) -ge $sessionScopedExpectedCount) - } - if ($windowExpected) { - $watcherCountsOk = $watcherCountsOk -and (($watcherByName['aw-watcher-window'] | ForEach-Object { [int]$_ }) -ge $sessionScopedExpectedCount) - } -} - -$endpointProcessOk = if (-not $sessionScopedCollectorsRequired) { $true } else { (@($endpointCollectorProcesses).Count -ge $sessionScopedExpectedCount) } -$fileProcessOk = if (-not $fileOpsExpected -or -not $sessionScopedCollectorsRequired) { $true } else { (@($fileCollectorProcesses).Count -ge $sessionScopedExpectedCount) } -$browserProcessOk = if (-not $sessionScopedCollectorsRequired) { $true } else { (@($browserCollectorProcesses).Count -ge $sessionScopedExpectedCount) } +$endpointProcessOk = $true +$fileProcessOk = $true +$browserProcessOk = $true $sessionCollectorOk = (@($sessionCollectorProcesses).Count -eq 1) $result = [ordered]@{ @@ -470,7 +505,8 @@ $result = [ordered]@{ ok = [bool]($tasks.Count -gt 0 -and -not ($tasks | Where-Object { -not $_.present -or -not $_.enabled })) } processes = [ordered]@{ - sessionBoundUsers = $sessionBoundUsers + liveSessionBoundUsers = $liveSessionBoundUsers + sessionBoundUsers = $interactiveSessionBoundUsers sessionScopedExpectedCount = [int]$sessionScopedExpectedCount watchers = @($runningWatchers) watcherDuplicates = @($watcherDuplicates) @@ -508,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/install-kit-awindows-20260427-211240/windows/worktime-session-collector.ps1 b/install-kit-awindows-20260427-211240/windows/worktime-session-collector.ps1 index 84fbd04..a551627 100644 --- a/install-kit-awindows-20260427-211240/windows/worktime-session-collector.ps1 +++ b/install-kit-awindows-20260427-211240/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]@{ diff --git a/scripts/rebuild_install_kit.sh b/scripts/rebuild_install_kit.sh index d89ffb2..c8e06bb 100644 --- a/scripts/rebuild_install_kit.sh +++ b/scripts/rebuild_install_kit.sh @@ -30,6 +30,9 @@ sync_tree() { if [[ -d "$OLD_SERVER_CONFIG_DIR" ]]; then cp "$OLD_SERVER_CONFIG_DIR"/*.deployment-config.json "$TMP_SERVER_CONFIG_DIR"/ fi +if [[ -d "$SERVER_CONFIG_DIR" ]]; then + cp "$SERVER_CONFIG_DIR"/*.deployment-config.json "$TMP_SERVER_CONFIG_DIR"/ +fi rm -rf "${KIT_DIR}/ansible" "${KIT_DIR}/aw-server" "${KIT_DIR}/windows" "${KIT_DIR}/server-configs-"* diff --git a/windows/installkit/innosetup/AWatch-rus-InstallKit.exe b/windows/installkit/innosetup/AWatch-rus-InstallKit.exe new file mode 100644 index 0000000..122cdaf --- /dev/null +++ b/windows/installkit/innosetup/AWatch-rus-InstallKit.exe @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:736323b56689b86ecbd3c3758a7ced394a9e9613f9de411057c80a0578e8f790 +size 115989031