feat(aw-rus): sync deploy stack, install kit, and health checks
This commit is contained in:
@@ -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
|
||||
|
||||
+3
@@ -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)"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user