feat(aw-rus): sync deploy stack, install kit, and health checks

This commit is contained in:
igor04091968
2026-05-21 15:16:23 +03:00
parent 45f9907450
commit 99143b108b
67 changed files with 6449 additions and 681 deletions
@@ -1,41 +1,47 @@
0754dcba7c651d67a40e09446868d2fcae623a100d4fb01794dd96272d353b49 install-kit-awindows-20260427-211240/README-INSTALL-KIT.txt
089595753398c8b82980919d230dafac548c3ba36817f5c96a68051582f9faa3 install-kit-awindows-20260427-211240/ansible/README.md
f3dde1e6d1532804379faf7e395deaf95cf3e0b97769d425f8a69f4572de2a2f install-kit-awindows-20260427-211240/README-INSTALL-KIT.txt
a08ccceada7a21e4396a640e54a354e4d4d760980ec3f18f1bc7f543cd8f4cc6 install-kit-awindows-20260427-211240/ansible/README.md
412bb766bbf0791c3593f38daa771d5d0aa58cc1f2d3c9010fcd4588d0fe87df install-kit-awindows-20260427-211240/ansible/deploy_aw_pfsense_poller.yml
90ac38a33918fcd3620f078f51fbf7c6a9d7f8fd1a34d16b38cb3ac45678b0d7 install-kit-awindows-20260427-211240/ansible/deploy_aw_server.yml
a649eeb57472fb259d248983c486f0474bfb7317600feb98f76a80e7af7e4549 install-kit-awindows-20260427-211240/ansible/deploy_aw_windows.yml
531bfec24f86d28a06e5c0d73005489a818e2c7b1cce1d98f524a3f76802b8ee install-kit-awindows-20260427-211240/ansible/group_vars/all.example.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
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
7c468f252e328fd3bb7ee776a45feea88efc4b438dfef55b832da4eea867aaf2 install-kit-awindows-20260427-211240/ansible/group_vars/windows.example.yml
195e7dbdb91f4e77db3263bd0812301ddc912a37ba1519688768bb64a2887567 install-kit-awindows-20260427-211240/ansible/install_full_stack.yml
2e4e94d90143923fefd3ec1257d0ec57daa3e96450d85471bc2c418aae37e105 install-kit-awindows-20260427-211240/ansible/inventory.example.ini
d9e43352fd6bdb647db9754ab2c557b6bb27f88f51b2bf23d8c19227e535e7b9 install-kit-awindows-20260427-211240/ansible/provision_proxmox_ct_and_deploy_aw.yml
f3d34547f345ad1c635ee44613a60e7f08479f9ae4d1111810bc7e5968bfb084 install-kit-awindows-20260427-211240/ansible/provision_proxmox_ct_matrix_and_deploy_aw.yml
ef4ed198745777bd1227170241b2db21dc853685f962d6116241c55216b466d6 install-kit-awindows-20260427-211240/ansible/tasks/provision_ct_and_deploy_aw.yml
a50dbadbf619342c2178e255b68f69a36503756daf80eedd9170311c63964f2e install-kit-awindows-20260427-211240/aw-server/activitywatch-server.service
2dbf55d4a8f204ebdc97af926d90435932c0aad7a2431e2b9987e47c5abf71a9 install-kit-awindows-20260427-211240/aw-server/apply_webui_ru_patch.sh
07d4e583f6e9757a11f01558e1f15cfd73c4d82695f1768204f2f50621712168 install-kit-awindows-20260427-211240/aw-server/aw-host-groups.json
09605da1754abb0dc0446825580b57ebad6e646dc670f9f072fce1489e88dd43 install-kit-awindows-20260427-211240/aw-server/aw-ru-patch.js
7c5952f8f0a8590e849ea8381bfcd7059b138250bca8551bd5625f395eb66cd8 install-kit-awindows-20260427-211240/aw-server/aw-server.env.example
5da847b74fac52e0fec2f60e134f4377cfa1581e026b291d3d3ac362371f6e49 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
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
98c0bed353bbda0fa7a69df23f3b008cb0e8e70cdff6cc63330d4caf79fd3280 install-kit-awindows-20260427-211240/aw-server/aw-sw-cleanup.js
dce731fdfdcfd773c154d12dbd6b9e621a0bff17ced5a05a65f0fdcb1adcb70f install-kit-awindows-20260427-211240/aw-server/install_aw_server.sh
1856e9f44636030b0cb9ece37ba2a0618eb5187fa82c7969976c1bb5f10fc622 install-kit-awindows-20260427-211240/aw-server/settings/classes-worktime.json
ff07b90cb6a7f09b27d522307cf55b0359e136a2e695190b8564e859f14f9204 install-kit-awindows-20260427-211240/aw-server/settings/views-default.json
1654cf688560465fcce629468a0be869b0c81b056179e1b5e9bcc7a2d5ed6ce0 install-kit-awindows-20260427-211240/server-configs-192.168.100.21/awatch-rus-admin.deployment-config.json
ac022b9a074c542ade66d18af8db385f6c14376ac4eadd54ef033dfa7f60fb50 install-kit-awindows-20260427-211240/server-configs-192.168.100.21/awatch-rus-u2u5.deployment-config.json
98cf4c54d494318b74cfbd3c8892830a34928bd76a2296d5333e5e176c1f3f49 install-kit-awindows-20260427-211240/server-configs-192.168.100.21/awatch-rus-user1.deployment-config.json
d8d6be450a726f51f87415eaebd67396468c73ddda9b942481d56b4d33d68bb7 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
aedffecfa24834968742cb2477faef80bf794345275a9679ac12c5a1f609acc2 install-kit-awindows-20260427-211240/aw-server/settings/classes-worktime.json
38fd98fe5816fb87055a9ad1fc570a65052785c829ecd8359fe8305949ab32ca 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
0cb9cd8d8b612429f79899c126f4141ab4fbbb6d919425c0b8fd8d0a74b1bd44 install-kit-awindows-20260427-211240/windows/ActivityWatch.Windows.Common.psm1
7db2d3767ae81c877e8f04ecbc77a2fc26b2d9bbbf77d0e774fba0a7956bd0a6 install-kit-awindows-20260427-211240/windows/browser-domains-native-collector.ps1
b497400a1ba57cddf28dc8e217115dc85eccb67150cbdbb6a81abd804ed20109 install-kit-awindows-20260427-211240/windows/deploy-domain-users.ps1
973db51854fc744539a7b75e13c6749e822a08a79f47447485611f07e8f902a8 install-kit-awindows-20260427-211240/windows/deploy-ensemble.ps1
0d66dcb551889e6b7bc21b29d53b77e46f41d61dd2e4e0d9913dbf0f8bd5eb18 install-kit-awindows-20260427-211240/windows/deploy-single-user.ps1
8857f3e17f3f3ed6f211ce7f0a0c46c586a2548541078401ee3befaa20924b3d install-kit-awindows-20260427-211240/windows/dlp-endpoint-signals-collector.ps1
7362e1aecb56d8863b8b2542f262f28827febf9b25a2a14b382750dc6368664f install-kit-awindows-20260427-211240/windows/email-outbound-collector.ps1
aef0032edd9b1e0c54f7b575664ed511dfc6cb53364e7496cbc95e137678e11a install-kit-awindows-20260427-211240/windows/dlp-policy.example.json
f03886caf56c6838e8a163d6b48d1f229e83a5682aeeb447c3a65f13d62dbca4 install-kit-awindows-20260427-211240/windows/hardening-recovery.ps1
71911cd53ad0abd8bf83994f8a79bbbfe2c0eaf4f4c5f6c2d636dd7786191c2f install-kit-awindows-20260427-211240/windows/migrate-awatch-rus-paths.ps1
5dcf249742bd82fa0c803c878bfa0a1344b7b14df85c05f12e8c205663aea158 install-kit-awindows-20260427-211240/windows/validate-deployment.ps1
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
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
5ef21a25d5e2da4eeaef17126e60f96f195f90f9dc17a776f8629334b904d096 install-kit-awindows-20260427-211240/windows/migrate-awatch-rus-paths.ps1
d01edd14b2c839ae171006fd3345dbddf1b683b1adcac885b6eabc52e3baeb79 install-kit-awindows-20260427-211240/windows/validate-deployment.ps1
731098681d89b9af6f3872abd586ac3b1faba2d7f9340211e503f52ad0243b3f install-kit-awindows-20260427-211240/windows/web-category-rules.example.json
41171f0d7ed1e8b00dd0faf1a4b75c9cb063fd09aba8d333851a7a31fb297de1 install-kit-awindows-20260427-211240/windows/worktime-session-collector.ps1
945ccfffd56697ed328b82e82d1cffbc83fa4e50a6120b27e3948f3d13fa8a33 install-kit-awindows-20260427-211240/windows/worktime-session-collector.ps1
@@ -3,8 +3,8 @@ ActivityWatch DetMir Windows Install Kit
Includes:
- windows/* (deploy scripts, collectors, common module, configs/examples)
- ansible/* (Windows and AW server playbooks, examples, inventory, tasks)
- aw-server/* (server installer, RU patch loader, host groups, default settings)
- server-configs-192.168.100.21/* (working Windows/RDP config snapshots)
- aw-server/* (server installer, health orchestrator, RU patch loader, host groups, default settings)
- server-configs-192.168.100.18/* (working Windows/RDP config snapshots)
Source:
- Local project snapshot at build time.
@@ -14,6 +14,8 @@
- `ansible/provision_proxmox_ct_matrix_and_deploy_aw.yml` — массовый полный playbook (несколько CT).
- `ansible/deploy_aw_windows.yml` — WinRM playbook для развёртывания Windows/RDP collector'ов.
- `ansible/deploy_aw_pfsense_poller.yml` — развёртывание pfSense poller'а.
- `ansible/deploy_grafana_dashboards.yml` — импорт version-controlled Grafana dashboard'ов через HTTP API.
- `ansible/deploy_tsj_guardian_bot_proxmox.yml` — развёртывание TSJ Guardian Telegram Bot на Proxmox host.
- `ansible/install_full_stack.yml` — полный установочный playbook (оркестратор всех этапов).
- `ansible/inventory.example.ini` — шаблон inventory.
- `ansible/group_vars/*.example.yml` — шаблоны переменных.
@@ -31,6 +33,15 @@ cd ansible
ansible-playbook -i inventory.ini deploy_aw_server.yml
```
## Секреты (пароли) безопасно
Рекомендуемый способ не хранить пароли в репозитории — перед запуском экспортировать их в переменные окружения:
- Linux `aw_server` (SSH пароль root): `AW_SSH_PASSWORD`
- Windows `aw_windows` (WinRM пароль): `AW_WINRM_PASSWORD`
В `group_vars/aw_server.yml` и `group_vars/windows.yml` они читаются через `lookup('env', ...)`.
## Полный установочный playbook (всё за один запуск)
Если нужно прогнать полный цикл одной командой:
@@ -45,7 +56,8 @@ ansible-playbook -i inventory.ini install_full_stack.yml
- `provision_proxmox_ct_and_deploy_aw.yml` (если есть хосты в группе `[proxmox]`);
- `deploy_aw_server.yml` (группа `[aw_server]`);
- `deploy_aw_windows.yml` (группа `[aw_windows]`);
- `deploy_aw_pfsense_poller.yml` (группа `[aw_pfsense_pollers]`).
- `deploy_aw_pfsense_poller.yml` (группа `[aw_pfsense_pollers]`);
- `deploy_grafana_dashboards.yml` (группа `[grafana]`).
Пустые группы в `inventory.ini` безопасны: соответствующий play будет пропущен.
@@ -87,18 +99,29 @@ ansible-playbook -i inventory.ini provision_proxmox_ct_matrix_and_deploy_aw.yml
```bash
cd ansible
ansible-playbook -i inventory.ini deploy_aw_windows.yml
AW_WINRM_PASSWORD='...' bash ./run_deploy_aw_windows.sh
```
`run_deploy_aw_windows.sh` автоматически:
- очищает proxy env (`http_proxy/https_proxy/...`), чтобы WinRM не уходил в локальный прокси;
- включает OpenSSL legacy provider, если на хосте отключён `MD4` (нужно для NTLM в pywinrm).
- перезапускает `ansible-playbook` при временных WinRM/NTLM сбоях (по умолчанию 5 попыток, пауза 30 сек).
Параметры retry:
- `AW_DEPLOY_RETRIES` (по умолчанию `5`);
- `AW_DEPLOY_RETRY_DELAY_SEC` (по умолчанию `30`).
Playbook:
- выгружает полный `windows/*` toolkit на целевой хост в InnoSetup-compatible каталог `C:\Program Files\AWatch-rus\windows`, включая DLP и `worktime-session-collector.ps1`;
- если найден legacy config `C:\ProgramData\ActivityWatch-Phase2\deployment-config.json`, выполняет безопасную миграцию через `migrate-awatch-rus-paths.ps1`: backup, остановка задач, перенос данных, переписывание путей, пересоздание scheduled tasks и validation;
- выполняет `deploy-ensemble.ps1` (deploy + hardening/recovery) с policy/rules из AWatch-rus toolkit;
- после deploy принудительно запускает `ActivityWatch Recovery` и все `ActivityWatch Launch *` задачи;
- выполняет API smoke-check bucket `aw-watcher-afk_<COMPUTERNAME>` и ожидает свежие `not-afk` события;
- включает (`Enable-ScheduledTask`) `ActivityWatch Recovery` и все `ActivityWatch Launch *` задачи перед запуском (иначе WebUI может показывать `Active time: 0s`);
- выполняет API smoke-check bucket `aw-watcher-afk_<COMPUTERNAME>` и ожидает свежие события;
- выполняет API smoke-check bucket `aw-watcher-window_<COMPUTERNAME>` и ожидает свежие события (по умолчанию включено);
- запускает `validate-deployment.ps1`;
- забирает JSON-отчёт в локальную директорию (`/tmp/aw-rus-validation` по умолчанию).
- забирает JSON-отчёт в локальную директорию (`/tmp/aw-rus-validation-<USER>` по умолчанию).
Дополнительные флаги:
@@ -116,6 +139,9 @@ Playbook:
- `aw_windows_migration_report_remote_path` — JSON-отчёт о миграции на Windows-хосте;
- `aw_windows_package_version`, `aw_windows_package_url`, `aw_windows_package_zip_path` — версия и источник Windows-пакета ActivityWatch;
- `aw_windows_api_smoke_check_bucket: ""` — автоматически использовать `aw-watcher-afk_<COMPUTERNAME>`;
- `aw_windows_api_smoke_check_window_enabled: true` — включить дополнительный smoke-check `aw-watcher-window_<COMPUTERNAME>`;
- `aw_windows_api_smoke_check_window_bucket: ""` — переопределить bucket для window smoke-check;
- `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-скрипта.
@@ -138,6 +164,68 @@ Playbook:
- пишет `/etc/aw-pfsense/poller.json`;
- поднимает `aw-pfsense-poller.service`.
## Импорт Grafana dashboard'ов
1. Подготовьте inventory и vars:
- `cp ansible/inventory.example.ini ansible/inventory.ini`
- `cp ansible/group_vars/grafana.example.yml ansible/group_vars/grafana.yml`
2. Укажите в inventory группу `[grafana]` и переменную `grafana_url`.
3. Экспортируйте пароль Grafana API:
```bash
export GRAFANA_ADMIN_PASSWORD='...'
```
4. Запустите:
```bash
cd ansible
ansible-playbook -i inventory.ini deploy_grafana_dashboards.yml
```
Playbook:
- проверяет `GET /api/health`;
- создает или актуализирует folder `AWatch-rus` в Grafana;
- импортирует dashboard JSON из каталога `grafana/`;
- верифицирует доступность dashboard'ов по `uid` через Grafana API.
По умолчанию импортируются:
- `DetMir: Работа пользователей в RDP`
- `DetMir: DLP и ИБ обзор`
- `DetMir: ИБ сводка для руководства`
- `AW-rus: DLP обзор`
Подробная документация: `docs/GRAFANA_DASHBOARDS_RU.md`
## Развёртывание TSJ Guardian Bot на Proxmox
1. Подготовьте vars:
- `cp ansible/group_vars/proxmox-bot.example.yml ansible/group_vars/proxmox-bot.yml`
2. Заполните минимум:
- `telegram_bot_token`
- `telegram_allowed_chat_ids`
- `tsj_bot_source_local_path`
3. Убедитесь, что в inventory есть группа `[proxmox]`.
Для текущего контура AW-Rus bot ожидает Proxmox host `10.10.10.2`.
Рабочая модель для этого контура: `igor` + `sudo`, а не обязательный `root` login.
4. При необходимости задайте recovery-команды для AW-Rus:
- `tsj_bot_aw_rus_worktime_heal_cmd`
- `tsj_bot_aw_rus_dlp_heal_cmd`
5. Запустите:
```bash
cd ansible
ansible-playbook -i inventory.ini deploy_tsj_guardian_bot_proxmox.yml
```
После актуального production hardening:
- bot различает `worktime idle` и реальную деградацию;
- bot поддерживает отдельный `AW_RUS_DLP_HEAL_CMD`;
- redeploy не должен терять runtime env-ключи, связанные с proxy, FS checks и AI escalation.
## Результат
- Установлен ActivityWatch Server.
@@ -149,3 +237,13 @@ Playbook:
- Для полного сценария CT создаётся автоматически через `pct create`.
- На Windows/RDP host развёрнуты AFK/window watchers, browser domain collector, DLP endpoint collector и worktime session collector.
- Проверочный JSON-отчёт Windows playbook должен иметь `overallOk=true`.
## Prod rollout одной командой
Для ручного запуска с dry-run и логированием используйте:
```bash
bash scripts/prod_rollout.sh
```
Скрипт попросит `AW_SSH_PASSWORD` и `AW_WINRM_PASSWORD` интерактивно (ввод скрыт) и сложит логи в `.rollout-logs/`.
File diff suppressed because it is too large Load Diff
@@ -4,6 +4,8 @@
gather_facts: false
vars:
ansible_winrm_operation_timeout_sec: 120
ansible_winrm_read_timeout_sec: 180
aw_windows_repo_root: "{{ playbook_dir | dirname }}"
aw_windows_deploy_root: "C:\\Program Files\\AWatch-rus"
aw_windows_server_scheme: "http"
@@ -14,6 +16,7 @@
aw_windows_package_zip_path: ""
aw_windows_domain: "SHARKON2025"
aw_windows_users:
- Администратор
- user1
- user2
- user3
@@ -23,24 +26,35 @@
aw_windows_users_effective: "{{ (aw_windows_users + aw_windows_extra_users) | unique }}"
aw_windows_install_root: "C:\\Program Files\\AWatch-rus\\bin"
aw_windows_state_root: "C:\\ProgramData\\AWatch-rus"
aw_windows_afk_enabled: true
aw_windows_window_enabled: true
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_afk_enabled_default: true
aw_windows_window_enabled_default: true
aw_windows_file_ops_enabled: true
aw_windows_local_agent_logs_enabled: false
aw_windows_incident_capture_enabled: true
aw_windows_incident_screenshot_enabled: true
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_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"
aw_windows_validation_remote_path: "{{ aw_windows_state_root }}\\aw_validate_ansible.json"
aw_windows_validation_local_dir: "/tmp/aw-rus-validation"
aw_windows_validation_local_dir: "/tmp/aw-rus-validation-{{ lookup('env','USER') | default('ansible', true) }}"
aw_windows_launch_task_pattern: "ActivityWatch Launch *"
aw_windows_recovery_task_name: "ActivityWatch Recovery"
aw_windows_force_task_restart: true
aw_windows_api_smoke_check_enabled: true
aw_windows_api_smoke_check_bucket: ""
aw_windows_api_smoke_check_limit: 10
aw_windows_api_smoke_check_window_enabled_default: true
aw_windows_api_smoke_check_window_bucket: ""
aw_windows_api_smoke_check_min_events: 1
aw_windows_fail_on_validation_error: true
aw_windows_migration_enabled: true
aw_windows_legacy_install_root: "C:\\Program Files\\ActivityWatch-Phase2"
@@ -60,6 +74,12 @@
- aw_windows_state_root is defined
fail_msg: "Не заданы обязательные переменные Windows-развёртывания."
- name: Нормализовать effective флаги collector'ов и smoke-check
ansible.builtin.set_fact:
aw_windows_afk_enabled_effective: "{{ (aw_windows_afk_enabled | default(aw_windows_afk_enabled_default)) | bool }}"
aw_windows_window_enabled_effective: "{{ (aw_windows_window_enabled | default(aw_windows_window_enabled_default)) | bool }}"
aw_windows_api_smoke_check_window_enabled_effective: "{{ (aw_windows_api_smoke_check_window_enabled | default(aw_windows_api_smoke_check_window_enabled_default)) | bool }}"
- name: Создать каталоги развёртывания
ansible.windows.win_file:
path: "{{ item }}"
@@ -77,16 +97,32 @@
- ActivityWatch.Windows.Common.psm1
- browser-domains-native-collector.ps1
- dlp-endpoint-signals-collector.ps1
- dlp-policy-client.ps1
- email-outbound-collector.ps1
- file-operations-collector.ps1
- worktime-session-collector.ps1
- export-evtx-for-hayabusa.ps1
- migrate-awatch-rus-paths.ps1
- deploy-domain-users.ps1
- deploy-ensemble.ps1
- hardening-recovery.ps1
- rebuild-worktime-tasks.ps1
- validate-deployment.ps1
- web-category-rules.example.json
- dlp-policy.example.json
- name: Нормализовать кодировку PowerShell файлов (UTF-8 BOM для Windows PowerShell)
ansible.windows.win_powershell:
script: |
$ErrorActionPreference = 'Stop'
$toolkitDir = "{{ aw_windows_deploy_root }}\windows"
$encIn = New-Object System.Text.UTF8Encoding($false)
$encOut = New-Object System.Text.UTF8Encoding($true)
Get-ChildItem -LiteralPath $toolkitDir -File -Include *.ps1,*.psm1,*.psd1 | ForEach-Object {
$text = [System.IO.File]::ReadAllText($_.FullName, $encIn)
[System.IO.File]::WriteAllText($_.FullName, $text, $encOut)
}
- name: Загрузить список пользователей для доменного развёртывания
ansible.windows.win_copy:
dest: "{{ aw_windows_deploy_root }}\\windows\\users.txt"
@@ -129,13 +165,22 @@
UserListPath = "{{ aw_windows_deploy_root }}\windows\users.txt"
InstallRoot = "{{ aw_windows_install_root }}"
StateRoot = "{{ aw_windows_state_root }}"
AfkEnabled = {{ '$true' if (aw_windows_afk_enabled | bool) else '$false' }}
WindowEnabled = {{ '$true' if (aw_windows_window_enabled | bool) else '$false' }}
AfkEnabled = {{ '$true' if (aw_windows_afk_enabled_effective | bool) else '$false' }}
WindowEnabled = {{ '$true' if (aw_windows_window_enabled_effective | bool) else '$false' }}
FileOpsEnabled = {{ '$true' if (aw_windows_file_ops_enabled | bool) else '$false' }}
LocalAgentLogsEnabled = {{ '$true' if (aw_windows_local_agent_logs_enabled | bool) else '$false' }}
IncidentCaptureEnabled = {{ '$true' if (aw_windows_incident_capture_enabled | bool) else '$false' }}
IncidentScreenshotEnabled = {{ '$true' if (aw_windows_incident_screenshot_enabled | bool) else '$false' }}
IncidentArtifactsRoot = "{{ aw_windows_incident_artifacts_root }}"
EvtxExportRoot = "{{ aw_windows_forensics_root }}"
EvtxRetentionDays = {{ aw_windows_evtx_retention_days | int }}
LogonMarkerEnabled = {{ '$true' if (aw_windows_logon_marker_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 }}"
PolicyEnginePort = {{ aw_windows_policy_engine_port }}
PolicyEngineScheme = "{{ aw_windows_policy_engine_scheme }}"
PolicyRefreshSeconds = {{ aw_windows_policy_refresh_seconds }}
CustomRulesPath = "{{ aw_windows_rules_path }}"
CustomPolicyPath = "{{ aw_windows_policy_path }}"
}
@@ -145,89 +190,230 @@
{% if (aw_windows_package_zip_path | default('') | string | length) > 0 %}
$params.PackageZipPath = "{{ aw_windows_package_zip_path }}"
{% endif %}
{% if (aw_windows_evtx_channels | default([]) | length) > 0 %}
$params.EvtxChannels = @(
{% for channel in aw_windows_evtx_channels %}
"{{ channel }}"{% if not loop.last %},{% endif %}
{% endfor %}
)
{% endif %}
{% if (aw_windows_hostname_override | default('') | string | length) > 0 %}
$params.AwHostname = "{{ aw_windows_hostname_override }}"
{% endif %}
{% if aw_windows_skip_hardening | bool %}
$params.SkipHardening = $true
{% endif %}
{% if aw_windows_integration_test_enabled | bool %}
$params.IntegrationTestEnabled = $true
{% endif %}
& "{{ aw_windows_deploy_root }}\windows\deploy-ensemble.ps1" @params
- name: Удалить лишние ActivityWatch Launch tasks вне текущего deployment-config
ansible.windows.win_powershell:
script: |
$ErrorActionPreference = 'Stop'
$config = Get-Content -Raw -LiteralPath "{{ aw_windows_state_root }}\deployment-config.json" | ConvertFrom-Json
$desired = @($config.userTasks | ForEach-Object { [string]$_.LaunchTaskName })
foreach ($task in @(Get-ScheduledTask | Where-Object { $_.TaskName -like 'ActivityWatch Launch *' })) {
if ($desired -notcontains [string]$task.TaskName) {
Unregister-ScheduledTask -TaskName $task.TaskName -Confirm:$false -ErrorAction SilentlyContinue
& cmd.exe /c "schtasks /Delete /TN `"$($task.TaskName)`" /F >nul 2>&1" | Out-Null
}
}
- name: Принудительно запустить ActivityWatch recovery и launch tasks
when: aw_windows_force_task_restart | bool
ansible.windows.win_powershell:
script: |
$ErrorActionPreference = 'Stop'
Start-ScheduledTask -TaskName "{{ aw_windows_recovery_task_name }}"
Get-ScheduledTask |
Where-Object TaskName -like "{{ aw_windows_launch_task_pattern }}" |
ForEach-Object { Start-ScheduledTask -TaskName $_.TaskName }
function Get-CollectorKey {
param([string]$CommandLine)
if (-not $CommandLine) { return $null }
$cl = $CommandLine.ToLowerInvariant()
if ($cl -like '*browser-domains-native-collector.ps1*') { return 'browser' }
if ($cl -like '*file-operations-collector.ps1*') { return 'fileops' }
if ($cl -like '*dlp-endpoint-signals-collector.ps1*') { return 'endpoint' }
if ($cl -like '*email-outbound-collector.ps1*') { return 'email' }
if ($cl -like '*worktime-session-collector.ps1*') { return 'worktime' }
return $null
}
$collectorProcs = Get-CimInstance Win32_Process |
Where-Object { $_.Name -eq 'powershell.exe' -and $_.CommandLine } |
ForEach-Object {
$key = Get-CollectorKey -CommandLine $_.CommandLine
if ($key) {
$groupKey = if ($key -eq 'worktime') { 'worktime::global' } else { '{0}::{1}' -f $key, ([int]$_.SessionId) }
[pscustomobject]@{
ProcessId = [int]$_.ProcessId
SessionId = [int]$_.SessionId
CreationDate = $_.CreationDate
CollectorKey = $key
GroupKey = $groupKey
}
}
} |
Where-Object { $_ -ne $null }
# Keep only one process per collector scope: worktime collector is global, others stay per-session.
foreach ($group in ($collectorProcs | Group-Object GroupKey)) {
$ordered = @($group.Group | Sort-Object CreationDate -Descending)
if ($ordered.Count -le 1) { continue }
foreach ($dup in $ordered | Select-Object -Skip 1) {
Stop-Process -Id $dup.ProcessId -Force -ErrorAction SilentlyContinue
}
}
# Force managed collectors/watchers to reload the freshly deployed scripts.
Get-CimInstance Win32_Process -ErrorAction SilentlyContinue |
Where-Object {
(
($_.Name -ieq 'powershell.exe' -or $_.Name -ieq 'pwsh.exe') -and
$_.CommandLine -match 'C:\\ProgramData\\AWatch-rus\\' -and
$_.CommandLine -match '(collector|launch-watchers|recovery-loop)\.ps1'
) -or
($_.Name -ieq 'aw-watcher-afk.exe') -or
($_.Name -ieq 'aw-watcher-window.exe')
} |
ForEach-Object {
Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue
}
Start-Sleep -Seconds 2
# Ensure tasks are enabled (some environments keep them disabled, causing "0s" in WebUI).
try {
Enable-ScheduledTask -TaskName "{{ aw_windows_recovery_task_name }}" -ErrorAction SilentlyContinue | Out-Null
} catch {}
$config = Get-Content -Raw -LiteralPath "{{ aw_windows_state_root }}\deployment-config.json" | ConvertFrom-Json
foreach ($taskDef in @($config.userTasks)) {
try { Enable-ScheduledTask -TaskName ([string]$taskDef.launchTaskName) -ErrorAction SilentlyContinue | Out-Null } catch {}
}
$recoveryTask = Get-ScheduledTask -TaskName "{{ aw_windows_recovery_task_name }}" -ErrorAction SilentlyContinue
if ($recoveryTask -and $recoveryTask.State -notin @('Running', 'Queued')) {
Start-ScheduledTask -TaskName "{{ aw_windows_recovery_task_name }}"
}
foreach ($taskDef in @($config.userTasks)) {
$launchTask = Get-ScheduledTask -TaskName ([string]$taskDef.launchTaskName) -ErrorAction SilentlyContinue
if ($launchTask -and $launchTask.State -notin @('Running', 'Queued')) {
Start-ScheduledTask -TaskName ([string]$taskDef.launchTaskName) -ErrorAction SilentlyContinue
}
}
- name: Получить Windows hostname для AW smoke-check bucket
when:
- aw_windows_api_smoke_check_enabled | bool
- aw_windows_afk_enabled | bool
ansible.windows.win_command: powershell.exe -NoProfile -Command "$env:COMPUTERNAME"
register: aw_windows_hostname_result
changed_when: false
- name: Вычислить AW AFK smoke-check bucket
- name: Вычислить AW worktime smoke-check bucket
when:
- aw_windows_api_smoke_check_enabled | bool
- aw_windows_afk_enabled | bool
- aw_windows_hostname_result.stdout is defined
ansible.builtin.set_fact:
aw_windows_api_smoke_check_bucket_effective: >-
{{
aw_windows_api_smoke_check_bucket
if (aw_windows_api_smoke_check_bucket | default('') | string | length) > 0
else 'aw-watcher-afk_' ~ (aw_windows_hostname_result.stdout | trim)
else 'aw-worktime-sessions_' ~ (aw_windows_hostname_result.stdout | trim)
}}
- name: Дождаться свежих AFK событий на AW server
- name: Вычислить AW Window smoke-check bucket
when:
- aw_windows_api_smoke_check_enabled | bool
- aw_windows_api_smoke_check_window_enabled_effective | bool
- aw_windows_window_enabled_effective | bool
- aw_windows_hostname_result.stdout is defined
ansible.builtin.set_fact:
aw_windows_api_smoke_check_window_bucket_effective: >-
{{
aw_windows_api_smoke_check_window_bucket
if (aw_windows_api_smoke_check_window_bucket | default('') | string | length) > 0
else 'aw-watcher-window_' ~ (aw_windows_hostname_result.stdout | trim)
}}
- name: Выполнить AW API smoke-check (worktime bucket должен получать события)
when:
- aw_windows_api_smoke_check_enabled | bool
- aw_windows_afk_enabled | bool
delegate_to: localhost
ansible.builtin.uri:
url: "{{ aw_windows_server_scheme }}://{{ aw_windows_server_host }}:{{ aw_windows_server_port }}/api/0/buckets/{{ aw_windows_api_smoke_check_bucket_effective }}/events?limit={{ aw_windows_api_smoke_check_limit }}"
method: GET
status_code: 200
return_content: true
register: aw_windows_api_smoke
until: >
aw_windows_api_smoke.status == 200 and
(aw_windows_api_smoke.json | length) > 0 and
(
aw_windows_api_smoke.json
| selectattr('data.status', 'equalto', 'not-afk')
register: aw_windows_api_smoke_result
failed_when: false
until: >-
(aw_windows_api_smoke_result.status | default(0)) == 200
and ((aw_windows_api_smoke_result.json | default([])) | length) >= aw_windows_api_smoke_check_min_events
and (
(aw_windows_api_smoke_result.json | default([]))
| selectattr('data.source', 'equalto', 'worktime-session-collector')
| list
| length
) > 0
retries: 10
delay: 6
retries: 12
delay: 5
ignore_errors: "{{ (not aw_windows_fail_on_validation_error | bool) }}"
delegate_to: localhost
changed_when: false
- name: Выполнить валидацию и сохранить отчёт на целевом Windows host
- name: Выполнить AW API smoke-check (Window bucket должен получать события)
when:
- aw_windows_api_smoke_check_enabled | bool
- aw_windows_api_smoke_check_window_enabled_effective | bool
- aw_windows_window_enabled_effective | bool
ansible.builtin.uri:
url: "{{ aw_windows_server_scheme }}://{{ aw_windows_server_host }}:{{ aw_windows_server_port }}/api/0/buckets/{{ aw_windows_api_smoke_check_window_bucket_effective }}/events?limit={{ aw_windows_api_smoke_check_limit }}"
method: GET
status_code: 200
return_content: true
register: aw_windows_api_smoke_window_result
failed_when: false
until: >-
(aw_windows_api_smoke_window_result.status | default(0)) == 200
and ((aw_windows_api_smoke_window_result.json | default([])) | length) >= aw_windows_api_smoke_check_min_events
retries: 12
delay: 5
ignore_errors: "{{ (not aw_windows_fail_on_validation_error | bool) }}"
delegate_to: localhost
changed_when: false
- name: Валидировать развёртывание на эндпоинте
ansible.windows.win_powershell:
script: |
$ErrorActionPreference = 'Stop'
$report = & "{{ aw_windows_deploy_root }}\windows\validate-deployment.ps1" `
$result = & "{{ aw_windows_deploy_root }}\windows\validate-deployment.ps1" `
-ConfigPath "{{ aw_windows_state_root }}\deployment-config.json"
$report | ConvertTo-Json -Depth 12 | Out-File -FilePath "{{ aw_windows_validation_remote_path }}" -Encoding utf8
if ({{ '$true' if (aw_windows_fail_on_validation_error | bool) else '$false' }} -and -not [bool]$report.overallOk) {
throw "Проверка развёртывания ActivityWatch завершилась ошибкой. Отчёт: {{ aw_windows_validation_remote_path }}"
}
$result | ConvertTo-Json -Depth 12 | Out-File -FilePath "{{ aw_windows_validation_remote_path }}" -Encoding utf8
return $result
- name: Создать локальный каталог для validation reports
- name: Создать локальную директорию для отчётов валидации
ansible.builtin.file:
path: "{{ aw_windows_validation_local_dir }}"
state: directory
mode: "0755"
delegate_to: localhost
- name: Забрать validation report
- name: Стянуть отчёт валидации с эндпоинта
ansible.builtin.fetch:
src: "{{ aw_windows_validation_remote_path }}"
dest: "{{ aw_windows_validation_local_dir }}/{{ inventory_hostname }}-aw_validate_ansible.json"
flat: true
- name: Показать путь к отчёту
ansible.builtin.debug:
msg:
- "Windows/RDP развёртывание завершено на {{ inventory_hostname }}."
- "Отчёт проверки: {{ aw_windows_validation_local_dir }}/{{ inventory_hostname }}-aw_validate_ansible.json"
- name: Проверить статус валидации
ansible.builtin.shell: |
python3 - <<'PY'
import json, sys
with open('{{ aw_windows_validation_local_dir }}/{{ inventory_hostname }}-aw_validate_ansible.json', 'r', encoding='utf-8-sig') as f:
data = json.load(f)
if not data.get('overallOk', False):
failed = ", ".join(data.get("summary", {}).get("failedSections", [])) or "unknown"
print(f"Validation failed for {{ inventory_hostname }}: {failed}")
sys.exit(1)
PY
delegate_to: localhost
when: aw_windows_fail_on_validation_error | bool
@@ -4,23 +4,69 @@ aw_server_bind_host: "0.0.0.0"
aw_server_port: 5600
aw_server_webui_dir: "/opt/activitywatch/webui-ru"
aw_server_data_dir: "/var/lib/activitywatch"
aw_server_db_path: "/var/lib/activitywatch/.local/share/activitywatch/aw-server-rust/sqlite.db"
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_worktime_timezone: "Europe/Moscow"
aw_worktime_influx_enabled: false
aw_worktime_influx_url: "http://10.10.10.10:8086"
aw_worktime_influx_org: "proxmox"
aw_worktime_influx_bucket: "aw_metrics"
aw_worktime_influx_hosts: "SHARKON2025"
aw_worktime_influx_days: "today,yesterday"
aw_worktime_influx_token: ""
aw_dlp_influx_enabled: false
aw_dlp_influx_url: "http://10.10.10.10:8086"
aw_dlp_influx_org: "proxmox"
aw_dlp_influx_bucket: "aw_metrics"
aw_dlp_influx_hosts: "SHARKON2025"
aw_dlp_influx_lookback_days: 30
aw_dlp_influx_event_limit: 2000
aw_dlp_influx_token: ""
aw_monitored_windows_host: "192.168.100.18"
aw_monitored_windows_hostname: "SHARKON2025"
aw_rus_health_state_dir: "{{ aw_server_data_dir }}/health"
aw_rus_health_validation_dir: "{{ aw_rus_health_state_dir }}/windows-validation"
aw_repo_root: "{{ playbook_dir | dirname }}"
# Опционально: применить базовые категории и views для рабочего времени через AW settings API.
# Внимание: это перезаписывает существующие server-side settings/classes/views.
aw_apply_worktime_settings: false
# Применить базовые категории и views для рабочего времени через AW settings API.
# При прод-обновлениях это нужно оставлять включённым, иначе UI остаётся без views/classes.
aw_apply_worktime_settings: true
# Дополнительные origin для aw-server-rust CORS.
# Обязательно включите тот origin, с которого реально открывается Web UI.
aw_server_cors_origins:
- "http://127.0.0.1:5600"
- "http://localhost:5600"
- "http://10.10.10.13:5600"
- "http://aw-server:5600"
# Опциональные значения периода рабочего времени в Web UI.
# startOfDay задаёт границу дня и стартовое время окна отчёта.
# durationDefault задаёт диапазон по умолчанию в секундах.
#
# Рекомендуется явно задать рабочий интервал и дать playbook вычислить duration.
aw_worktime_from: "08:00"
aw_worktime_from: "00:00"
aw_worktime_to: "17:00"
aw_worktime_start_of_day: "{{ aw_worktime_from }}"
aw_server_always_active_pattern: "aw-watcher-window"
aw_server_landingpage: "/activity/SHARKON2025/view/"
aw_health_strict_fileops: 0
aw_dlp_policy_engine_enabled: true
aw_dlp_policy_engine_bind_host: "0.0.0.0"
aw_dlp_policy_engine_port: 5601
aw_dlp_policy_engine_db_path: "{{ aw_server_data_dir }}/dlp-policy-engine.sqlite"
aw_dlp_content_analysis_enabled: true
aw_dlp_integrations_enabled: true
aw_dlp_case_management_enabled: true
aw_dlp_case_bind_host: "0.0.0.0"
aw_dlp_case_port: 5602
aw_dlp_case_db_path: "/opt/activitywatch/dlp-case-management/cases.db"
aw_dlp_compliance_enabled: true
aw_dlp_compliance_report_dir: "/opt/activitywatch/dlp-compliance/reports"
aw_dlp_compliance_template_path: "/opt/activitywatch/dlp-compliance/templates/152-fz-report.html"
aw_server_post_deploy_health_check_enabled: true
@@ -8,6 +8,7 @@ aw_windows_package_url: "https://github.com/ActivityWatch/activitywatch/releases
aw_windows_package_zip_path: ""
aw_windows_domain: "SHARKON2025"
aw_windows_users:
- Администратор
- user1
- user2
- user3
@@ -21,12 +22,23 @@ aw_windows_extra_users: []
# Единые Windows/RDP пути: те же, что использует InnoSetup.
aw_windows_install_root: "C:\\Program Files\\AWatch-rus\\bin"
aw_windows_state_root: "C:\\ProgramData\\AWatch-rus"
aw_windows_hostname_override: "" # Например: SHARKON2025
aw_windows_afk_enabled: true
aw_windows_window_enabled: true
aw_windows_file_ops_enabled: true
aw_windows_local_agent_logs_enabled: false
aw_windows_incident_capture_enabled: true
aw_windows_incident_screenshot_enabled: true
aw_windows_incident_artifacts_root: "{{ aw_windows_state_root }}\\incident-artifacts"
aw_windows_forensics_root: "{{ aw_windows_state_root }}\\forensics\\evtx-exports"
aw_windows_evtx_retention_days: 14
aw_windows_evtx_channels:
- Security
- System
- Application
- Microsoft-Windows-PowerShell/Operational
- Microsoft-Windows-TerminalServices-LocalSessionManager/Operational
- Microsoft-Windows-TerminalServices-RemoteConnectionManager/Operational
aw_windows_logon_marker_enabled: true
aw_windows_skip_hardening: false
@@ -34,7 +46,7 @@ aw_windows_rules_path: "{{ aw_windows_deploy_root }}\\windows\\web-category-rule
aw_windows_policy_path: "{{ aw_windows_deploy_root }}\\windows\\dlp-policy.example.json"
aw_windows_validation_remote_path: "{{ aw_windows_state_root }}\\aw_validate_ansible.json"
aw_windows_validation_local_dir: "/tmp/aw-rus-validation"
aw_windows_validation_local_dir: "/tmp/aw-rus-validation-{{ lookup('env','USER') | default('ansible', true) }}"
aw_windows_fail_on_validation_error: true
# Безопасная миграция текущего прода со старых путей в единый профиль AWatch-rus.
@@ -5,6 +5,7 @@
# 2) развёртывание AW server на хостах [aw_server]
# 3) развёртывание Windows/RDP collector'ов на [aw_windows]
# 4) развёртывание pfSense poller'а на [aw_pfsense_pollers]
# 5) импорт Grafana dashboard'ов на [grafana]
#
# Примечания:
# - Заполняйте только нужные группы inventory для своего окружения.
@@ -14,3 +15,4 @@
- import_playbook: deploy_aw_server.yml
- import_playbook: deploy_aw_windows.yml
- import_playbook: deploy_aw_pfsense_poller.yml
- import_playbook: deploy_grafana_dashboards.yml
@@ -6,7 +6,12 @@ aw-ct ansible_host=10.20.30.13 ansible_user=root ansible_port=22
[aw_windows]
# Примечание: в русифицированных Windows часто нужен "Администратор", а не "Administrator".
win-node1 ansible_host=192.168.100.21 ansible_user=Администратор ansible_password=CHANGE_ME ansible_connection=winrm ansible_winrm_transport=ntlm ansible_port=5985 ansible_winrm_server_cert_validation=ignore
win-node1 ansible_host=192.168.100.18 ansible_user=Администратор ansible_password=CHANGE_ME ansible_connection=winrm ansible_winrm_transport=ntlm ansible_port=5985 ansible_winrm_server_cert_validation=ignore
[aw_pfsense_pollers]
# pfsense-poller1 ansible_host=192.168.100.30 ansible_user=root ansible_port=22
[grafana]
# Для API-import playbook достаточно указать grafana_url.
# SSH-подключение не требуется: playbook работает через HTTP API с control host.
grafana-main grafana_url=http://10.20.30.11:3000
@@ -11,6 +11,9 @@
- activitywatch-server.service
- aw-worktime-api.py
- aw-worktime-api.service
- aw-worktime-ui-bridge.py
- aw-worktime-ui-bridge.service
- aw-worktime-ui-bridge.timer
- aw-worktime-panel.js
- aw-server.env.example
- aw-ru-patch.js
@@ -11,6 +11,9 @@
- activitywatch-server.service
- aw-worktime-api.py
- aw-worktime-api.service
- aw-worktime-ui-bridge.py
- aw-worktime-ui-bridge.service
- aw-worktime-ui-bridge.timer
- aw-worktime-panel.js
- aw-server.env.example
- aw-ru-patch.js
@@ -159,6 +159,7 @@
AW_SERVER_LOG_DIR={{ aw_server_log_dir }}
AW_SERVER_USER={{ aw_server_user }}
AW_SERVER_GROUP={{ aw_server_group }}
AW_SERVER_URL=http://127.0.0.1:{{ aw_server_port }}
AW_WORKTIME_REPORT_BASE={{ aw_worktime_report_base }}
AW_WORKTIME_TZ={{ aw_worktime_timezone }}
no_log: true
@@ -9,7 +9,7 @@ EnvironmentFile=/etc/activitywatch/aw-server.env
User=__AW_SERVER_USER__
Group=__AW_SERVER_GROUP__
WorkingDirectory=__AW_SERVER_DATA_DIR__
ExecStart=/bin/sh -lc 'exec /opt/activitywatch/bin/aw-server-rust --host "$AW_SERVER_BIND_HOST" --port "$AW_SERVER_PORT"'
ExecStart=/bin/sh -lc 'exec /opt/activitywatch/bin/aw-server-rust --host "$AW_SERVER_BIND_HOST" --port "$AW_SERVER_PORT" --dbpath "$AW_SERVER_DB_PATH" --webpath "$AW_SERVER_WEBUI_DIR"'
Restart=on-failure
RestartSec=5s
StateDirectory=activitywatch
@@ -17,7 +17,7 @@ LogsDirectory=activitywatch
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ProtectHome=true
ProtectHome=read-only
LimitNOFILE=65535
[Install]
@@ -11,6 +11,8 @@ 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}"
CASE_PORT="${AW_DLP_CASE_PORT:-5602}"
CASE_BASE="${AW_DLP_CASE_PUBLIC_BASE:-}"
PATCH_JS_SRC="/root/bootstrap/aw-ru-patch.js"
SW_CLEANUP_SRC="/root/bootstrap/aw-sw-cleanup.js"
WORKTIME_PANEL_SRC="/root/bootstrap/aw-worktime-panel.js"
@@ -35,6 +37,17 @@ CATEGORY_HELPER_REPLACEMENT='hostname:t.hostnameChoices.filter((function(t){retu
[[ -f "$HOST_GROUPS_SRC" ]] || { echo "missing $HOST_GROUPS_SRC" >&2; exit 1; }
[[ -f "$INDEX_HTML" ]] || { echo "missing $INDEX_HTML" >&2; exit 1; }
if [[ ! -s "$INDEX_HTML" ]]; then
latest_nonempty_backup="$(find "$WEBUI_DIR" -maxdepth 1 -type f -name 'index.html.bak.*' -size +0c | sort | tail -n 1 || true)"
if [[ -n "$latest_nonempty_backup" ]]; then
cp "$latest_nonempty_backup" "$INDEX_HTML"
echo "restored empty index.html from backup: $latest_nonempty_backup"
else
echo "index.html is empty and no non-empty backup exists: $INDEX_HTML" >&2
exit 1
fi
fi
install -d "$WEBUI_DIR/js"
install -m 0644 "$PATCH_JS_SRC" "$PATCH_TARGET"
install -m 0644 "$SW_CLEANUP_SRC" "$SW_TARGET"
@@ -46,6 +59,21 @@ patch_hash="$(sha1sum "$PATCH_TARGET" | awk '{print substr($1,1,12)}')"
sw_hash="$(sha1sum "$SW_TARGET" | awk '{print substr($1,1,12)}')"
worktime_panel_hash="$(sha1sum "$WORKTIME_PANEL_TARGET" | awk '{print substr($1,1,12)}')"
if [[ -z "$CASE_BASE" ]]; then
CASE_BASE="$(python3 - "$REPORT_BASE" "$CASE_PORT" <<'PY'
from urllib.parse import urlsplit, urlunsplit
import sys
report_base = sys.argv[1]
case_port = sys.argv[2]
parts = urlsplit(report_base)
hostname = parts.hostname or "10.10.10.13"
scheme = parts.scheme or "http"
print(urlunsplit((scheme, f"{hostname}:{case_port}", "", "", "")))
PY
)"
fi
python3 - "$WORKTIME_PANEL_TARGET" "$REPORT_BASE" <<'PY'
from pathlib import Path
import sys
@@ -57,16 +85,18 @@ text = text.replace("__AW_WORKTIME_REPORT_BASE__", report_base)
path.write_text(text)
PY
python3 - "$INDEX_HTML" "$sw_hash" "$patch_hash" "$worktime_panel_hash" "$REPORT_BASE" <<'PY'
python3 - "$INDEX_HTML" "$sw_hash" "$patch_hash" "$worktime_panel_hash" "$REPORT_BASE" "$CASE_BASE" <<'PY'
from pathlib import Path
import re
import sys
from urllib.parse import urlsplit
path = Path(sys.argv[1])
sw_hash = sys.argv[2]
patch_hash = sys.argv[3]
panel_hash = sys.argv[4]
report_base = sys.argv[5]
case_base = sys.argv[6]
content = path.read_text()
content = re.sub(
@@ -74,23 +104,33 @@ content = re.sub(
'',
content,
)
content = re.sub(r"; frame-src 'self' [^\";>]*", "", content)
content = content.replace(
"script-src 'self' 'unsafe-eval'",
f"script-src 'self' 'unsafe-eval'; frame-src 'self' {report_base}",
1,
content = re.sub(r";\s*frame-src 'self' [^\";>]*", "", content)
content = re.sub(r";\s*connect-src 'self' [^\";>]*", "", content)
report_origin = urlsplit(report_base)
case_origin = urlsplit(case_base)
connect_targets = " ".join(
[
f"{report_origin.scheme}://{report_origin.netloc}",
f"{case_origin.scheme}://{case_origin.netloc}",
]
)
content = re.sub(
r"script-src 'self' 'unsafe-eval'(?:;\s*connect-src 'self' [^\";>]*)?(?:;\s*frame-src 'self' [^\";>]*)?",
f"script-src 'self' 'unsafe-eval'; connect-src 'self' {connect_targets}; frame-src 'self' {report_base}",
content,
count=1,
)
content = content.replace(
"</head>",
f'<script src="/js/sw-cleanup.js?v={sw_hash}"></script></head>',
(
f'<script src="/js/sw-cleanup.js?v={sw_hash}"></script>'
f'<script src="/js/ru-patch-v5.js?v={patch_hash}"></script></head>'
),
1,
)
content = content.replace(
"</body>",
(
f'<script defer="defer" src="/js/ru-patch-v5.js?v={patch_hash}"></script>'
f'<script defer="defer" src="/js/aw-worktime-panel.js?v={panel_hash}"></script></body>'
),
f'<script defer="defer" src="/js/aw-worktime-panel.js?v={panel_hash}"></script></body>',
1,
)
if 'id="aw-report-links"' not in content:
@@ -103,6 +143,11 @@ path.write_text(content)
PY
cp "$SW_CLEANUP_SRC" "$SERVICE_WORKER"
if [[ ! -s "$INDEX_HTML" ]]; then
echo "index.html became empty after RU patch: $INDEX_HTML" >&2
exit 1
fi
trends_chunk="$(grep -Rsl "$TRENDS_NEEDLE" "$WEBUI_DIR/js"/*.js 2>/dev/null | head -n 1 || true)"
if [[ -n "$trends_chunk" ]]; then
cp "$trends_chunk" "$trends_chunk.bak.$TS"
@@ -29,6 +29,21 @@
{ "label": "DLP", "type": "bucket", "bucket_prefix": "aw-dlp-endpoint-signals_" }
]
},
{
"id": "linux-remote",
"name": "Linux remote workers",
"description": "Linux-хосты удалённых сотрудников: GUI активность, SSH/console и browser admin UI.",
"patterns": [
"^(LINUX-WS|LINUX-DESKTOP|LX-|DESKTOP-|ADMIN-|WORKSTATION-|DEVBOX-)"
],
"links": [
{ "label": "Активность", "type": "activity" },
{ "label": "SSH сессии", "type": "bucket", "bucket_prefix": "aw-ssh-sessions_" },
{ "label": "Команды shell", "type": "bucket", "bucket_prefix": "aw-console-commands_" },
{ "label": "Web категории", "type": "bucket", "bucket_prefix": "aw-detmir-web-category_" },
{ "label": "Все бакеты", "type": "buckets" }
]
},
{
"id": "virtual-infra",
"name": "Virtual servers + Proxmox",
@@ -1,6 +1,10 @@
(function () {
window.__awRuPatchVersion = "template-v12-activity-heading-ru";
document.documentElement.setAttribute("data-aw-ru-patch", "template-v12-activity-heading-ru");
if (window.__awRuPatchBootstrapped) {
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");
const exact = new Map([
["ActivityWatch", "АктивВотч"],
@@ -370,25 +374,43 @@
return /^pve[-_]/i.test(String(host || ""));
}
function isLikelyClientHost(host) {
const value = String(host || "").trim();
if (!value) return false;
if (/^(?:unknown|undefined|null)$/i.test(value)) return false;
if (/^(?:localhost|127\.0\.0\.1|0\.0\.0\.0|::1)$/i.test(value)) return false;
if (/^(?:\d{1,3}\.){3}\d{1,3}$/.test(value)) return false;
if (value.indexOf(":") !== -1 && /^[0-9a-f:\[\]]+$/i.test(value)) return false;
return true;
}
function enforceSafeActivityViewForPveHost() {
const hash = window.location.hash || "";
const match = hash.match(/^#\/activity\/([^/]+)\/day\/([^/]+)\/view\/([^/?#]+)/i);
const match = hash.match(/^#\/activity\/([^/]+)(?:\/day\/([^/]+))?\/view\/([^/?#]+)/i);
if (!match) return;
const host = decodeURIComponent(match[1] || "");
const day = decodeURIComponent(match[2] || "");
const day = match[2] ? decodeURIComponent(match[2]) : "";
const viewId = decodeURIComponent(match[3] || "");
if (!isPveLikeHost(host)) return;
const safeHash = "#/activity/" + encodeURIComponent(host) + "/day/" + encodeURIComponent(day) + "/view/" + encodeURIComponent("pve_audit");
if (safeHash !== hash && !/^pve_audit$/i.test(viewId)) {
window.location.replace(safeHash);
const prefix = day
? "#/activity/" + encodeURIComponent(host) + "/day/" + encodeURIComponent(day) + "/view/"
: "#/activity/" + encodeURIComponent(host) + "/view/";
if (isPveLikeHost(host)) {
const safeHash = prefix + encodeURIComponent("pve_audit");
if (safeHash !== hash && !/^pve_audit$/i.test(viewId)) {
window.location.replace(safeHash);
}
return;
}
if (/^pve_audit$/i.test(viewId)) {
window.location.replace(prefix + encodeURIComponent("summary"));
}
}
function getDlpHostFromSettings(settings) {
const routeHost = getCurrentHostFromHash();
if (routeHost) return routeHost;
if (isLikelyClientHost(routeHost)) return routeHost;
const bucketHost = getDlpHostFromBucketId(getDlpBucketIdFromHash());
if (bucketHost) return bucketHost;
if (isLikelyClientHost(bucketHost)) return bucketHost;
return getTrendsHostFromSettings(settings);
}
@@ -680,6 +702,19 @@
{ label: "DLP", type: "bucket", bucket_prefix: "aw-dlp-endpoint-signals_" }
]
},
{
id: "linux-remote",
name: "Linux remote workers",
description: "Linux-хосты удалённых сотрудников: GUI активность, SSH/console и browser admin UI.",
patterns: ["^(LINUX-WS|LINUX-DESKTOP|LX-|DESKTOP-|ADMIN-|WORKSTATION-|DEVBOX-)"],
links: [
{ label: "Активность", type: "activity" },
{ label: "SSH сессии", type: "bucket", bucket_prefix: "aw-ssh-sessions_" },
{ label: "Команды shell", type: "bucket", bucket_prefix: "aw-console-commands_" },
{ label: "Web категории", type: "bucket", bucket_prefix: "aw-detmir-web-category_" },
{ label: "Все бакеты", type: "buckets" }
]
},
{
id: "virtual-infra",
name: "Virtual servers + Proxmox",
@@ -740,7 +775,15 @@
const prefixes = [
"aw-watcher-window_",
"aw-watcher-afk_",
"aw-console-commands_",
"aw-ssh-sessions_",
"aw-linux-web-context_",
"aw-detmir-web-category_",
"aw-dlp-endpoint-signals_",
"aw-session-events_",
"aw-worktime-sessions_",
"aw-pve-webadmin-events_",
"aw-pve-task-events_",
"aw-dlp-incidents_",
"aw-pfsense-health_",
"aw-pfsense-gateways_",
@@ -770,7 +813,27 @@
return result;
}
function matchHostGroup(host, groups) {
function hostHasBucketPrefix(hostBuckets, prefix) {
return (hostBuckets || []).some(function (bucketId) {
return String(bucketId || "").indexOf(prefix) === 0;
});
}
function matchHostGroup(host, groups, hostBuckets) {
const bucketList = hostBuckets || [];
if (hostHasBucketPrefix(bucketList, "aw-dlp-endpoint-signals_") || hostHasBucketPrefix(bucketList, "aw-session-events_")) {
return "windows-rdp";
}
if (
hostHasBucketPrefix(bucketList, "aw-console-commands_") ||
hostHasBucketPrefix(bucketList, "aw-ssh-sessions_") ||
hostHasBucketPrefix(bucketList, "aw-linux-web-context_") ||
hostHasBucketPrefix(bucketList, "aw-detmir-web-category_")
) {
if (!hostHasBucketPrefix(bucketList, "aw-pve-webadmin-events_") && !hostHasBucketPrefix(bucketList, "aw-pve-task-events_")) {
return "linux-remote";
}
}
for (const group of groups) {
const patterns = Array.isArray(group.patterns) ? group.patterns : [];
for (const pattern of patterns) {
@@ -813,7 +876,7 @@
grouped.set("__ungrouped__", []);
Array.from(hostBuckets.keys()).sort().forEach(function (host) {
const groupId = matchHostGroup(host, groups) || "__ungrouped__";
const groupId = matchHostGroup(host, groups, hostBuckets.get(host) || []) || "__ungrouped__";
grouped.get(groupId).push(host);
});
@@ -869,7 +932,7 @@
center.setAttribute("data-aw-ru-host-groups", "1");
center.innerHTML =
'<h4>Разделы хостов</h4>' +
'<p>Здесь хосты разделены на пользовательские Windows RDP и инфраструктурные виртуальные серверы/Proxmox.</p>' +
'<p>Здесь хосты разделены на Windows RDP, Linux remote workers и инфраструктурные узлы.</p>' +
'<div class="aw-ru-host-groups-grid" data-aw-ru-host-groups-grid><section class="aw-ru-host-group-card"><p>Загрузка...</p></section></div>';
heading.parentElement.insertBefore(center, heading.nextSibling);
}
@@ -885,9 +948,10 @@
const hideSuppressed = center.querySelector("[data-aw-ru-hide-suppressed]") && center.querySelector("[data-aw-ru-hide-suppressed]").checked;
const rows = [];
for (const event of state.events) {
const data = event.data || {};
if (String(data.signalType || "").toLowerCase() === "self_test") continue;
const matchedRule = state.activeRules.find(function (rule) { return ruleMatchesEvent(rule, event); }) || null;
if (hideSuppressed && matchedRule) continue;
const data = event.data || {};
const eventKey = buildDlpKey(event);
rows.push(
'<tr class="aw-ru-dlp-row' + (matchedRule ? ' aw-ru-dlp-muted' : '') + '" data-aw-ru-dlp-key="' + escapeHtml(eventKey) + '">' +
@@ -907,6 +971,7 @@
'<td class="aw-ru-dlp-actions">' +
'<button type="button" data-aw-ru-save-review>Сохранить</button>' +
'<button type="button" data-aw-ru-save-rule>Правило</button>' +
'<button type="button" data-aw-ru-create-case>Кейс</button>' +
"</td>" +
"</tr>"
);
@@ -977,6 +1042,58 @@
}, 1);
}
function getCaseApiBase() {
if (window.__awCaseApiBase && typeof window.__awCaseApiBase === "string") {
return window.__awCaseApiBase.replace(/\/+$/, "");
}
try {
const origin = window.location.origin || "";
if (/:\d+$/.test(origin)) return origin.replace(/:\d+$/, ":5602");
return origin + ":5602";
} catch (error) {
return "http://127.0.0.1:5602";
}
}
async function caseApi(path, init) {
const response = await fetch(getCaseApiBase() + path, Object.assign({ credentials: "omit" }, init || {}));
if (!response.ok) throw new Error("Case API HTTP " + response.status);
if (response.status === 204) return null;
return response.json();
}
async function createCaseFromEvent(host, event, row) {
const data = event.data || {};
if (String(data.signalType || "").toLowerCase() === "self_test") {
throw new Error("self_test не должен превращаться в кейс");
}
const verdict = row.querySelector("[data-aw-ru-dlp-verdict]").value;
const category = row.querySelector("[data-aw-ru-dlp-category]").value.trim();
const comment = row.querySelector("[data-aw-ru-dlp-comment]").value.trim();
const incidentId = buildDlpKey(event);
const title = "DLP " + (data.signalType || "incident") + " · " + (data.username || data.owner || host || "unknown");
return caseApi("/api/0/dlp/cases", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
incident_id: incidentId,
host: host,
title: title,
severity: verdict === "incident" ? "high" : "medium",
source_bucket: getDlpBucketIdFromHash(),
source_event_ts: event.timestamp,
evidence: {
signalType: data.signalType || "",
username: data.username || data.owner || "",
documentName: data.documentName || "",
printerName: data.printerName || "",
category: category,
comment: comment
}
})
});
}
async function saveDlpRule(host, event, row) {
const bucketId = "aw-dlp-rules_" + host;
await ensureAwBucket(bucketId, "aw-dlp-rules", "aw.dlp.rule", host);
@@ -1030,9 +1147,57 @@
message.textContent = "Ошибка сохранения правила: " + error.message;
}
});
row.querySelector("[data-aw-ru-create-case]").addEventListener("click", async function () {
const message = center.querySelector("[data-aw-ru-dlp-message]");
try {
const created = await createCaseFromEvent(host, event, row);
await renderCaseManager(center, host);
message.textContent = "Кейс создан: #" + (created && created.id ? created.id : "?");
} catch (error) {
message.textContent = "Ошибка создания кейса: " + error.message;
}
});
});
}
async function renderCaseManager(center, host) {
const tbody = center.querySelector("[data-aw-ru-dlp-cases]");
if (!tbody) return;
try {
const cases = await caseApi("/api/0/dlp/cases?host=" + encodeURIComponent(host) + "&limit=100", { method: "GET" });
function renderCaseDfir(c) {
const hayabusa = c && c.forensics && c.forensics.hayabusa;
if (!hayabusa) return "";
const status = String(hayabusa.status || "");
const mode = String(hayabusa.mode || "");
const reportDir = String(hayabusa.report_dir || "");
const title = reportDir ? ' title="' + escapeHtml(reportDir) + '"' : "";
return '<span' + title + '>Hayabusa ' + escapeHtml(status) + (mode ? " · " + escapeHtml(mode) : "") + '</span>';
}
const rows = (cases || []).map(function (c) {
return (
"<tr>" +
"<td>" + escapeHtml(String(c.id || "")) + "</td>" +
"<td>" + escapeHtml(String(c.status || "")) + "</td>" +
"<td>" + escapeHtml(String(c.severity || "")) + "</td>" +
"<td>" + escapeHtml(String(c.title || "")) + "</td>" +
"<td>" + escapeHtml(String(c.assignee || "")) + "</td>" +
"<td>" + escapeHtml(String(c.incident_id || "")) + "</td>" +
"<td>" + renderCaseDfir(c) + "</td>" +
"<td>" + escapeHtml(String(c.updated_at || c.created_at || "")) + "</td>" +
"</tr>"
);
});
tbody.innerHTML = rows.length ? rows.join("") : '<tr><td colspan="8">Кейсов нет.</td></tr>';
const status = center.querySelector("[data-aw-ru-dlp-cases-status]");
if (status) status.textContent = "Кейсов: " + (cases || []).length;
} catch (error) {
tbody.innerHTML = '<tr><td colspan="8">Ошибка загрузки кейсов: ' + escapeHtml(error.message) + '</td></tr>';
const status = center.querySelector("[data-aw-ru-dlp-cases-status]");
if (status) status.textContent = "Кейсы недоступны";
}
}
async function setDlpRuleEnabled(host, ruleEvent, enabled) {
const bucketId = "aw-dlp-rules_" + host;
await ensureAwBucket(bucketId, "aw-dlp-rules", "aw.dlp.rule", host);
@@ -1187,6 +1352,7 @@
state.reviews = [];
}
renderDlpTableRows(center, host);
await renderCaseManager(center, host);
center.querySelector("[data-aw-ru-dlp-message]").textContent = "DLP review центр обновлен.";
} catch (error) {
center.querySelector("[data-aw-ru-dlp-message]").textContent = "Ошибка загрузки DLP-событий: " + error.message;
@@ -1242,6 +1408,16 @@
'<tbody data-aw-ru-dlp-reviews><tr><td colspan="7">Загрузка...</td></tr></tbody>' +
'</table>' +
'</div>' +
'<div class="aw-ru-dlp-section">' +
'<div class="aw-ru-dlp-toolbar">' +
'<h5>Case Management</h5>' +
'<div class="aw-ru-dlp-status" data-aw-ru-dlp-cases-status>Кейсов: 0</div>' +
'</div>' +
'<table class="aw-ru-dlp-table">' +
'<thead><tr><th>ID</th><th>Статус</th><th>Severity</th><th>Заголовок</th><th>Исполнитель</th><th>Incident ID</th><th>DFIR</th><th>Обновлено</th></tr></thead>' +
'<tbody data-aw-ru-dlp-cases><tr><td colspan="8">Загрузка...</td></tr></tbody>' +
'</table>' +
'</div>' +
'<div class="aw-ru-dlp-message" data-aw-ru-dlp-message></div>';
heading.parentElement.insertBefore(center, heading.nextSibling);
center.querySelector("[data-aw-ru-refresh-dlp]").addEventListener("click", function () {
@@ -1378,6 +1554,16 @@
}
}
function hidePveAuditTabForRegularHost(root) {
const hash = window.location.hash || "";
const match = hash.match(/^#\/activity\/([^/]+)/i);
const host = match && match[1] ? decodeURIComponent(match[1]) : "";
if (!host || isPveLikeHost(host)) return;
Array.from(root.querySelectorAll('a[href*="/view/pve_audit"]')).forEach(function (link) {
link.style.display = "none";
});
}
function injectDlpAlertsCenter(root) {
if (!isAlertsRoute()) return;
const host = window.__awRuPatchSettingsHost || getCurrentHostFromHash();
@@ -1414,18 +1600,27 @@
center.setAttribute("data-aw-ru-loaded", "1");
refreshDlpAlertsCenter(center, host);
}
Array.from(heading.parentElement.children).forEach(function (child) {
if (child === heading || child === center) return;
child.style.display = "none";
});
}
let trendsRedirectInFlight = false;
let settingsHostFetchInFlight = false;
let applyPatchScheduled = false;
let networkPatchesInstalled = false;
let dlpOverlayFailureCount = 0;
let applyPatchInFlight = false;
let observerAttached = false;
let staticPatchRouteKey = "";
function getTrendsHostFromSettings(settings) {
if (!settings || typeof settings !== "object") return "";
const landingpage = typeof settings.landingpage === "string" ? settings.landingpage : "";
const match = landingpage.match(/\/activity\/([^/]+)/);
return match && match[1] ? match[1] : "";
const host = match && match[1] ? decodeURIComponent(match[1]) : "";
return isLikelyClientHost(host) ? host : "";
}
function getTrendsPath(hash) {
@@ -1477,6 +1672,9 @@
.finally(function () {
settingsHostFetchInFlight = false;
injectDlpNavigation(document.body);
if (isAlertsRoute()) {
scheduleApplyPatch();
}
});
}
@@ -1492,14 +1690,25 @@
.map(function (bucketId) { return bucketId.replace(/^aw-watcher-window_/i, ""); })
.filter(Boolean)
.filter(function (host) { return !/^unknown$/i.test(host); });
if (settingsHost && hosts.indexOf(settingsHost) >= 0) return settingsHost;
if (settingsHost) return settingsHost;
if (isLikelyClientHost(settingsHost) && hosts.indexOf(settingsHost) >= 0) return settingsHost;
if (isLikelyClientHost(settingsHost) && !hosts.length) return settingsHost;
hosts.sort();
return hosts[0] || "";
}
function rewriteUnknownCategoryBuilderQueryBody(body) {
if (typeof body !== "string") return body;
function stripUnknownBucketTokens(raw) {
return raw
.replace(/aw-watcher-window_unknown/gi, "__AW_RU_UNKNOWN_WINDOW__")
.replace(/aw-watcher-afk_unknown/gi, "__AW_RU_UNKNOWN_AFK__")
.replace(/find_bucket\((\\?["'])__AW_RU_UNKNOWN_WINDOW__(\\?["'])\)/gi, "[]")
.replace(/find_bucket\((\\?["'])__AW_RU_UNKNOWN_AFK__(\\?["'])\)/gi, "[]")
.replace(/query_bucket\((\\?["'])__AW_RU_UNKNOWN_WINDOW__(\\?["'])\)/gi, "[]")
.replace(/query_bucket\((\\?["'])__AW_RU_UNKNOWN_AFK__(\\?["'])\)/gi, "[]")
.replace(/__AW_RU_UNKNOWN_WINDOW__/g, "")
.replace(/__AW_RU_UNKNOWN_AFK__/g, "");
}
function stripUnknownBucketQueries(raw) {
return raw
.replace(/flood\(query_bucket\(find_bucket\(\\"aw-watcher-window_unknown\\"\)\)\)/g, '[]')
@@ -1534,6 +1743,10 @@
body = stripUnknownBucketQueries(body);
}
}
if (body.indexOf("aw-watcher-window_unknown") !== -1 || body.indexOf("aw-watcher-afk_unknown") !== -1) {
body = stripUnknownBucketQueries(body);
body = stripUnknownBucketTokens(body);
}
return body;
}
@@ -1541,20 +1754,25 @@
if (networkPatchesInstalled) return;
networkPatchesInstalled = true;
const originalFetch = window.fetch ? window.fetch.bind(window) : null;
if (originalFetch) {
window.fetch = function (input, init) {
const originalFetch = window.fetch;
if (typeof originalFetch === "function" && !originalFetch.__awRuCategoryBuilderPatched) {
const patchedFetch = function (input, init) {
let nextInput = input;
let nextInit = init;
try {
const url = typeof input === "string" ? input : String(input && input.url || "");
if (/\/api\/0\/query\/?$/i.test(url) && init && typeof init.body === "string") {
init = Object.assign({}, init, {
body: rewriteUnknownCategoryBuilderQueryBody(init.body)
const url = typeof nextInput === "string" ? nextInput : String(nextInput && nextInput.url || "");
if (/\/api\/0\/query\/?$/i.test(url) && nextInit && typeof nextInit.body === "string") {
nextInit = Object.assign({}, nextInit, {
body: rewriteUnknownCategoryBuilderQueryBody(nextInit.body)
});
}
} catch (error) {
}
return originalFetch(input, init);
return originalFetch.call(this, nextInput, nextInit);
};
patchedFetch.__awRuCategoryBuilderPatched = true;
patchedFetch.__awRuOriginalFetch = originalFetch;
window.fetch = patchedFetch;
}
if (window.XMLHttpRequest && window.XMLHttpRequest.prototype) {
@@ -1599,6 +1817,45 @@
});
}
function normalizeCategoryBuilderUnknownHostRefs() {
const hash = window.location.hash || "";
if (!/^#\/settings\/category-builder(?:[/?#]|$)/i.test(hash)) return;
const preferredHost = getPreferredWindowHostFromBuckets();
if (!preferredHost) return;
const nextHash = hash
.replace(/aw-watcher-window_unknown/gi, "aw-watcher-window_" + preferredHost)
.replace(/aw-watcher-afk_unknown/gi, "aw-watcher-afk_" + preferredHost);
if (nextHash !== hash) {
window.location.replace(nextHash);
return;
}
try {
for (let i = 0; i < window.localStorage.length; i += 1) {
const key = window.localStorage.key(i);
if (!key) continue;
const value = window.localStorage.getItem(key);
if (!value || (value.indexOf("aw-watcher-window_unknown") === -1 && value.indexOf("aw-watcher-afk_unknown") === -1)) continue;
window.localStorage.setItem(
key,
value
.replace(/aw-watcher-window_unknown/gi, "aw-watcher-window_" + preferredHost)
.replace(/aw-watcher-afk_unknown/gi, "aw-watcher-afk_" + preferredHost)
);
}
} catch (error) {
}
}
function primeCategoryBuilderEarlyFix() {
const hash = window.location.hash || "";
if (!/^#\/settings\/category-builder(?:[/?#]|$)/i.test(hash)) return;
ensureSettingsHost();
ensureHostGroupsData().catch(function () {});
normalizeCategoryBuilderUnknownHostRefs();
}
function patchActivityHeading(root) {
const heading = root.querySelector("h3");
if (!heading) return;
@@ -1611,27 +1868,64 @@
});
}
function detachObserver() {
if (!observerAttached) return;
observer.disconnect();
observerAttached = false;
}
function attachObserver() {
if (observerAttached || !document.body) return;
observer.observe(document.body, { childList: true, subtree: true });
observerAttached = true;
}
function applyPatch() {
enforceSafeActivityViewForPveHost();
ensureSettingsHost();
ensureHostGroupsData().catch(function () {});
installCategoryBuilderNetworkPatch();
injectStyles();
walk(document.body);
translateAttributes(document.body);
hideNoiseNavigation(document.body);
patchActivityHeading(document.body);
patchCategoryBuilderHostLabel(document.body);
injectPveAuditCenter(document.body);
injectDlpNavigation(document.body);
injectDlpReviewCenter(document.body);
injectDlpAlertsCenter(document.body);
injectHostGroupsCenter(document.body).catch(function () {});
redirectBareTrendsRoute();
if (applyPatchInFlight || !document.body) return;
applyPatchInFlight = true;
detachObserver();
try {
const routeKey = window.location.hash || "#";
const routeChanged = routeKey !== staticPatchRouteKey;
enforceSafeActivityViewForPveHost();
ensureSettingsHost();
ensureHostGroupsData().catch(function () {});
normalizeCategoryBuilderUnknownHostRefs();
installCategoryBuilderNetworkPatch();
injectStyles();
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);
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);
}
injectDlpAlertsCenter(document.body);
injectHostGroupsCenter(document.body).catch(function () {});
redirectBareTrendsRoute();
} finally {
applyPatchInFlight = false;
attachObserver();
}
}
function scheduleApplyPatch() {
if (applyPatchScheduled) return;
if (applyPatchScheduled || applyPatchInFlight) return;
applyPatchScheduled = true;
window.setTimeout(function () {
applyPatchScheduled = false;
@@ -1640,15 +1934,20 @@
}
const observer = new MutationObserver(function () {
if (applyPatchInFlight) return;
scheduleApplyPatch();
});
installCategoryBuilderNetworkPatch();
primeCategoryBuilderEarlyFix();
window.addEventListener("load", function () {
applyPatch();
observer.observe(document.body, { childList: true, subtree: true });
attachObserver();
});
window.addEventListener("hashchange", function () {
redirectBareTrendsRoute();
staticPatchRouteKey = "";
scheduleApplyPatch();
});
})();
@@ -0,0 +1,371 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import os
import socket
import subprocess
import sys
import tempfile
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from urllib import request
ENV_FILE = Path("/etc/activitywatch/aw-server.env")
def load_env_file(path: Path) -> None:
if not path.exists():
return
for raw_line in path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
key = key.strip()
value = value.strip().strip("'").strip('"')
os.environ.setdefault(key, value)
def env(name: str, default: str) -> str:
value = os.environ.get(name)
return value if value not in (None, "") else default
def now_utc() -> datetime:
return datetime.now(UTC)
def parse_ts(value: str | None) -> datetime | None:
if not value:
return None
try:
return datetime.fromisoformat(value.replace("Z", "+00:00")).astimezone(UTC)
except ValueError:
return None
def age_seconds(ts: datetime | None, now: datetime) -> int | None:
if ts is None:
return None
return max(0, int((now - ts).total_seconds()))
def http_json(url: str, timeout: int = 10) -> Any:
with request.urlopen(url, timeout=timeout) as resp:
return json.loads(resp.read().decode("utf-8"))
def run_command(cmd: list[str]) -> tuple[int, str]:
proc = subprocess.run(
cmd,
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
)
return proc.returncode, proc.stdout.strip()
def tcp_connect(host: str, port: int, timeout: float) -> tuple[bool, str]:
try:
with socket.create_connection((host, port), timeout=timeout):
return True, "connected"
except OSError as exc:
return False, str(exc)
@dataclass
class CheckResult:
name: str
status: str
summary: str
details: dict[str, Any]
class Report:
def __init__(self) -> None:
self.results: list[CheckResult] = []
def add(self, name: str, status: str, summary: str, **details: Any) -> None:
self.results.append(CheckResult(name=name, status=status, summary=summary, details=details))
@property
def ok(self) -> bool:
return not any(item.status == "fail" for item in self.results)
def as_dict(self) -> dict[str, Any]:
counts = {"ok": 0, "warn": 0, "fail": 0}
for item in self.results:
counts[item.status] = counts.get(item.status, 0) + 1
return {
"generated_at_utc": now_utc().isoformat().replace("+00:00", "Z"),
"ok": self.ok,
"counts": counts,
"results": [
{
"name": item.name,
"status": item.status,
"summary": item.summary,
"details": item.details,
}
for item in self.results
],
}
def render_text(self) -> str:
icon = {"ok": "OK", "warn": "WARN", "fail": "FAIL"}
lines = ["=== AW-RUS Health ===", f"Timestamp: {now_utc().isoformat().replace('+00:00', 'Z')}", ""]
for item in self.results:
lines.append(f"[{icon.get(item.status, item.status.upper())}] {item.name}: {item.summary}")
lines.append("")
payload = self.as_dict()
lines.append(
"Counts: ok={ok} warn={warn} fail={fail}".format(
ok=payload["counts"]["ok"],
warn=payload["counts"]["warn"],
fail=payload["counts"]["fail"],
)
)
lines.append(f"Overall: {'OK' if payload['ok'] else 'FAIL'}")
return "\n".join(lines)
def latest_bucket_event(api_base: str, bucket_id: str) -> dict[str, Any] | None:
events = http_json(f"{api_base}/buckets/{bucket_id}/events?limit=20")
if isinstance(events, list) and events:
events = [item for item in events if isinstance(item, dict)]
if not events:
return None
events.sort(key=lambda item: item.get("timestamp") or "", reverse=True)
return events[0]
return None
def host_activity_from_worktime(event: dict[str, Any] | None, max_age_seconds: int) -> dict[str, Any]:
now = now_utc()
if not event:
return {"fresh": False, "active": False, "age_seconds": None, "timestamp": None}
ts = parse_ts(event.get("timestamp"))
age = age_seconds(ts, now)
data = event.get("data") or {}
is_fresh = age is not None and age <= max_age_seconds
is_active = bool(is_fresh and data.get("active"))
return {
"fresh": bool(is_fresh),
"active": bool(is_active),
"age_seconds": age,
"timestamp": event.get("timestamp"),
"data": data,
}
def bucket_health(
api_base: str,
bucket_id: str,
max_age_seconds: int,
missing_status: str,
stale_status: str,
) -> tuple[str, str, dict[str, Any]]:
try:
event = latest_bucket_event(api_base, bucket_id)
except Exception as exc:
return "fail", f"bucket query failed: {exc}", {"bucket": bucket_id}
if not event:
return missing_status, "no events", {"bucket": bucket_id}
ts = parse_ts(event.get("timestamp"))
age = age_seconds(ts, now_utc())
details = {"bucket": bucket_id, "timestamp": event.get("timestamp"), "age_seconds": age}
if age is None:
return "warn", "timestamp parse failed", details
if age > max_age_seconds:
return stale_status, f"stale ({age}s)", details
return "ok", f"fresh ({age}s)", details
def latest_validation_report(validation_dir: Path) -> Path | None:
candidates = sorted(
(path for path in validation_dir.glob("*-aw_validate_ansible.json") if path.is_file()),
key=lambda item: item.stat().st_mtime,
reverse=True,
)
return candidates[0] if candidates else None
def write_atomic(path: Path, content: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=path.parent, delete=False) as handle:
handle.write(content)
tmp_name = handle.name
os.replace(tmp_name, path)
def check_wrapper(report: Report, name: str, cmd: list[str], json_mode: bool = False) -> None:
if not Path(cmd[0]).exists():
report.add(name, "warn", "binary missing", command=cmd)
return
rc, output = run_command(cmd)
details: dict[str, Any] = {"command": cmd, "returncode": rc}
if json_mode:
try:
details["payload"] = json.loads(output) if output else {}
except json.JSONDecodeError:
details["raw_output"] = output
report.add(name, "fail", "invalid JSON output", **details)
return
else:
details["output"] = output
report.add(name, "ok" if rc == 0 else "fail", "passed" if rc == 0 else "failed", **details)
def main() -> int:
load_env_file(ENV_FILE)
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("--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("--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")
args = parser.parse_args()
report = Report()
aw_api_base = args.aw_server.rstrip("/")
if not aw_api_base.endswith("/api/0"):
aw_api_base = aw_api_base.rstrip("/") + "/api/0"
check_wrapper(report, "wrapper:aw-health-check", ["/usr/local/bin/aw-health-check"])
check_wrapper(report, "wrapper:dlp-health-check", ["/usr/local/bin/dlp-health-check", "--json"], json_mode=True)
try:
info = http_json(f"{aw_api_base}/info")
report.add("http:aw-server", "ok", "activitywatch API responded", version=info.get("version"))
except Exception as exc:
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)
except Exception as exc:
report.add("http:worktime-api", "fail", f"worktime API failed: {exc}", url=args.worktime_api)
for port, label in ((5985, "winrm"), (3389, "rdp")):
ok, message = tcp_connect(args.rdp_host, port, args.tcp_timeout_seconds)
report.add(f"tcp:{label}", "ok" if ok else "fail", message if ok else f"unreachable: {message}", host=args.rdp_host, port=port)
try:
buckets = http_json(f"{aw_api_base}/buckets")
if not isinstance(buckets, dict):
raise RuntimeError("bucket index is not a dict")
report.add("aw:buckets-index", "ok", "bucket index loaded", total=len(buckets))
except Exception as exc:
report.add("aw:buckets-index", "fail", f"failed to load bucket index: {exc}")
buckets = {}
host = args.rdp_hostname
worktime_bucket = f"aw-worktime-sessions_{host}"
worktime_event = None
if buckets:
try:
worktime_event = latest_bucket_event(aw_api_base, worktime_bucket)
except Exception:
worktime_event = None
activity = host_activity_from_worktime(worktime_event, args.session_max_age_seconds)
if worktime_event:
status, summary, details = bucket_health(
aw_api_base,
worktime_bucket,
args.session_max_age_seconds,
missing_status="fail",
stale_status="fail",
)
details["host_activity"] = activity
report.add("bucket:worktime-sessions", status, summary, **details)
else:
report.add("bucket:worktime-sessions", "fail", "no events", bucket=worktime_bucket, host_activity=activity)
interactive_required = bool(activity["active"])
for bucket_name, label in (
("aw-watcher-afk", "bucket:afk"),
("aw-watcher-window", "bucket:window"),
("aw-dlp-endpoint-signals", "bucket:endpoint-signals"),
):
status, summary, details = bucket_health(
aw_api_base,
f"{bucket_name}_{host}",
args.interactive_max_age_seconds,
missing_status="fail" if interactive_required else "warn",
stale_status="fail" if interactive_required else "warn",
)
details["interactive_required"] = interactive_required
details["host_activity"] = activity
report.add(label, status, summary, **details)
session_status, session_summary, session_details = bucket_health(
aw_api_base,
f"aw-session-events_{host}",
args.session_events_max_age_seconds,
missing_status="fail",
stale_status="warn",
)
report.add("bucket:session-events", session_status, session_summary, **session_details)
validation_dir = Path(args.validation_dir)
validation_report = latest_validation_report(validation_dir)
if validation_report is None:
report.add("validation:windows", "warn", "no validation report snapshot", directory=str(validation_dir))
else:
try:
payload = json.loads(validation_report.read_text(encoding="utf-8-sig"))
age = age_seconds(datetime.fromtimestamp(validation_report.stat().st_mtime, tz=UTC), now_utc())
if age is not None and age > args.validation_max_age_seconds:
report.add(
"validation:windows",
"warn",
f"validation snapshot is stale ({age}s)",
path=str(validation_report),
overall_ok=payload.get("overallOk"),
failed_sections=payload.get("summary", {}).get("failedSections", []),
)
elif payload.get("overallOk") is True:
report.add("validation:windows", "ok", "validation snapshot OK", path=str(validation_report), age_seconds=age)
else:
report.add(
"validation:windows",
"fail",
"validation snapshot reports failure",
path=str(validation_report),
age_seconds=age,
failed_sections=payload.get("summary", {}).get("failedSections", []),
)
except Exception as exc:
report.add("validation:windows", "fail", f"invalid validation snapshot: {exc}", path=str(validation_report))
payload = report.as_dict()
state_dir = Path(args.state_dir)
write_atomic(state_dir / "aw-rus-health.json", json.dumps(payload, ensure_ascii=False, indent=2) + "\n")
write_atomic(state_dir / "aw-rus-health.txt", report.render_text() + "\n")
if args.json:
print(json.dumps(payload, ensure_ascii=False, indent=2))
else:
print(report.render_text())
return 0 if payload["ok"] else 1
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,14 @@
[Unit]
Description=AW-RUS unified health orchestrator
After=network-online.target activitywatch-server.service aw-worktime-api.service
Wants=network-online.target activitywatch-server.service aw-worktime-api.service
[Service]
Type=oneshot
EnvironmentFile=/etc/activitywatch/aw-server.env
ExecStart=/usr/bin/python3 /usr/local/bin/aw-rus-healthd.py
User=root
Group=root
StandardOutput=journal
StandardError=journal
SyslogIdentifier=aw-rus-healthd
@@ -0,0 +1,11 @@
[Unit]
Description=Run AW-RUS unified health orchestrator every 2 minutes
[Timer]
OnBootSec=2min
OnUnitActiveSec=2min
Unit=aw-rus-healthd.service
Persistent=true
[Install]
WantedBy=timers.target
@@ -1,13 +1,46 @@
# Copy to /etc/activitywatch/aw-server.env and fill with real values.
# Core AW Server Configuration
AW_SERVER_VERSION=0.13.2
AW_SERVER_DOWNLOAD_URL=https://github.com/ActivityWatch/aw-server-rust/releases/download/v0.13.2/aw-server-rust-linux-x86_64.zip
AW_SERVER_BIND_HOST=0.0.0.0
AW_SERVER_PORT=5600
AW_SERVER_WEBUI_DIR=/opt/activitywatch/webui-ru
AW_SERVER_DATA_DIR=/var/lib/activitywatch
AW_SERVER_DB_PATH=/var/lib/activitywatch/pebble.db
AW_SERVER_LOG_DIR=/var/log/activitywatch
AW_SERVER_USER=activitywatch
AW_SERVER_GROUP=activitywatch
# Worktime API Configuration
AW_WORKTIME_REPORT_BASE=http://10.10.10.13:5610
AW_WORKTIME_TZ=Europe/Moscow
AW_SERVER_URL=http://127.0.0.1:5600
# DLP IOC Configuration
AW_DLP_IOC_DIR=/opt/activitywatch/dlp-ioc/output
# DLP Policy Engine Configuration
AW_DLP_POLICY_ENGINE_BIND_HOST=0.0.0.0
AW_DLP_POLICY_ENGINE_PORT=5601
AW_DLP_POLICY_ENGINE_DB_PATH=/var/lib/activitywatch/dlp-policy-engine.sqlite
# Logging Configuration
AW_LOG_LEVEL=info
AW_LOG_TO_JOURNAL=true
AW_LOG_TO_FILE=true
# Health Check Configuration
AW_HEALTH_CHECK_ENABLED=true
AW_HEALTH_CHECK_INTERVAL=60
AW_EXPECT_START_OF_DAY=00:00
AW_EXPECT_ALWAYS_ACTIVE_PATTERN=aw-watcher-window
AW_EXPECT_LANDINGPAGE=/activity/SHARKON2025/view/
AW_HEALTH_STRICT_FILEOPS=0
AW_MONITORED_WINDOWS_HOST=192.168.100.18
AW_MONITORED_WINDOWS_HOSTNAME=SHARKON2025
AW_RUS_HEALTH_STATE_DIR=/var/lib/activitywatch/health
AW_RUS_HEALTH_VALIDATION_DIR=/var/lib/activitywatch/health/windows-validation
# Integration Test Configuration
AW_INTEGRATION_TEST_ENABLED=false
@@ -1,15 +1,36 @@
#!/usr/bin/env python3
from http.server import BaseHTTPRequestHandler, HTTPServer
import csv
import html
import io
import importlib.util
import json
import os
import sys
import urllib.request
from datetime import datetime, timezone, timedelta
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
from urllib.parse import parse_qs, urlencode, urlparse
from zoneinfo import ZoneInfo
AW = "http://127.0.0.1:5600/api/0"
def build_aw_api_base(raw_url):
url = (raw_url or "http://127.0.0.1:5600").strip().rstrip("/")
if url.endswith("/api/0"):
return url
return url + "/api/0"
AW_SERVER_URL = os.environ.get("AW_SERVER_URL", "http://127.0.0.1:5600")
AW = build_aw_api_base(AW_SERVER_URL)
REPORT_TZ = ZoneInfo(os.environ.get("AW_WORKTIME_TZ", "Europe/Moscow"))
IOC_DIR = os.environ.get("AW_DLP_IOC_DIR", "/opt/activitywatch/dlp-ioc/output")
DEFAULT_HOST = os.environ.get("AW_WORKTIME_HOST", "SHARKON2025").strip() or "SHARKON2025"
DEFAULT_SAMPLE_SECONDS = max(1.0, float(os.environ.get("AW_WORKTIME_DEFAULT_SAMPLE_SECONDS", "30")))
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"))
MODULE_PATH = Path(__file__).resolve()
def get(u):
@@ -17,80 +38,404 @@ def get(u):
return json.loads(r.read().decode())
def log_warning(message):
print(f"[aw-worktime-api] {message}", file=sys.stderr, flush=True)
def pts(s):
return datetime.fromisoformat(s.replace("Z", "+00:00")).astimezone(timezone.utc)
def report_today():
def to_iso_utc(dt):
return dt.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
def hhmm(total_seconds):
total_seconds = max(0, int(total_seconds))
return "%02d:%02d" % (total_seconds // 3600, (total_seconds % 3600) // 60)
def safe_slug(value):
text = str(value or "").strip().lower()
slug = []
for char in text:
if char.isalnum():
slug.append(char)
else:
slug.append("-")
normalized = "".join(slug).strip("-")
while "--" in normalized:
normalized = normalized.replace("--", "-")
return normalized or "user"
def clamp_seconds(value, fallback=DEFAULT_SAMPLE_SECONDS):
try:
seconds = float(value)
except Exception:
seconds = float(fallback)
if seconds <= 0:
seconds = float(fallback)
return min(seconds, MAX_SAMPLE_SECONDS)
def resolve_host(request_host=None):
host = (request_host or DEFAULT_HOST).strip()
if not host:
host = DEFAULT_HOST
return host
def get_sessions_bucket_id(host):
return f"aw-worktime-sessions_{resolve_host(host)}"
def resolve_report_date(day=None, date_text=None):
now_local = datetime.now(REPORT_TZ)
start_local = datetime(now_local.year, now_local.month, now_local.day, tzinfo=REPORT_TZ)
if date_text:
return datetime.strptime(date_text, "%Y-%m-%d").date()
if day == "yesterday":
return (now_local - timedelta(days=1)).date()
return now_local.date()
def get_report_bounds(report_date):
start_local = datetime(report_date.year, report_date.month, report_date.day, tzinfo=REPORT_TZ)
end_local = start_local + timedelta(days=1) - timedelta(seconds=1)
start = start_local.astimezone(timezone.utc)
end = end_local.astimezone(timezone.utc)
b = get(AW + "/buckets")
sb = next((k for k in b if k.startswith("aw-worktime-sessions_")), None)
if not sb:
end_exclusive = end + timedelta(seconds=1)
return {
"start_local": start_local,
"end_local": end_local,
"start": start,
"end": end,
"end_exclusive": end_exclusive,
}
def _is_machine_user(user: str):
u = (user or "").strip().lower()
return u.endswith("$") or u in {"system", "localservice", "networkservice"}
def _is_active_sample(data: dict):
state = str(data.get("state") or "").strip().lower()
if isinstance(data.get("active"), bool) and data.get("active"):
return True
if ("актив" in state) or (state == "active"):
return True
if state == "unknown":
try:
sid = int(data.get("sessionId"))
except Exception:
sid = -1
user = str(data.get("username") or "").strip()
session_name = str(data.get("sessionName") or "").strip().lower()
if sid > 0 and user and (not _is_machine_user(user)) and (session_name.startswith("rdp-") or session_name == "console"):
return True
return False
def _normalize_user_id(data, host, username):
user_id = str(data.get("userId") or "").strip()
if user_id:
left, sep, right = user_id.partition("\\")
if sep and right:
return f"{resolve_host(host)}\\{right}"
return user_id
return f"{resolve_host(host)}\\{username}"
def _event_sample_seconds(event, next_same_session_ts=None):
data = event.get("data") or {}
for key in ("sampleSeconds", "pollSeconds"):
value = data.get(key)
try:
if float(value) > 0:
return clamp_seconds(value)
except Exception:
pass
try:
duration = float(event.get("duration") or 0.0)
except Exception:
duration = 0.0
if duration > 0:
return clamp_seconds(duration)
if next_same_session_ts is not None:
delta = (next_same_session_ts - event["_ts"]).total_seconds()
if delta > 0:
return clamp_seconds(delta)
return clamp_seconds(DEFAULT_SAMPLE_SECONDS)
def _merge_intervals(intervals):
if not intervals:
return []
ev = get(f"{AW}/buckets/{sb}/events?limit=50000")
by = {}
for e in ev:
ts = pts(e.get("timestamp"))
ordered = sorted(intervals, key=lambda item: item[0])
merged = [ordered[0]]
for start, end in ordered[1:]:
last_start, last_end = merged[-1]
if start <= last_end:
if end > last_end:
merged[-1] = (last_start, end)
continue
merged.append((start, end))
return merged
def _collect_user_rows(events, start, end, host):
end_exclusive = end + timedelta(seconds=1)
by_user = {}
by_identity = {}
for event in events:
ts = pts(event.get("timestamp"))
if ts < start or ts > end:
continue
d = e.get("data") or {}
user = (d.get("username") or "").strip()
if not user:
data = event.get("data") or {}
username = str(data.get("username") or "").strip()
if not username:
continue
state = (d.get("state") or "").lower()
active = ("актив" in state) or (state == "active")
row = by.setdefault(user, {"active": set(), "first": None, "last": None, "rows": 0})
row["rows"] += 1
if active:
second = ts.replace(microsecond=0)
row["active"].add(second)
row["first"] = second if row["first"] is None or second < row["first"] else row["first"]
row["last"] = second if row["last"] is None or second > row["last"] else row["last"]
session_id = str(data.get("sessionId") or "").strip() or "unknown"
event_copy = {
"_ts": ts,
"data": data,
"duration": event.get("duration"),
}
by_identity.setdefault((username, session_id), []).append(event_copy)
for (username, session_id), samples in by_identity.items():
ordered = sorted(samples, key=lambda item: item["_ts"])
for idx, sample in enumerate(ordered):
data = sample["data"]
active = _is_active_sample(data)
next_ts = ordered[idx + 1]["_ts"] if idx + 1 < len(ordered) else None
sample_seconds = _event_sample_seconds(sample, next_ts)
row = by_user.setdefault(
username,
{
"user": username,
"user_id": _normalize_user_id(data, host, username),
"samples_count": 0,
"active_samples": 0,
"session_ids": set(),
"intervals": [],
},
)
row["samples_count"] += 1
row["session_ids"].add(session_id)
if active:
row["active_samples"] += 1
interval_start = sample["_ts"]
interval_end = min(sample["_ts"] + timedelta(seconds=sample_seconds), end_exclusive)
if interval_end > interval_start:
row["intervals"].append((interval_start, interval_end))
return by_user
def aggregate_rows(events, start, end, host):
by_user = _collect_user_rows(events, start, end, host)
rows = []
full = int((end_local - start_local).total_seconds())
for user in sorted(by):
row = by[user]
active_seconds = len(row["active"])
rows.append({
"user": user,
"active_seconds": active_seconds,
"active_hhmm": "%02d:%02d" % (active_seconds // 3600, (active_seconds % 3600) // 60),
"first_activity": row["first"].isoformat().replace("+00:00", "Z") if row["first"] else "",
"last_activity": row["last"].isoformat().replace("+00:00", "Z") if row["last"] else "",
"idle_seconds": max(0, full - active_seconds),
"sessions_count": row["rows"],
})
full_range = int((end - start).total_seconds()) + 1
for username in sorted(by_user):
row = by_user[username]
merged = _merge_intervals(row["intervals"])
active_seconds = int(sum((end_dt - start_dt).total_seconds() for start_dt, end_dt in merged))
active_seconds = min(active_seconds, full_range)
first_activity = to_iso_utc(merged[0][0]) if merged else ""
last_activity = to_iso_utc(merged[-1][1]) if merged else ""
rows.append(
{
"user": row["user"],
"user_id": row["user_id"],
"active_seconds": active_seconds,
"active_hhmm": hhmm(active_seconds),
"first_activity": first_activity,
"last_activity": last_activity,
"idle_seconds": max(0, full_range - active_seconds),
"sessions_count": len(row["session_ids"]),
"samples_count": row["samples_count"],
"active_samples": row["active_samples"],
}
)
return rows
def render_html(rows):
def aggregate_hourly_rows(events, start, end, host):
by_user = _collect_user_rows(events, start, end, host)
rows = []
for username in sorted(by_user):
row = by_user[username]
merged = _merge_intervals(row["intervals"])
per_bucket = {}
for interval_start, interval_end in merged:
cursor = interval_start
while cursor < interval_end:
bucket_local = cursor.astimezone(REPORT_TZ).replace(minute=0, second=0, microsecond=0)
bucket_start = bucket_local.astimezone(timezone.utc)
bucket_end = (bucket_local + timedelta(hours=1)).astimezone(timezone.utc)
overlap_start = max(interval_start, bucket_start)
overlap_end = min(interval_end, bucket_end)
if overlap_end > overlap_start:
key = bucket_start
per_bucket[key] = per_bucket.get(key, 0) + int((overlap_end - overlap_start).total_seconds())
cursor = bucket_end
for bucket_start in sorted(per_bucket):
active_seconds = per_bucket[bucket_start]
if active_seconds <= 0:
continue
bucket_local = bucket_start.astimezone(REPORT_TZ)
rows.append(
{
"user": row["user"],
"user_id": row["user_id"],
"bucket_start_utc": to_iso_utc(bucket_start),
"bucket_start_local": bucket_local.isoformat(),
"report_date": bucket_local.date().isoformat(),
"hour_local": bucket_local.strftime("%H:00"),
"active_seconds": active_seconds,
"active_hhmm": hhmm(active_seconds),
}
)
return rows
def fetch_events_for_date(host, report_date):
bounds = get_report_bounds(report_date)
bucket_id = get_sessions_bucket_id(host)
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, []
try:
events = get(f"{AW}/buckets/{bucket_id}/events?limit=50000")
except Exception:
log_warning(f"events fetch failed for host={host} bucket={bucket_id} aw_base={AW}")
return bounds, []
return bounds, events
def build_report_summary(rows):
if not rows:
return {
"users_count": 0,
"total_active_seconds": 0,
"total_active_hhmm": "00:00",
"first_activity": "",
"last_activity": "",
"top_user": "",
"top_user_active_hhmm": "00:00",
}
total_active_seconds = sum(int(row.get("active_seconds", 0) or 0) for row in rows)
first_values = [row.get("first_activity") for row in rows if row.get("first_activity")]
last_values = [row.get("last_activity") for row in rows if row.get("last_activity")]
top_row = max(rows, key=lambda row: int(row.get("active_seconds", 0) or 0))
return {
"users_count": len(rows),
"total_active_seconds": total_active_seconds,
"total_active_hhmm": hhmm(total_active_seconds),
"first_activity": min(first_values) if first_values else "",
"last_activity": max(last_values) if last_values else "",
"top_user": top_row.get("user", ""),
"top_user_active_hhmm": top_row.get("active_hhmm", "00:00"),
}
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 report_today(host):
return report_for_date(host, resolve_report_date())
def report_for_date_fresh(host, report_date):
spec = importlib.util.spec_from_file_location("aw_worktime_runtime", MODULE_PATH)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module.report_for_date(host, report_date)
def render_html(rows, host, report_date, selected_day=None):
generated = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
date_local = datetime.now(REPORT_TZ).strftime("%Y-%m-%d")
date_local = report_date.strftime("%Y-%m-%d")
day_query = f"&day={selected_day}" if selected_day in {"today", "yesterday"} else ""
date_query = f"&date={date_local}" if not day_query else ""
summary = build_report_summary(rows)
today_url = "/reports/worktime/today?" + urlencode({"format": "html", "host": resolve_host(host), "day": "today"})
yesterday_url = "/reports/worktime/today?" + urlencode({"format": "html", "host": resolve_host(host), "day": "yesterday"})
csv_url = "/reports/worktime/today?" + urlencode({"format": "csv", "host": resolve_host(host), **({"day": selected_day} if selected_day in {"today", "yesterday"} else {"date": date_local})})
json_url = "/reports/worktime/today?" + urlencode({"host": resolve_host(host), **({"day": selected_day} if selected_day in {"today", "yesterday"} else {"date": date_local})})
form_action = "/reports/worktime/today"
cards = [
("Пользователи", str(summary["users_count"])),
("Активное время", summary["total_active_hhmm"]),
("Лидер дня", f"{summary['top_user']} · {summary['top_user_active_hhmm']}" if summary["top_user"] else "н/д"),
("Диапазон", f"{summary['first_activity']} -> {summary['last_activity']}" if summary["first_activity"] else "нет активности"),
]
trs = []
detail_cards = []
for row in rows:
user_slug = safe_slug(row["user"])
active_seconds = int(row.get("active_seconds", 0) or 0)
utilization = 0.0
day_total = 24 * 3600
if day_total > 0:
utilization = round((active_seconds / day_total) * 100.0, 2)
trs.append(
"<tr>"
f"<td>{row['user']}</td>"
f"<td>{row['active_hhmm']}</td>"
f"<td><a class='user-link' href='#{user_slug}'>{html.escape(row['user'])}</a></td>"
f"<td>{html.escape(row['user_id'])}</td>"
f"<td class='good'>{row['active_hhmm']}</td>"
f"<td>{row['active_seconds']}</td>"
f"<td>{row['first_activity']}</td>"
f"<td>{row['last_activity']}</td>"
f"<td>{html.escape(row['first_activity'])}</td>"
f"<td>{html.escape(row['last_activity'])}</td>"
f"<td>{row['idle_seconds']}</td>"
f"<td>{row['sessions_count']}</td>"
f"<td>{row['samples_count']}</td>"
"</tr>"
)
detail_cards.append(
"<article class='detail-card' id='{slug}'>"
"<div class='detail-head'>"
"<h3>{user}</h3>"
"<span class='badge'>{active}</span>"
"</div>"
"<div class='detail-grid'>"
"<div><span>Пользователь</span><strong>{user_id}</strong></div>"
"<div><span>Загрузка</span><strong>{utilization}%</strong></div>"
"<div><span>Начало активности</span><strong>{first_activity}</strong></div>"
"<div><span>Конец активности</span><strong>{last_activity}</strong></div>"
"<div><span>Сессии</span><strong>{sessions}</strong></div>"
"<div><span>Активные сэмплы</span><strong>{active_samples} / {samples}</strong></div>"
"</div>"
"</article>"
.format(
slug=user_slug,
user=html.escape(row["user"]),
active=html.escape(row["active_hhmm"]),
user_id=html.escape(row["user_id"]),
utilization=utilization,
first_activity=html.escape(row["first_activity"] or "н/д"),
last_activity=html.escape(row["last_activity"] or "н/д"),
sessions=row["sessions_count"],
active_samples=row["active_samples"],
samples=row["samples_count"],
))
if not trs:
trs.append('<tr><td colspan="7">No data for today yet.</td></tr>')
trs.append('<tr><td colspan="9">За выбранную дату данных пока нет.</td></tr>')
detail_cards.append("<article class='detail-card empty'><h3>За выбранную дату нет активности пользователей.</h3></article>")
return f"""<!doctype html>
<html lang="en">
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>AW-rus Worktime</title>
<title>AW-rus Отчёт по работе в RDP</title>
<style>
:root {{
color-scheme: light;
@@ -100,7 +445,6 @@ def render_html(rows):
--text: #0f172a;
--muted: #475569;
--accent: #0f766e;
--accent-2: #1d4ed8;
}}
* {{ box-sizing: border-box; }}
body {{
@@ -112,7 +456,7 @@ def render_html(rows):
radial-gradient(circle at top right, rgba(15,118,110,.10), transparent 24%),
var(--bg);
}}
.wrap {{ max-width: 1180px; margin: 0 auto; padding: 24px; }}
.wrap {{ max-width: 1340px; margin: 0 auto; padding: 24px; }}
.hero {{
background: linear-gradient(135deg, #0f172a, #1e293b 58%, #0f766e);
color: #fff;
@@ -131,6 +475,59 @@ def render_html(rows):
padding: 8px 12px;
border-radius: 999px;
}}
.toolbar {{
margin-top: 16px;
display: flex;
gap: 12px;
flex-wrap: wrap;
align-items: center;
}}
.toolbar form {{
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
}}
.toolbar input, .toolbar button {{
border-radius: 10px;
border: 1px solid rgba(255,255,255,.22);
background: rgba(255,255,255,.14);
color: #fff;
padding: 9px 12px;
font: inherit;
}}
.toolbar button {{
cursor: pointer;
font-weight: 600;
}}
.toolbar input::-webkit-calendar-picker-indicator {{ filter: invert(1); }}
.summary-grid {{
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px;
margin-top: 18px;
}}
.summary-card {{
background: rgba(255,255,255,.1);
border: 1px solid rgba(255,255,255,.14);
border-radius: 14px;
padding: 14px 16px;
min-height: 96px;
}}
.summary-card span {{
display: block;
color: rgba(255,255,255,.78);
font-size: 12px;
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: .04em;
}}
.summary-card strong {{
display: block;
font-size: 22px;
line-height: 1.25;
word-break: break-word;
}}
.card {{
margin-top: 18px;
background: var(--card);
@@ -143,38 +540,115 @@ def render_html(rows):
th, td {{ padding: 12px 14px; border-bottom: 1px solid var(--line); text-align: left; }}
th {{ background: #eef4fb; color: var(--muted); font-weight: 600; position: sticky; top: 0; }}
tr:nth-child(even) td {{ background: rgba(148,163,184,.06); }}
.num {{ font-variant-numeric: tabular-nums; }}
.good {{ color: var(--accent); font-weight: 700; }}
.muted {{ color: var(--muted); }}
.user-link {{ color: #0f4db3; text-decoration: none; font-weight: 600; }}
.section-title {{
margin: 0;
padding: 18px 18px 0;
color: var(--text);
font-size: 18px;
}}
.details-wrap {{
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
padding: 18px;
}}
.detail-card {{
border: 1px solid var(--line);
border-radius: 14px;
padding: 16px;
background: linear-gradient(180deg, rgba(238,244,251,.7), #fff);
scroll-margin-top: 16px;
}}
.detail-card.empty {{
grid-column: 1 / -1;
text-align: center;
color: var(--muted);
}}
.detail-head {{
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}}
.detail-head h3 {{
margin: 0;
font-size: 18px;
}}
.badge {{
display: inline-block;
padding: 6px 10px;
background: #d1fae5;
color: #065f46;
border-radius: 999px;
font-weight: 700;
font-size: 12px;
}}
.detail-grid {{
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}}
.detail-grid span {{
display: block;
color: var(--muted);
font-size: 12px;
margin-bottom: 4px;
}}
.detail-grid strong {{
display: block;
word-break: break-word;
}}
@media (max-width: 900px) {{
.wrap {{ padding: 14px; }}
.hero h1 {{ font-size: 22px; }}
.summary-grid {{ grid-template-columns: 1fr; }}
.card {{ overflow-x: auto; }}
table {{ min-width: 820px; }}
table {{ min-width: 1080px; }}
.details-wrap {{ grid-template-columns: 1fr; }}
.detail-grid {{ grid-template-columns: 1fr; }}
}}
</style>
</head>
<body>
<div class="wrap">
<section class="hero">
<h1>RDP Worktime Report</h1>
<div class="meta">Date: {date_local} · Timezone: {REPORT_TZ} · Generated UTC: {generated}</div>
<h1>Отчёт по работе в RDP</h1>
<div class="meta">Хост: {resolve_host(host)} · Дата: {date_local} · Часовой пояс: {REPORT_TZ} · Сформировано UTC: {generated}</div>
<div class="actions">
<a href="/reports/worktime/today?format=csv">Download CSV</a>
<a href="/reports/worktime/today">View JSON</a>
<a href="{today_url}">Сегодня</a>
<a href="{yesterday_url}">Вчера</a>
<a href="{csv_url}">Скачать CSV</a>
<a href="{json_url}">Открыть JSON</a>
</div>
<div class="toolbar">
<form method="get" action="{form_action}">
<input type="hidden" name="format" value="html">
<input type="hidden" name="host" value="{html.escape(resolve_host(host))}">
<input type="date" name="date" value="{date_local}">
<button type="submit">Открыть дату</button>
</form>
</div>
<div class="summary-grid">
{''.join(f"<div class='summary-card'><span>{html.escape(label)}</span><strong>{html.escape(value)}</strong></div>" for label, value in cards)}
</div>
</section>
<section class="card">
<h2 class="section-title">Таблица по пользователям</h2>
<table>
<thead>
<tr>
<th>User</th>
<th>Active</th>
<th>Active sec</th>
<th>First activity</th>
<th>Last activity</th>
<th>Idle sec</th>
<th>Samples</th>
<th>Пользователь</th>
<th>Учётная запись</th>
<th>Активно</th>
<th>Активно, сек</th>
<th>Начало активности</th>
<th>Конец активности</th>
<th>Простой, сек</th>
<th>Сессии</th>
<th>Сэмплы</th>
</tr>
</thead>
<tbody>
@@ -182,6 +656,12 @@ def render_html(rows):
</tbody>
</table>
</section>
<section class="card">
<h2 class="section-title">Детали по пользователям</h2>
<div class="details-wrap">
{''.join(detail_cards)}
</div>
</section>
</div>
</body>
</html>"""
@@ -189,28 +669,65 @@ def render_html(rows):
class H(BaseHTTPRequestHandler):
def do_GET(self):
if not self.path.startswith("/reports/worktime/today"):
parsed = urlparse(self.path)
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"}:
self.send_response(404)
self.end_headers()
return
path = os.path.join(IOC_DIR, name)
if not os.path.isfile(path):
self.send_response(404)
self.end_headers()
return
with open(path, "rb") as f:
data = f.read()
if name.endswith(".json"):
ctype = "application/json; charset=utf-8"
elif name.endswith(".csv"):
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)
return
if parsed.path != "/reports/worktime/today":
self.send_response(404)
self.end_headers()
return
params = parse_qs(parsed.query, keep_blank_values=False)
fmt = "json"
if "format=csv" in self.path:
if params.get("format", ["json"])[0] == "csv":
fmt = "csv"
elif "format=html" in self.path:
elif params.get("format", ["json"])[0] == "html":
fmt = "html"
rows = report_today()
host = resolve_host(params.get("host", [DEFAULT_HOST])[0])
day = params.get("day", ["today"])[0]
date_text = params.get("date", [None])[0]
report_date = resolve_report_date(day=day, date_text=date_text)
rows = report_for_date_fresh(host, report_date)
if fmt == "csv":
out = io.StringIO()
writer = csv.DictWriter(
out,
fieldnames=[
"user",
"user_id",
"active_seconds",
"active_hhmm",
"first_activity",
"last_activity",
"idle_seconds",
"sessions_count",
"samples_count",
"active_samples",
],
)
writer.writeheader()
@@ -222,17 +739,22 @@ class H(BaseHTTPRequestHandler):
self.end_headers()
self.wfile.write(data)
return
if fmt == "html":
data = render_html(rows).encode("utf-8")
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)
return
obj = {
"generated_at_utc": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
"report_timezone": str(REPORT_TZ),
"host": host,
"report_date": report_date.isoformat(),
"bucket_id": get_sessions_bucket_id(host),
"rows": rows,
}
data = json.dumps(obj, ensure_ascii=False, indent=2).encode("utf-8")
@@ -243,4 +765,9 @@ class H(BaseHTTPRequestHandler):
self.wfile.write(data)
HTTPServer(("0.0.0.0", 5610), H).serve_forever()
def main():
HTTPServer((LISTEN_HOST, LISTEN_PORT), H).serve_forever()
if __name__ == "__main__":
main()
@@ -2,15 +2,20 @@
Description=AW Worktime Report API
After=network.target activitywatch-server.service
Wants=activitywatch-server.service
StartLimitBurst=3
StartLimitIntervalSec=60
[Service]
Type=simple
EnvironmentFile=/etc/activitywatch/aw-server.env
ExecStart=/usr/bin/python3 /usr/local/bin/aw-worktime-api.py
Restart=always
RestartSec=2
Restart=on-failure
RestartSec=5
User=activitywatch
Group=activitywatch
StandardOutput=journal
StandardError=journal
SyslogIdentifier=aw-worktime-api
[Install]
WantedBy=multi-user.target
@@ -1,15 +1,23 @@
(function () {
var reportBase = "__AW_WORKTIME_REPORT_BASE__";
var reportUrl = reportBase + "/reports/worktime/today?format=html";
function defaultDayQuery() {
var now = new Date();
return now.getHours() < 6 ? "day=yesterday" : "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 existing = document.getElementById("aw-report-links");
if (!existing) return;
existing.innerHTML =
'RDP report: ' +
'<a href="' + reportUrl + '" style="color:#fcd34d" target="_blank">HTML</a> | ' +
'<a href="' + reportBase + '/reports/worktime/today?format=csv" style="color:#7dd3fc" target="_blank">CSV</a> | ' +
'<a href="' + reportBase + '/reports/worktime/today" style="color:#86efac" target="_blank">JSON</a> | ' +
'<a href="#" id="aw-report-toggle" style="color:#f9fafb">Panel</a>';
'RDP отчёт: ' +
'<a href="' + htmlUrl + '" style="color:#fcd34d" target="_blank">HTML</a> | ' +
'<a href="' + csvUrl + '" style="color:#7dd3fc" target="_blank">CSV</a> | ' +
'<a href="' + jsonUrl + '" style="color:#86efac" target="_blank">JSON</a> | ' +
'<a href="#" id="aw-report-toggle" style="color:#f9fafb">Панель</a>';
var panel = document.createElement("div");
panel.id = "aw-report-panel";
@@ -30,12 +38,12 @@
panel.innerHTML =
'<div style="display:flex;align-items:center;justify-content:space-between;padding:10px 14px;background:#0f172a;color:#fff;font:600 13px/1.2 sans-serif">' +
'<div>RDP Worktime Report</div>' +
'<div>Отчёт по работе в RDP</div>' +
'<div style="display:flex;gap:12px;align-items:center">' +
'<a href="' + reportUrl + '" target="_blank" style="color:#93c5fd;text-decoration:none">Open</a>' +
'<a href="#" id="aw-report-close" style="color:#fff;text-decoration:none">Close</a>' +
'<a href="' + htmlUrl + '" target="_blank" style="color:#93c5fd;text-decoration:none">Открыть</a>' +
'<a href="#" id="aw-report-close" style="color:#fff;text-decoration:none">Закрыть</a>' +
"</div></div>" +
'<iframe src="' + reportUrl + '" title="RDP Worktime Report" style="border:0;width:100%;height:calc(100% - 42px);background:#fff"></iframe>';
'<iframe src="' + htmlUrl + '" title="Отчёт по работе в RDP" style="border:0;width:100%;height:calc(100% - 42px);background:#fff"></iframe>';
document.body.appendChild(panel);
@@ -26,6 +26,12 @@ 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_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"
HEALTHD_SRC="$BOOTSTRAP_DIR/aw-rus-healthd.py"
HEALTHD_SERVICE_SRC="$BOOTSTRAP_DIR/aw-rus-healthd.service"
HEALTHD_TIMER_SRC="$BOOTSTRAP_DIR/aw-rus-healthd.timer"
for var_name in "${required_vars[@]}"; do
if [[ -z "${!var_name:-}" ]]; then
@@ -51,6 +57,7 @@ install -d -o "$AW_SERVER_USER" -g "$AW_SERVER_GROUP" /opt/activitywatch/release
install -d -o "$AW_SERVER_USER" -g "$AW_SERVER_GROUP" "$AW_SERVER_WEBUI_DIR"
install -d -o "$AW_SERVER_USER" -g "$AW_SERVER_GROUP" "$AW_SERVER_DATA_DIR"
install -d -o "$AW_SERVER_USER" -g "$AW_SERVER_GROUP" "$AW_SERVER_LOG_DIR"
install -d -o "$AW_SERVER_USER" -g "$AW_SERVER_GROUP" "$AW_SERVER_DATA_DIR/health/windows-validation"
tmp_dir=$(mktemp -d)
trap 'rm -rf "$tmp_dir"' EXIT
@@ -103,6 +110,41 @@ if [[ -f "$WORKTIME_API_SERVICE_SRC" ]]; then
systemctl --no-pager --full status aw-worktime-api.service || true
fi
if [[ -f "$WORKTIME_UI_BRIDGE_SRC" ]]; then
install -m 0755 "$WORKTIME_UI_BRIDGE_SRC" /usr/local/bin/aw-worktime-ui-bridge.py
fi
if [[ -f "$WORKTIME_UI_BRIDGE_SERVICE_SRC" ]]; then
install -m 0644 "$WORKTIME_UI_BRIDGE_SERVICE_SRC" /etc/systemd/system/aw-worktime-ui-bridge.service
fi
if [[ -f "$WORKTIME_UI_BRIDGE_TIMER_SRC" ]]; then
install -m 0644 "$WORKTIME_UI_BRIDGE_TIMER_SRC" /etc/systemd/system/aw-worktime-ui-bridge.timer
systemctl daemon-reload
systemctl disable --now aw-worktime-afk-bridge.timer >/dev/null 2>&1 || true
systemctl enable aw-worktime-ui-bridge.timer
systemctl restart aw-worktime-ui-bridge.timer
systemctl start aw-worktime-ui-bridge.service || true
systemctl --no-pager --full status aw-worktime-ui-bridge.timer || true
fi
if [[ -f "$HEALTHD_SRC" ]]; then
install -m 0755 "$HEALTHD_SRC" /usr/local/bin/aw-rus-healthd.py
fi
if [[ -f "$HEALTHD_SERVICE_SRC" ]]; then
install -m 0644 "$HEALTHD_SERVICE_SRC" /etc/systemd/system/aw-rus-healthd.service
fi
if [[ -f "$HEALTHD_TIMER_SRC" ]]; then
install -m 0644 "$HEALTHD_TIMER_SRC" /etc/systemd/system/aw-rus-healthd.timer
systemctl daemon-reload
systemctl enable aw-rus-healthd.timer
systemctl restart aw-rus-healthd.timer
systemctl start aw-rus-healthd.service || true
systemctl --no-pager --full status aw-rus-healthd.timer || true
fi
for _ in $(seq 1 20); do
if curl -fsS "http://127.0.0.1:${AW_SERVER_PORT}/api/0/info" >/dev/null 2>&1; then
break
@@ -20,7 +20,7 @@
"name": ["Работа", "Документы"],
"rule": {
"type": "regex",
"regex": "\\b(winword|excel|powerpnt|outlook|acrord32|acrord64)\\.exe\\b|Adobe Reader|Acrobat",
"regex": "\\b(winword|excel|powerpnt|outlook|acrord32|acrord64|libreoffice|writer|calc)\\.exe\\b|LibreOffice|OnlyOffice|Adobe Reader|Acrobat",
"ignore_case": true
},
"data": { "color": "#2E7D32" }
@@ -40,7 +40,7 @@
"name": ["Работа", "Администрирование"],
"rule": {
"type": "regex",
"regex": "\\b(mstsc|putty|kitty|winscp|anydesk|teamviewer|vncviewer|mmc|regedit|services|control|powershell|cmd)\\.exe\\b",
"regex": "\\b(mstsc|putty|kitty|winscp|anydesk|teamviewer|vncviewer|mmc|regedit|services|control|powershell|cmd|gnome-terminal|gnome-terminal-server|xfce4-terminal|konsole|tilix|alacritty|xterm|remmina|virt-manager)\\.exe\\b|\\b(gnome-terminal|gnome-terminal-server|xfce4-terminal|konsole|tilix|alacritty|xterm|remmina|virt-manager)\\b|Proxmox Virtual Environment|\\bpfSense\\b|\\bGrafana\\b|\\bKibana\\b|\\bPortainer\\b",
"ignore_case": true
},
"data": { "color": "#6D4C41" }
@@ -56,7 +56,7 @@
"name": ["Интернет", "Браузер"],
"rule": {
"type": "regex",
"regex": "\\b(chrome|msedge|firefox|opera|brave|vivaldi|browser)\\.exe\\b",
"regex": "\\b(chrome|msedge|firefox|opera|brave|vivaldi|browser|chromium)\\.exe\\b|\\b(chrome|chromium|firefox|opera|brave|vivaldi)\\b",
"ignore_case": true
},
"data": { "color": "#00897B" }
@@ -82,7 +82,7 @@
"name": ["ActivityWatch"],
"rule": {
"type": "regex",
"regex": "ActivityWatch|\\baw-(watcher|qt)\\.exe\\b",
"regex": "ActivityWatch|\\baw-(watcher|qt)\\.exe\\b|\\baw-(watcher|qt)\\b",
"ignore_case": true
},
"data": {}
@@ -30,10 +30,5 @@
{ "type": "category_tree", "size": 3, "props": {} },
{ "type": "top_apps", "size": 3, "props": {} }
]
},
{
"id": "pve_audit",
"name": "PVE Audit",
"elements": []
}
]
@@ -1,4 +1,4 @@
Set-StrictMode -Version Latest
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
function Assert-Administrator {
@@ -83,6 +83,41 @@ function Get-ActivityWatchPackageRoot {
return (Split-Path -Path (Split-Path -Path $afkBinary.FullName -Parent) -Parent)
}
function Expand-ActivityWatchArchiveSafe {
param(
[Parameter(Mandatory = $true)]
[string]$ArchivePath,
[Parameter(Mandatory = $true)]
[string]$DestinationPath,
[int]$Attempts = 3
)
for ($attempt = 1; $attempt -le $Attempts; $attempt++) {
try {
if (Test-Path -LiteralPath $DestinationPath) {
Remove-Item -LiteralPath $DestinationPath -Recurse -Force -ErrorAction SilentlyContinue
}
New-ActivityWatchDirectory -Path $DestinationPath
Expand-Archive -Path $ArchivePath -DestinationPath $DestinationPath -Force -ErrorAction Stop
return
}
catch {
if ($attempt -lt $Attempts) {
Start-Sleep -Milliseconds (500 * $attempt)
continue
}
}
}
# Fallback for intermittent Expand-Archive issues in Windows PowerShell.
if (Test-Path -LiteralPath $DestinationPath) {
Remove-Item -LiteralPath $DestinationPath -Recurse -Force -ErrorAction SilentlyContinue
}
New-ActivityWatchDirectory -Path $DestinationPath
Add-Type -AssemblyName System.IO.Compression.FileSystem
[System.IO.Compression.ZipFile]::ExtractToDirectory($ArchivePath, $DestinationPath)
}
function Install-ActivityWatchPackage {
param(
[Parameter(Mandatory = $true)]
@@ -98,6 +133,13 @@ function Install-ActivityWatchPackage {
New-ActivityWatchDirectory -Path $WorkingRoot
New-ActivityWatchDirectory -Path $BackupRoot
# Cleanup stale extraction directories from previous failed deployments.
Get-ChildItem -LiteralPath $WorkingRoot -Directory -ErrorAction SilentlyContinue |
Where-Object { $_.Name -like 'extract-*' } |
ForEach-Object {
try { Remove-Item -LiteralPath $_.FullName -Recurse -Force -ErrorAction SilentlyContinue } catch {}
}
# Ensure nothing is holding locks inside InstallRoot during upgrade.
foreach ($procName in @('aw-watcher-afk', 'aw-watcher-window', 'aw-server', 'aw-qt')) {
try {
@@ -114,7 +156,17 @@ function Install-ActivityWatchPackage {
}
New-ActivityWatchDirectory -Path $extractRoot
Expand-Archive -Path $ArchivePath -DestinationPath $extractRoot -Force
$archiveSize = (Get-Item -LiteralPath $ArchivePath -ErrorAction Stop).Length
$workDrive = (Get-PSDrive -Name ([System.IO.Path]::GetPathRoot($WorkingRoot).TrimEnd('\').TrimEnd(':')) -ErrorAction SilentlyContinue)
if ($workDrive) {
# 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)
}
}
Expand-ActivityWatchArchiveSafe -ArchivePath $ArchivePath -DestinationPath $extractRoot
$packageRoot = Get-ActivityWatchPackageRoot -ExpandedRoot $extractRoot
if (Test-Path -LiteralPath $InstallRoot) {
@@ -181,7 +233,7 @@ function Normalize-ActivityWatchUsers {
$resolved = Resolve-Path -LiteralPath $UserListPath -ErrorAction Stop
$extension = [IO.Path]::GetExtension($resolved.Path)
if ($extension -ieq '.csv') {
$rows = Import-Csv -LiteralPath $resolved.Path
$rows = Import-Csv -LiteralPath $resolved.Path -Encoding UTF8
foreach ($row in $rows) {
foreach ($column in 'User', 'Username', 'SamAccountName', 'Login') {
if ($row.PSObject.Properties.Name -contains $column) {
@@ -195,7 +247,7 @@ function Normalize-ActivityWatchUsers {
}
}
else {
Get-Content -LiteralPath $resolved.Path | ForEach-Object {
Get-Content -LiteralPath $resolved.Path -Encoding UTF8 | ForEach-Object {
$line = $_.Trim()
if ($line -and -not $line.StartsWith('#')) {
$collected.Add($line)
@@ -259,16 +311,95 @@ function New-ActivityWatchUserTaskDefinitions {
return @($result)
}
function Get-ActivityWatchLoggedOnUsers {
$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-ActivityWatchUserHasSession {
param(
[Parameter(Mandatory = $true)]
[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 Copy-ActivityWatchCollectorAssets {
param(
[Parameter(Mandatory = $true)]
[string]$CollectorScriptSource,
[Parameter(Mandatory = $true)]
[string]$EndpointCollectorScriptSource,
[string]$PolicyClientScriptSource,
[Parameter(Mandatory = $true)]
[string]$FileCollectorScriptSource,
[Parameter(Mandatory = $true)]
[string]$SessionCollectorScriptSource,
[string]$EvtxExportScriptSource,
[string]$EmailCollectorScriptSource,
[Parameter(Mandatory = $true)]
[string]$ExampleRulesSource,
@@ -284,8 +415,10 @@ function Copy-ActivityWatchCollectorAssets {
$collectorTarget = Join-Path $StateRoot 'browser-domains-native-collector.ps1'
$endpointCollectorTarget = Join-Path $StateRoot 'dlp-endpoint-signals-collector.ps1'
$policyClientTarget = Join-Path $StateRoot 'dlp-policy-client.ps1'
$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'
$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'
@@ -294,8 +427,14 @@ function Copy-ActivityWatchCollectorAssets {
Copy-Item -LiteralPath $CollectorScriptSource -Destination $collectorTarget -Force
Copy-Item -LiteralPath $EndpointCollectorScriptSource -Destination $endpointCollectorTarget -Force
if ($PolicyClientScriptSource -and (Test-Path -LiteralPath $PolicyClientScriptSource)) {
Copy-Item -LiteralPath $PolicyClientScriptSource -Destination $policyClientTarget -Force
}
Copy-Item -LiteralPath $FileCollectorScriptSource -Destination $fileCollectorTarget -Force
Copy-Item -LiteralPath $SessionCollectorScriptSource -Destination $sessionCollectorTarget -Force
if ($EvtxExportScriptSource -and (Test-Path -LiteralPath $EvtxExportScriptSource)) {
Copy-Item -LiteralPath $EvtxExportScriptSource -Destination $evtxExportTarget -Force
}
if ($EmailCollectorScriptSource -and (Test-Path -LiteralPath $EmailCollectorScriptSource)) {
Copy-Item -LiteralPath $EmailCollectorScriptSource -Destination $emailCollectorTarget -Force
}
@@ -306,6 +445,9 @@ function Copy-ActivityWatchCollectorAssets {
$resolvedRules = Resolve-Path -LiteralPath $CustomRulesSource -ErrorAction Stop
Copy-Item -LiteralPath $resolvedRules.Path -Destination $rulesTarget -Force
}
else {
Copy-Item -LiteralPath $exampleRulesTarget -Destination $rulesTarget -Force
}
if ($CustomPolicySource) {
$resolvedPolicy = Resolve-Path -LiteralPath $CustomPolicySource -ErrorAction Stop
@@ -318,8 +460,10 @@ function Copy-ActivityWatchCollectorAssets {
return [pscustomobject]@{
CollectorScript = $collectorTarget
EndpointCollectorScript = $endpointCollectorTarget
PolicyClientScript = $policyClientTarget
FileCollectorScript = $fileCollectorTarget
SessionCollectorScript = $sessionCollectorTarget
EvtxExportScript = $evtxExportTarget
EmailCollectorScript = $emailCollectorTarget
ExampleRules = $exampleRulesTarget
ActiveRules = $rulesTarget
@@ -346,10 +490,12 @@ function New-ActivityWatchDeploymentConfig {
[string]$CollectorScript,
[Parameter(Mandatory = $true)]
[string]$EndpointCollectorScript,
[string]$PolicyClientScript,
[Parameter(Mandatory = $true)]
[string]$FileCollectorScript,
[Parameter(Mandatory = $true)]
[string]$SessionCollectorScript,
[string]$EvtxExportScript,
[string]$EmailCollectorScript,
[Parameter(Mandatory = $true)]
[string]$RulesPath,
@@ -368,21 +514,51 @@ function New-ActivityWatchDeploymentConfig {
[bool]$IncidentCaptureEnabled = $true,
[bool]$IncidentScreenshotEnabled = $true,
[string]$IncidentArtifactsRoot,
[string]$EvtxExportRoot,
[int]$EvtxRetentionDays = 14,
[string[]]$EvtxChannels = @(),
[bool]$LogonMarkerEnabled = $true,
[Parameter(Mandatory = $true)]
[string]$LaunchScriptPath,
[Parameter(Mandatory = $true)]
[string]$RecoveryScriptPath,
[string]$AwHostname,
[ValidateSet('local', 'server')]
[string]$PolicyMode = 'local',
[bool]$PolicyEngineEnabled = $false,
[string]$PolicyEngineHost,
[int]$PolicyEnginePort = 5601,
[ValidateSet('http', 'https')]
[string]$PolicyEngineScheme = 'http',
[int]$PolicyRefreshSeconds = 300,
[string]$PolicyCachePath,
[Parameter(Mandatory = $true)]
[pscustomobject[]]$UserTasks,
[string]$PackageVersion = 'v0.13.2'
[string]$PackageVersion = 'v0.13.2',
[switch]$IntegrationTestEnabled
)
$effectiveIncidentArtifactsRoot = if ($IncidentArtifactsRoot) { $IncidentArtifactsRoot } else { Join-Path $StateRoot 'incident-artifacts' }
$effectiveEvtxExportRoot = if ($EvtxExportRoot) { $EvtxExportRoot } else { Join-Path $StateRoot 'forensics\evtx-exports' }
$effectiveEvtxChannels = if ($EvtxChannels -and $EvtxChannels.Count -gt 0) {
@($EvtxChannels)
} else {
@(
'Security',
'System',
'Application',
'Microsoft-Windows-PowerShell/Operational',
'Microsoft-Windows-TerminalServices-LocalSessionManager/Operational',
'Microsoft-Windows-TerminalServices-RemoteConnectionManager/Operational'
)
}
$effectivePolicyEngineHost = if ([string]::IsNullOrWhiteSpace($PolicyEngineHost)) { $ServerHost } else { $PolicyEngineHost }
$effectivePolicyCachePath = if ([string]::IsNullOrWhiteSpace($PolicyCachePath)) { Join-Path $StateRoot 'dlp-policy-cache.json' } else { $PolicyCachePath }
return [pscustomobject]@{
version = 1
generatedAtUtc = (Get-Date).ToUniversalTime().ToString('o')
awHostname = if ([string]::IsNullOrWhiteSpace($AwHostname)) { [string]$env:COMPUTERNAME } else { [string]$AwHostname }
server = [pscustomobject]@{
host = $ServerHost
port = $ServerPort
@@ -394,9 +570,11 @@ function New-ActivityWatchDeploymentConfig {
logsRoot = $LogsRoot
collectorScript = $CollectorScript
endpointCollectorScript = $EndpointCollectorScript
policyClientScript = $PolicyClientScript
emailCollectorScript = $EmailCollectorScript
fileCollectorScript = $FileCollectorScript
sessionCollectorScript = $SessionCollectorScript
evtxExportScript = $EvtxExportScript
rulesPath = $RulesPath
policyPath = $PolicyPath
launchScript = $LaunchScriptPath
@@ -410,7 +588,7 @@ function New-ActivityWatchDeploymentConfig {
afkEnabled = $AfkEnabled
windowEnabled = $WindowEnabled
fileOpsEnabled = $FileOpsEnabled
emailEnabled = ($null -ne $EmailCollectorScript -and $EmailCollectorScript -ne '')
emailEnabled = $false
}
logging = [pscustomobject]@{
localAgentLogsEnabled = $LocalAgentLogsEnabled
@@ -420,6 +598,11 @@ function New-ActivityWatchDeploymentConfig {
screenshotEnabled = $IncidentScreenshotEnabled
artifactsRoot = $effectiveIncidentArtifactsRoot
}
forensics = [pscustomobject]@{
evtxExportRoot = $effectiveEvtxExportRoot
retentionDays = $EvtxRetentionDays
evtxChannels = @($effectiveEvtxChannels)
}
sessionEvents = [pscustomobject]@{
logonEnabled = $LogonMarkerEnabled
bucketPrefix = 'aw-session-events'
@@ -432,10 +615,20 @@ function New-ActivityWatchDeploymentConfig {
incidentBucketPrefix = 'aw-dlp-incidents'
enabled = $true
}
policyEngine = [pscustomobject]@{
enabled = $PolicyEngineEnabled
mode = $PolicyMode
host = $effectivePolicyEngineHost
port = $PolicyEnginePort
scheme = $PolicyEngineScheme
refreshSeconds = $PolicyRefreshSeconds
cachePath = $effectivePolicyCachePath
}
package = [pscustomobject]@{
version = $PackageVersion
}
userTasks = @($UserTasks)
integrationTestEnabled = [bool]$IntegrationTestEnabled
}
}
@@ -534,11 +727,7 @@ function Get-CollectorPowerShellProcessCount {
function New-LaunchLock {
param([string]`$StateRoot, [int]`$SessionId)
if (-not (Test-Path -LiteralPath `$StateRoot)) {
New-Item -Path `$StateRoot -ItemType Directory -Force | Out-Null
}
`$lockPath = Join-Path `$StateRoot ("launch-watchers-session-{0}.lock" -f `$SessionId)
`$lockPath = Join-Path `$env:TEMP ("launch-watchers-session-{0}.lock" -f `$SessionId)
if (Test-Path -LiteralPath `$lockPath) {
try {
`$lockData = Get-Content -LiteralPath `$lockPath -Raw | ConvertFrom-Json
@@ -560,6 +749,33 @@ function New-LaunchLock {
return `$lockPath
}
function Get-SessionMarkerToken {
param([int]`$SessionId)
try {
`$explorer = Get-Process -Name 'explorer' -ErrorAction SilentlyContinue |
Where-Object { `$_.SessionId -eq `$SessionId } |
Sort-Object StartTime |
Select-Object -First 1
if (`$explorer -and `$explorer.StartTime) {
return `$explorer.StartTime.ToUniversalTime().ToString('yyyyMMddTHHmmssZ')
}
}
catch {
}
try {
`$currentProcess = Get-Process -Id `$PID -ErrorAction Stop
if (`$currentProcess.StartTime) {
return `$currentProcess.StartTime.ToUniversalTime().ToString('yyyyMMddTHHmmssZ')
}
}
catch {
}
return [string]`$SessionId
}
function Invoke-AwJsonPost {
param(
[Parameter(Mandatory = `$true)][string]`$Uri,
@@ -675,7 +891,8 @@ function Send-LogonMarkerIfNeeded {
return
}
`$markerFile = Join-Path `$markerDir ("logon-{0}-{1}.marker" -f `$env:USERNAME, `$SessionId)
`$sessionMarkerToken = Get-SessionMarkerToken -SessionId `$SessionId
`$markerFile = Join-Path `$markerDir ("logon-{0}-{1}-{2}.marker" -f `$env:USERNAME, `$SessionId, `$sessionMarkerToken)
if (Test-Path -LiteralPath `$markerFile) {
return
}
@@ -731,13 +948,11 @@ function Start-CollectorScriptIfNeeded {
return
}
Start-Process -FilePath `$PowerShellExe -ArgumentList @(
'-NoProfile',
'-WindowStyle', 'Hidden',
'-ExecutionPolicy', 'Bypass',
'-File', `$ScriptPath,
'-ConfigPath', `$ConfigPath
) -WindowStyle Hidden
`$staParam = if (`$ScriptPath -like "*endpoint-signals*") { "-STA" } else { `$null }
`$argumentList = @('-NoProfile', '-WindowStyle', 'Hidden', '-ExecutionPolicy', 'Bypass')
if (`$staParam) { `$argumentList += `$staParam }
`$argumentList += @('-File', `$ScriptPath, '-ConfigPath', `$ConfigPath)
Start-Process -FilePath `$PowerShellExe -ArgumentList `$argumentList -WindowStyle Hidden
}
`$config = Get-DeploymentConfig -Path `$ConfigPath
@@ -745,7 +960,7 @@ function Start-CollectorScriptIfNeeded {
`$installRoot = [string]`$config.paths.installRoot
`$stateRoot = [string]`$config.paths.stateRoot
`$script:ApiBase = '{0}://{1}:{2}/api/0' -f [string]`$config.server.scheme, [string]`$config.server.host, [string]`$config.server.port
`$script:Hostname = `$env:COMPUTERNAME
`$script:Hostname = if (`$config.PSObject.Properties.Name -contains 'awHostname' -and -not [string]::IsNullOrWhiteSpace([string]`$config.awHostname)) { [string]`$config.awHostname } else { `$env:COMPUTERNAME }
`$script:KnownBuckets = @{}
`$collectorScript = [string]`$config.paths.collectorScript
`$endpointCollectorScript = if (`$config.paths.PSObject.Properties.Name -contains 'endpointCollectorScript') { [string]`$config.paths.endpointCollectorScript } else { Join-Path `$stateRoot 'dlp-endpoint-signals-collector.ps1' }
@@ -792,7 +1007,6 @@ try {
if (`$fileOpsEnabled) {
Start-CollectorScriptIfNeeded -ScriptPath `$fileCollectorScript -ConfigPath `$ConfigPath -PowerShellExe `$powershellExe -SessionId `$sessionId
}
Start-CollectorScriptIfNeeded -ScriptPath `$sessionCollectorScript -ConfigPath `$ConfigPath -PowerShellExe `$powershellExe -SessionId `$sessionId
if (`$emailEnabled -and (Test-Path -LiteralPath `$emailCollectorScript)) {
Start-CollectorScriptIfNeeded -ScriptPath `$emailCollectorScript -ConfigPath `$ConfigPath -PowerShellExe `$powershellExe -SessionId `$sessionId
}
@@ -859,17 +1073,21 @@ function Get-RecoveryConfigPaths {
return @(`$paths | Sort-Object -Unique)
}
function Get-RecoveryTaskNames {
function Get-RecoveryTaskDefinitions {
param([string[]]`$ConfigPaths)
`$taskNames = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase)
`$taskMap = [ordered]@{}
foreach (`$candidatePath in @(`$ConfigPaths)) {
try {
`$config = Get-DeploymentConfig -Path `$candidatePath
foreach (`$task in @(`$config.userTasks)) {
`$taskName = [string]`$task.launchTaskName
if (-not [string]::IsNullOrWhiteSpace(`$taskName)) {
[void]`$taskNames.Add(`$taskName)
`$userId = [string]`$task.userId
if (-not [string]::IsNullOrWhiteSpace(`$taskName) -and -not `$taskMap.Contains(`$taskName)) {
`$taskMap[`$taskName] = [pscustomobject]@{
taskName = `$taskName
userId = `$userId
}
}
}
}
@@ -877,7 +1095,7 @@ function Get-RecoveryTaskNames {
}
}
return @(`$taskNames)
return @(`$taskMap.Values)
}
function New-RecoveryLock {
@@ -910,11 +1128,23 @@ function New-RecoveryLock {
}
function Start-TaskIfNotRunning {
param([string]`$TaskName)
param(
[string]`$TaskName,
[string]`$UserId,
[string[]]`$LoggedOnUsers
)
if ([string]::IsNullOrWhiteSpace(`$TaskName)) {
return
}
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) {
@@ -929,6 +1159,120 @@ function Start-TaskIfNotRunning {
}
}
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
}
return [bool]@(
Get-CimInstance Win32_Process -ErrorAction SilentlyContinue |
Where-Object {
(`$_.Name -ieq 'powershell.exe' -or `$_.Name -ieq 'pwsh.exe') -and
`$_.CommandLine -match [Regex]::Escape(`$ScriptPath)
}
).Count
}
function Start-CollectorScriptGlobalIfNeeded {
param(
[string]`$ScriptPath,
[string]`$ConfigPath
)
if ([string]::IsNullOrWhiteSpace(`$ScriptPath)) {
return
}
if (-not (Test-Path -LiteralPath `$ScriptPath)) {
return
}
if (Test-CollectorRunningGlobal -ScriptPath `$ScriptPath) {
return
}
`$powershellExe = Join-Path `$env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'
`$argumentList = @('-NoProfile', '-WindowStyle', 'Hidden', '-ExecutionPolicy', 'Bypass', '-File', `$ScriptPath, '-ConfigPath', `$ConfigPath)
Start-Process -FilePath `$powershellExe -ArgumentList `$argumentList -WindowStyle Hidden
}
`$recoveryLockPath = New-RecoveryLock -PrimaryConfigPath `$ConfigPath
if (-not `$recoveryLockPath) {
return
@@ -939,11 +1283,15 @@ try {
`$sleepSeconds = 180
try {
`$configPaths = Get-RecoveryConfigPaths -PrimaryConfigPath `$ConfigPath
foreach (`$taskName in Get-RecoveryTaskNames -ConfigPaths `$configPaths) {
Start-TaskIfNotRunning -TaskName `$taskName
`$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
}
`$config = Get-DeploymentConfig -Path `$ConfigPath
if (`$config -and `$config.recovery -and `$config.recovery.intervalSeconds) {
`$sleepSeconds = [Math]::Max([int]`$config.recovery.intervalSeconds, 30)
}
@@ -1049,10 +1397,24 @@ function Set-ActivityWatchScheduledTaskAction {
[string]$Arguments
)
$taskCommand = ('"{0}" {1}' -f $Execute, $Arguments)
& schtasks.exe /Change /TN $TaskName /TR $taskCommand | Out-Null
if ($LASTEXITCODE -ne 0) {
throw "schtasks.exe /Change завершился с ошибкой для $TaskName"
$task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
if (-not $task) {
return $false
}
$newAction = New-ScheduledTaskAction -Execute $Execute -Argument $Arguments
try {
# Non-interactive update path. Avoids schtasks.exe /Change password prompt for user-bound tasks.
Set-ScheduledTask -TaskName $TaskName -Action $newAction -ErrorAction Stop | Out-Null
return $true
}
catch {
$taskCommand = ('"{0}" {1}' -f $Execute, $Arguments)
& schtasks.exe /Change /TN $TaskName /TR $taskCommand | Out-Null
if ($LASTEXITCODE -ne 0) {
throw "Не удалось обновить action задачи ${TaskName}: $($_.Exception.Message)"
}
return $true
}
}
@@ -1137,8 +1499,10 @@ function Register-ActivityWatchUserTasks {
$existingTask = Get-ActivityWatchScheduledTaskByCommand -TaskName $definition.LaunchTaskName -CommandMatch $ConfigPath
if ($existingTask) {
Set-ActivityWatchScheduledTaskAction -TaskName $existingTask.TaskName -Execute $wscriptExe -Arguments $action.Arguments
continue
$updated = Set-ActivityWatchScheduledTaskAction -TaskName $existingTask.TaskName -Execute $wscriptExe -Arguments $action.Arguments
if ($updated) {
continue
}
}
Remove-ActivityWatchScheduledTask -TaskName $definition.LaunchTaskName
@@ -1206,8 +1570,12 @@ function Start-ActivityWatchTasks {
[string]$RecoveryTaskName = 'ActivityWatch Recovery'
)
$loggedOnUsers = Get-ActivityWatchLoggedOnUsers
foreach ($definition in $TaskDefinitions) {
Start-ScheduledTask -TaskName $definition.LaunchTaskName -ErrorAction SilentlyContinue
if (Test-ActivityWatchUserHasSession -UserId $definition.UserId -LoggedOnUsers $loggedOnUsers) {
Start-ScheduledTask -TaskName $definition.LaunchTaskName -ErrorAction SilentlyContinue
}
}
Start-ScheduledTask -TaskName $RecoveryTaskName -ErrorAction SilentlyContinue
@@ -62,13 +62,14 @@ $resolvedIncidentLogPath = if ($IncidentLogPath) { $IncidentLogPath } else { Joi
$resolvedLocalAgentLogsEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'logging' -and $deploymentConfig.logging.PSObject.Properties.Name -contains 'localAgentLogsEnabled') { [bool]$deploymentConfig.logging.localAgentLogsEnabled } else { $true }
$resolvedIncidentArtifactsRoot = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'artifactsRoot') { [string]$deploymentConfig.incidentCapture.artifactsRoot } else { Join-Path $env:LOCALAPPDATA 'AWatch-rus\\incident-artifacts' }
$resolvedIncidentScreenshotEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'screenshotEnabled') { [bool]$deploymentConfig.incidentCapture.screenshotEnabled } else { $true }
$resolvedHostname = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'awHostname' -and -not [string]::IsNullOrWhiteSpace([string]$deploymentConfig.awHostname)) { [string]$deploymentConfig.awHostname } else { [string]$env:COMPUTERNAME }
if ($resolvedLocalAgentLogsEnabled -and -not (Test-Path -LiteralPath $resolvedLogsRoot)) {
New-Item -Path $resolvedLogsRoot -ItemType Directory -Force | Out-Null
}
$script:ApiBase = '{0}://{1}:{2}/api/0' -f $resolvedServerScheme, $resolvedServerHost, $resolvedServerPort
$script:Hostname = $env:COMPUTERNAME
$script:Hostname = $resolvedHostname
$script:SessionId = (Get-Process -Id $PID).SessionId
$script:KnownBuckets = @{}
$script:LocalAgentLogsEnabled = $resolvedLocalAgentLogsEnabled
@@ -541,7 +542,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 | Out-Null
Invoke-RestMethod -Method Post -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$resolvedPulseSeconds" -ContentType 'application/json' -Body $event -TimeoutSec 15 -DisableKeepAlive | Out-Null
}
function Get-FileSha256Hex {
@@ -701,13 +702,26 @@ function Ensure-Bucket {
return
}
try {
Invoke-RestMethod -Method Get -Uri "$($script:ApiBase)/buckets/$BucketId" | Out-Null
$script:KnownBuckets[$BucketId] = $true
return
}
catch {
}
$body = @{
client = $ClientName
type = $BucketType
hostname = $script:Hostname
} | ConvertTo-Json -Compress
Invoke-RestMethod -Method Post -Uri "$($script:ApiBase)/buckets/$BucketId" -ContentType 'application/json' -Body $body | Out-Null
try {
Invoke-RestMethod -Method Post -Uri "$($script:ApiBase)/buckets/$BucketId" -ContentType 'application/json; charset=utf-8' -Body ([Text.Encoding]::UTF8.GetBytes($body)) | Out-Null
}
catch {
Invoke-RestMethod -Method Get -Uri "$($script:ApiBase)/buckets/$BucketId" | Out-Null
}
$script:KnownBuckets[$BucketId] = $true
}
@@ -733,7 +747,7 @@ 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 | Out-Null
Invoke-RestMethod -Method Post -Uri "$($script:ApiBase)/buckets/$BucketId/heartbeat?pulsetime=$resolvedPulseSeconds" -ContentType 'application/json' -Body $event -TimeoutSec 15 -DisableKeepAlive | Out-Null
}
function Send-CategoryHeartbeat {
@@ -770,7 +784,7 @@ 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 | Out-Null
Invoke-RestMethod -Method Post -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$resolvedPulseSeconds" -ContentType 'application/json' -Body $event -TimeoutSec 15 -DisableKeepAlive | Out-Null
}
Load-CustomCategoryRules -Path $resolvedRulesPath
@@ -23,9 +23,23 @@ param(
[bool]$IncidentCaptureEnabled = $true,
[bool]$IncidentScreenshotEnabled = $true,
[string]$IncidentArtifactsRoot,
[string]$EvtxExportRoot,
[int]$EvtxRetentionDays = 14,
[string[]]$EvtxChannels = @(),
[bool]$LogonMarkerEnabled = $true,
[string]$AwHostname,
[string]$CustomRulesPath,
[string]$CustomPolicyPath
[string]$CustomPolicyPath,
[ValidateSet('local', 'server')]
[string]$PolicyMode = 'local',
[bool]$PolicyEngineEnabled = $false,
[string]$PolicyEngineHost,
[int]$PolicyEnginePort = 5601,
[ValidateSet('http', 'https')]
[string]$PolicyEngineScheme = 'http',
[int]$PolicyRefreshSeconds = 300,
[string]$PolicyCachePath,
[switch]$IntegrationTestEnabled
)
Set-StrictMode -Version Latest
@@ -45,9 +59,11 @@ $launchScriptPath = Join-Path $StateRoot 'launch-watchers.ps1'
$recoveryScriptPath = Join-Path $StateRoot 'recovery-loop.ps1'
$collectorSource = Join-Path $PSScriptRoot 'browser-domains-native-collector.ps1'
$endpointCollectorSource = Join-Path $PSScriptRoot 'dlp-endpoint-signals-collector.ps1'
$policyClientSource = Join-Path $PSScriptRoot 'dlp-policy-client.ps1'
$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'
$exampleRulesSource = Join-Path $PSScriptRoot 'web-category-rules.example.json'
$examplePolicySource = Join-Path $PSScriptRoot 'dlp-policy.example.json'
@@ -62,9 +78,11 @@ Get-ActivityWatchExecutableMap -InstallRoot $InstallRoot | Out-Null
$assetResult = Copy-ActivityWatchCollectorAssets `
-CollectorScriptSource $collectorSource `
-EndpointCollectorScriptSource $endpointCollectorSource `
-PolicyClientScriptSource $policyClientSource `
-EmailCollectorScriptSource $emailCollectorSource `
-FileCollectorScriptSource $fileCollectorSource `
-SessionCollectorScriptSource $sessionCollectorSource `
-EvtxExportScriptSource $evtxExportScriptSource `
-ExampleRulesSource $exampleRulesSource `
-ExamplePolicySource $examplePolicySource `
-StateRoot $StateRoot `
@@ -84,9 +102,11 @@ $config = New-ActivityWatchDeploymentConfig `
-LogsRoot $logsRoot `
-CollectorScript $assetResult.CollectorScript `
-EndpointCollectorScript $assetResult.EndpointCollectorScript `
-PolicyClientScript $assetResult.PolicyClientScript `
-EmailCollectorScript $assetResult.EmailCollectorScript `
-FileCollectorScript $assetResult.FileCollectorScript `
-SessionCollectorScript $assetResult.SessionCollectorScript `
-EvtxExportScript $assetResult.EvtxExportScript `
-RulesPath $assetResult.ActiveRules `
-PolicyPath $assetResult.ActivePolicy `
-PollSeconds $PollSeconds `
@@ -99,11 +119,23 @@ $config = New-ActivityWatchDeploymentConfig `
-IncidentCaptureEnabled $IncidentCaptureEnabled `
-IncidentScreenshotEnabled $IncidentScreenshotEnabled `
-IncidentArtifactsRoot $IncidentArtifactsRoot `
-EvtxExportRoot $EvtxExportRoot `
-EvtxRetentionDays $EvtxRetentionDays `
-EvtxChannels $EvtxChannels `
-LogonMarkerEnabled $LogonMarkerEnabled `
-AwHostname $AwHostname `
-PolicyMode $PolicyMode `
-PolicyEngineEnabled $PolicyEngineEnabled `
-PolicyEngineHost $PolicyEngineHost `
-PolicyEnginePort $PolicyEnginePort `
-PolicyEngineScheme $PolicyEngineScheme `
-PolicyRefreshSeconds $PolicyRefreshSeconds `
-PolicyCachePath $PolicyCachePath `
-LaunchScriptPath $launchScriptPath `
-RecoveryScriptPath $recoveryScriptPath `
-UserTasks $taskDefinitions `
-PackageVersion $Version
-PackageVersion $Version `
-IntegrationTestEnabled:$IntegrationTestEnabled
Write-ActivityWatchDeploymentConfig -Config $config -Path $configPath
Remove-LegacyActivityWatchEntries
@@ -23,12 +23,26 @@ param(
[bool]$IncidentCaptureEnabled = $true,
[bool]$IncidentScreenshotEnabled = $true,
[string]$IncidentArtifactsRoot,
[string]$EvtxExportRoot,
[int]$EvtxRetentionDays = 14,
[string[]]$EvtxChannels = @(),
[bool]$LogonMarkerEnabled = $true,
[string]$AwHostname,
[string]$CustomRulesPath,
[string]$CustomPolicyPath,
[ValidateSet('local', 'server')]
[string]$PolicyMode = 'local',
[bool]$PolicyEngineEnabled = $false,
[string]$PolicyEngineHost,
[int]$PolicyEnginePort = 5601,
[ValidateSet('http', 'https')]
[string]$PolicyEngineScheme = 'http',
[int]$PolicyRefreshSeconds = 300,
[string]$PolicyCachePath,
[string]$ReportPath,
[switch]$SkipHardening,
[switch]$ValidateAfterDeploy
[switch]$ValidateAfterDeploy,
[switch]$IntegrationTestEnabled
)
Set-StrictMode -Version Latest
@@ -70,9 +84,21 @@ if (-not (Test-Path -LiteralPath $deployScript)) {
-IncidentCaptureEnabled $IncidentCaptureEnabled `
-IncidentScreenshotEnabled $IncidentScreenshotEnabled `
-IncidentArtifactsRoot $IncidentArtifactsRoot `
-EvtxExportRoot $EvtxExportRoot `
-EvtxRetentionDays $EvtxRetentionDays `
-EvtxChannels $EvtxChannels `
-LogonMarkerEnabled $LogonMarkerEnabled `
-AwHostname $AwHostname `
-CustomRulesPath $CustomRulesPath `
-CustomPolicyPath $CustomPolicyPath
-CustomPolicyPath $CustomPolicyPath `
-PolicyMode $PolicyMode `
-PolicyEngineEnabled $PolicyEngineEnabled `
-PolicyEngineHost $PolicyEngineHost `
-PolicyEnginePort $PolicyEnginePort `
-PolicyEngineScheme $PolicyEngineScheme `
-PolicyRefreshSeconds $PolicyRefreshSeconds `
-PolicyCachePath $PolicyCachePath `
-IntegrationTestEnabled:$IntegrationTestEnabled
if (-not $SkipHardening) {
& $hardeningScript `
@@ -93,9 +119,20 @@ if (-not $SkipHardening) {
-IncidentCaptureEnabled $IncidentCaptureEnabled `
-IncidentScreenshotEnabled $IncidentScreenshotEnabled `
-IncidentArtifactsRoot $IncidentArtifactsRoot `
-EvtxExportRoot $EvtxExportRoot `
-EvtxRetentionDays $EvtxRetentionDays `
-EvtxChannels $EvtxChannels `
-LogonMarkerEnabled $LogonMarkerEnabled `
-AwHostname $AwHostname `
-CustomRulesPath $CustomRulesPath `
-CustomPolicyPath $CustomPolicyPath
-CustomPolicyPath $CustomPolicyPath `
-PolicyMode $PolicyMode `
-PolicyEngineEnabled $PolicyEngineEnabled `
-PolicyEngineHost $PolicyEngineHost `
-PolicyEnginePort $PolicyEnginePort `
-PolicyEngineScheme $PolicyEngineScheme `
-PolicyRefreshSeconds $PolicyRefreshSeconds `
-PolicyCachePath $PolicyCachePath
}
$report = [ordered]@{
@@ -21,7 +21,11 @@ param(
[bool]$IncidentCaptureEnabled = $true,
[bool]$IncidentScreenshotEnabled = $true,
[string]$IncidentArtifactsRoot,
[string]$EvtxExportRoot,
[int]$EvtxRetentionDays = 14,
[string[]]$EvtxChannels = @(),
[bool]$LogonMarkerEnabled = $true,
[string]$AwHostname,
[string]$CustomRulesPath,
[string]$CustomPolicyPath
)
@@ -44,6 +48,7 @@ $collectorSource = Join-Path $PSScriptRoot 'browser-domains-native-collector.ps1
$endpointCollectorSource = Join-Path $PSScriptRoot 'dlp-endpoint-signals-collector.ps1'
$emailCollectorSource = Join-Path $PSScriptRoot 'email-outbound-collector.ps1'
$sessionCollectorSource = Join-Path $PSScriptRoot 'worktime-session-collector.ps1'
$evtxExportScriptSource = Join-Path $PSScriptRoot 'export-evtx-for-hayabusa.ps1'
$exampleRulesSource = Join-Path $PSScriptRoot 'web-category-rules.example.json'
$examplePolicySource = Join-Path $PSScriptRoot 'dlp-policy.example.json'
@@ -59,6 +64,7 @@ $assetResult = Copy-ActivityWatchCollectorAssets `
-EndpointCollectorScriptSource $endpointCollectorSource `
-EmailCollectorScriptSource $emailCollectorSource `
-SessionCollectorScriptSource $sessionCollectorSource `
-EvtxExportScriptSource $evtxExportScriptSource `
-ExampleRulesSource $exampleRulesSource `
-ExamplePolicySource $examplePolicySource `
-StateRoot $StateRoot `
@@ -80,6 +86,7 @@ $config = New-ActivityWatchDeploymentConfig `
-EndpointCollectorScript $assetResult.EndpointCollectorScript `
-EmailCollectorScript $assetResult.EmailCollectorScript `
-SessionCollectorScript $assetResult.SessionCollectorScript `
-EvtxExportScript $assetResult.EvtxExportScript `
-RulesPath $assetResult.ActiveRules `
-PolicyPath $assetResult.ActivePolicy `
-PollSeconds $PollSeconds `
@@ -91,7 +98,11 @@ $config = New-ActivityWatchDeploymentConfig `
-IncidentCaptureEnabled $IncidentCaptureEnabled `
-IncidentScreenshotEnabled $IncidentScreenshotEnabled `
-IncidentArtifactsRoot $IncidentArtifactsRoot `
-EvtxExportRoot $EvtxExportRoot `
-EvtxRetentionDays $EvtxRetentionDays `
-EvtxChannels $EvtxChannels `
-LogonMarkerEnabled $LogonMarkerEnabled `
-AwHostname $AwHostname `
-LaunchScriptPath $launchScriptPath `
-RecoveryScriptPath $recoveryScriptPath `
-UserTasks $taskDefinitions `
@@ -1,11 +1,19 @@
[CmdletBinding()]
[CmdletBinding()]
param(
[string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json',
[string]$ServerHost,
[int]$ServerPort,
[ValidateSet('http', 'https')]
[string]$ServerScheme,
[string]$PolicyEngineHost,
[int]$PolicyEnginePort,
[ValidateSet('http', 'https')]
[string]$PolicyEngineScheme,
[string]$PolicyPath,
[ValidateSet('local', 'server')]
[string]$PolicyMode,
[int]$PolicyRefreshSeconds,
[string]$PolicyCachePath,
[string]$LogPath,
[int]$PollSeconds
)
@@ -13,6 +21,36 @@ param(
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
# Ensure HttpClient is available (Windows PowerShell 5 may not auto-load it)
try {
Add-Type -AssemblyName System.Net.Http
}
catch {
}
$script:TransportQueuePath = $null
$script:TransportQueueLockPath = $null
$script:TransportMetrics = @{
eventsEnqueued = 0
eventsFlushed = 0
sendFailures = 0
queueDepth = 0
}
$policyClientModulePath = Join-Path $PSScriptRoot 'dlp-policy-client.ps1'
if (Test-Path -LiteralPath $policyClientModulePath) {
try {
Import-Module $policyClientModulePath -Force -DisableNameChecking
$script:PolicyClientAvailable = $true
}
catch {
$script:PolicyClientAvailable = $false
}
}
else {
$script:PolicyClientAvailable = $false
}
function Get-DeploymentConfig {
param([string]$Path)
if ($Path -and (Test-Path -LiteralPath $Path)) {
@@ -39,8 +77,146 @@ function Invoke-AwJsonPost {
[Parameter(Mandatory = $true)][string]$Json
)
$bytes = [Text.Encoding]::UTF8.GetBytes($Json)
Invoke-RestMethod -Method Post -Uri $Uri -ContentType 'application/json; charset=utf-8' -Body $bytes | Out-Null
try {
$bytes = [System.Text.Encoding]::UTF8.GetBytes($Json)
$req = [System.Net.HttpWebRequest]::Create($Uri)
$req.Method = 'POST'
$req.ContentType = 'application/json'
$req.Accept = 'application/json'
$req.KeepAlive = $false
$req.Timeout = 15000
$req.ReadWriteTimeout = 15000
$req.ContentLength = $bytes.Length
$stream = $req.GetRequestStream()
try { $stream.Write($bytes, 0, $bytes.Length) } finally { $stream.Close() }
$resp = $req.GetResponse()
try {
# read body for debugging, but discard on success
$rs = $resp.GetResponseStream()
if ($rs) { $sr = New-Object System.IO.StreamReader($rs); $null = $sr.ReadToEnd(); $sr.Close() }
} finally {
$resp.Close()
}
return
}
catch [System.Net.WebException] {
$status = $null
$body = ''
try {
if ($_.Exception.Response) {
try { $status = [int]$_.Exception.Response.StatusCode } catch {}
$rs = $_.Exception.Response.GetResponseStream()
if ($rs) { $sr = New-Object System.IO.StreamReader($rs); $body = $sr.ReadToEnd(); $sr.Close() }
}
} catch {}
# aw-server-rust may return 304 for idempotent bucket create. Treat it as OK.
if ($status -eq 304) {
Write-EndpointLog ("POST bucket exists (304): uri={0}" -f $Uri)
return
}
Write-EndpointLog ("POST failed: uri={0} status={1} err={2} body={3}" -f $Uri, $status, $_.Exception.Message, $body)
throw
}
catch {
Write-EndpointLog ("POST error: uri={0} err={1}" -f $Uri, $_.Exception.Message)
throw
}
}
function Initialize-TransportQueue {
param([Parameter(Mandatory = $true)][string]$StateRoot)
$script:TransportQueuePath = Join-Path $StateRoot 'dlp-endpoint-signals-queue.jsonl'
$script:TransportQueueLockPath = Join-Path $StateRoot 'dlp-endpoint-signals-queue.lock'
if (-not (Test-Path -LiteralPath $script:TransportQueuePath)) {
New-Item -Path $script:TransportQueuePath -ItemType File -Force | Out-Null
}
}
function Get-TransportQueueLock {
$tries = 0
while ($tries -lt 50) {
try {
return [System.IO.File]::Open($script:TransportQueueLockPath, [System.IO.FileMode]::OpenOrCreate, [System.IO.FileAccess]::ReadWrite, [System.IO.FileShare]::None)
}
catch {
Start-Sleep -Milliseconds 50
$tries++
}
}
throw "Failed to acquire transport queue lock: $script:TransportQueueLockPath"
}
function Add-TransportQueueItem {
param(
[Parameter(Mandatory = $true)][string]$Uri,
[Parameter(Mandatory = $true)][string]$Payload,
[string]$Kind = 'endpoint'
)
$lock = Get-TransportQueueLock
try {
$line = @{
ts = (Get-Date).ToUniversalTime().ToString('o')
uri = $Uri
payload = $Payload
kind = $Kind
} | ConvertTo-Json -Compress
Add-Content -LiteralPath $script:TransportQueuePath -Value $line -Encoding UTF8
$script:TransportMetrics.eventsEnqueued++
}
finally {
$lock.Dispose()
}
}
function Read-TransportQueueItems {
if (-not (Test-Path -LiteralPath $script:TransportQueuePath)) { return @() }
$items = @()
foreach ($line in @(Get-Content -LiteralPath $script:TransportQueuePath -ErrorAction SilentlyContinue)) {
if ([string]::IsNullOrWhiteSpace($line)) { continue }
try { $items += ($line | ConvertFrom-Json) } catch {}
}
return $items
}
function Flush-TransportQueue {
param([int]$MaxItems = 200)
if (-not (Test-Path -LiteralPath $script:TransportQueuePath)) { return }
$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)
}
}
foreach ($item in $items | Select-Object -Skip ($sent + $left.Count)) {
$left.Add($item)
}
$lines = @($left | ForEach-Object { $_ | ConvertTo-Json -Compress })
Set-Content -LiteralPath $script:TransportQueuePath -Value $lines -Encoding UTF8
$script:TransportMetrics.queueDepth = $left.Count
}
finally {
$lock.Dispose()
}
}
function Ensure-Bucket {
@@ -54,13 +230,39 @@ function Ensure-Bucket {
return
}
if ($script:KnownBuckets.ContainsKey($BucketId)) {
return
}
# Fast-path: if bucket already exists, don't POST.
try {
Invoke-RestMethod -Method Get -Uri "$($script:ApiBase)/buckets/$BucketId" -TimeoutSec 10 -DisableKeepAlive -ErrorAction Stop | Out-Null
Write-EndpointLog ("bucket ok (GET): {0}" -f $BucketId)
$script:KnownBuckets[$BucketId] = $true
return
}
catch {
Write-EndpointLog ("bucket GET failed: {0} err={1}" -f $BucketId, $_.Exception.Message)
}
$body = @{
client = $ClientName
type = $BucketType
hostname = $script:Hostname
} | ConvertTo-Json -Compress
Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$BucketId" -Json $body
try {
Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$BucketId" -Json $body
}
catch {
# If create failed (race), verify it exists now.
try {
Invoke-RestMethod -Method Get -Uri "$($script:ApiBase)/buckets/$BucketId" -TimeoutSec 10 -DisableKeepAlive | Out-Null
}
catch {
throw
}
}
$script:KnownBuckets[$BucketId] = $true
}
@@ -85,7 +287,8 @@ function Send-EndpointSignalHeartbeat {
} + $Data
} | ConvertTo-Json -Depth 6 -Compress
Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$script:PulseSeconds" -Json $payload
Add-TransportQueueItem -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$script:PulseSeconds" -Payload $payload -Kind 'endpoint_signal'
Flush-TransportQueue -MaxItems 50
}
function Send-DlpIncidentHeartbeat {
@@ -126,7 +329,8 @@ function Send-DlpIncidentHeartbeat {
} + $Data + $captureData
} | ConvertTo-Json -Depth 7 -Compress
Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$script:PulseSeconds" -Json $payload
Add-TransportQueueItem -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$script:PulseSeconds" -Payload $payload -Kind 'dlp_incident'
Flush-TransportQueue -MaxItems 100
}
function Get-FileSha256Hex {
@@ -321,6 +525,53 @@ function Get-StringHash {
}
}
function Get-ClipboardTextSafe {
[OutputType([string])]
param()
try {
$v = Get-Clipboard -Raw -ErrorAction Stop
if ($null -ne $v) { return [string]$v }
}
catch {
Write-EndpointLog ("clipboard direct read failed: {0}" -f $_.Exception.Message)
}
# Clipboard is not reliably accessible from Session 0 (SYSTEM). Avoid noisy thread hacks there.
if ($script:SessionId -eq 0) {
return $null
}
# Fallback: read clipboard in a dedicated STA thread for RDP/user-session edge cases.
try {
Add-Type -AssemblyName System.Windows.Forms -ErrorAction SilentlyContinue | Out-Null
$result = [string]::Empty
$script:__aw_clip = $null
$threadStart = [System.Threading.ThreadStart]{
try {
$script:__aw_clip = [System.Windows.Forms.Clipboard]::GetText()
}
catch {
$script:__aw_clip = $null
}
}
$thread = New-Object System.Threading.Thread($threadStart)
$thread.SetApartmentState([System.Threading.ApartmentState]::STA)
$thread.Start()
$thread.Join(3000) | Out-Null
if ($thread.IsAlive) {
try { $thread.Abort() } catch {}
}
$result = [string]$script:__aw_clip
Remove-Variable -Name __aw_clip -Scope Script -ErrorAction SilentlyContinue
return $result
}
catch {
Write-EndpointLog ("clipboard STA read failed: {0}" -f $_.Exception.Message)
return $null
}
}
function Load-DlpPolicy {
param([string]$Path)
@@ -336,8 +587,17 @@ function Load-DlpPolicy {
usb = @()
print = @()
}
contentAnalysis = [ordered]@{
dictionaryPack = $null
regexPack = $null
ocrEnabled = $false
}
}
$script:PolicySource = 'defaults'
$script:PolicyVersion = $null
$script:PolicyChecksum = $null
if (-not $Path -or -not (Test-Path -LiteralPath $Path)) {
Write-EndpointLog ("policy not found, using defaults: {0}" -f $Path)
return
@@ -353,16 +613,240 @@ function Load-DlpPolicy {
}
if ($raw.endpoint) {
if ($raw.endpoint.clipboard) { $script:Policy.endpoint.clipboard = @($raw.endpoint.clipboard) }
if ($raw.endpoint.usb) { $script:Policy.endpoint.usb = @($raw.endpoint.usb) }
if ($raw.endpoint.print) { $script:Policy.endpoint.print = @($raw.endpoint.print) }
$props = @()
try { $props = @($raw.endpoint.PSObject.Properties.Name) } catch { $props = @() }
if ($props -contains 'clipboard' -and $raw.endpoint.clipboard) { $script:Policy.endpoint.clipboard = @($raw.endpoint.clipboard) }
if ($props -contains 'usb' -and $raw.endpoint.usb) { $script:Policy.endpoint.usb = @($raw.endpoint.usb) }
if ($props -contains 'print' -and $raw.endpoint.print) { $script:Policy.endpoint.print = @($raw.endpoint.print) }
}
if ($raw.contentAnalysis) {
if ($raw.contentAnalysis.PSObject.Properties.Name -contains 'dictionaryPack' -and $raw.contentAnalysis.dictionaryPack) {
$script:Policy.contentAnalysis.dictionaryPack = [string]$raw.contentAnalysis.dictionaryPack
}
if ($raw.contentAnalysis.PSObject.Properties.Name -contains 'regexPack' -and $raw.contentAnalysis.regexPack) {
$script:Policy.contentAnalysis.regexPack = [string]$raw.contentAnalysis.regexPack
}
if ($raw.contentAnalysis.PSObject.Properties.Name -contains 'ocrEnabled') {
$script:Policy.contentAnalysis.ocrEnabled = [bool]$raw.contentAnalysis.ocrEnabled
}
}
$script:PolicySource = 'local'
}
catch {
Write-EndpointLog ("policy parse failed: {0}" -f $_.Exception.Message)
}
}
function Test-ValidInn {
param([string]$Value)
$digits = ($Value -replace '\D', '')
if ($digits.Length -eq 10) {
$coef = @(2, 4, 10, 3, 5, 9, 4, 6, 8)
$sum = 0
for ($i = 0; $i -lt 9; $i++) { $sum += ([int][string]$digits[$i]) * $coef[$i] }
$chk = ($sum % 11) % 10
return $chk -eq ([int][string]$digits[9])
}
if ($digits.Length -eq 12) {
$c11 = @(7, 2, 4, 10, 3, 5, 9, 4, 6, 8)
$c12 = @(3, 7, 2, 4, 10, 3, 5, 9, 4, 6, 8)
$sum11 = 0
for ($i = 0; $i -lt 10; $i++) { $sum11 += ([int][string]$digits[$i]) * $c11[$i] }
$sum12 = 0
for ($i = 0; $i -lt 11; $i++) { $sum12 += ([int][string]$digits[$i]) * $c12[$i] }
return ((($sum11 % 11) % 10) -eq ([int][string]$digits[10])) -and ((($sum12 % 11) % 10) -eq ([int][string]$digits[11]))
}
return $false
}
function Test-ValidSnils {
param([string]$Value)
$digits = ($Value -replace '\D', '')
if ($digits.Length -ne 11) { return $false }
$num = $digits.Substring(0, 9)
$checksum = [int]$digits.Substring(9, 2)
$sum = 0
for ($i = 0; $i -lt 9; $i++) { $sum += ([int][string]$num[$i]) * (9 - $i) }
if ($sum -lt 100) { $expected = $sum }
elseif ($sum -eq 100 -or $sum -eq 101) { $expected = 0 }
else {
$expected = $sum % 101
if ($expected -eq 100) { $expected = 0 }
}
return $checksum -eq $expected
}
function Test-ValidPassport {
param([string]$Value)
$digits = ($Value -replace '\D', '')
if ($digits.Length -ne 10) { return $false }
if ($digits -eq '0000000000') { return $false }
return ($digits.ToCharArray() | Select-Object -Unique).Count -gt 1
}
function Get-AdvancedContentMatches {
param(
[string]$Text,
[string]$DictionaryPack,
[string]$RegexPack
)
$result = @{
dictionaryMatches = @()
regexMatches = @()
}
if ([string]::IsNullOrWhiteSpace($Text)) { return $result }
if ($DictionaryPack -eq '152-fz-pdn') {
$m = [regex]::Matches($Text, '\b\d{10}\b|\b\d{12}\b')
foreach ($item in $m) {
if (Test-ValidInn -Value $item.Value) {
$result.dictionaryMatches += @{ name = 'inn'; value = $item.Value; severity = 'high' }
}
}
$m = [regex]::Matches($Text, '\b\d{3}-\d{3}-\d{3}\s?\d{2}\b')
foreach ($item in $m) {
if (Test-ValidSnils -Value $item.Value) {
$result.dictionaryMatches += @{ name = 'snils'; value = $item.Value; severity = 'high' }
}
}
$m = [regex]::Matches($Text, '\b\d{4}\s?\d{6}\b')
foreach ($item in $m) {
if (Test-ValidPassport -Value $item.Value) {
$result.dictionaryMatches += @{ name = 'passport'; value = $item.Value; severity = 'high' }
}
}
}
$regexRules = @()
switch ($RegexPack) {
'financial' {
$regexRules = @(
@{ id = 'card-pan'; regex = '\b(?:\d[ -]*?){13,19}\b'; severity = 'high' },
@{ id = 'iban'; regex = '\b[A-Z]{2}\d{2}[A-Z0-9]{11,30}\b'; severity = 'medium' }
)
}
'contacts' {
$regexRules = @(
@{ id = 'email'; regex = '[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}'; severity = 'low' },
@{ id = 'phone-ru'; regex = '(?:\+7|8)\s*\(?\d{3}\)?\s*\d{3}[- ]?\d{2}[- ]?\d{2}'; severity = 'low' }
)
}
'secrets' {
$regexRules = @(
@{ id = 'aws-access-key'; regex = 'AKIA[0-9A-Z]{16}'; severity = 'high' },
@{ id = 'generic-password'; regex = '(?i)(password|пароль)\s*[:=]\s*\S{6,}'; severity = 'medium' }
)
}
}
foreach ($rule in $regexRules) {
$m = [regex]::Matches($Text, [string]$rule.regex)
foreach ($item in $m) {
$result.regexMatches += @{ name = [string]$rule.id; value = $item.Value; severity = [string]$rule.severity }
}
}
return $result
}
function Apply-PolicyFromBundle {
param(
[Parameter(Mandatory = $true)]$Bundle,
[Parameter(Mandatory = $true)][string]$Source
)
if (-not $Bundle.policy) {
throw 'Policy bundle has no policy payload.'
}
$tempPath = [System.IO.Path]::GetTempFileName()
try {
$Bundle.policy | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $tempPath -Encoding UTF8
Load-DlpPolicy -Path $tempPath
$script:PolicySource = $Source
$script:PolicyVersion = if ($Bundle.PSObject.Properties.Name -contains 'version') { [string]$Bundle.version } else { $null }
$script:PolicyChecksum = if ($Bundle.PSObject.Properties.Name -contains 'checksum') { [string]$Bundle.checksum } else { $null }
}
finally {
Remove-Item -LiteralPath $tempPath -Force -ErrorAction SilentlyContinue
}
}
function Refresh-DlpPolicyFromServer {
if (-not $script:PolicyEngineEnabled) {
return $false
}
if (-not $script:PolicyClientAvailable) {
Write-EndpointLog 'policy client module unavailable, cannot use server mode'
return $false
}
try {
$bundle = Get-RemoteDlpPolicyBundle -ApiBase $script:PolicyApiBase -TimeoutSec 10
Save-CachedDlpPolicyBundle -Bundle $bundle -CachePath $script:PolicyCachePath
Apply-PolicyFromBundle -Bundle $bundle -Source 'server'
$script:LastPolicyRefreshAt = (Get-Date).ToUniversalTime()
Write-EndpointLog ("policy refreshed from server version={0} checksum={1}" -f $script:PolicyVersion, $script:PolicyChecksum)
return $true
}
catch {
Write-EndpointLog ("policy refresh failed: {0}" -f $_.Exception.Message)
return $false
}
}
function Sync-DlpPolicyDesiredState {
if (-not $script:PolicyEngineEnabled -or -not $script:PolicyClientAvailable) {
return $false
}
if (-not $script:PolicyAgentId) {
return $false
}
try {
[void](Send-DlpPolicyAgentHeartbeat -ApiBase $script:PolicyApiBase -AgentId $script:PolicyAgentId -Hostname $script:Hostname -Version $script:PolicyVersion -Checksum $script:PolicyChecksum -TimeoutSec 10)
$desired = Get-RemoteDlpPolicyDesired -ApiBase $script:PolicyApiBase -AgentId $script:PolicyAgentId -TimeoutSec 10
if ($desired -and $desired.refreshNow -eq $true) {
Write-EndpointLog ("policy desired refresh requested: reason={0}" -f $desired.reason)
return (Refresh-DlpPolicyFromServer)
}
return $true
}
catch {
Write-EndpointLog ("policy desired sync failed: {0}" -f $_.Exception.Message)
return $false
}
}
function Initialize-DlpPolicy {
if ($script:PolicyMode -eq 'server') {
if (Refresh-DlpPolicyFromServer) {
return
}
if ($script:PolicyClientAvailable) {
$cached = Read-CachedDlpPolicyBundle -CachePath $script:PolicyCachePath
if ($cached) {
try {
Apply-PolicyFromBundle -Bundle $cached -Source 'cache'
Write-EndpointLog ("policy loaded from cache version={0} checksum={1}" -f $script:PolicyVersion, $script:PolicyChecksum)
return
}
catch {
Write-EndpointLog ("cached policy load failed: {0}" -f $_.Exception.Message)
}
}
}
Load-DlpPolicy -Path $script:LocalPolicyPath
$script:PolicySource = 'local-fallback'
return
}
Load-DlpPolicy -Path $script:LocalPolicyPath
}
function Should-EmitByCooldown {
param(
[string]$Fingerprint,
@@ -394,6 +878,9 @@ function Evaluate-ClipboardRules {
if (-not $ruleId) { continue }
$minLength = if ($rule.minLength) { [int]$rule.minLength } else { 0 }
$regexPatterns = if ($rule.regexPatterns) { @($rule.regexPatterns) } else { @() }
$dictionaryPack = if ($rule.dictionaryPack) { [string]$rule.dictionaryPack } elseif ($script:Policy.contentAnalysis.dictionaryPack) { [string]$script:Policy.contentAnalysis.dictionaryPack } else { $null }
$regexPack = if ($rule.regexPack) { [string]$rule.regexPack } elseif ($script:Policy.contentAnalysis.regexPack) { [string]$script:Policy.contentAnalysis.regexPack } else { $null }
$ocrEnabled = if ($rule.PSObject.Properties.Name -contains 'ocrEnabled') { [bool]$rule.ocrEnabled } else { [bool]$script:Policy.contentAnalysis.ocrEnabled }
if ($ClipboardText.Length -lt $minLength) { continue }
$matched = $false
@@ -403,6 +890,9 @@ function Evaluate-ClipboardRules {
break
}
}
$advanced = Get-AdvancedContentMatches -Text $ClipboardText -DictionaryPack $dictionaryPack -RegexPack $regexPack
$advancedMatched = (@($advanced.dictionaryMatches).Count -gt 0) -or (@($advanced.regexMatches).Count -gt 0)
if ($advancedMatched) { $matched = $true }
if (-not $matched) { continue }
@@ -424,6 +914,11 @@ function Evaluate-ClipboardRules {
clipboardHash = $ClipboardHash
clipboardLength = $ClipboardText.Length
enforced = $enforced
dictionaryPack = $dictionaryPack
regexPack = $regexPack
dictionaryMatches = @($advanced.dictionaryMatches)
regexMatches = @($advanced.regexMatches)
ocrRequested = $ocrEnabled
}
Write-EndpointLog ("incident clipboard rule={0} action={1} severity={2} enforced={3}" -f $ruleId, $action, $severity, $enforced)
}
@@ -484,6 +979,12 @@ function Evaluate-PrintRules {
if ($rule.documentRegex) {
$match = $match -and ($DocumentName -match [string]$rule.documentRegex)
}
$dictionaryPack = if ($rule.dictionaryPack) { [string]$rule.dictionaryPack } elseif ($script:Policy.contentAnalysis.dictionaryPack) { [string]$script:Policy.contentAnalysis.dictionaryPack } else { $null }
$regexPack = if ($rule.regexPack) { [string]$rule.regexPack } elseif ($script:Policy.contentAnalysis.regexPack) { [string]$script:Policy.contentAnalysis.regexPack } else { $null }
$ocrEnabled = if ($rule.PSObject.Properties.Name -contains 'ocrEnabled') { [bool]$rule.ocrEnabled } else { [bool]$script:Policy.contentAnalysis.ocrEnabled }
$advanced = Get-AdvancedContentMatches -Text $DocumentName -DictionaryPack $dictionaryPack -RegexPack $regexPack
$advancedMatched = (@($advanced.dictionaryMatches).Count -gt 0) -or (@($advanced.regexMatches).Count -gt 0)
if ($advancedMatched) { $match = $true }
if (-not $match) { continue }
$cooldown = if ($rule.cooldownSeconds) { [int]$rule.cooldownSeconds } else { [int]$script:Policy.defaults.cooldownSeconds }
@@ -505,6 +1006,11 @@ function Evaluate-PrintRules {
documentName = $DocumentName
owner = $Owner
enforced = $enforced
dictionaryPack = $dictionaryPack
regexPack = $regexPack
dictionaryMatches = @($advanced.dictionaryMatches)
regexMatches = @($advanced.regexMatches)
ocrRequested = $ocrEnabled
}
Write-EndpointLog ("incident print rule={0} action={1} severity={2} printer={3} enforced={4}" -f $ruleId, $action, $severity, $PrinterName, $enforced)
}
@@ -734,19 +1240,29 @@ $resolvedServerHost = if ($ServerHost) { $ServerHost } elseif ($deploymentConfig
$resolvedServerPort = if ($PSBoundParameters.ContainsKey('ServerPort')) { $ServerPort } elseif ($deploymentConfig) { [int]$deploymentConfig.server.port } else { 5600 }
$resolvedServerScheme = if ($ServerScheme) { $ServerScheme } elseif ($deploymentConfig) { [string]$deploymentConfig.server.scheme } else { 'http' }
$resolvedPolicyPath = if ($PolicyPath) { $PolicyPath } elseif ($deploymentConfig -and $deploymentConfig.paths.PSObject.Properties.Name -contains 'policyPath') { [string]$deploymentConfig.paths.policyPath } else { 'C:\ProgramData\AWatch-rus\dlp-policy.json' }
$resolvedStateRoot = if ($deploymentConfig -and $deploymentConfig.paths.PSObject.Properties.Name -contains 'stateRoot') { [string]$deploymentConfig.paths.stateRoot } else { Split-Path -Path $resolvedPolicyPath -Parent }
$resolvedPollSeconds = if ($PSBoundParameters.ContainsKey('PollSeconds')) { $PollSeconds } elseif ($deploymentConfig) { [int]$deploymentConfig.collector.pollSeconds } else { 5 }
$resolvedLogsRoot = if ($deploymentConfig) { [string]$deploymentConfig.paths.logsRoot } else { 'C:\ProgramData\AWatch-rus\logs' }
$resolvedLogPath = if ($LogPath) { $LogPath } else { Join-Path $resolvedLogsRoot ("endpoint-signals-{0}.log" -f $env:USERNAME) }
$resolvedLocalAgentLogsEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'logging' -and $deploymentConfig.logging.PSObject.Properties.Name -contains 'localAgentLogsEnabled') { [bool]$deploymentConfig.logging.localAgentLogsEnabled } else { $true }
$resolvedIncidentArtifactsRoot = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'artifactsRoot') { [string]$deploymentConfig.incidentCapture.artifactsRoot } else { Join-Path $env:LOCALAPPDATA 'AWatch-rus\\incident-artifacts' }
$resolvedIncidentScreenshotEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'screenshotEnabled') { [bool]$deploymentConfig.incidentCapture.screenshotEnabled } else { $true }
$resolvedHostname = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'awHostname' -and -not [string]::IsNullOrWhiteSpace([string]$deploymentConfig.awHostname)) { [string]$deploymentConfig.awHostname } else { [string]$env:COMPUTERNAME }
$resolvedPolicyMode = if ($PolicyMode) { [string]$PolicyMode } elseif ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'policyEngine' -and $deploymentConfig.policyEngine.PSObject.Properties.Name -contains 'mode') { [string]$deploymentConfig.policyEngine.mode } else { 'local' }
$resolvedPolicyEngineEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'policyEngine' -and $deploymentConfig.policyEngine.PSObject.Properties.Name -contains 'enabled') { [bool]$deploymentConfig.policyEngine.enabled } else { $false }
$resolvedPolicyEngineHost = if ($PolicyEngineHost) { [string]$PolicyEngineHost } elseif ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'policyEngine' -and $deploymentConfig.policyEngine.PSObject.Properties.Name -contains 'host') { [string]$deploymentConfig.policyEngine.host } else { $resolvedServerHost }
$resolvedPolicyEnginePort = if ($PSBoundParameters.ContainsKey('PolicyEnginePort')) { $PolicyEnginePort } elseif ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'policyEngine' -and $deploymentConfig.policyEngine.PSObject.Properties.Name -contains 'port') { [int]$deploymentConfig.policyEngine.port } else { $resolvedServerPort }
$resolvedPolicyEngineScheme = if ($PolicyEngineScheme) { [string]$PolicyEngineScheme } elseif ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'policyEngine' -and $deploymentConfig.policyEngine.PSObject.Properties.Name -contains 'scheme') { [string]$deploymentConfig.policyEngine.scheme } else { $resolvedServerScheme }
$resolvedPolicyRefreshSeconds = if ($PSBoundParameters.ContainsKey('PolicyRefreshSeconds')) { $PolicyRefreshSeconds } elseif ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'policyEngine' -and $deploymentConfig.policyEngine.PSObject.Properties.Name -contains 'refreshSeconds') { [int]$deploymentConfig.policyEngine.refreshSeconds } else { 300 }
$resolvedPolicyCachePath = if ($PolicyCachePath) { [string]$PolicyCachePath } elseif ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'policyEngine' -and $deploymentConfig.policyEngine.PSObject.Properties.Name -contains 'cachePath') { [string]$deploymentConfig.policyEngine.cachePath } else { Join-Path $resolvedStateRoot 'dlp-policy-cache.json' }
if ($resolvedLocalAgentLogsEnabled -and -not (Test-Path -LiteralPath $resolvedLogsRoot)) {
New-Item -Path $resolvedLogsRoot -ItemType Directory -Force | Out-Null
}
$script:ApiBase = '{0}://{1}:{2}/api/0' -f $resolvedServerScheme, $resolvedServerHost, $resolvedServerPort
$script:Hostname = $env:COMPUTERNAME
$script:PolicyApiBase = '{0}://{1}:{2}/api/0' -f $resolvedPolicyEngineScheme, $resolvedPolicyEngineHost, $resolvedPolicyEnginePort
$script:Hostname = $resolvedHostname
$script:SessionId = (Get-Process -Id $PID).SessionId
$script:KnownBuckets = @{}
$script:Cooldown = @{}
@@ -762,17 +1278,59 @@ $script:LogPath = $resolvedLogPath
$script:IncidentArtifactsRoot = $resolvedIncidentArtifactsRoot
$script:IncidentScreenshotEnabled = $resolvedIncidentScreenshotEnabled
$script:ScreenshotTypesLoaded = $false
$script:PolicyMode = $resolvedPolicyMode
$script:PolicyEngineEnabled = $resolvedPolicyEngineEnabled
$script:PolicyRefreshSeconds = [Math]::Max($resolvedPolicyRefreshSeconds, 60)
$script:PolicyCachePath = $resolvedPolicyCachePath
$script:LocalPolicyPath = $resolvedPolicyPath
$script:LastPolicyRefreshAt = [datetime]::MinValue
$script:PolicyAgentId = $resolvedHostname
$script:TransportBackoffSeconds = 1
# Integration test flag (backward compatible - defaults to false)
$script:IntegrationTestEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'integrationTestEnabled') { [bool]$deploymentConfig.integrationTestEnabled } else { $false }
Load-DlpPolicy -Path $resolvedPolicyPath
# Integration metadata tracking (backward compatible)
$script:TotalEventsProcessed = 0
$script:LastEventTime = $null
Initialize-TransportQueue -StateRoot $resolvedStateRoot
Initialize-DlpPolicy
Write-EndpointLog ("endpoint collector started against {0}" -f $script:ApiBase)
while ($true) {
try {
try {
Flush-TransportQueue -MaxItems 200
$script:TransportBackoffSeconds = 1
}
catch {
$script:TransportBackoffSeconds = [Math]::Min($script:TransportBackoffSeconds * 2, 60)
Write-EndpointLog ("transport flush failed, backoff={0}s err={1}" -f $script:TransportBackoffSeconds, $_.Exception.Message)
}
if ($script:PolicyMode -eq 'server') {
$policyAge = ((Get-Date).ToUniversalTime() - $script:LastPolicyRefreshAt).TotalSeconds
if ($policyAge -ge $script:PolicyRefreshSeconds) {
[void](Refresh-DlpPolicyFromServer)
}
else {
[void](Sync-DlpPolicyDesiredState)
}
}
$nowUtc = (Get-Date).ToUniversalTime()
if (($nowUtc - $script:LastSelfTestAt).TotalSeconds -ge $script:SelfTestIntervalSeconds) {
Send-EndpointSignalHeartbeat -SignalType 'self_test' -Data @{
collector = 'dlp-endpoint-signals'
policyEnabled = [bool]$script:Policy.defaults.enabled
policyMode = $script:PolicyMode
policySource = $script:PolicySource
policyVersion = $script:PolicyVersion
policyChecksum = $script:PolicyChecksum
queueDepth = [int]$script:TransportMetrics.queueDepth
eventsEnqueued = [int]$script:TransportMetrics.eventsEnqueued
eventsFlushed = [int]$script:TransportMetrics.eventsFlushed
sendFailures = [int]$script:TransportMetrics.sendFailures
}
$script:LastSelfTestAt = $nowUtc
}
@@ -783,7 +1341,7 @@ while ($true) {
}
try {
$clipboardText = Get-Clipboard -Raw -ErrorAction SilentlyContinue
$clipboardText = Get-ClipboardTextSafe
if ($clipboardText) {
$clipboardHash = Get-StringHash -Value $clipboardText
if ($clipboardHash -and $clipboardHash -ne $script:LastClipboardHash) {
@@ -792,6 +1350,8 @@ while ($true) {
clipboardHash = $clipboardHash
clipboardLength = $clipboardText.Length
}
$script:TotalEventsProcessed++
$script:LastEventTime = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
Evaluate-ClipboardRules -ClipboardText $clipboardText -ClipboardHash $clipboardHash
}
}
@@ -813,6 +1373,8 @@ while ($true) {
driveLetter = $deviceId
volumeName = $volumeName
}
$script:TotalEventsProcessed++
$script:LastEventTime = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
Evaluate-UsbRules -DriveLetter $deviceId -VolumeName $volumeName
}
}
@@ -852,6 +1414,8 @@ while ($true) {
documentNameOriginal = $documentNameOriginal
owner = $owner
}
$script:TotalEventsProcessed++
$script:LastEventTime = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
Evaluate-PrintRules -PrinterName $printerName -DocumentName $documentName -Owner $owner
}
@@ -899,6 +1463,8 @@ while ($true) {
eventRecordId = $recordId
eventSource = 'printservice-307'
}
$script:TotalEventsProcessed++
$script:LastEventTime = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
Evaluate-PrintRules -PrinterName $printerName -DocumentName (if ($resolvedDocument) { $resolvedDocument } else { $documentName }) -Owner $owner
}
@@ -917,5 +1483,35 @@ while ($true) {
Write-EndpointLog ("collector error: {0}" -f $_.Exception.Message)
}
Start-Sleep -Seconds $resolvedPollSeconds
# Integration metadata self-test (backward compatible)
if ($script:IntegrationTestEnabled -and (Get-Date).Minute -eq 0) {
try {
$testMetadata = @{
timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
collector = 'dlp-endpoint-signals'
version = '1.0.0'
hostname = $env:COMPUTERNAME
username = $env:USERNAME
status = 'healthy'
checks = @{
eventsProcessed = $script:TotalEventsProcessed
lastEventTime = $script:LastEventTime
iocRulesLoaded = if ($script:IocRules) { @($script:IocRules).Count } else { 0 }
policyRulesLoaded = if ($script:Policy -and $script:Policy.endpoint) { (@($script:Policy.endpoint.clipboard).Count + @($script:Policy.endpoint.usb).Count + @($script:Policy.endpoint.print).Count) } else { 0 }
}
}
Send-EndpointSignalHeartbeat -SignalType 'integration_test' -Data $testMetadata
Write-EndpointLog "Integration metadata test sent"
}
catch {
Write-EndpointLog "Integration test failed: $($_.Exception.Message)"
}
}
if ($script:TransportBackoffSeconds -gt $resolvedPollSeconds) {
Start-Sleep -Seconds $script:TransportBackoffSeconds
}
else {
Start-Sleep -Seconds $resolvedPollSeconds
}
}
@@ -95,5 +95,16 @@
"documentRegex": "(?i)(salary|зарплат|passport|паспорт|договор|contract)"
}
]
},
"contentAnalysis": {
"dictionaryPack": "152-fz-pdn",
"regexPack": "secrets",
"ocrEnabled": true
},
"ioc": {
"enabled": true,
"source": "http://10.10.10.13:5610/dlp-ioc/ioc_blacklist.json",
"format": "hayabusa_sigma_v1",
"refreshMinutes": 360
}
}
@@ -26,7 +26,7 @@ param(
[string]$LogPath,
[int]$PollSeconds,
[ValidateSet('outlook', 'smtp', 'both')]
[string]$Mode = 'both'
[string]$Mode = 'smtp'
)
Set-StrictMode -Version Latest
@@ -322,8 +322,23 @@ function Invoke-EmailEnforcement {
# ---------------------------------------------------------------------------
function Initialize-OutlookCom {
if ($script:OutlookDisabled) {
return $false
}
if (-not (Test-OutlookProfileConfigured)) {
Write-CollectorLog "Outlook profile not configured for current user, Outlook mode disabled"
$script:OutlookDisabled = $true
return $false
}
if (-not (Get-Process -Name OUTLOOK -ErrorAction SilentlyContinue | Select-Object -First 1)) {
Write-CollectorLog "Outlook process not running, skipping COM initialization"
return $false
}
try {
$script:OutlookApp = New-Object -ComObject Outlook.Application
$script:OutlookApp = [Runtime.InteropServices.Marshal]::GetActiveObject('Outlook.Application')
$script:OutlookNamespace = $script:OutlookApp.GetNamespace('MAPI')
$script:SentFolder = $script:OutlookNamespace.GetDefaultFolder(5) # olFolderSentMail
Write-CollectorLog "Outlook COM initialized, Sent Items folder opened"
@@ -335,6 +350,32 @@ function Initialize-OutlookCom {
}
}
function Test-OutlookProfileConfigured {
[OutputType([bool])]
$officeRoots = @(
'HKCU:\Software\Microsoft\Office',
'HKCU:\Software\WOW6432Node\Microsoft\Office'
)
foreach ($root in $officeRoots) {
if (-not (Test-Path -LiteralPath $root)) { continue }
$versions = Get-ChildItem -LiteralPath $root -ErrorAction SilentlyContinue |
Where-Object { $_.PSChildName -match '^\d+\.\d+$' } |
Sort-Object { [version]$_.PSChildName } -Descending
foreach ($ver in $versions) {
$profilesPath = Join-Path $ver.PSPath 'Outlook\Profiles'
if (Test-Path -LiteralPath $profilesPath) {
$profiles = Get-ChildItem -LiteralPath $profilesPath -ErrorAction SilentlyContinue
if ($profiles -and $profiles.Count -gt 0) {
return $true
}
}
}
}
return $false
}
function Get-OutlookSentItems {
param([datetime]$Since)
@@ -521,6 +562,7 @@ $script:OutlookApp = $null
$script:OutlookNamespace = $null
$script:SentFolder = $null
$script:OutlookLastPoll = (Get-Date).AddMinutes(-5)
$script:OutlookDisabled = $false
Load-EmailPolicy -Path $resolvedPolicyPath
Write-CollectorLog ("email collector started mode={0} against {1}" -f $Mode, $script:ApiBase)
@@ -20,9 +20,22 @@ param(
[bool]$IncidentCaptureEnabled,
[bool]$IncidentScreenshotEnabled,
[string]$IncidentArtifactsRoot,
[string]$EvtxExportRoot,
[int]$EvtxRetentionDays,
[string[]]$EvtxChannels,
[bool]$LogonMarkerEnabled,
[string]$AwHostname,
[string]$CustomRulesPath,
[string]$CustomPolicyPath,
[ValidateSet('local', 'server')]
[string]$PolicyMode,
[bool]$PolicyEngineEnabled,
[string]$PolicyEngineHost,
[int]$PolicyEnginePort,
[ValidateSet('http', 'https')]
[string]$PolicyEngineScheme,
[int]$PolicyRefreshSeconds,
[string]$PolicyCachePath,
[switch]$RepairPackage,
[string]$Version,
[string]$PackageUrl,
@@ -56,8 +69,10 @@ $effectiveCollector = Join-Path $effectiveStateRoot 'browser-domains-native-coll
$effectiveEndpointCollector = if ($existingConfig -and $existingConfig.paths.PSObject.Properties.Name -contains 'endpointCollectorScript') { [string]$existingConfig.paths.endpointCollectorScript } else { Join-Path $effectiveStateRoot 'dlp-endpoint-signals-collector.ps1' }
$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' }
$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' }
$effectiveServerHost = if ($ServerHost) { $ServerHost } elseif ($existingConfig) { [string]$existingConfig.server.host } else { $null }
$effectiveServerPort = if ($PSBoundParameters.ContainsKey('ServerPort')) { $ServerPort } elseif ($existingConfig) { [int]$existingConfig.server.port } else { 5600 }
@@ -72,8 +87,19 @@ $effectiveLocalAgentLogsEnabled = if ($PSBoundParameters.ContainsKey('LocalAgent
$effectiveIncidentCaptureEnabled = if ($PSBoundParameters.ContainsKey('IncidentCaptureEnabled')) { [bool]$IncidentCaptureEnabled } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $existingConfig.incidentCapture.PSObject.Properties.Name -contains 'enabled') { [bool]$existingConfig.incidentCapture.enabled } else { $true }
$effectiveIncidentScreenshotEnabled = if ($PSBoundParameters.ContainsKey('IncidentScreenshotEnabled')) { [bool]$IncidentScreenshotEnabled } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $existingConfig.incidentCapture.PSObject.Properties.Name -contains 'screenshotEnabled') { [bool]$existingConfig.incidentCapture.screenshotEnabled } else { $true }
$effectiveIncidentArtifactsRoot = if ($PSBoundParameters.ContainsKey('IncidentArtifactsRoot') -and $IncidentArtifactsRoot) { $IncidentArtifactsRoot } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $existingConfig.incidentCapture.PSObject.Properties.Name -contains 'artifactsRoot') { [string]$existingConfig.incidentCapture.artifactsRoot } else { Join-Path $effectiveStateRoot 'incident-artifacts' }
$effectiveEvtxExportRoot = if ($PSBoundParameters.ContainsKey('EvtxExportRoot') -and $EvtxExportRoot) { $EvtxExportRoot } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'forensics' -and $existingConfig.forensics.PSObject.Properties.Name -contains 'evtxExportRoot') { [string]$existingConfig.forensics.evtxExportRoot } else { Join-Path $effectiveStateRoot 'forensics\evtx-exports' }
$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 }
$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' }
$effectivePolicyEngineEnabled = if ($PSBoundParameters.ContainsKey('PolicyEngineEnabled')) { [bool]$PolicyEngineEnabled } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'policyEngine' -and $existingConfig.policyEngine.PSObject.Properties.Name -contains 'enabled') { [bool]$existingConfig.policyEngine.enabled } else { $false }
$effectivePolicyEngineHost = if ($PSBoundParameters.ContainsKey('PolicyEngineHost') -and -not [string]::IsNullOrWhiteSpace($PolicyEngineHost)) { [string]$PolicyEngineHost } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'policyEngine' -and $existingConfig.policyEngine.PSObject.Properties.Name -contains 'host') { [string]$existingConfig.policyEngine.host } else { [string]$effectiveServerHost }
$effectivePolicyEnginePort = if ($PSBoundParameters.ContainsKey('PolicyEnginePort')) { [int]$PolicyEnginePort } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'policyEngine' -and $existingConfig.policyEngine.PSObject.Properties.Name -contains 'port') { [int]$existingConfig.policyEngine.port } else { 5601 }
$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' }
$effectiveUsers = if ($Users -or $UserListPath) {
Normalize-ActivityWatchUsers -Users $Users -UserListPath $UserListPath -Domain $Domain
@@ -104,6 +130,7 @@ $assetResult = Copy-ActivityWatchCollectorAssets `
-EmailCollectorScriptSource (Join-Path $PSScriptRoot 'email-outbound-collector.ps1') `
-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') `
-ExampleRulesSource (Join-Path $PSScriptRoot 'web-category-rules.example.json') `
-ExamplePolicySource (Join-Path $PSScriptRoot 'dlp-policy.example.json') `
-StateRoot $effectiveStateRoot `
@@ -123,9 +150,11 @@ $config = New-ActivityWatchDeploymentConfig `
-LogsRoot $effectiveLogsRoot `
-CollectorScript $effectiveCollector `
-EndpointCollectorScript $effectiveEndpointCollector `
-PolicyClientScript $effectivePolicyClientScript `
-EmailCollectorScript $assetResult.EmailCollectorScript `
-FileCollectorScript $effectiveFileCollector `
-SessionCollectorScript $effectiveSessionCollector `
-EvtxExportScript $effectiveEvtxExportScript `
-RulesPath $effectiveRules `
-PolicyPath $effectivePolicy `
-PollSeconds $effectivePollSeconds `
@@ -138,7 +167,18 @@ $config = New-ActivityWatchDeploymentConfig `
-IncidentCaptureEnabled $effectiveIncidentCaptureEnabled `
-IncidentScreenshotEnabled $effectiveIncidentScreenshotEnabled `
-IncidentArtifactsRoot $effectiveIncidentArtifactsRoot `
-EvtxExportRoot $effectiveEvtxExportRoot `
-EvtxRetentionDays $effectiveEvtxRetentionDays `
-EvtxChannels $effectiveEvtxChannels `
-LogonMarkerEnabled $effectiveLogonMarkerEnabled `
-AwHostname $effectiveAwHostname `
-PolicyMode $effectivePolicyMode `
-PolicyEngineEnabled $effectivePolicyEngineEnabled `
-PolicyEngineHost $effectivePolicyEngineHost `
-PolicyEnginePort $effectivePolicyEnginePort `
-PolicyEngineScheme $effectivePolicyEngineScheme `
-PolicyRefreshSeconds $effectivePolicyRefreshSeconds `
-PolicyCachePath $effectivePolicyCachePath `
-LaunchScriptPath $effectiveLaunchScript `
-RecoveryScriptPath $effectiveRecoveryScript `
-UserTasks $taskDefinitions `
@@ -154,7 +154,36 @@ if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Миграция ActivityWatch W
@{ Source = $NewStateRoot; Name = 'new-state' }
)) {
if (Test-Path -LiteralPath $item.Source) {
Copy-Item -LiteralPath $item.Source -Destination (Join-Path $backupRoot $item.Name) -Recurse -Force
$backupDest = Join-Path $backupRoot $item.Name
New-ActivityWatchDirectory -Path $backupDest
$excludeDirs = @()
if ($item.Source -eq $NewStateRoot) {
# Avoid infinite recursion: backupRoot is inside NewStateRoot by default.
$excludeDirs += $backupRoot
}
$robocopyArgs = @(
$item.Source,
$backupDest,
'/E',
'/R:1',
'/W:1',
'/NFL',
'/NDL',
'/NJH',
'/NJS',
'/NP'
)
if ($excludeDirs.Count -gt 0) {
$robocopyArgs += '/XD'
$robocopyArgs += $excludeDirs
}
& robocopy @robocopyArgs | Out-Null
if ($LASTEXITCODE -ge 8) {
throw "Backup robocopy failed (exit=$LASTEXITCODE) for source '$($item.Source)' to '$backupDest'"
}
}
}
@@ -14,24 +14,327 @@ $installRoot = [string]$config.paths.installRoot
$stateRoot = [string]$config.paths.stateRoot
$collectorScript = [string]$config.paths.collectorScript
$endpointCollectorScript = if ($config.paths.PSObject.Properties.Name -contains 'endpointCollectorScript') { [string]$config.paths.endpointCollectorScript } else { Join-Path $stateRoot 'dlp-endpoint-signals-collector.ps1' }
$fileCollectorScript = if ($config.paths.PSObject.Properties.Name -contains 'fileCollectorScript') { [string]$config.paths.fileCollectorScript } else { Join-Path $stateRoot 'file-operations-collector.ps1' }
$sessionCollectorScript = if ($config.paths.PSObject.Properties.Name -contains 'sessionCollectorScript') { [string]$config.paths.sessionCollectorScript } else { Join-Path $stateRoot 'worktime-session-collector.ps1' }
$evtxExportScript = if ($config.paths.PSObject.Properties.Name -contains 'evtxExportScript') { [string]$config.paths.evtxExportScript } else { Join-Path $stateRoot 'export-evtx-for-hayabusa.ps1' }
$rulesPath = [string]$config.paths.rulesPath
$policyPath = if ($config.paths.PSObject.Properties.Name -contains 'policyPath') { [string]$config.paths.policyPath } else { Join-Path $stateRoot 'dlp-policy.json' }
$policyClientScript = if ($config.paths.PSObject.Properties.Name -contains 'policyClientScript') { [string]$config.paths.policyClientScript } else { Join-Path $stateRoot 'dlp-policy-client.ps1' }
$launchScript = [string]$config.paths.launchScript
$recoveryScript = [string]$config.paths.recoveryScript
$awHostname = if ($config.PSObject.Properties.Name -contains 'awHostname' -and -not [string]::IsNullOrWhiteSpace([string]$config.awHostname)) { [string]$config.awHostname } else { [string]$env:COMPUTERNAME }
$serverUrl = '{0}://{1}:{2}' -f [string]$config.server.scheme, [string]$config.server.host, [int]$config.server.port
$apiBase = "$serverUrl/api/0"
$pollSeconds = if ($config.PSObject.Properties.Name -contains 'collector' -and $config.collector.PSObject.Properties.Name -contains 'pollSeconds') { [int]$config.collector.pollSeconds } else { 5 }
$pulseSeconds = if ($config.PSObject.Properties.Name -contains 'collector' -and $config.collector.PSObject.Properties.Name -contains 'pulseSeconds') { [int]$config.collector.pulseSeconds } else { [Math]::Max($pollSeconds * 3, 30) }
$freshnessSeconds = [Math]::Max($pollSeconds * 3, 30)
$sessionFreshnessSeconds = [Math]::Max($pollSeconds * 4, 45)
$transportStaleSeconds = [Math]::Max($pollSeconds * 12, 180)
$queueMaxDepth = 1000
$afkExpected = if ($config.PSObject.Properties.Name -contains 'collectors' -and $config.collectors.PSObject.Properties.Name -contains 'afkEnabled') { [bool]$config.collectors.afkEnabled } else { $true }
$windowExpected = if ($config.PSObject.Properties.Name -contains 'collectors' -and $config.collectors.PSObject.Properties.Name -contains 'windowEnabled') { [bool]$config.collectors.windowEnabled } else { $true }
$fileOpsExpected = if ($config.PSObject.Properties.Name -contains 'collectors' -and $config.collectors.PSObject.Properties.Name -contains 'fileOpsEnabled') { [bool]$config.collectors.fileOpsEnabled } else { $true }
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 Get-CollectorProcesses {
param(
[Parameter(Mandatory = $true)]
[string]$ScriptPath
)
return @(
Get-CimInstance Win32_Process -ErrorAction SilentlyContinue |
Where-Object {
($_.Name -ieq 'powershell.exe' -or $_.Name -ieq 'pwsh.exe') -and
$_.CommandLine -and
$_.CommandLine -match [Regex]::Escape($ScriptPath)
} |
Select-Object @{ Name = 'Name'; Expression = { $_.Name } }, @{ Name = 'Id'; Expression = { [int]$_.ProcessId } }, @{ Name = 'SessionId'; Expression = { [int]$_.SessionId } }, @{ Name = 'CommandLine'; Expression = { [string]$_.CommandLine } }
)
}
function Get-DuplicateProcessGroups {
param(
[object[]]$Processes,
[bool]$PerSession = $true
)
if (-not $Processes -or @($Processes).Count -eq 0) { return @() }
$groups = if ($PerSession) {
$Processes | Group-Object -Property Name, SessionId
}
else {
$Processes | Group-Object -Property Name
}
return @(
$groups |
Where-Object { $_.Count -gt 1 } |
ForEach-Object {
[pscustomobject]@{
name = [string]$_.Name
count = [int]$_.Count
members = @($_.Group | Select-Object Name, Id, SessionId, CommandLine)
}
}
)
}
function Convert-ToUtcDate {
param($Value)
if ($null -eq $Value) { return $null }
try {
return ([DateTimeOffset]::Parse([string]$Value)).UtcDateTime
}
catch {
return $null
}
}
function Get-BucketHealth {
param(
[Parameter(Mandatory = $true)]
[string]$BucketId,
[Parameter(Mandatory = $true)]
[int]$MaxAgeSeconds,
[bool]$Required = $true,
[bool]$RequireFreshEvent = $true
)
$events = @()
$queryOk = $false
$errorMessage = $null
try {
$response = Invoke-RestMethod -Method Get -Uri "$apiBase/buckets/$BucketId/events?limit=25" -TimeoutSec 15 -DisableKeepAlive -ErrorAction Stop
$events = @($response)
$queryOk = $true
}
catch {
$errorMessage = $_.Exception.Message
}
$latestTimestampUtc = $null
$ageSeconds = $null
if ($events.Count -gt 0) {
$latestTimestampUtc = @(
$events |
ForEach-Object { Convert-ToUtcDate $_.timestamp } |
Where-Object { $null -ne $_ } |
Sort-Object -Descending
) | Select-Object -First 1
if ($null -ne $latestTimestampUtc) {
$ageSeconds = [int][Math]::Floor(((Get-Date).ToUniversalTime() - $latestTimestampUtc).TotalSeconds)
}
}
$hasFreshEvent = ($null -ne $ageSeconds -and $ageSeconds -le $MaxAgeSeconds)
$hasAnyEvent = ($events.Count -gt 0)
$ok = if (-not $Required) { $true } elseif ($RequireFreshEvent) { $queryOk -and $hasFreshEvent } else { $queryOk -and $hasAnyEvent }
return [pscustomobject]@{
bucketId = $BucketId
required = [bool]$Required
requireFreshEvent = [bool]$RequireFreshEvent
maxAgeSeconds = [int]$MaxAgeSeconds
queryOk = [bool]$queryOk
latestTimestampUtc = if ($null -ne $latestTimestampUtc) { $latestTimestampUtc.ToString('o') } else { $null }
ageSeconds = if ($null -ne $ageSeconds) { [int]$ageSeconds } else { $null }
count = [int]$events.Count
ok = [bool]$ok
error = $errorMessage
}
}
function Get-TransportQueueHealth {
param(
[Parameter(Mandatory = $true)]
[string]$Name,
[Parameter(Mandatory = $true)]
[string]$QueuePath,
[Parameter(Mandatory = $true)]
[string]$LockPath,
[Parameter(Mandatory = $true)]
[int]$StaleAfterSeconds,
[Parameter(Mandatory = $true)]
[int]$MaxDepth,
[int]$ActiveProcessCount = 0,
[bool]$Required = $true
)
$queueExists = Test-Path -LiteralPath $QueuePath
$depth = 0
$sizeBytes = 0
$ageSeconds = $null
$lastWriteUtc = $null
if ($queueExists) {
$item = Get-Item -LiteralPath $QueuePath -ErrorAction SilentlyContinue
if ($item) {
$sizeBytes = [int64]$item.Length
$lastWriteUtc = $item.LastWriteTimeUtc
$ageSeconds = [int][Math]::Floor(((Get-Date).ToUniversalTime() - $lastWriteUtc).TotalSeconds)
}
try {
$depth = [int]((Get-Content -LiteralPath $QueuePath -ErrorAction SilentlyContinue | Measure-Object).Count)
}
catch {
$depth = 0
}
}
$lockExists = Test-Path -LiteralPath $LockPath
$lockHeld = $false
if ($lockExists) {
try {
$lockHandle = [System.IO.File]::Open($LockPath, [System.IO.FileMode]::Open, [System.IO.FileAccess]::ReadWrite, [System.IO.FileShare]::None)
$lockHandle.Dispose()
}
catch {
$lockHeld = $true
}
}
$staleQueue = ($depth -gt 0 -and $null -ne $ageSeconds -and $ageSeconds -gt $StaleAfterSeconds -and -not $lockHeld)
$orphanedQueue = ($depth -gt 0 -and $ActiveProcessCount -le 0 -and $null -ne $ageSeconds -and $ageSeconds -gt $StaleAfterSeconds)
$oversizedQueue = ($depth -gt $MaxDepth)
$ok = if (-not $Required) { $true } else { -not ($staleQueue -or $orphanedQueue -or $oversizedQueue) }
return [pscustomobject]@{
name = $Name
required = [bool]$Required
queuePath = $QueuePath
queueExists = [bool]$queueExists
depth = [int]$depth
sizeBytes = [int64]$sizeBytes
lastWriteUtc = if ($null -ne $lastWriteUtc) { $lastWriteUtc.ToString('o') } else { $null }
ageSeconds = if ($null -ne $ageSeconds) { [int]$ageSeconds } else { $null }
lockPath = $LockPath
lockExists = [bool]$lockExists
lockHeld = [bool]$lockHeld
activeProcessCount = [int]$ActiveProcessCount
staleAfterSeconds = [int]$StaleAfterSeconds
maxDepth = [int]$MaxDepth
staleQueue = [bool]$staleQueue
orphanedQueue = [bool]$orphanedQueue
oversizedQueue = [bool]$oversizedQueue
ok = [bool]$ok
}
}
function Get-TaskSnapshot {
param(
[Parameter(Mandatory = $true)]
[string[]]$TaskNames
)
return @(
foreach ($taskName in @($TaskNames | Sort-Object -Unique)) {
$task = Get-ScheduledTask -ErrorAction SilentlyContinue | Where-Object { $_.TaskName -eq $taskName } | Select-Object -First 1
if ($null -eq $task) {
[pscustomobject]@{
taskName = $taskName
present = $false
enabled = $false
state = 'Отсутствует'
lastResult = $null
ok = $false
}
continue
}
$taskInfo = $null
try {
$taskInfo = Get-ScheduledTaskInfo -TaskName $task.TaskName -TaskPath $task.TaskPath -ErrorAction Stop
}
catch {
}
$enabled = $true
try {
if ($task.Settings.PSObject.Properties.Name -contains 'Enabled') {
$enabled = [bool]$task.Settings.Enabled
}
}
catch {
}
[pscustomobject]@{
taskName = [string]$task.TaskName
present = $true
enabled = [bool]$enabled
state = [string]$task.State
lastResult = if ($taskInfo) { [int64]$taskInfo.LastTaskResult } else { $null }
ok = [bool]($enabled)
}
}
)
}
$requiredFiles = @(
$collectorScript,
$endpointCollectorScript,
$sessionCollectorScript,
$evtxExportScript,
$rulesPath,
$policyPath,
$policyClientScript,
$launchScript,
$recoveryScript,
$ConfigPath
)
if ($fileOpsExpected) {
$requiredFiles += $fileCollectorScript
}
if ($afkExpected) {
$requiredFiles += (Join-Path $installRoot 'aw-watcher-afk\aw-watcher-afk.exe')
}
@@ -40,55 +343,123 @@ if ($windowExpected) {
}
$missingFiles = @(
$requiredFiles | Where-Object { -not (Test-Path -LiteralPath $_) }
$requiredFiles |
Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) } |
Where-Object { -not (Test-Path -LiteralPath $_) }
)
$processNames = @()
if ($afkExpected) { $processNames += 'aw-watcher-afk' }
if ($windowExpected) { $processNames += 'aw-watcher-window' }
$runningProcesses = @()
if ($processNames.Count -gt 0) {
$runningProcesses = Get-Process -Name $processNames -ErrorAction SilentlyContinue | Select-Object Name, Id, SessionId
$runningWatchers = @()
$expectedWatcherNames = @()
if ($afkExpected) { $expectedWatcherNames += 'aw-watcher-afk' }
if ($windowExpected) { $expectedWatcherNames += 'aw-watcher-window' }
if ($expectedWatcherNames.Count -gt 0) {
$runningWatchers = @(Get-Process -Name $expectedWatcherNames -ErrorAction SilentlyContinue | Select-Object Name, Id, SessionId)
}
$sessionCollectorProcesses = Get-CimInstance Win32_Process -ErrorAction SilentlyContinue |
Where-Object {
($_.Name -ieq 'powershell.exe' -or $_.Name -ieq 'pwsh.exe') -and
$_.CommandLine -match [Regex]::Escape($sessionCollectorScript)
} |
Select-Object Name, ProcessId, SessionId, CommandLine
$sessionCollectorProcesses = @(Get-CollectorProcesses -ScriptPath $sessionCollectorScript)
$endpointCollectorProcesses = @(Get-CollectorProcesses -ScriptPath $endpointCollectorScript)
$fileCollectorProcesses = if ($fileOpsExpected) { @(Get-CollectorProcesses -ScriptPath $fileCollectorScript) } else { @() }
$browserCollectorProcesses = @(Get-CollectorProcesses -ScriptPath $collectorScript)
$loggedOnUsers = Get-LoggedOnUsers
$sessionBoundUsers = @(
@($config.userTasks) |
Where-Object { Test-UserHasSession -UserId ([string]$_.userId) -LoggedOnUsers $loggedOnUsers } |
ForEach-Object { [string]$_.userId }
)
$sessionScopedExpectedCount = [int]$sessionBoundUsers.Count
$sessionScopedCollectorsRequired = ($sessionScopedExpectedCount -gt 0)
$taskNames = @()
if ($config.userTasks) {
$taskNames += @($config.userTasks | ForEach-Object { [string]$_.launchTaskName })
}
$taskNames += [string]$config.recovery.taskName
$taskNames = $taskNames | Sort-Object -Unique
$tasks = @(Get-TaskSnapshot -TaskNames $taskNames)
$tasks = foreach ($taskName in $taskNames) {
$task = Get-ScheduledTask -ErrorAction SilentlyContinue | Where-Object { $_.TaskName -eq $taskName } | Select-Object -First 1
if ($task) {
[pscustomobject]@{
taskName = $task.TaskName
state = [string]$task.State
present = $true
}
$watcherDuplicates = @(Get-DuplicateProcessGroups -Processes $runningWatchers -PerSession $true)
$sessionCollectorDuplicates = @(Get-DuplicateProcessGroups -Processes $sessionCollectorProcesses -PerSession $false)
$endpointCollectorDuplicates = @(Get-DuplicateProcessGroups -Processes $endpointCollectorProcesses -PerSession $true)
$fileCollectorDuplicates = @(Get-DuplicateProcessGroups -Processes $fileCollectorProcesses -PerSession $true)
$browserCollectorDuplicates = @(Get-DuplicateProcessGroups -Processes $browserCollectorProcesses -PerSession $true)
$watcherByName = @{}
foreach ($watcher in $runningWatchers) {
if (-not $watcherByName.ContainsKey([string]$watcher.Name)) {
$watcherByName[[string]$watcher.Name] = 0
}
else {
[pscustomobject]@{
taskName = $taskName
state = 'Отсутствует'
present = $false
}
$watcherByName[[string]$watcher.Name]++
}
$bucketChecks = @(
Get-BucketHealth -BucketId ('aw-worktime-sessions_' + $awHostname) -MaxAgeSeconds $sessionFreshnessSeconds -Required $true -RequireFreshEvent $true
)
if ($sessionScopedCollectorsRequired -and $afkExpected) {
$bucketChecks += Get-BucketHealth -BucketId ('aw-watcher-afk_' + $awHostname) -MaxAgeSeconds $freshnessSeconds -Required $true -RequireFreshEvent $false
}
if ($sessionScopedCollectorsRequired -and $windowExpected) {
$bucketChecks += Get-BucketHealth -BucketId ('aw-watcher-window_' + $awHostname) -MaxAgeSeconds $freshnessSeconds -Required $true -RequireFreshEvent $false
}
if ($sessionScopedCollectorsRequired) {
$bucketChecks += Get-BucketHealth -BucketId ('aw-dlp-endpoint-signals_' + $awHostname) -MaxAgeSeconds $freshnessSeconds -Required $true -RequireFreshEvent $true
}
if ($sessionScopedCollectorsRequired -and $fileOpsExpected) {
$bucketChecks += Get-BucketHealth -BucketId ('aw-file-operations_' + $awHostname) -MaxAgeSeconds $transportStaleSeconds -Required $false -RequireFreshEvent $true
}
$queueChecks = @(
Get-TransportQueueHealth -Name 'endpoint' -QueuePath (Join-Path $stateRoot 'dlp-endpoint-signals-queue.jsonl') -LockPath (Join-Path $stateRoot 'dlp-endpoint-signals-queue.lock') -StaleAfterSeconds $transportStaleSeconds -MaxDepth $queueMaxDepth -ActiveProcessCount @($endpointCollectorProcesses).Count -Required $sessionScopedCollectorsRequired
)
if ($fileOpsExpected) {
$queueChecks += Get-TransportQueueHealth -Name 'fileops' -QueuePath (Join-Path $stateRoot 'file-operations-queue.jsonl') -LockPath (Join-Path $stateRoot 'file-operations-queue.lock') -StaleAfterSeconds $transportStaleSeconds -MaxDepth $queueMaxDepth -ActiveProcessCount @($fileCollectorProcesses).Count -Required $sessionScopedCollectorsRequired
}
$printServiceOperationalEnabled = $false
try {
$printServiceLog = Get-WinEvent -ListLog 'Microsoft-Windows-PrintService/Operational' -ErrorAction Stop
$printServiceOperationalEnabled = [bool]$printServiceLog.IsEnabled
}
catch {
}
$printJobTitlePolicyEnabled = $false
try {
$printPolicy = Get-ItemProperty -LiteralPath 'HKLM:\Software\Policies\Microsoft\Windows NT\Printers' -Name 'ShowJobTitleInEventLogs' -ErrorAction Stop
$printJobTitlePolicyEnabled = ([int]$printPolicy.ShowJobTitleInEventLogs -eq 1)
}
catch {
}
$watcherCountsOk = $true
if ($sessionScopedCollectorsRequired) {
if ($afkExpected) {
$watcherCountsOk = $watcherCountsOk -and (($watcherByName['aw-watcher-afk'] | ForEach-Object { [int]$_ }) -ge $sessionScopedExpectedCount)
}
if ($windowExpected) {
$watcherCountsOk = $watcherCountsOk -and (($watcherByName['aw-watcher-window'] | ForEach-Object { [int]$_ }) -ge $sessionScopedExpectedCount)
}
}
$serverUrl = '{0}://{1}:{2}' -f [string]$config.server.scheme, [string]$config.server.host, [int]$config.server.port
$endpointProcessOk = if (-not $sessionScopedCollectorsRequired) { $true } else { (@($endpointCollectorProcesses).Count -ge $sessionScopedExpectedCount) }
$fileProcessOk = if (-not $fileOpsExpected -or -not $sessionScopedCollectorsRequired) { $true } else { (@($fileCollectorProcesses).Count -ge $sessionScopedExpectedCount) }
$browserProcessOk = if (-not $sessionScopedCollectorsRequired) { $true } else { (@($browserCollectorProcesses).Count -ge $sessionScopedExpectedCount) }
$sessionCollectorOk = (@($sessionCollectorProcesses).Count -eq 1)
$result = [ordered]@{
generatedAtUtc = (Get-Date).ToUniversalTime().ToString('o')
configPath = $ConfigPath
serverUrl = $serverUrl
apiBase = $apiBase
awHostname = $awHostname
installRoot = $installRoot
stateRoot = $stateRoot
timing = [ordered]@{
pollSeconds = [int]$pollSeconds
pulseSeconds = [int]$pulseSeconds
freshnessSeconds = [int]$freshnessSeconds
sessionFreshnessSeconds = [int]$sessionFreshnessSeconds
transportStaleSeconds = [int]$transportStaleSeconds
}
files = [ordered]@{
required = $requiredFiles
missing = $missingFiles
@@ -96,22 +467,76 @@ $result = [ordered]@{
}
tasks = [ordered]@{
list = $tasks
ok = [bool]($tasks.Count -gt 0 -and -not ($tasks | Where-Object { -not $_.present }))
ok = [bool]($tasks.Count -gt 0 -and -not ($tasks | Where-Object { -not $_.present -or -not $_.enabled }))
}
processes = [ordered]@{
expected = $processNames
list = @($runningProcesses)
sessionBoundUsers = $sessionBoundUsers
sessionScopedExpectedCount = [int]$sessionScopedExpectedCount
watchers = @($runningWatchers)
watcherDuplicates = @($watcherDuplicates)
sessionCollectors = @($sessionCollectorProcesses)
sessionCollectorDuplicates = @($sessionCollectorDuplicates)
browserCollectors = @($browserCollectorProcesses)
browserCollectorDuplicates = @($browserCollectorDuplicates)
endpointCollectors = @($endpointCollectorProcesses)
endpointCollectorDuplicates = @($endpointCollectorDuplicates)
fileCollectors = @($fileCollectorProcesses)
fileCollectorDuplicates = @($fileCollectorDuplicates)
ok = [bool](
(
($processNames.Count -eq 0) -or
(($runningProcesses | Select-Object -ExpandProperty Name -Unique).Count -ge $processNames.Count)
) -and
($sessionCollectorProcesses.Count -ge 1)
$watcherCountsOk -and
$sessionCollectorOk -and
$browserProcessOk -and
$endpointProcessOk -and
$fileProcessOk -and
($watcherDuplicates.Count -eq 0) -and
($sessionCollectorDuplicates.Count -eq 0) -and
($browserCollectorDuplicates.Count -eq 0) -and
($endpointCollectorDuplicates.Count -eq 0) -and
($fileCollectorDuplicates.Count -eq 0)
)
}
buckets = [ordered]@{
list = @($bucketChecks)
ok = [bool](-not ($bucketChecks | Where-Object { -not $_.ok }))
}
queues = [ordered]@{
list = @($queueChecks)
ok = [bool](-not ($queueChecks | Where-Object { -not $_.ok }))
}
printTelemetry = [ordered]@{
operationalLogEnabled = $printServiceOperationalEnabled
jobTitlePolicyEnabled = $printJobTitlePolicyEnabled
ok = [bool]($printServiceOperationalEnabled -and $printJobTitlePolicyEnabled)
}
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 }
evtxChannels = if ($config.PSObject.Properties.Name -contains 'forensics' -and $config.forensics.PSObject.Properties.Name -contains 'evtxChannels') { @($config.forensics.evtxChannels) } else { @() }
ok = [bool](
($config.PSObject.Properties.Name -contains 'forensics') -and
($config.forensics.PSObject.Properties.Name -contains 'evtxExportRoot') -and
($config.forensics.PSObject.Properties.Name -contains 'retentionDays') -and
($config.forensics.PSObject.Properties.Name -contains 'evtxChannels') -and
(@($config.forensics.evtxChannels).Count -gt 0)
)
}
}
$result.overallOk = [bool]($result.files.ok -and $result.tasks.ok -and $result.processes.ok)
$result.summary = [ordered]@{
failedSections = @(
'files', 'tasks', 'processes', 'buckets', 'queues', 'printTelemetry', 'forensics' |
Where-Object { -not [bool]$result.$_.ok }
)
}
$result.overallOk = [bool](
$result.files.ok -and
$result.tasks.ok -and
$result.processes.ok -and
$result.buckets.ok -and
$result.queues.ok -and
$result.printTelemetry.ok -and
$result.forensics.ok
)
$result
@@ -1,20 +1,77 @@
param(
[string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json',
[string]$Hostname,
[int]$PollSeconds = 30
[int]$PollSeconds = 0
)
# Force UTF-8 for console I/O
try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 } catch {}
try { [Console]::InputEncoding = [System.Text.Encoding]::UTF8 } catch {}
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$ErrorActionPreference = 'Continue'
function Decode-Bytes-Auto {
param([byte[]]$Bytes)
if (-not $Bytes) { return '' }
$candidates = @()
# Try strict UTF8 first (detect invalid sequences)
try {
$utf8Strict = New-Object System.Text.UTF8Encoding($false,$true)
$txt = $utf8Strict.GetString($Bytes)
$candidates += @{enc='utf8'; text=$txt}
}
catch {
# invalid UTF8 sequences; ignore
}
# Try CP866 and CP1251
try { $cp866 = [System.Text.Encoding]::GetEncoding(866); $txt866 = $cp866.GetString($Bytes); $candidates += @{enc='cp866'; text=$txt866} } catch {}
try { $cp1251 = [System.Text.Encoding]::GetEncoding(1251); $txt1251 = $cp1251.GetString($Bytes); $candidates += @{enc='cp1251'; text=$txt1251} } catch {}
# If nothing decoded yet, fallback to UTF8 permissive
if ($candidates.Count -eq 0) {
try { $txt = [System.Text.Encoding]::UTF8.GetString($Bytes); return $txt } catch { return '' }
}
# Score decodings by count of Cyrillic letters; prefer highest
$best = $null; $bestScore = -1
foreach ($c in $candidates) {
$t = $c.text
if (-not $t) { continue }
$score = 0
try { $score = ([regex]::Matches($t,'\p{IsCyrillic}')).Count } catch { $score = 0 }
if ($score -gt $bestScore) { $best = $c; $bestScore = $score }
}
if ($best -ne $null) { return $best.text }
# Final fallback: first candidate text
return $candidates[0].text
}
function Get-Config {
param([string]$Path)
if (-not (Test-Path -LiteralPath $Path)) {
throw "Конфигурация не найдена: $Path"
throw "Config not found: $Path"
}
try {
$bytes = [System.IO.File]::ReadAllBytes($Path)
# Config is JSON. Prefer deterministic BOM-based decoding over heuristics.
if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) {
$text = [System.Text.Encoding]::UTF8.GetString($bytes)
} elseif ($bytes.Length -ge 2 -and $bytes[0] -eq 0xFF -and $bytes[1] -eq 0xFE) {
$text = [System.Text.Encoding]::Unicode.GetString($bytes)
} else {
$text = [System.Text.Encoding]::UTF8.GetString($bytes)
}
$text = $text -replace '^\uFEFF', ''
return $text | ConvertFrom-Json -ErrorAction Stop
}
catch {
throw "Failed to read config: $Path - $($_.Exception.Message)"
}
Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json
}
function Invoke-AwJsonPost {
@@ -22,9 +79,15 @@ function Invoke-AwJsonPost {
[Parameter(Mandatory = $true)][string]$Uri,
[Parameter(Mandatory = $true)][string]$Json
)
$bytes = [Text.Encoding]::UTF8.GetBytes($Json)
Invoke-RestMethod -Method Post -Uri $Uri -ContentType 'application/json; charset=utf-8' -Body $bytes | Out-Null
try {
$bytes = [System.Text.Encoding]::UTF8.GetBytes($Json)
Invoke-RestMethod -Method Post -Uri $Uri -ContentType 'application/json; charset=utf-8' -Body $bytes -ErrorAction Stop | Out-Null
return $true
}
catch {
Write-Verbose "POST error: $($_.Exception.Message)"
return $false
}
}
function Ensure-Bucket {
@@ -33,127 +96,202 @@ function Ensure-Bucket {
[Parameter(Mandatory = $true)][string]$BucketId,
[Parameter(Mandatory = $true)][string]$HostnameValue
)
try { Invoke-RestMethod -Method Get -Uri "$ApiBase/buckets/$BucketId" -ErrorAction Stop | Out-Null; return } catch { Write-Verbose "Bucket not found, creating: $BucketId" }
try {
Invoke-RestMethod -Method Get -Uri "$ApiBase/buckets/$BucketId" | Out-Null
return
$body = @{ client='aw-worktime-session-collector'; type='aw.worktime.session'; hostname=$HostnameValue } | ConvertTo-Json -Compress
$attempts = 0
while ($attempts -lt 3) {
$attempts++
$ok = Invoke-AwJsonPost -Uri "$ApiBase/buckets/$BucketId" -Json $body
if ($ok) { return }
Start-Sleep -Seconds (2 * $attempts)
}
catch {
}
$body = @{
client = 'aw-worktime-session-collector'
type = 'aw.worktime.session'
hostname = $HostnameValue
} | ConvertTo-Json -Compress
Invoke-AwJsonPost -Uri "$ApiBase/buckets/$BucketId" -Json $body
try { Invoke-RestMethod -Method Get -Uri "$ApiBase/buckets/$BucketId" -ErrorAction Stop | Out-Null } catch { Write-Verbose "Ensure-Bucket final check failed: $BucketId" }
}
function Get-SessionRecords {
function Run-QueryUser {
$tries = @(
@{File='cmd.exe';Args='/c query user'},
@{File='cmd.exe';Args='/c quser'},
@{File='query.exe';Args='user'},
@{File='quser.exe';Args=''}
)
foreach ($t in $tries) {
try {
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = $t.File
if ($t.Args) { $psi.Arguments = $t.Args }
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
$psi.UseShellExecute = $false
$psi.CreateNoWindow = $true
$proc = [System.Diagnostics.Process]::Start($psi)
$stream = $proc.StandardOutput.BaseStream
$ms = New-Object System.IO.MemoryStream
$buffer = New-Object byte[] 4096
while (($read = $stream.Read($buffer,0,$buffer.Length)) -gt 0) { $ms.Write($buffer,0,$read) }
if (-not $proc.WaitForExit(8000)) {
try { $proc.Kill() } catch {}
continue
}
$bytes = $ms.ToArray()
$text = Decode-Bytes-Auto -Bytes $bytes
if ($text -and $text.Trim()) { return ($text -split "\r?\n") | Where-Object { $_ -ne '' } }
}
catch {
# try next
}
}
return @()
}
function Parse-SessionLines {
param([string[]]$Lines)
$records = @()
if (-not $Lines) { return $records }
try {
$lines = quser 2>$null
if (-not $lines) {
return @()
$startIndex = 0
# NOTE: Keep this script ASCII-only to stay compatible with Windows PowerShell 5
# when the file is UTF-8 without BOM. Avoid Cyrillic literals in regex patterns.
if ($Lines.Count -gt 0 -and $Lines[0] -match '\b(USERNAME|UserName|USER)\b') { $startIndex = 1 }
for ($i = $startIndex; $i -lt $Lines.Count; $i++) {
$line = ($Lines[$i] -replace '^\s*>', '').Trim()
if (-not $line) { continue }
$parts = $line -split '\s+'
if ($parts.Count -lt 3) { continue }
$user = $parts[0]
$sess = ''
$id = -1
$state = ''
if ($parts.Count -ge 4 -and $parts[1] -match '^\d+$') {
$sess = ''
$id = [int]$parts[1]
$state = [string]$parts[2]
}
elseif ($parts.Count -ge 4 -and $parts[2] -match '^\d+$') {
$sess = [string]$parts[1]
$id = [int]$parts[2]
$state = [string]$parts[3]
}
else {
continue
}
foreach ($line in ($lines | Select-Object -Skip 1)) {
$clean = ($line -replace '^\s*>?', '').Trim()
if (-not $clean) {
continue
}
if ($id -lt 0) { continue }
$parts = $clean -split '\s+'
if ($parts.Count -lt 4) {
continue
}
$sessionName = ''
$sessionIdIndex = 2
if ($parts[1] -match '^\d+$') {
$sessionIdIndex = 1
}
else {
$sessionName = $parts[1]
}
$sessionId = 0
if ($parts[$sessionIdIndex] -match '^\d+$') {
$sessionId = [int]$parts[$sessionIdIndex]
}
$records += [pscustomobject]@{
username = $parts[0]
sessionName = $sessionName
sessionId = $sessionId
state = $parts[$sessionIdIndex + 1]
}
}
$records += [pscustomobject]@{ username=$user; sessionName=$sess; sessionId=$id; state=$state }
}
catch {
}
return $records
}
function Test-SessionIsActive {
param([AllowNull()][string]$State)
if ([string]::IsNullOrWhiteSpace($State)) { return $false }
param([string]$State)
if (-not $State) { return $false }
$s = $State.Trim().ToLowerInvariant()
return ($s -eq 'active') -or ($s -like 'актив*')
# Match English "active" and Russian "актив*" without embedding Cyrillic.
# "актив" = \u0430\u043A\u0442\u0438\u0432
return ($s -match 'active') -or ($s -match '\u0430\u043a\u0442\u0438\u0432')
}
function Get-CanonicalUserId {
param(
[pscustomobject]$Config,
[string]$HostnameValue,
[string]$Username
)
$normalizedUser = [string]$Username
if ([string]::IsNullOrWhiteSpace($normalizedUser)) {
return ''
}
if ($Config -and $Config.PSObject.Properties.Name -contains 'userTasks' -and $Config.userTasks) {
foreach ($task in @($Config.userTasks)) {
try {
$taskUserId = [string]$task.userId
if ([string]::IsNullOrWhiteSpace($taskUserId)) {
continue
}
$parts = $taskUserId -split '\\', 2
if ($parts.Count -eq 2 -and $parts[1].Equals($normalizedUser, [System.StringComparison]::OrdinalIgnoreCase)) {
return $taskUserId
}
}
catch {
}
}
}
return "$HostnameValue\$normalizedUser"
}
# Main
$cfg = Get-Config -Path $ConfigPath
$hostValue = if ($Hostname) { $Hostname } else { [string]$env:COMPUTERNAME }
$apiBase = '{0}://{1}:{2}/api/0' -f [string]$cfg.server.scheme, [string]$cfg.server.host, [string]$cfg.server.port
$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
$pulse = 120
$sleepSec = if ($PollSeconds -gt 0) {
$PollSeconds
}
elseif ($cfg.collector -and $cfg.collector.pollSeconds) {
[int]$cfg.collector.pollSeconds
}
else {
30
}
$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
while ($true) {
$now = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
$records = Get-SessionRecords
try {
$lines = Run-QueryUser
$records = Parse-SessionLines -Lines $lines
}
catch {
Write-Verbose "Session parse error: $($_.Exception.Message)"
$records = @()
}
if (-not $records -or $records.Count -eq 0) {
$records = @([pscustomobject]@{
username = $env:USERNAME
sessionName = ''
sessionId = (Get-Process -Id $PID).SessionId
state = 'Unknown'
})
# Fallback sample: keep bucket alive even when query user output is unavailable
# in non-interactive/session-0 contexts.
$records = @(
[pscustomobject]@{
username = [string]$env:USERNAME
sessionName = ''
sessionId = [int](Get-Process -Id $PID).SessionId
state = 'Unknown'
}
)
}
foreach ($rec in $records) {
$payload = @{
$canonicalUserId = Get-CanonicalUserId -Config $cfg -HostnameValue $hostValue -Username ([string]$rec.username)
$payloadObj = [PSCustomObject]@{
timestamp = $now
duration = 0
data = @{
duration = $sleepSec
data = [PSCustomObject]@{
username = [string]$rec.username
userId = "$($env:USERDOMAIN)\$($rec.username)"
userId = $canonicalUserId
sessionId = [int]$rec.sessionId
sessionName = [string]$rec.sessionName
state = [string]$rec.state
active = (Test-SessionIsActive -State ([string]$rec.state))
active = Test-SessionIsActive -State ([string]$rec.state)
sampleSeconds = $sleepSec
pollSeconds = $sleepSec
hostname = $hostValue
source = 'worktime-session-collector'
}
} | ConvertTo-Json -Depth 6 -Compress
}
$payload = $payloadObj | ConvertTo-Json -Depth 6 -Compress
try {
Invoke-AwJsonPost -Uri "$apiBase/buckets/$bucketId/heartbeat?pulsetime=$pulse" -Json $payload
$ok = Invoke-AwJsonPost -Uri "$apiBase/buckets/$bucketId/heartbeat?pulsetime=$pulse" -Json $payload
if (-not $ok) { Write-Verbose "Heartbeat not confirmed for user $($rec.username)" }
}
catch {
Write-Verbose "Heartbeat error: $($_.Exception.Message)"
}
}