Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89cda28185 | ||
|
|
fa4bf96ebf | ||
|
|
b28cfabd57 | ||
|
|
4936d6bca3 | ||
|
|
c54b237f80 | ||
|
|
48223fbeeb | ||
|
|
7f131a6310 | ||
|
|
77591c10ce | ||
|
|
947717251f | ||
|
|
0da0d880c0 | ||
|
|
5adb22754f | ||
|
|
76004e30fe | ||
|
|
b48b18f225 | ||
|
|
5a41c4066b | ||
|
|
fff4c10e64 | ||
|
|
5105155323 | ||
|
|
ced44c347c | ||
|
|
c70fa019ad | ||
|
|
e22bd697f7 | ||
|
|
e80ae5fbc3 | ||
|
|
70ffd1f3e8 | ||
|
|
1e9628b01b | ||
|
|
f26012ae52 | ||
|
|
63c3d96a19 | ||
|
|
5584144978 | ||
|
|
0ae2ac7ec1 | ||
|
|
53acdcb6f4 | ||
|
|
bdf44b868e | ||
|
|
aa00d5633f | ||
|
|
6694e0682d | ||
|
|
a61b4cee2b | ||
|
|
428a8623a9 | ||
|
|
48bb4e4c4c | ||
|
|
bb200cab96 | ||
|
|
1b968f9382 | ||
|
|
97fe09a150 | ||
|
|
7ef8166504 | ||
|
|
f54284a0f9 | ||
|
|
bf27369b2a | ||
|
|
c3a49e658c | ||
|
|
016a323afc | ||
|
|
9e53272ce9 | ||
|
|
92eaa0635c |
@@ -10,3 +10,5 @@ windows/*.report.json
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
__pycache__/
|
||||
|
||||
@@ -9,10 +9,17 @@
|
||||
- `docs/runbook.md` — быстрый runbook для оператора.
|
||||
- `docs/operations.md` — регламент сопровождения, бэкапов, обновлений и rollback.
|
||||
- `docs/windows/ensemble.md` — orchestration-пакет для Windows-деплоя и проверки.
|
||||
- `docs/linux-client.md` — user-space rollout Linux-клиента ActivityWatch на удалённый `AW server`.
|
||||
- `docs/console-ssh-logger.md` — логирование только консольных команд и SSH-сессий в AW.
|
||||
- `docs/dlp-gap-analysis.md` — разрыв до enterprise DLP и roadmap.
|
||||
- `proxmox/` — шаблонные скрипты подготовки и наполнения CT на стороне Proxmox.
|
||||
- `aw-server/` — установочные скрипты, env-шаблон, systemd unit и RU patch для Web UI.
|
||||
- `ansible/` — Ansible-ensemble для автоматизированного сервера (Debian/CT).
|
||||
- `windows/` — PowerShell toolkit: single-user, domain-users, ensemble orchestration, hardening/recovery, validation.
|
||||
- `pfsense/` — внешний poller для pfSense API и systemd unit под Debian/Ubuntu utility VM.
|
||||
- `windows/` — PowerShell toolkit: single-user, domain-users, ensemble orchestration, hardening/recovery, validation, phase-2 DLP telemetry (`aw-dlp-incidents_*`, `aw-dlp-endpoint-signals_*`).
|
||||
- `scripts/quality-gate.sh` — локальный preflight-пайплайн проверок.
|
||||
- `scripts/install_aw_linux_client.sh` — установка Linux bundle + autostart для remote AW server.
|
||||
- `scripts/install_aw_console_ssh_logger.sh` — user-space установка console/ssh logger.
|
||||
|
||||
## Базовый сценарий
|
||||
|
||||
@@ -26,6 +33,29 @@
|
||||
8. Развернуть Windows-клиентов через `windows/deploy-ensemble.ps1`.
|
||||
9. Проверить итог через `windows/validate-deployment.ps1`.
|
||||
|
||||
Для полного Ansible-сценария “с нуля” в Proxmox используйте:
|
||||
|
||||
- `ansible/provision_proxmox_ct_and_deploy_aw.yml`
|
||||
- `ansible/provision_proxmox_ct_matrix_and_deploy_aw.yml` (массово по матрице CT)
|
||||
|
||||
Для централизованного phase-2 деплоя Windows-клиентов через WinRM:
|
||||
|
||||
- `ansible/deploy_aw_windows_phase2.yml`
|
||||
|
||||
Для внешнего pfSense poller'а:
|
||||
|
||||
- `ansible/deploy_aw_pfsense_poller.yml`
|
||||
|
||||
Для Linux desktop/admin host, который должен слать watcher'ы на удалённый AW server:
|
||||
|
||||
- `docs/linux-client.md`
|
||||
- `scripts/install_aw_linux_client.sh`
|
||||
|
||||
Для режима “только консоль/ssh” без GUI watcher'ов:
|
||||
|
||||
- `docs/console-ssh-logger.md`
|
||||
- `scripts/install_aw_console_ssh_logger.sh`
|
||||
|
||||
Скрипты `proxmox/create-ct.sh` и `proxmox/push-aw-artifacts.sh` по умолчанию читают:
|
||||
|
||||
- `secrets/deploy.secrets.env`
|
||||
|
||||
+114
-1
@@ -1,12 +1,26 @@
|
||||
# Ansible ensemble for AWatch-rus
|
||||
|
||||
Эта директория содержит минимальный Ansible-ensemble для повторяемого развёртывания ActivityWatch Server в Debian/LXC.
|
||||
Эта директория содержит Ansible-ensemble для двух сценариев:
|
||||
|
||||
- деплой на уже существующий Debian host/CT;
|
||||
- полный цикл с нуля в Proxmox: создание CT + bootstrap + установка ActivityWatch + RU patch.
|
||||
- централизованный деплой Windows phase-2 collectors по WinRM.
|
||||
- deployment внешнего pfSense poller'а на Debian/Ubuntu utility VM.
|
||||
|
||||
## Файлы
|
||||
|
||||
- `/home/igor/tmp/AWatch-rus/ansible/deploy_aw_server.yml` — основной playbook.
|
||||
- `/home/igor/tmp/AWatch-rus/ansible/provision_proxmox_ct_and_deploy_aw.yml` — full-stack playbook для Proxmox.
|
||||
- `/home/igor/tmp/AWatch-rus/ansible/provision_proxmox_ct_matrix_and_deploy_aw.yml` — массовый full-stack playbook (несколько CT).
|
||||
- `/home/igor/tmp/AWatch-rus/ansible/deploy_aw_windows_phase2.yml` — WinRM playbook для развёртывания phase-2 Windows collector'ов.
|
||||
- `/home/igor/tmp/AWatch-rus/ansible/deploy_aw_pfsense_poller.yml` — deployment pfSense poller'а.
|
||||
- `/home/igor/tmp/AWatch-rus/ansible/install_full_stack.yml` — полный установочный playbook (оркестратор всех этапов).
|
||||
- `/home/igor/tmp/AWatch-rus/ansible/inventory.example.ini` — шаблон inventory.
|
||||
- `/home/igor/tmp/AWatch-rus/ansible/group_vars/all.example.yml` — шаблон переменных.
|
||||
- `/home/igor/tmp/AWatch-rus/ansible/group_vars/proxmox.example.yml` — шаблон переменных CT в Proxmox.
|
||||
- `/home/igor/tmp/AWatch-rus/ansible/group_vars/proxmox-matrix.example.yml` — шаблон матрицы CT.
|
||||
- `/home/igor/tmp/AWatch-rus/ansible/group_vars/windows.example.yml` — шаблон переменных Windows phase-2.
|
||||
- `/home/igor/tmp/AWatch-rus/ansible/group_vars/pfsense-poller.example.yml` — шаблон переменных pfSense poller'а.
|
||||
|
||||
## Быстрый запуск
|
||||
|
||||
@@ -21,9 +35,108 @@ cd /home/igor/tmp/AWatch-rus/ansible
|
||||
ansible-playbook -i inventory.ini deploy_aw_server.yml
|
||||
```
|
||||
|
||||
## Полный установочный playbook (всё за один запуск)
|
||||
|
||||
Если нужно прогнать полный цикл одной командой:
|
||||
|
||||
```bash
|
||||
cd /home/igor/tmp/AWatch-rus/ansible
|
||||
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_phase2.yml` (группа `[aw_windows]`);
|
||||
- `deploy_aw_pfsense_poller.yml` (группа `[aw_pfsense_pollers]`).
|
||||
|
||||
Пустые группы в `inventory.ini` безопасны: соответствующий play будет пропущен.
|
||||
|
||||
## Полный запуск с нуля в Proxmox
|
||||
|
||||
1. Подготовьте inventory и vars:
|
||||
- `cp /home/igor/tmp/AWatch-rus/ansible/inventory.example.ini /home/igor/tmp/AWatch-rus/ansible/inventory.ini`
|
||||
- `cp /home/igor/tmp/AWatch-rus/ansible/group_vars/all.example.yml /home/igor/tmp/AWatch-rus/ansible/group_vars/all.yml`
|
||||
- `cp /home/igor/tmp/AWatch-rus/ansible/group_vars/proxmox.example.yml /home/igor/tmp/AWatch-rus/ansible/group_vars/proxmox.yml`
|
||||
2. Заполните `group_vars/proxmox.yml` и `group_vars/all.yml`.
|
||||
3. Запустите playbook:
|
||||
|
||||
```bash
|
||||
cd /home/igor/tmp/AWatch-rus/ansible
|
||||
ansible-playbook -i inventory.ini provision_proxmox_ct_and_deploy_aw.yml
|
||||
```
|
||||
|
||||
## Массовый запуск (матрица CT)
|
||||
|
||||
1. Подготовьте матрицу:
|
||||
- `cp /home/igor/tmp/AWatch-rus/ansible/group_vars/proxmox-matrix.example.yml /home/igor/tmp/AWatch-rus/ansible/group_vars/proxmox-matrix.yml`
|
||||
2. Заполните `proxmox-matrix.yml`.
|
||||
3. Запустите:
|
||||
|
||||
```bash
|
||||
cd /home/igor/tmp/AWatch-rus/ansible
|
||||
ansible-playbook -i inventory.ini provision_proxmox_ct_matrix_and_deploy_aw.yml
|
||||
```
|
||||
|
||||
## Windows phase-2 rollout (WinRM)
|
||||
|
||||
1. Подготовьте inventory и vars:
|
||||
- `cp /home/igor/tmp/AWatch-rus/ansible/inventory.example.ini /home/igor/tmp/AWatch-rus/ansible/inventory.ini`
|
||||
- `cp /home/igor/tmp/AWatch-rus/ansible/group_vars/windows.example.yml /home/igor/tmp/AWatch-rus/ansible/group_vars/windows.yml`
|
||||
2. Заполните `inventory.ini` (секция `[aw_windows]`) и `group_vars/windows.yml`.
|
||||
- Для русской локализации Windows часто нужен `ansible_user=Администратор` (а не `Administrator`).
|
||||
- Если WinRM закрыт, playbook не сможет стартовать и нужно сначала открыть `5985/5986` и `wsman`.
|
||||
3. Запустите:
|
||||
|
||||
```bash
|
||||
cd /home/igor/tmp/AWatch-rus/ansible
|
||||
ansible-playbook -i inventory.ini deploy_aw_windows_phase2.yml
|
||||
```
|
||||
|
||||
Playbook:
|
||||
|
||||
- выгружает `windows/*` toolkit на целевой хост в `C:\Deploy\AWatch-rus\windows`;
|
||||
- выполняет `deploy-ensemble.ps1` (deploy + hardening/recovery) с phase-2 policy/rules;
|
||||
- после deploy принудительно запускает `ActivityWatch Recovery` и все `ActivityWatch Launch *` задачи;
|
||||
- выполняет API smoke-check bucket `aw-watcher-afk_SHARKON2025` и ожидает свежие `not-afk` события;
|
||||
- запускает `validate-deployment.ps1`;
|
||||
- забирает JSON-отчёт в локальную директорию (`/tmp/aw-rus-validation` по умолчанию).
|
||||
|
||||
Дополнительные флаги:
|
||||
|
||||
- `aw_windows_afk_enabled: false` — не запускать `aw-watcher-afk`;
|
||||
- `aw_windows_window_enabled: false` — не запускать `aw-watcher-window`;
|
||||
- `aw_windows_incident_capture_enabled: false` — отключить блок incidentCapture;
|
||||
- `aw_windows_incident_screenshot_enabled: false` — не делать скриншот при DLP-инциденте;
|
||||
- `aw_windows_incident_artifacts_root: 'C:\...\incident-artifacts'` — переопределить путь артефактов;
|
||||
- `aw_windows_skip_hardening: true` — пропустить `hardening-recovery.ps1` внутри ensemble-скрипта.
|
||||
|
||||
## pfSense poller rollout
|
||||
|
||||
1. Подготовьте vars:
|
||||
- `cp /home/igor/tmp/AWatch-rus/ansible/group_vars/pfsense-poller.example.yml /home/igor/tmp/AWatch-rus/ansible/group_vars/pfsense-poller.yml`
|
||||
2. Добавьте inventory group `[aw_pfsense_pollers]`.
|
||||
3. Запустите:
|
||||
|
||||
```bash
|
||||
cd /home/igor/tmp/AWatch-rus/ansible
|
||||
ansible-playbook -i inventory.ini deploy_aw_pfsense_poller.yml
|
||||
```
|
||||
|
||||
Playbook:
|
||||
|
||||
- ставит `python3`;
|
||||
- копирует `pfsense-aw-poller.py`;
|
||||
- пишет `/etc/aw-pfsense/poller.json`;
|
||||
- поднимает `aw-pfsense-poller.service`.
|
||||
|
||||
## Результат
|
||||
|
||||
- Установлен ActivityWatch Server.
|
||||
- Создан systemd-unit `activitywatch-server.service`.
|
||||
- Установлен RU Web UI patch.
|
||||
- Для Web UI используется checksum-based cache-bust для `ru-patch-v5.js` и `sw-cleanup.js`, чтобы браузер не держал старую DLP/русскую статику после деплоя.
|
||||
- На `#/home` Web UI делит хосты на `Windows RDP` и `Virtual servers + Proxmox`.
|
||||
- Выполнена валидация API `http://127.0.0.1:5600/api/0/info`.
|
||||
- Для full-stack сценария CT создаётся автоматически через `pct create`.
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
- name: Deploy pfSense ActivityWatch poller
|
||||
hosts: aw_pfsense_pollers
|
||||
become: true
|
||||
gather_facts: true
|
||||
|
||||
vars:
|
||||
aw_pfsense_install_root: "/opt/aw-pfsense"
|
||||
aw_pfsense_config_dir: "/etc/aw-pfsense"
|
||||
aw_pfsense_service_name: "aw-pfsense-poller.service"
|
||||
|
||||
tasks:
|
||||
- name: Install required packages
|
||||
ansible.builtin.apt:
|
||||
name:
|
||||
- python3
|
||||
state: present
|
||||
update_cache: true
|
||||
|
||||
- name: Ensure directories exist
|
||||
ansible.builtin.file:
|
||||
path: "{{ item }}"
|
||||
state: directory
|
||||
mode: "0755"
|
||||
loop:
|
||||
- "{{ aw_pfsense_install_root }}"
|
||||
- "{{ aw_pfsense_config_dir }}"
|
||||
|
||||
- name: Install pfSense poller script
|
||||
ansible.builtin.copy:
|
||||
src: "{{ aw_repo_root }}/pfsense/pfsense-aw-poller.py"
|
||||
dest: "{{ aw_pfsense_install_root }}/pfsense-aw-poller.py"
|
||||
mode: "0755"
|
||||
|
||||
- name: Install systemd service
|
||||
ansible.builtin.copy:
|
||||
src: "{{ aw_repo_root }}/pfsense/pfsense-aw-poller.service"
|
||||
dest: "/etc/systemd/system/{{ aw_pfsense_service_name }}"
|
||||
mode: "0644"
|
||||
notify:
|
||||
- Reload systemd
|
||||
|
||||
- name: Write pfSense poller config
|
||||
ansible.builtin.copy:
|
||||
dest: "{{ aw_pfsense_config_dir }}/poller.json"
|
||||
mode: "0600"
|
||||
content: "{{ aw_pfsense_poller_config | to_nice_json }}"
|
||||
notify:
|
||||
- Restart pfSense poller
|
||||
|
||||
- name: Enable and start pfSense poller
|
||||
ansible.builtin.systemd:
|
||||
name: "{{ aw_pfsense_service_name }}"
|
||||
enabled: true
|
||||
state: restarted
|
||||
daemon_reload: true
|
||||
|
||||
handlers:
|
||||
- name: Reload systemd
|
||||
ansible.builtin.systemd:
|
||||
daemon_reload: true
|
||||
|
||||
- name: Restart pfSense poller
|
||||
ansible.builtin.systemd:
|
||||
name: "{{ aw_pfsense_service_name }}"
|
||||
state: restarted
|
||||
@@ -9,6 +9,11 @@
|
||||
aw_release_dir: "{{ aw_release_root }}/{{ aw_server_version }}"
|
||||
aw_archive_path: "/tmp/activitywatch-{{ aw_server_version }}.zip"
|
||||
aw_bootstrap_dir: "/tmp/aw-rus-bootstrap"
|
||||
aw_ru_patch_cache_bust: "{{ lookup('file', aw_repo_root + '/aw-server/aw-ru-patch.js') | hash('sha1') | truncate(12, true, '') }}"
|
||||
aw_sw_cleanup_cache_bust: "{{ lookup('file', aw_repo_root + '/aw-server/aw-sw-cleanup.js') | hash('sha1') | truncate(12, true, '') }}"
|
||||
aw_host_groups_cache_bust: "{{ lookup('file', aw_repo_root + '/aw-server/aw-host-groups.json') | hash('sha1') | truncate(12, true, '') }}"
|
||||
aw_worktime_classes: "{{ lookup('file', aw_repo_root + '/aw-server/settings/classes-worktime.json') | from_json }}"
|
||||
aw_default_views: "{{ lookup('file', aw_repo_root + '/aw-server/settings/views-default.json') | from_json }}"
|
||||
|
||||
tasks:
|
||||
- name: Install base packages
|
||||
@@ -88,6 +93,7 @@
|
||||
- { src: "{{ aw_repo_root }}/aw-server/activitywatch-server.service", dest: "/etc/systemd/system/activitywatch-server.service", mode: "0644" }
|
||||
- { src: "{{ aw_repo_root }}/aw-server/aw-ru-patch.js", dest: "{{ aw_server_webui_dir }}/js/ru-patch-v5.js", mode: "0644" }
|
||||
- { src: "{{ aw_repo_root }}/aw-server/aw-sw-cleanup.js", dest: "{{ aw_server_webui_dir }}/js/sw-cleanup.js", mode: "0644" }
|
||||
- { src: "{{ aw_repo_root }}/aw-server/aw-host-groups.json", dest: "{{ aw_server_webui_dir }}/js/aw-host-groups.json", mode: "0644" }
|
||||
notify:
|
||||
- Reload systemd
|
||||
- Restart activitywatch
|
||||
@@ -103,13 +109,13 @@
|
||||
ansible.builtin.replace:
|
||||
path: "{{ aw_server_webui_dir }}/index.html"
|
||||
regexp: '</head>'
|
||||
replace: '<script src="/js/sw-cleanup.js?v=ansible1"></script></head>'
|
||||
replace: '<script src="/js/sw-cleanup.js?v={{ aw_sw_cleanup_cache_bust }}"></script></head>'
|
||||
|
||||
- name: Insert RU patch loader before body end
|
||||
ansible.builtin.replace:
|
||||
path: "{{ aw_server_webui_dir }}/index.html"
|
||||
regexp: '</body>'
|
||||
replace: '<script defer="defer" src="/js/ru-patch-v5.js?v=ansible1"></script></body>'
|
||||
replace: '<script defer="defer" src="/js/ru-patch-v5.js?v={{ aw_ru_patch_cache_bust }}"></script></body>'
|
||||
|
||||
- name: Write /etc/activitywatch/aw-server.env
|
||||
ansible.builtin.copy:
|
||||
@@ -141,6 +147,77 @@
|
||||
delay: 3
|
||||
until: aw_api.status == 200
|
||||
|
||||
- name: Apply baseline worktime settings (classes)
|
||||
ansible.builtin.uri:
|
||||
url: "http://127.0.0.1:{{ aw_server_port }}/api/0/settings/classes"
|
||||
method: POST
|
||||
body: "{{ aw_worktime_classes }}"
|
||||
body_format: json
|
||||
status_code: 201
|
||||
when: aw_apply_worktime_settings | default(false) | bool
|
||||
|
||||
- name: Apply baseline views (include DLP and worktime)
|
||||
ansible.builtin.uri:
|
||||
url: "http://127.0.0.1:{{ aw_server_port }}/api/0/settings/views"
|
||||
method: POST
|
||||
body: "{{ aw_default_views }}"
|
||||
body_format: json
|
||||
status_code: 201
|
||||
when: aw_apply_worktime_settings | default(false) | bool
|
||||
|
||||
- name: Derive worktime durationDefault from aw_worktime_from/to
|
||||
ansible.builtin.set_fact:
|
||||
aw_worktime_from_h: "{{ (aw_worktime_from | default('08:00')).split(':')[0] | int }}"
|
||||
aw_worktime_from_m: "{{ (aw_worktime_from | default('08:00')).split(':')[1] | int }}"
|
||||
aw_worktime_to_h: "{{ (aw_worktime_to | default('17:00')).split(':')[0] | int }}"
|
||||
aw_worktime_to_m: "{{ (aw_worktime_to | default('17:00')).split(':')[1] | int }}"
|
||||
aw_worktime_duration_default_derived: >-
|
||||
{{
|
||||
(
|
||||
(
|
||||
((aw_worktime_to_h | int) * 60 + (aw_worktime_to_m | int)) -
|
||||
((aw_worktime_from_h | int) * 60 + (aw_worktime_from_m | int))
|
||||
) * 60
|
||||
)
|
||||
}}
|
||||
when: aw_apply_worktime_settings | default(false) | bool
|
||||
|
||||
- name: Normalize derived durationDefault for overnight shifts
|
||||
ansible.builtin.set_fact:
|
||||
aw_worktime_duration_default_effective: >-
|
||||
{{
|
||||
(aw_worktime_duration_default_derived | int)
|
||||
if (aw_worktime_duration_default_derived | int) > 0
|
||||
else ((aw_worktime_duration_default_derived | int) + 86400)
|
||||
}}
|
||||
when: aw_apply_worktime_settings | default(false) | bool
|
||||
|
||||
- name: Validate derived durationDefault is sane
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- aw_worktime_duration_default_effective | int > 0
|
||||
- aw_worktime_duration_default_effective | int <= 86400
|
||||
fail_msg: "Invalid worktime window: {{ aw_worktime_from }}..{{ aw_worktime_to }}"
|
||||
when: aw_apply_worktime_settings | default(false) | bool
|
||||
|
||||
- name: Apply baseline worktime period (startOfDay)
|
||||
ansible.builtin.uri:
|
||||
url: "http://127.0.0.1:{{ aw_server_port }}/api/0/settings/startOfDay"
|
||||
method: POST
|
||||
body: "{{ aw_worktime_start_of_day }}"
|
||||
body_format: json
|
||||
status_code: 200
|
||||
when: aw_apply_worktime_settings | default(false) | bool
|
||||
|
||||
- name: Apply baseline worktime period (durationDefault seconds)
|
||||
ansible.builtin.uri:
|
||||
url: "http://127.0.0.1:{{ aw_server_port }}/api/0/settings/durationDefault"
|
||||
method: POST
|
||||
body: "{{ aw_worktime_duration_default_effective }}"
|
||||
body_format: json
|
||||
status_code: 200
|
||||
when: aw_apply_worktime_settings | default(false) | bool
|
||||
|
||||
handlers:
|
||||
- name: Reload systemd
|
||||
ansible.builtin.systemd:
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
---
|
||||
- name: Deploy AWatch-rus Windows phase2 collectors
|
||||
hosts: aw_windows
|
||||
gather_facts: false
|
||||
|
||||
vars:
|
||||
aw_windows_repo_root: "/home/igor/tmp/AWatch-rus"
|
||||
aw_windows_deploy_root: "C:\\Deploy\\AWatch-rus"
|
||||
aw_windows_server_host: "10.10.10.13"
|
||||
aw_windows_server_port: 5600
|
||||
aw_windows_domain: "SHARKON2025"
|
||||
aw_windows_users:
|
||||
- user1
|
||||
- user2
|
||||
- user3
|
||||
- user4
|
||||
- user5
|
||||
aw_windows_extra_users: []
|
||||
aw_windows_users_effective: "{{ (aw_windows_users + aw_windows_extra_users) | unique }}"
|
||||
aw_windows_install_root: "C:\\Program Files\\ActivityWatch-Phase2"
|
||||
aw_windows_state_root: "C:\\ProgramData\\ActivityWatch"
|
||||
aw_windows_afk_enabled: true
|
||||
aw_windows_window_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_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: "C:\\Windows\\Temp\\aw_validate_phase2_ansible.json"
|
||||
aw_windows_validation_local_dir: "/tmp/aw-rus-validation"
|
||||
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-watcher-afk_SHARKON2025"
|
||||
aw_windows_api_smoke_check_limit: 10
|
||||
|
||||
tasks:
|
||||
- name: Validate required variables
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- aw_windows_server_host is defined
|
||||
- aw_windows_server_port is defined
|
||||
- aw_windows_domain is defined
|
||||
- aw_windows_users_effective | length > 0
|
||||
- aw_windows_install_root is defined
|
||||
- aw_windows_state_root is defined
|
||||
fail_msg: "Missing required Windows deployment variables."
|
||||
|
||||
- name: Ensure deploy directories exist
|
||||
ansible.windows.win_file:
|
||||
path: "{{ item }}"
|
||||
state: directory
|
||||
loop:
|
||||
- "{{ aw_windows_deploy_root }}"
|
||||
- "{{ aw_windows_deploy_root }}\\windows"
|
||||
|
||||
- name: Upload Windows deployment toolkit
|
||||
ansible.windows.win_copy:
|
||||
src: "{{ aw_windows_repo_root }}/windows/{{ item }}"
|
||||
dest: "{{ aw_windows_deploy_root }}\\windows\\{{ item }}"
|
||||
loop:
|
||||
- ActivityWatch.Windows.Common.psd1
|
||||
- ActivityWatch.Windows.Common.psm1
|
||||
- browser-domains-native-collector.ps1
|
||||
- dlp-endpoint-signals-collector.ps1
|
||||
- deploy-domain-users.ps1
|
||||
- deploy-ensemble.ps1
|
||||
- hardening-recovery.ps1
|
||||
- validate-deployment.ps1
|
||||
- web-category-rules.example.json
|
||||
- dlp-policy.example.json
|
||||
|
||||
- name: Upload user list for domain deploy
|
||||
ansible.windows.win_copy:
|
||||
dest: "{{ aw_windows_deploy_root }}\\windows\\users.txt"
|
||||
content: |
|
||||
{% for user in aw_windows_users -%}
|
||||
{{ user }}
|
||||
{% endfor -%}
|
||||
{% for user in aw_windows_extra_users -%}
|
||||
{{ user }}
|
||||
{% endfor -%}
|
||||
|
||||
- name: Run phase2 ensemble deployment
|
||||
ansible.windows.win_powershell:
|
||||
script: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$params = @{
|
||||
ServerHost = "{{ aw_windows_server_host }}"
|
||||
ServerPort = {{ aw_windows_server_port }}
|
||||
Domain = "{{ aw_windows_domain }}"
|
||||
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' }}
|
||||
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 }}"
|
||||
LogonMarkerEnabled = {{ '$true' if (aw_windows_logon_marker_enabled | bool) else '$false' }}
|
||||
CustomRulesPath = "{{ aw_windows_rules_path }}"
|
||||
CustomPolicyPath = "{{ aw_windows_policy_path }}"
|
||||
}
|
||||
{% if aw_windows_skip_hardening | bool %}
|
||||
$params.SkipHardening = $true
|
||||
{% endif %}
|
||||
& "{{ aw_windows_deploy_root }}\windows\deploy-ensemble.ps1" @params
|
||||
|
||||
- name: Force start ActivityWatch recovery and 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 }
|
||||
|
||||
- name: Wait for fresh AFK events to appear on AW server
|
||||
when: aw_windows_api_smoke_check_enabled | bool
|
||||
delegate_to: localhost
|
||||
ansible.builtin.uri:
|
||||
url: "http://{{ aw_windows_server_host }}:{{ aw_windows_server_port }}/api/0/buckets/{{ aw_windows_api_smoke_check_bucket }}/events?limit={{ aw_windows_api_smoke_check_limit }}"
|
||||
method: GET
|
||||
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')
|
||||
| list
|
||||
| length
|
||||
) > 0
|
||||
retries: 10
|
||||
delay: 6
|
||||
|
||||
- name: Run validation and store report on target
|
||||
ansible.windows.win_powershell:
|
||||
script: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$report = & "{{ 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
|
||||
|
||||
- name: Ensure local validation directory exists
|
||||
ansible.builtin.file:
|
||||
path: "{{ aw_windows_validation_local_dir }}"
|
||||
state: directory
|
||||
mode: "0755"
|
||||
delegate_to: localhost
|
||||
|
||||
- name: Fetch validation report
|
||||
ansible.builtin.fetch:
|
||||
src: "{{ aw_windows_validation_remote_path }}"
|
||||
dest: "{{ aw_windows_validation_local_dir }}/"
|
||||
flat: false
|
||||
|
||||
- name: Show report location
|
||||
ansible.builtin.debug:
|
||||
msg:
|
||||
- "Windows phase2 deploy completed on {{ inventory_hostname }}."
|
||||
- "Validation report: {{ aw_windows_validation_local_dir }}/{{ inventory_hostname }}/C$/Windows/Temp/aw_validate_phase2_ansible.json"
|
||||
@@ -9,3 +9,16 @@ aw_server_user: "activitywatch"
|
||||
aw_server_group: "activitywatch"
|
||||
|
||||
aw_repo_root: "/home/igor/tmp/AWatch-rus"
|
||||
|
||||
# Optional: apply a baseline worktime-focused categorization and views via AW settings API.
|
||||
# WARNING: this overwrites existing server-side settings/classes/views.
|
||||
aw_apply_worktime_settings: false
|
||||
|
||||
# Optional defaults for the worktime period in Web UI.
|
||||
# startOfDay controls day-boundary and default report window start.
|
||||
# durationDefault controls default time range (seconds) shown in UI.
|
||||
#
|
||||
# Recommended: set worktime window explicitly and let the playbook derive duration.
|
||||
aw_worktime_from: "08:00"
|
||||
aw_worktime_to: "17:00"
|
||||
aw_worktime_start_of_day: "{{ aw_worktime_from }}"
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
aw_pfsense_poller_config:
|
||||
poll_interval_seconds: 60
|
||||
aw:
|
||||
server_host: "10.10.10.13"
|
||||
server_port: 5600
|
||||
hostname: "PFSENSE-EDGE01"
|
||||
pulse_time_seconds: 120
|
||||
timeout_seconds: 15
|
||||
pfsense:
|
||||
name: "pfSense Edge 01"
|
||||
host: "10.10.10.1"
|
||||
scheme: "https"
|
||||
verify_tls: false
|
||||
timeout_seconds: 15
|
||||
headers:
|
||||
X-API-Key: "replace-me"
|
||||
X-API-Secret: "replace-me"
|
||||
endpoints:
|
||||
- name: "system-status"
|
||||
path: "/api/v2/status/system"
|
||||
bucket_prefix: "aw-pfsense-health"
|
||||
bucket_type: "aw.pfsense.health"
|
||||
- name: "interfaces"
|
||||
path: "/api/v2/interface"
|
||||
bucket_prefix: "aw-pfsense-interfaces"
|
||||
bucket_type: "aw.pfsense.interfaces"
|
||||
- name: "gateways"
|
||||
path: "/api/v2/status/gateways"
|
||||
bucket_prefix: "aw-pfsense-gateways"
|
||||
bucket_type: "aw.pfsense.gateways"
|
||||
@@ -0,0 +1,38 @@
|
||||
proxmox_ct_matrix:
|
||||
- id: "203"
|
||||
hostname: "activitywatch-user1"
|
||||
storage: "local-lvm"
|
||||
template: "local:vztmpl/debian-12-standard_12.7-1_amd64.tar.zst"
|
||||
rootfs_size: "8G"
|
||||
cores: "2"
|
||||
memory: "2048"
|
||||
swap: "512"
|
||||
bridge: "vmbr10"
|
||||
ip: "10.20.30.13/24"
|
||||
gw: "10.20.30.1"
|
||||
vlan: ""
|
||||
nameserver: "1.1.1.1 8.8.8.8"
|
||||
searchdomain: "example.internal"
|
||||
password: "CHANGE_ME"
|
||||
unprivileged: "1"
|
||||
onboot: "1"
|
||||
features: "nesting=1,keyctl=1"
|
||||
|
||||
- id: "204"
|
||||
hostname: "activitywatch-user2"
|
||||
storage: "local-lvm"
|
||||
template: "local:vztmpl/debian-12-standard_12.7-1_amd64.tar.zst"
|
||||
rootfs_size: "8G"
|
||||
cores: "2"
|
||||
memory: "2048"
|
||||
swap: "512"
|
||||
bridge: "vmbr10"
|
||||
ip: "10.20.30.14/24"
|
||||
gw: "10.20.30.1"
|
||||
vlan: ""
|
||||
nameserver: "1.1.1.1 8.8.8.8"
|
||||
searchdomain: "example.internal"
|
||||
password: "CHANGE_ME"
|
||||
unprivileged: "1"
|
||||
onboot: "1"
|
||||
features: "nesting=1,keyctl=1"
|
||||
@@ -0,0 +1,18 @@
|
||||
proxmox_ct_id: "203"
|
||||
proxmox_ct_hostname: "activitywatch-server"
|
||||
proxmox_ct_storage: "local-lvm"
|
||||
proxmox_ct_template: "local:vztmpl/debian-12-standard_12.7-1_amd64.tar.zst"
|
||||
proxmox_ct_rootfs_size: "8G"
|
||||
proxmox_ct_cores: "2"
|
||||
proxmox_ct_memory: "2048"
|
||||
proxmox_ct_swap: "512"
|
||||
proxmox_ct_bridge: "vmbr10"
|
||||
proxmox_ct_ip: "10.20.30.13/24"
|
||||
proxmox_ct_gw: "10.20.30.1"
|
||||
proxmox_ct_vlan: ""
|
||||
proxmox_ct_nameserver: "1.1.1.1 8.8.8.8"
|
||||
proxmox_ct_searchdomain: "example.internal"
|
||||
proxmox_ct_password: "CHANGE_ME"
|
||||
proxmox_ct_unprivileged: "1"
|
||||
proxmox_ct_onboot: "1"
|
||||
proxmox_ct_features: "nesting=1,keyctl=1"
|
||||
@@ -0,0 +1,33 @@
|
||||
aw_windows_repo_root: "/home/igor/tmp/AWatch-rus"
|
||||
aw_windows_deploy_root: "C:\\Deploy\\AWatch-rus"
|
||||
aw_windows_server_host: "10.10.10.13"
|
||||
aw_windows_server_port: 5600
|
||||
aw_windows_domain: "SHARKON2025"
|
||||
aw_windows_users:
|
||||
- user1
|
||||
- user2
|
||||
- user3
|
||||
- user4
|
||||
- user5
|
||||
aw_windows_extra_users: []
|
||||
# Например:
|
||||
# aw_windows_extra_users:
|
||||
# - Администратор
|
||||
|
||||
# Рекомендуемый изолированный профиль для фазового раската.
|
||||
aw_windows_install_root: "C:\\Program Files\\ActivityWatch-Phase2"
|
||||
aw_windows_state_root: "C:\\ProgramData\\ActivityWatch"
|
||||
aw_windows_afk_enabled: true
|
||||
aw_windows_window_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_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: "C:\\Windows\\Temp\\aw_validate_phase2_ansible.json"
|
||||
aw_windows_validation_local_dir: "/tmp/aw-rus-validation"
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
# Full-stack installer for AWatch-rus.
|
||||
# Runs end-to-end rollout in one command:
|
||||
# 1) Proxmox CT provision + AW bootstrap (if [proxmox] exists in inventory)
|
||||
# 2) AW server deploy on [aw_server] hosts
|
||||
# 3) Windows phase2 rollout on [aw_windows] hosts
|
||||
# 4) pfSense poller deploy on [aw_pfsense_pollers] hosts
|
||||
#
|
||||
# Notes:
|
||||
# - Keep only relevant inventory groups filled for your environment.
|
||||
# - Plays with unmatched host groups are skipped automatically by Ansible.
|
||||
|
||||
- import_playbook: provision_proxmox_ct_and_deploy_aw.yml
|
||||
- import_playbook: deploy_aw_server.yml
|
||||
- import_playbook: deploy_aw_windows_phase2.yml
|
||||
- import_playbook: deploy_aw_pfsense_poller.yml
|
||||
@@ -1,2 +1,9 @@
|
||||
[proxmox]
|
||||
pve-main ansible_host=192.168.10.2 ansible_user=root ansible_port=22
|
||||
|
||||
[aw_server]
|
||||
aw-ct ansible_host=10.20.30.13 ansible_user=root ansible_port=22
|
||||
|
||||
[aw_windows]
|
||||
# NOTE: in RU-localized installs this account is often "Администратор" instead of "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
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
- name: Provision single Proxmox CT and deploy AWatch-rus
|
||||
hosts: proxmox
|
||||
gather_facts: false
|
||||
|
||||
vars:
|
||||
proxmox_bootstrap_dir: "/tmp/aw-rus-bootstrap"
|
||||
aw_bootstrap_files:
|
||||
- install_aw_server.sh
|
||||
- apply_webui_ru_patch.sh
|
||||
- activitywatch-server.service
|
||||
- aw-server.env.example
|
||||
- aw-ru-patch.js
|
||||
- aw-sw-cleanup.js
|
||||
- aw-host-groups.json
|
||||
- settings/classes-worktime.json
|
||||
- settings/views-default.json
|
||||
|
||||
tasks:
|
||||
- name: Execute single-CT provisioning workflow
|
||||
ansible.builtin.include_tasks: tasks/provision_ct_and_deploy_aw.yml
|
||||
vars:
|
||||
ct_id: "{{ proxmox_ct_id }}"
|
||||
ct_hostname: "{{ proxmox_ct_hostname }}"
|
||||
ct_storage: "{{ proxmox_ct_storage }}"
|
||||
ct_template: "{{ proxmox_ct_template }}"
|
||||
ct_rootfs_size: "{{ proxmox_ct_rootfs_size }}"
|
||||
ct_cores: "{{ proxmox_ct_cores }}"
|
||||
ct_memory: "{{ proxmox_ct_memory }}"
|
||||
ct_swap: "{{ proxmox_ct_swap }}"
|
||||
ct_bridge: "{{ proxmox_ct_bridge }}"
|
||||
ct_ip: "{{ proxmox_ct_ip }}"
|
||||
ct_gw: "{{ proxmox_ct_gw }}"
|
||||
ct_vlan: "{{ proxmox_ct_vlan | default('') }}"
|
||||
ct_nameserver: "{{ proxmox_ct_nameserver | default('') }}"
|
||||
ct_searchdomain: "{{ proxmox_ct_searchdomain | default('') }}"
|
||||
ct_password: "{{ proxmox_ct_password }}"
|
||||
ct_unprivileged: "{{ proxmox_ct_unprivileged }}"
|
||||
ct_onboot: "{{ proxmox_ct_onboot }}"
|
||||
ct_features: "{{ proxmox_ct_features }}"
|
||||
@@ -0,0 +1,50 @@
|
||||
---
|
||||
- name: Provision Proxmox CT matrix and deploy AWatch-rus with RU patch
|
||||
hosts: proxmox
|
||||
gather_facts: false
|
||||
|
||||
vars:
|
||||
proxmox_bootstrap_dir: "/tmp/aw-rus-bootstrap"
|
||||
aw_bootstrap_files:
|
||||
- install_aw_server.sh
|
||||
- apply_webui_ru_patch.sh
|
||||
- activitywatch-server.service
|
||||
- aw-server.env.example
|
||||
- aw-ru-patch.js
|
||||
- aw-sw-cleanup.js
|
||||
- aw-host-groups.json
|
||||
- settings/classes-worktime.json
|
||||
- settings/views-default.json
|
||||
|
||||
tasks:
|
||||
- name: Validate CT matrix is provided
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- proxmox_ct_matrix is defined
|
||||
- proxmox_ct_matrix | length > 0
|
||||
fail_msg: "Define proxmox_ct_matrix in group_vars/proxmox-matrix.yml"
|
||||
|
||||
- name: Execute provisioning workflow for each CT
|
||||
ansible.builtin.include_tasks: tasks/provision_ct_and_deploy_aw.yml
|
||||
vars:
|
||||
ct_id: "{{ item.id }}"
|
||||
ct_hostname: "{{ item.hostname }}"
|
||||
ct_storage: "{{ item.storage }}"
|
||||
ct_template: "{{ item.template }}"
|
||||
ct_rootfs_size: "{{ item.rootfs_size }}"
|
||||
ct_cores: "{{ item.cores }}"
|
||||
ct_memory: "{{ item.memory }}"
|
||||
ct_swap: "{{ item.swap }}"
|
||||
ct_bridge: "{{ item.bridge }}"
|
||||
ct_ip: "{{ item.ip }}"
|
||||
ct_gw: "{{ item.gw }}"
|
||||
ct_vlan: "{{ item.vlan | default('') }}"
|
||||
ct_nameserver: "{{ item.nameserver | default('') }}"
|
||||
ct_searchdomain: "{{ item.searchdomain | default('') }}"
|
||||
ct_password: "{{ item.password }}"
|
||||
ct_unprivileged: "{{ item.unprivileged }}"
|
||||
ct_onboot: "{{ item.onboot }}"
|
||||
ct_features: "{{ item.features }}"
|
||||
loop: "{{ proxmox_ct_matrix }}"
|
||||
loop_control:
|
||||
label: "ct={{ item.id }} host={{ item.hostname }} ip={{ item.ip }}"
|
||||
@@ -0,0 +1,217 @@
|
||||
---
|
||||
- name: Validate required per-CT variables
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- ct_id is defined
|
||||
- ct_hostname is defined
|
||||
- ct_storage is defined
|
||||
- ct_template is defined
|
||||
- ct_rootfs_size is defined
|
||||
- ct_cores is defined
|
||||
- ct_memory is defined
|
||||
- ct_swap is defined
|
||||
- ct_bridge is defined
|
||||
- ct_ip is defined
|
||||
- ct_gw is defined
|
||||
- ct_password is defined
|
||||
- ct_unprivileged is defined
|
||||
- ct_onboot is defined
|
||||
- ct_features is defined
|
||||
- aw_repo_root is defined
|
||||
- aw_server_version is defined
|
||||
- aw_server_download_url is defined
|
||||
- aw_server_bind_host is defined
|
||||
- aw_server_port is defined
|
||||
- aw_server_webui_dir is defined
|
||||
- aw_server_data_dir is defined
|
||||
- aw_server_log_dir is defined
|
||||
- aw_server_user is defined
|
||||
- aw_server_group is defined
|
||||
fail_msg: "Missing required variables for CT provisioning/deploy."
|
||||
|
||||
- name: Build CT network string
|
||||
ansible.builtin.set_fact:
|
||||
ct_net0: >-
|
||||
name=eth0,bridge={{ ct_bridge }},ip={{ ct_ip }},gw={{ ct_gw }}{% if (ct_vlan | default('') | string | length) > 0 %},tag={{ ct_vlan }}{% endif %}
|
||||
|
||||
- name: Check whether CT already exists
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- pct
|
||||
- status
|
||||
- "{{ ct_id }}"
|
||||
register: ct_status_check
|
||||
failed_when: false
|
||||
changed_when: false
|
||||
|
||||
- name: Create CT when absent
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- pct
|
||||
- create
|
||||
- "{{ ct_id }}"
|
||||
- "{{ ct_template }}"
|
||||
- --hostname
|
||||
- "{{ ct_hostname }}"
|
||||
- --cores
|
||||
- "{{ ct_cores }}"
|
||||
- --memory
|
||||
- "{{ ct_memory }}"
|
||||
- --swap
|
||||
- "{{ ct_swap }}"
|
||||
- --rootfs
|
||||
- "{{ ct_storage }}:{{ ct_rootfs_size }}"
|
||||
- --password
|
||||
- "{{ ct_password }}"
|
||||
- --unprivileged
|
||||
- "{{ ct_unprivileged }}"
|
||||
- --onboot
|
||||
- "{{ ct_onboot }}"
|
||||
- --features
|
||||
- "{{ ct_features }}"
|
||||
- --net0
|
||||
- "{{ ct_net0 }}"
|
||||
- --nameserver
|
||||
- "{{ ct_nameserver | default('') }}"
|
||||
- --searchdomain
|
||||
- "{{ ct_searchdomain | default('') }}"
|
||||
- --ostype
|
||||
- debian
|
||||
when: ct_status_check.rc != 0
|
||||
|
||||
- name: Check current CT runtime state
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- pct
|
||||
- status
|
||||
- "{{ ct_id }}"
|
||||
register: ct_runtime_status
|
||||
changed_when: false
|
||||
|
||||
- name: Start CT when stopped
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- pct
|
||||
- start
|
||||
- "{{ ct_id }}"
|
||||
when: "'stopped' in ct_runtime_status.stdout"
|
||||
|
||||
- name: Ensure bootstrap directory on Proxmox host
|
||||
ansible.builtin.file:
|
||||
path: "{{ proxmox_bootstrap_dir }}"
|
||||
state: directory
|
||||
mode: "0700"
|
||||
|
||||
- name: Copy AW bootstrap files to Proxmox host temp
|
||||
ansible.builtin.copy:
|
||||
src: "{{ aw_repo_root }}/aw-server/{{ item }}"
|
||||
dest: "{{ proxmox_bootstrap_dir }}/{{ item }}"
|
||||
mode: "0644"
|
||||
loop: "{{ aw_bootstrap_files }}"
|
||||
|
||||
- name: Bootstrap CT OS dependencies
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- pct
|
||||
- exec
|
||||
- "{{ ct_id }}"
|
||||
- --
|
||||
- bash
|
||||
- -lc
|
||||
- |
|
||||
set -euo pipefail
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update
|
||||
apt-get install -y curl ca-certificates bash unzip xz-utils jq rsync openssh-server
|
||||
mkdir -p /root/bootstrap /etc/activitywatch
|
||||
systemctl enable ssh || true
|
||||
systemctl restart ssh || true
|
||||
|
||||
- name: Push bootstrap files into CT
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- pct
|
||||
- push
|
||||
- "{{ ct_id }}"
|
||||
- "{{ proxmox_bootstrap_dir }}/{{ item }}"
|
||||
- "/root/bootstrap/{{ item }}"
|
||||
loop: "{{ aw_bootstrap_files }}"
|
||||
|
||||
- name: Write AW server env file on Proxmox host temp
|
||||
ansible.builtin.copy:
|
||||
dest: "{{ proxmox_bootstrap_dir }}/aw-server.env"
|
||||
mode: "0600"
|
||||
content: |
|
||||
AW_SERVER_VERSION={{ aw_server_version }}
|
||||
AW_SERVER_DOWNLOAD_URL={{ aw_server_download_url }}
|
||||
AW_SERVER_BIND_HOST={{ aw_server_bind_host }}
|
||||
AW_SERVER_PORT={{ aw_server_port }}
|
||||
AW_SERVER_WEBUI_DIR={{ aw_server_webui_dir }}
|
||||
AW_SERVER_DATA_DIR={{ aw_server_data_dir }}
|
||||
AW_SERVER_LOG_DIR={{ aw_server_log_dir }}
|
||||
AW_SERVER_USER={{ aw_server_user }}
|
||||
AW_SERVER_GROUP={{ aw_server_group }}
|
||||
|
||||
- name: Push AW server env into CT
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- pct
|
||||
- push
|
||||
- "{{ ct_id }}"
|
||||
- "{{ proxmox_bootstrap_dir }}/aw-server.env"
|
||||
- /etc/activitywatch/aw-server.env
|
||||
|
||||
- name: Set mode for env inside CT
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- pct
|
||||
- exec
|
||||
- "{{ ct_id }}"
|
||||
- --
|
||||
- chmod
|
||||
- "0600"
|
||||
- /etc/activitywatch/aw-server.env
|
||||
|
||||
- name: Install server and apply RU patch inside CT
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- pct
|
||||
- exec
|
||||
- "{{ ct_id }}"
|
||||
- --
|
||||
- bash
|
||||
- -lc
|
||||
- |
|
||||
set -euo pipefail
|
||||
chmod +x /root/bootstrap/install_aw_server.sh /root/bootstrap/apply_webui_ru_patch.sh
|
||||
bash /root/bootstrap/install_aw_server.sh
|
||||
bash /root/bootstrap/apply_webui_ru_patch.sh
|
||||
systemctl restart activitywatch-server.service
|
||||
|
||||
- name: Validate AW API from inside CT
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- pct
|
||||
- exec
|
||||
- "{{ ct_id }}"
|
||||
- --
|
||||
- bash
|
||||
- -lc
|
||||
- "curl -fsS http://127.0.0.1:{{ aw_server_port }}/api/0/info >/dev/null"
|
||||
|
||||
- name: Validate RU patch hooks in index
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- pct
|
||||
- exec
|
||||
- "{{ ct_id }}"
|
||||
- --
|
||||
- bash
|
||||
- -lc
|
||||
- "grep -q 'ru-patch-v5.js' {{ aw_server_webui_dir }}/index.html && grep -q 'sw-cleanup.js' {{ aw_server_webui_dir }}/index.html"
|
||||
|
||||
- name: Show final endpoint
|
||||
ansible.builtin.debug:
|
||||
msg:
|
||||
- "CT {{ ct_id }} is provisioned and configured."
|
||||
- "ActivityWatch endpoint: http://{{ ct_ip | regex_replace('/[0-9]+$', '') }}:{{ aw_server_port }}"
|
||||
@@ -9,25 +9,103 @@ fi
|
||||
|
||||
source "$ENV_FILE"
|
||||
|
||||
WEBUI_DIR="${AW_SERVER_WEBUI_DIR:-/opt/activitywatch/webui-ru}"
|
||||
WEBUI_DIR="${AW_SERVER_WEBUI_DIR:-${AW_WEBUI_DIR:-/opt/activitywatch/webui-ru}}"
|
||||
PATCH_JS_SRC="/root/bootstrap/aw-ru-patch.js"
|
||||
SW_CLEANUP_SRC="/root/bootstrap/aw-sw-cleanup.js"
|
||||
HOST_GROUPS_SRC="/root/bootstrap/aw-host-groups.json"
|
||||
INDEX_HTML="$WEBUI_DIR/index.html"
|
||||
SERVICE_WORKER="$WEBUI_DIR/service-worker.js"
|
||||
TS=$(date +%Y%m%d%H%M%S)
|
||||
PATCH_TARGET="$WEBUI_DIR/js/ru-patch-v5.js"
|
||||
SW_TARGET="$WEBUI_DIR/js/sw-cleanup.js"
|
||||
HOST_GROUPS_TARGET="$WEBUI_DIR/js/aw-host-groups.json"
|
||||
TRENDS_NEEDLE='this.activityStore.query_category_time_by_period(r)'
|
||||
TRENDS_REPLACEMENT='this.activityStore.ensure_loaded(r)'
|
||||
TIMESPIRAL_NEEDLE='start:new Date("2022-08-08")'
|
||||
TIMESPIRAL_REPLACEMENT='start:new Date(Date.now()-12*36e5)'
|
||||
CATEGORY_HELPER_NEEDLE='hostname:t.hostnameChoices[0]'
|
||||
CATEGORY_HELPER_REPLACEMENT='hostname:t.hostnameChoices.filter((function(t){return"unknown"!==t}))[0]||t.hostnameChoices[0]'
|
||||
|
||||
[[ -f "$PATCH_JS_SRC" ]] || { echo "missing $PATCH_JS_SRC" >&2; exit 1; }
|
||||
[[ -f "$SW_CLEANUP_SRC" ]] || { echo "missing $SW_CLEANUP_SRC" >&2; exit 1; }
|
||||
[[ -f "$HOST_GROUPS_SRC" ]] || { echo "missing $HOST_GROUPS_SRC" >&2; exit 1; }
|
||||
[[ -f "$INDEX_HTML" ]] || { echo "missing $INDEX_HTML" >&2; exit 1; }
|
||||
|
||||
install -d "$WEBUI_DIR/js"
|
||||
install -m 0644 "$PATCH_JS_SRC" "$WEBUI_DIR/js/aw-ru-patch.js"
|
||||
install -m 0644 "$SW_CLEANUP_SRC" "$WEBUI_DIR/js/aw-sw-cleanup.js"
|
||||
install -m 0644 "$PATCH_JS_SRC" "$PATCH_TARGET"
|
||||
install -m 0644 "$SW_CLEANUP_SRC" "$SW_TARGET"
|
||||
install -m 0644 "$HOST_GROUPS_SRC" "$HOST_GROUPS_TARGET"
|
||||
cp "$INDEX_HTML" "$INDEX_HTML.bak.$TS"
|
||||
|
||||
sed -i '/aw-ru-patch.js/d;/aw-sw-cleanup.js/d' "$INDEX_HTML"
|
||||
sed -i 's#</head>#<script src="/js/aw-sw-cleanup.js"></script></head>#' "$INDEX_HTML"
|
||||
sed -i 's#</body>#<script defer="defer" src="/js/aw-ru-patch.js"></script></body>#' "$INDEX_HTML"
|
||||
patch_hash="$(sha1sum "$PATCH_TARGET" | awk '{print substr($1,1,12)}')"
|
||||
sw_hash="$(sha1sum "$SW_TARGET" | awk '{print substr($1,1,12)}')"
|
||||
|
||||
sed -i '/ru-patch-v5.js/d;/sw-cleanup.js/d;/aw-ru-patch.js/d;/aw-sw-cleanup.js/d' "$INDEX_HTML"
|
||||
sed -i "s#</head>#<script src=\"/js/sw-cleanup.js?v=$sw_hash\"></script></head>#" "$INDEX_HTML"
|
||||
sed -i "s#</body>#<script defer=\"defer\" src=\"/js/ru-patch-v5.js?v=$patch_hash\"></script></body>#" "$INDEX_HTML"
|
||||
cp "$SW_CLEANUP_SRC" "$SERVICE_WORKER"
|
||||
|
||||
echo "RU patch applied to $WEBUI_DIR"
|
||||
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"
|
||||
python3 - "$trends_chunk" "$TRENDS_NEEDLE" "$TRENDS_REPLACEMENT" <<'PY'
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
path = Path(sys.argv[1])
|
||||
old = sys.argv[2]
|
||||
new = sys.argv[3]
|
||||
content = path.read_text()
|
||||
if old in content:
|
||||
path.write_text(content.replace(old, new, 1))
|
||||
print(f"Trends hotfix applied to {path}")
|
||||
else:
|
||||
print(f"Trends hotfix already present in {path}")
|
||||
PY
|
||||
else
|
||||
echo "Trends hotfix skipped: chunk not found"
|
||||
fi
|
||||
|
||||
timespiral_chunk="$(grep -Rsl "$TIMESPIRAL_NEEDLE" "$WEBUI_DIR/js"/*.js 2>/dev/null | head -n 1 || true)"
|
||||
if [[ -n "$timespiral_chunk" ]]; then
|
||||
cp "$timespiral_chunk" "$timespiral_chunk.bak.$TS"
|
||||
python3 - "$timespiral_chunk" "$TIMESPIRAL_NEEDLE" "$TIMESPIRAL_REPLACEMENT" <<'PY'
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
path = Path(sys.argv[1])
|
||||
old = sys.argv[2]
|
||||
new = sys.argv[3]
|
||||
content = path.read_text()
|
||||
if old in content:
|
||||
path.write_text(content.replace(old, new, 1))
|
||||
print(f"Timespiral hotfix applied to {path}")
|
||||
else:
|
||||
print(f"Timespiral hotfix already present in {path}")
|
||||
PY
|
||||
else
|
||||
echo "Timespiral hotfix skipped: chunk not found"
|
||||
fi
|
||||
|
||||
category_helper_chunk="$(grep -Rsl "$CATEGORY_HELPER_NEEDLE" "$WEBUI_DIR/js"/*.js 2>/dev/null | head -n 1 || true)"
|
||||
if [[ -n "$category_helper_chunk" ]]; then
|
||||
cp "$category_helper_chunk" "$category_helper_chunk.bak.$TS"
|
||||
python3 - "$category_helper_chunk" "$CATEGORY_HELPER_NEEDLE" "$CATEGORY_HELPER_REPLACEMENT" <<'PY'
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
path = Path(sys.argv[1])
|
||||
old = sys.argv[2]
|
||||
new = sys.argv[3]
|
||||
content = path.read_text()
|
||||
if old in content:
|
||||
path.write_text(content.replace(old, new, 1))
|
||||
print(f"Category helper host hotfix applied to {path}")
|
||||
else:
|
||||
print(f"Category helper host hotfix already present in {path}")
|
||||
PY
|
||||
else
|
||||
echo "Category helper host hotfix skipped: chunk not found"
|
||||
fi
|
||||
|
||||
echo "RU patch applied to $WEBUI_DIR (ru-patch-v5.js?v=$patch_hash)"
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"groups": [
|
||||
{
|
||||
"id": "pve-detmir",
|
||||
"name": "pve-detmir",
|
||||
"description": "Выделенный клиент DetMir в разделе Активность.",
|
||||
"patterns": [
|
||||
"^pve-detmir$"
|
||||
],
|
||||
"links": [
|
||||
{ "label": "Активность", "type": "activity", "view": "pve_audit" },
|
||||
{ "label": "Web-admin аудит", "type": "bucket", "bucket_prefix": "aw-pve-webadmin-events_" },
|
||||
{ "label": "PVE tasks", "type": "bucket", "bucket_prefix": "aw-pve-task-events_" },
|
||||
{ "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": "windows-rdp",
|
||||
"name": "Windows RDP",
|
||||
"description": "Пользовательские Windows/RDP хосты с активностью, DLP и рабочим временем.",
|
||||
"patterns": [
|
||||
"^(SHARKON|WIN|RDP|TERM|TS-|WS-)"
|
||||
],
|
||||
"links": [
|
||||
{ "label": "Активность", "type": "activity" },
|
||||
{ "label": "DLP", "type": "bucket", "bucket_prefix": "aw-dlp-endpoint-signals_" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "virtual-infra",
|
||||
"name": "Virtual servers + Proxmox",
|
||||
"description": "Инфраструктурные VM и сетевые узлы. Здесь должны лежать Proxmox, pfSense, Debian и Ubuntu серверы.",
|
||||
"patterns": [
|
||||
"^(PFSENSE|PVE|PROXMOX|DEBIAN|UBUNTU|LINUX|VM-|SRV-|INFRA-)"
|
||||
],
|
||||
"links": [
|
||||
{ "label": "pfSense health", "type": "bucket", "bucket_prefix": "aw-pfsense-health_" },
|
||||
{ "label": "pfSense gateways", "type": "bucket", "bucket_prefix": "aw-pfsense-gateways_" },
|
||||
{ "label": "Все бакеты", "type": "buckets" }
|
||||
]
|
||||
}
|
||||
],
|
||||
"ungrouped_name": "Прочие хосты"
|
||||
}
|
||||
+1510
-5
File diff suppressed because it is too large
Load Diff
@@ -9,3 +9,6 @@ AW_SERVER_DATA_DIR=/var/lib/activitywatch
|
||||
AW_SERVER_LOG_DIR=/var/log/activitywatch
|
||||
AW_SERVER_USER=activitywatch
|
||||
AW_SERVER_GROUP=activitywatch
|
||||
|
||||
# 1 = apply bundled Russian WebUI patch during install, 0 = skip
|
||||
AW_SERVER_ENABLE_RU_PATCH=1
|
||||
|
||||
@@ -21,6 +21,10 @@ required_vars=(
|
||||
AW_SERVER_GROUP
|
||||
)
|
||||
|
||||
BOOTSTRAP_DIR="/root/bootstrap"
|
||||
VIEWS_JSON="$BOOTSTRAP_DIR/settings/views-default.json"
|
||||
CLASSES_JSON="$BOOTSTRAP_DIR/settings/classes-worktime.json"
|
||||
|
||||
for var_name in "${required_vars[@]}"; do
|
||||
if [[ -z "${!var_name:-}" ]]; then
|
||||
echo "missing required variable: $var_name" >&2
|
||||
@@ -30,7 +34,7 @@ done
|
||||
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update
|
||||
apt-get install -y curl ca-certificates unzip jq
|
||||
apt-get install -y curl ca-certificates unzip jq python3
|
||||
|
||||
if ! getent group "$AW_SERVER_GROUP" >/dev/null; then
|
||||
groupadd --system "$AW_SERVER_GROUP"
|
||||
@@ -84,3 +88,41 @@ systemctl daemon-reload
|
||||
systemctl enable activitywatch-server.service
|
||||
systemctl restart activitywatch-server.service
|
||||
systemctl --no-pager --full status activitywatch-server.service || true
|
||||
|
||||
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
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
if [[ "${AW_SERVER_ENABLE_RU_PATCH:-1}" == "1" ]]; then
|
||||
if [[ -x /root/bootstrap/apply_webui_ru_patch.sh ]]; then
|
||||
/root/bootstrap/apply_webui_ru_patch.sh
|
||||
echo "Applied Web UI Russian patch"
|
||||
else
|
||||
echo "RU patch script not found or not executable, skipped: /root/bootstrap/apply_webui_ru_patch.sh"
|
||||
fi
|
||||
else
|
||||
echo "RU patch disabled by AW_SERVER_ENABLE_RU_PATCH=${AW_SERVER_ENABLE_RU_PATCH}"
|
||||
fi
|
||||
|
||||
if [[ -f "$CLASSES_JSON" ]]; then
|
||||
curl -fsS -X POST \
|
||||
-H 'Content-Type: application/json' \
|
||||
--data-binary @"$CLASSES_JSON" \
|
||||
"http://127.0.0.1:${AW_SERVER_PORT}/api/0/settings/classes" >/dev/null
|
||||
echo "Applied worktime classes from $CLASSES_JSON"
|
||||
else
|
||||
echo "Worktime classes bootstrap not found, skipped: $CLASSES_JSON"
|
||||
fi
|
||||
|
||||
if [[ -f "$VIEWS_JSON" ]]; then
|
||||
curl -fsS -X POST \
|
||||
-H 'Content-Type: application/json' \
|
||||
--data-binary @"$VIEWS_JSON" \
|
||||
"http://127.0.0.1:${AW_SERVER_PORT}/api/0/settings/views" >/dev/null
|
||||
echo "Applied baseline views from $VIEWS_JSON"
|
||||
else
|
||||
echo "Views bootstrap not found, skipped: $VIEWS_JSON"
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
[
|
||||
{
|
||||
"id": 0,
|
||||
"name": ["Работа"],
|
||||
"rule": { "type": "none" },
|
||||
"data": {}
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"name": ["Работа", "1С"],
|
||||
"rule": {
|
||||
"type": "regex",
|
||||
"regex": "\\b(1cv8s?|1cv8c|1cestart)\\.exe\\b|1С:Предприятие|Запуск 1С:Предприятия|Загрузка конфигурационной информации|Доступ к информационной базе",
|
||||
"ignore_case": true
|
||||
},
|
||||
"data": { "color": "#194D33" }
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": ["Работа", "Документы"],
|
||||
"rule": {
|
||||
"type": "regex",
|
||||
"regex": "\\b(winword|excel|powerpnt|outlook|acrord32|acrord64)\\.exe\\b|Adobe Reader|Acrobat",
|
||||
"ignore_case": true
|
||||
},
|
||||
"data": { "color": "#2E7D32" }
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"name": ["Работа", "Коммуникации"],
|
||||
"rule": {
|
||||
"type": "regex",
|
||||
"regex": "\\b(teams|telegram|slack|thunderbird|zoom|skype|whatsapp|viber|discord)\\.exe\\b|Mattermost|Element|Riot",
|
||||
"ignore_case": true
|
||||
},
|
||||
"data": { "color": "#1E88E5" }
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"name": ["Работа", "Администрирование"],
|
||||
"rule": {
|
||||
"type": "regex",
|
||||
"regex": "\\b(mstsc|putty|kitty|winscp|anydesk|teamviewer|vncviewer|mmc|regedit|services|control|powershell|cmd)\\.exe\\b",
|
||||
"ignore_case": true
|
||||
},
|
||||
"data": { "color": "#6D4C41" }
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"name": ["Интернет"],
|
||||
"rule": { "type": "none" },
|
||||
"data": {}
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"name": ["Интернет", "Браузер"],
|
||||
"rule": {
|
||||
"type": "regex",
|
||||
"regex": "\\b(chrome|msedge|firefox|opera|brave|vivaldi|browser)\\.exe\\b",
|
||||
"ignore_case": true
|
||||
},
|
||||
"data": { "color": "#00897B" }
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"name": ["Система"],
|
||||
"rule": { "type": "none" },
|
||||
"data": {}
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"name": ["Система", "Windows"],
|
||||
"rule": {
|
||||
"type": "regex",
|
||||
"regex": "\\b(SearchHost|explorer|ShellExperienceHost|ApplicationFrameHost|RuntimeBroker|sihost|dwm|svchost|fontdrvhost|userinit)\\.exe\\b|\\\\Windows\\\\System32",
|
||||
"ignore_case": true
|
||||
},
|
||||
"data": { "color": "#607D8B" }
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"name": ["ActivityWatch"],
|
||||
"rule": {
|
||||
"type": "regex",
|
||||
"regex": "ActivityWatch|\\baw-(watcher|qt)\\.exe\\b",
|
||||
"ignore_case": true
|
||||
},
|
||||
"data": {}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,39 @@
|
||||
[
|
||||
{
|
||||
"id": "summary",
|
||||
"name": "Summary",
|
||||
"elements": [
|
||||
{ "type": "top_titles", "size": 3 },
|
||||
{ "type": "timeline_barchart", "size": 3 },
|
||||
{ "type": "top_categories", "size": 3 },
|
||||
{ "type": "category_tree", "size": 3 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "window",
|
||||
"name": "Window",
|
||||
"elements": [
|
||||
{ "type": "top_apps", "size": 3, "props": {} }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "DLP",
|
||||
"name": "DLP",
|
||||
"elements": []
|
||||
},
|
||||
{
|
||||
"id": "worktime",
|
||||
"name": "Worktime",
|
||||
"elements": [
|
||||
{ "type": "top_categories", "size": 3, "props": {} },
|
||||
{ "type": "timeline_barchart", "size": 3, "props": {} },
|
||||
{ "type": "category_tree", "size": 3, "props": {} },
|
||||
{ "type": "top_apps", "size": 3, "props": {} }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "pve_audit",
|
||||
"name": "PVE Audit",
|
||||
"elements": []
|
||||
}
|
||||
]
|
||||
Executable
+113
@@ -0,0 +1,113 @@
|
||||
# 1С-Бухгалтерия: сбор и анализ в Grafana
|
||||
|
||||
Документ описывает внедрение контура мониторинга 1С в Grafana через Prometheus и SQL Exporter.
|
||||
|
||||
## 1. Целевая схема
|
||||
|
||||
1. База 1С (PostgreSQL или MS SQL) содержит KPI views.
|
||||
2. `sql-exporter` опрашивает views read-only пользователем.
|
||||
3. `prometheus` собирает метрики и считает alert-rules.
|
||||
4. `grafana` визуализирует KPI и алерты.
|
||||
|
||||
## 2. Подготовка
|
||||
|
||||
Рабочий каталог:
|
||||
|
||||
- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c`
|
||||
|
||||
Подготовка env:
|
||||
|
||||
```bash
|
||||
cd /mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c
|
||||
cp /mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/.env.example \
|
||||
/mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/.env
|
||||
```
|
||||
|
||||
Обязательно изменить:
|
||||
|
||||
- `GRAFANA_ADMIN_PASSWORD`
|
||||
- `ONEC_DSN`
|
||||
|
||||
## 3. SQL-представления KPI
|
||||
|
||||
Используйте шаблон под вашу СУБД:
|
||||
|
||||
- PostgreSQL: `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/sql/postgres_views_template.sql`
|
||||
- MS SQL: `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/sql/mssql_views_template.sql`
|
||||
|
||||
После адаптации шаблона под фактические таблицы 1С создайте views в БД.
|
||||
|
||||
Быстрое профилирование схемы 1С (PostgreSQL):
|
||||
|
||||
```bash
|
||||
sh /mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/tools/discover_postgres_1c.sh \
|
||||
"postgres://user:pass@db-host:5432/db?sslmode=disable"
|
||||
```
|
||||
|
||||
Проверка KPI views перед запуском:
|
||||
|
||||
```bash
|
||||
sh /mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/tools/validate_kpi_views.sh \
|
||||
"postgres://user:pass@db-host:5432/db?sslmode=disable"
|
||||
```
|
||||
|
||||
## 4. Запуск стека
|
||||
|
||||
```bash
|
||||
cd /mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## 5. Верификация
|
||||
|
||||
Проверка метрик sql-exporter:
|
||||
|
||||
```bash
|
||||
curl -fsS http://127.0.0.1:9399/metrics | rg "onec_"
|
||||
```
|
||||
|
||||
Проверка Prometheus:
|
||||
|
||||
```bash
|
||||
curl -fsS http://127.0.0.1:9090/-/healthy
|
||||
```
|
||||
|
||||
Проверка Grafana:
|
||||
|
||||
- URL: `http://<host>:3000`
|
||||
- Дашборд: `1C Бухгалтерия — Обзор`
|
||||
- Дашборд: `1C Бухгалтерия — SRE`
|
||||
|
||||
E2E health-check:
|
||||
|
||||
```bash
|
||||
sh /mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/tools/check_pipeline.sh
|
||||
```
|
||||
|
||||
## 6. KPI и алерты
|
||||
|
||||
Метрики:
|
||||
|
||||
- `onec_unposted_documents_total`
|
||||
- `onec_sales_amount_today`
|
||||
- `onec_overdue_receivables_total`
|
||||
- `onec_posting_errors_total`
|
||||
- `onec_data_freshness_seconds`
|
||||
|
||||
Алерты:
|
||||
|
||||
- `OneCUnpostedDocumentsHigh` (warning)
|
||||
- `OneCOverdueReceivablesCritical` (critical)
|
||||
- `OneCSQLExporterDown` (critical)
|
||||
- `OneCDataFreshnessCritical` (critical)
|
||||
|
||||
Файл правил:
|
||||
|
||||
- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/prometheus/alerts.yml`
|
||||
|
||||
## 7. Эксплуатационный минимум
|
||||
|
||||
- Доступ к БД 1С только read-only.
|
||||
- Права только на KPI views.
|
||||
- Ротация пароля read-only пользователя не реже 90 дней.
|
||||
- Бэкап `grafana-data` и `prometheus-data` (docker volumes).
|
||||
@@ -6,18 +6,22 @@
|
||||
|
||||
## 0) Структура проекта (полные пути)
|
||||
|
||||
- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/secrets/deploy.secrets.env`
|
||||
- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/proxmox/create-ct.sh`
|
||||
- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/proxmox/push-aw-artifacts.sh`
|
||||
- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/aw-server/install_aw_server.sh`
|
||||
- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/aw-server/apply_webui_ru_patch.sh`
|
||||
- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/windows/deploy-single-user.ps1`
|
||||
- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/windows/deploy-domain-users.ps1`
|
||||
- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/windows/deploy-ensemble.ps1`
|
||||
- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/windows/hardening-recovery.ps1`
|
||||
- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/windows/validate-deployment.ps1`
|
||||
- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/windows/browser-domains-native-collector.ps1`
|
||||
- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/ansible/deploy_aw_server.yml`
|
||||
- `/home/igor/tmp/AWatch-rus/secrets/deploy.secrets.env`
|
||||
- `/home/igor/tmp/AWatch-rus/proxmox/create-ct.sh`
|
||||
- `/home/igor/tmp/AWatch-rus/proxmox/push-aw-artifacts.sh`
|
||||
- `/home/igor/tmp/AWatch-rus/aw-server/install_aw_server.sh`
|
||||
- `/home/igor/tmp/AWatch-rus/aw-server/apply_webui_ru_patch.sh`
|
||||
- `/home/igor/tmp/AWatch-rus/windows/deploy-single-user.ps1`
|
||||
- `/home/igor/tmp/AWatch-rus/windows/deploy-domain-users.ps1`
|
||||
- `/home/igor/tmp/AWatch-rus/windows/deploy-ensemble.ps1`
|
||||
- `/home/igor/tmp/AWatch-rus/windows/hardening-recovery.ps1`
|
||||
- `/home/igor/tmp/AWatch-rus/windows/validate-deployment.ps1`
|
||||
- `/home/igor/tmp/AWatch-rus/windows/browser-domains-native-collector.ps1`
|
||||
- `/home/igor/tmp/AWatch-rus/windows/dlp-endpoint-signals-collector.ps1`
|
||||
- `/home/igor/tmp/AWatch-rus/ansible/deploy_aw_server.yml`
|
||||
- `/home/igor/tmp/AWatch-rus/ansible/provision_proxmox_ct_and_deploy_aw.yml`
|
||||
- `/home/igor/tmp/AWatch-rus/ansible/provision_proxmox_ct_matrix_and_deploy_aw.yml`
|
||||
- `/home/igor/tmp/AWatch-rus/ansible/deploy_aw_windows_phase2.yml`
|
||||
|
||||
---
|
||||
|
||||
@@ -35,11 +39,11 @@
|
||||
Скопируйте шаблон:
|
||||
|
||||
```bash
|
||||
cp /mnt/usb_hdd2/Projects/ActivityWatch-Russian/secrets/deploy.secrets.env.example \
|
||||
/mnt/usb_hdd2/Projects/ActivityWatch-Russian/secrets/deploy.secrets.env
|
||||
cp /home/igor/tmp/AWatch-rus/secrets/deploy.secrets.env.example \
|
||||
/home/igor/tmp/AWatch-rus/secrets/deploy.secrets.env
|
||||
```
|
||||
|
||||
Заполните в файле `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/secrets/deploy.secrets.env`:
|
||||
Заполните в файле `/home/igor/tmp/AWatch-rus/secrets/deploy.secrets.env`:
|
||||
|
||||
- все `CT_*` параметры контейнера;
|
||||
- все `AW_SERVER_*` параметры сервера;
|
||||
@@ -51,30 +55,60 @@ cp /mnt/usb_hdd2/Projects/ActivityWatch-Russian/secrets/deploy.secrets.env.examp
|
||||
|
||||
## 2) Развёртывание сервера в Proxmox
|
||||
|
||||
### 2.0 Ansible full-stack (создание CT + установка AW)
|
||||
|
||||
Подготовьте:
|
||||
|
||||
- `/home/igor/tmp/AWatch-rus/ansible/inventory.ini`
|
||||
- `/home/igor/tmp/AWatch-rus/ansible/group_vars/all.yml`
|
||||
- `/home/igor/tmp/AWatch-rus/ansible/group_vars/proxmox.yml`
|
||||
|
||||
Запуск:
|
||||
|
||||
```bash
|
||||
cd /home/igor/tmp/AWatch-rus/ansible
|
||||
ansible-playbook -i inventory.ini provision_proxmox_ct_and_deploy_aw.yml
|
||||
```
|
||||
|
||||
Этот сценарий полностью закрывает:
|
||||
|
||||
- создание CT в Proxmox;
|
||||
- bootstrap пакетов в CT;
|
||||
- установку ActivityWatch Server;
|
||||
- применение RU Web UI patch;
|
||||
- проверку API.
|
||||
|
||||
Для массового режима (несколько CT):
|
||||
|
||||
```bash
|
||||
cd /home/igor/tmp/AWatch-rus/ansible
|
||||
ansible-playbook -i inventory.ini provision_proxmox_ct_matrix_and_deploy_aw.yml
|
||||
```
|
||||
|
||||
### 2.1 Создать LXC контейнер
|
||||
|
||||
На узле Proxmox:
|
||||
|
||||
```bash
|
||||
cd /mnt/usb_hdd2/Projects/ActivityWatch-Russian
|
||||
/mnt/usb_hdd2/Projects/ActivityWatch-Russian/proxmox/create-ct.sh
|
||||
cd /home/igor/tmp/AWatch-rus
|
||||
/home/igor/tmp/AWatch-rus/proxmox/create-ct.sh
|
||||
```
|
||||
|
||||
По умолчанию читается:
|
||||
|
||||
- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/secrets/deploy.secrets.env`
|
||||
- `/home/igor/tmp/AWatch-rus/secrets/deploy.secrets.env`
|
||||
|
||||
При необходимости можно передать другой путь:
|
||||
|
||||
```bash
|
||||
/mnt/usb_hdd2/Projects/ActivityWatch-Russian/proxmox/create-ct.sh /absolute/path/to/deploy.secrets.env
|
||||
/home/igor/tmp/AWatch-rus/proxmox/create-ct.sh /absolute/path/to/deploy.secrets.env
|
||||
```
|
||||
|
||||
### 2.2 Загрузить bootstrap-артефакты и env внутрь CT
|
||||
|
||||
```bash
|
||||
cd /mnt/usb_hdd2/Projects/ActivityWatch-Russian
|
||||
/mnt/usb_hdd2/Projects/ActivityWatch-Russian/proxmox/push-aw-artifacts.sh
|
||||
cd /home/igor/tmp/AWatch-rus
|
||||
/home/igor/tmp/AWatch-rus/proxmox/push-aw-artifacts.sh
|
||||
```
|
||||
|
||||
Скрипт загружает в CT:
|
||||
@@ -100,6 +134,13 @@ bash /root/bootstrap/apply_webui_ru_patch.sh
|
||||
systemctl restart activitywatch-server.service
|
||||
```
|
||||
|
||||
После применения патча доступны:
|
||||
|
||||
- верхнее меню `DLP` в Web UI;
|
||||
- DLP-страница bucket `aw-dlp-endpoint-signals_<HOST>`;
|
||||
- встроенный центр `DLP review и правила`;
|
||||
- служебные buckets `aw-dlp-review_<HOST>` и `aw-dlp-rules_<HOST>`.
|
||||
|
||||
### 2.5 Проверка сервера
|
||||
|
||||
В CT:
|
||||
@@ -118,6 +159,12 @@ grep -n 'aw-ru-patch\|aw-sw-cleanup' /opt/activitywatch/webui-ru/index.html
|
||||
- порт 5600 слушается;
|
||||
- в `index.html` присутствуют оба скрипта.
|
||||
|
||||
Дополнительно после первого входа в Web UI:
|
||||
|
||||
- `#/home` должен показывать один корректный пункт `DLP`;
|
||||
- `#/buckets/aw-dlp-endpoint-signals_<HOST>` должен открываться без ошибок;
|
||||
- сохранение review/rule должно создавать buckets `aw-dlp-review_<HOST>` и `aw-dlp-rules_<HOST>`.
|
||||
|
||||
---
|
||||
|
||||
## 3) Развёртывание Windows-клиентов (другой AD-домен)
|
||||
@@ -126,7 +173,7 @@ grep -n 'aw-ru-patch\|aw-sw-cleanup' /opt/activitywatch/webui-ru/index.html
|
||||
|
||||
Скопируйте каталог:
|
||||
|
||||
- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/windows`
|
||||
- `/home/igor/tmp/AWatch-rus/windows`
|
||||
|
||||
например в:
|
||||
|
||||
@@ -245,6 +292,9 @@ curl -sS http://127.0.0.1:5600/api/0/buckets | jq 'keys'
|
||||
- `aw-watcher-window_<HOST>`
|
||||
- `aw-watcher-web-<browser>_<HOST>`
|
||||
- `aw-detmir-web-category_<HOST>` (категоризованный поток)
|
||||
- `aw-dlp-endpoint-signals_<HOST>` (endpoint сигналы)
|
||||
- `aw-dlp-review_<HOST>` (ручная классификация через UI)
|
||||
- `aw-dlp-rules_<HOST>` (suppress/rule записи через UI)
|
||||
|
||||
Проверка событий браузера:
|
||||
|
||||
@@ -258,6 +308,31 @@ curl -sS "http://127.0.0.1:5600/api/0/buckets/aw-watcher-web-edge_<HOST>/events?
|
||||
curl -sS "http://127.0.0.1:5600/api/0/buckets/aw-detmir-web-category_<HOST>/events?limit=5" | jq
|
||||
```
|
||||
|
||||
Проверка DLP review/rules:
|
||||
|
||||
```bash
|
||||
curl -sS "http://127.0.0.1:5600/api/0/buckets/aw-dlp-review_<HOST>/events?limit=20" | jq
|
||||
curl -sS "http://127.0.0.1:5600/api/0/buckets/aw-dlp-rules_<HOST>/events?limit=20" | jq
|
||||
```
|
||||
|
||||
Ожидаемые поля review:
|
||||
|
||||
- `reviewId`
|
||||
- `signalType`
|
||||
- `verdict`
|
||||
- `category`
|
||||
- `comment`
|
||||
- `archived`
|
||||
|
||||
Ожидаемые поля rules:
|
||||
|
||||
- `ruleId`
|
||||
- `signalType`
|
||||
- `match`
|
||||
- `category`
|
||||
- `comment`
|
||||
- `enabled`
|
||||
|
||||
---
|
||||
|
||||
## 6) Сопровождение (обязательно)
|
||||
@@ -283,11 +358,11 @@ pct exec <CT_ID> -- tar -C / -czf /root/activitywatch-config-backup.tgz \
|
||||
### 6.2 Обновление сервера
|
||||
|
||||
1. Обновить `AW_SERVER_VERSION` и `AW_SERVER_DOWNLOAD_URL` в
|
||||
`/mnt/usb_hdd2/Projects/ActivityWatch-Russian/secrets/deploy.secrets.env`
|
||||
`/home/igor/tmp/AWatch-rus/secrets/deploy.secrets.env`
|
||||
2. Выполнить:
|
||||
|
||||
```bash
|
||||
/mnt/usb_hdd2/Projects/ActivityWatch-Russian/proxmox/push-aw-artifacts.sh
|
||||
/home/igor/tmp/AWatch-rus/proxmox/push-aw-artifacts.sh
|
||||
pct enter <CT_ID>
|
||||
bash /root/bootstrap/install_aw_server.sh
|
||||
bash /root/bootstrap/apply_webui_ru_patch.sh
|
||||
@@ -315,7 +390,7 @@ systemctl restart activitywatch-server.service
|
||||
|
||||
## 7) Безопасность
|
||||
|
||||
- Не хранить реальные секреты вне `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/secrets/deploy.secrets.env`.
|
||||
- Не хранить реальные секреты вне `/home/igor/tmp/AWatch-rus/secrets/deploy.secrets.env`.
|
||||
- Не открывать `5600/tcp` в интернет напрямую.
|
||||
- Публиковать через VPN или reverse proxy с ограничением доступа.
|
||||
- Перед изменениями всегда делать backup.
|
||||
@@ -324,9 +399,9 @@ systemctl restart activitywatch-server.service
|
||||
|
||||
## 8) Короткий чек-лист ввода в эксплуатацию
|
||||
|
||||
1. Заполнен `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/secrets/deploy.secrets.env`.
|
||||
2. Выполнен `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/proxmox/create-ct.sh`.
|
||||
3. Выполнен `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/proxmox/push-aw-artifacts.sh`.
|
||||
1. Заполнен `/home/igor/tmp/AWatch-rus/secrets/deploy.secrets.env`.
|
||||
2. Выполнен `/home/igor/tmp/AWatch-rus/proxmox/create-ct.sh`.
|
||||
3. Выполнен `/home/igor/tmp/AWatch-rus/proxmox/push-aw-artifacts.sh`.
|
||||
4. В CT выполнены `/root/bootstrap/install_aw_server.sh` и `/root/bootstrap/apply_webui_ru_patch.sh`.
|
||||
5. Сервер API/порт/UI проверены.
|
||||
6. На Windows выполнен `deploy-domain-users.ps1`.
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# POST MEMORY — AWatch-rus (2026-04-27)
|
||||
|
||||
## Контекст дня
|
||||
|
||||
Работа велась по проекту `AWatch-rus / ActivityWatch-Russian`.
|
||||
|
||||
## Что подтверждено по runtime
|
||||
|
||||
- `10.10.10.2` (`pve-detmir`, admin host) реально запускает:
|
||||
- `/usr/local/bin/aw-server-rust --host 0.0.0.0 --port 5600 --webpath /opt/aw-webui-ru`
|
||||
- `/usr/bin/python3 /opt/aw-pfsense/pfsense-aw-poller.py --config /etc/aw-pfsense/poller.json`
|
||||
- `10.10.10.1` используется только как API-цель для `pfSense poller`.
|
||||
- Значит интеграция `pfSense -> AW` работает через внешний poller, а не через установку чего-либо на сам firewall.
|
||||
|
||||
## Что сделано
|
||||
|
||||
1. Восстановлена и синхронизирована локальная копия проекта.
|
||||
- В `/mnt/usb_hdd2/Projects/ActivityWatch-Russian` был поврежден `.git`.
|
||||
- Источник истины: `/home/igor/tmp/AWatch-rus` с `origin https://github.com/igor04091968/AWatch-rus.git`.
|
||||
- Локальная копия приведена к актуальному содержимому `main`.
|
||||
|
||||
2. Завершена русификация overview-дашборда Grafana.
|
||||
- `1C Бухгалтерия — Overview` заменено на `1C Бухгалтерия — Обзор`.
|
||||
- Документация синхронизирована с фактическим названием.
|
||||
- Проверка `rg -n "Overview"` по репозиторию после правки не дала совпадений.
|
||||
|
||||
3. Проверен локальный прокси-контур.
|
||||
- `socks5://127.0.0.1:1080` активен.
|
||||
- `sing-box` запущен и пропускает трафик.
|
||||
|
||||
4. Подготовлен и развернут Linux-клиент ActivityWatch для `10.10.10.2`.
|
||||
- В репозиторий добавлен rollout:
|
||||
- `scripts/install_aw_linux_client.sh`
|
||||
- `docs/linux-client.md`
|
||||
- На `10.10.10.2` под пользователем `admin` установлен bundle `ActivityWatch 0.13.2` в:
|
||||
- `~/.local/opt/activitywatch/v0.13.2/activitywatch`
|
||||
- Созданы:
|
||||
- `~/.config/activitywatch/aw-client/aw-client.toml` с `10.10.10.13:5600`
|
||||
- `~/.config/activitywatch/aw-qt/aw-qt.toml`
|
||||
- `~/.local/bin/activitywatch-remote-aw`
|
||||
- `~/.config/autostart/activitywatch-remote-aw.desktop`
|
||||
- Проверка показала:
|
||||
- удаленный `AW server` с `10.10.10.2` достижим;
|
||||
- `aw-qt --no-gui` стартует;
|
||||
- bucket `aw-watcher-afk_pve-detmir` появился на сервере.
|
||||
- Текущий blocker:
|
||||
- на `10.10.10.2` нет активной `X11/GUI` сессии;
|
||||
- `aw-watcher-window` падает с `DISPLAY environment variable not set`;
|
||||
- `aw-watcher-afk` падает на `failed to acquire X connection`.
|
||||
|
||||
## Зафиксированные выводы
|
||||
|
||||
- На текущем этапе `10.10.10.2` является фактическим runtime-хостом для `AW server` и `pfSense poller`.
|
||||
- Документация должна исходить из host-based сценария как из подтвержденного рабочего контура, а CT/LXC-схему держать как отдельный вариант развертывания.
|
||||
- Для Linux-клиента на `10.10.10.2` установка завершена, но полноценные watcher-события начнутся только после реального графического логина пользователя.
|
||||
Executable
+60
@@ -0,0 +1,60 @@
|
||||
# Console/SSH logger deployment
|
||||
|
||||
## Назначение
|
||||
|
||||
Этот сценарий логирует только консольные действия и SSH-сессии:
|
||||
|
||||
- команды из `bash_history` (`aw-console-commands_<HOST>`);
|
||||
- логины/логауты SSH TTY по `who -u` (`aw-ssh-sessions_<HOST>`, best-effort).
|
||||
|
||||
GUI watcher'ы (`aw-watcher-window`, `aw-watcher-afk`) не требуются.
|
||||
|
||||
## Установка (user-space)
|
||||
|
||||
```bash
|
||||
cd /path/to/AWatch-rus
|
||||
sh ./scripts/install_aw_console_ssh_logger.sh \
|
||||
--server-host 10.10.10.13 \
|
||||
--server-port 5600
|
||||
```
|
||||
|
||||
## Что создается
|
||||
|
||||
- `~/.local/opt/aw-console-ssh-logger/collector.py`
|
||||
- `~/.local/opt/aw-console-ssh-logger/config.json`
|
||||
- `~/.local/bin/aw-console-ssh-logger-start`
|
||||
- `~/.local/bin/aw-console-ssh-logger-stop`
|
||||
- `~/.local/bin/aw-console-ssh-logger-status`
|
||||
- `~/.config/systemd/user/aw-console-ssh-logger.service`
|
||||
- `~/.local/state/aw-console-ssh-logger/` (state + logs)
|
||||
|
||||
## Shell hooks
|
||||
|
||||
Installer добавляет в `~/.bashrc`:
|
||||
|
||||
- `HISTTIMEFORMAT="%s "` для epoch timestamp;
|
||||
- `histappend`;
|
||||
- `PROMPT_COMMAND` с `history -a; history -n;`.
|
||||
|
||||
Это нужно, чтобы команды попадали в `~/.bash_history` сразу после выполнения, а не только при logout.
|
||||
|
||||
## Проверка
|
||||
|
||||
На клиенте:
|
||||
|
||||
```bash
|
||||
~/.local/bin/aw-console-ssh-logger-status
|
||||
tail -n 50 ~/.local/state/aw-console-ssh-logger/logs/collector.log
|
||||
```
|
||||
|
||||
На AW сервере:
|
||||
|
||||
```bash
|
||||
curl -fsS http://10.10.10.13:5600/api/0/buckets | jq -r 'keys[]' | grep -E '^aw-console-commands_|^aw-ssh-sessions_'
|
||||
```
|
||||
|
||||
## Ограничения
|
||||
|
||||
- Источник команд — `bash_history`; если пользователь использует не `bash`, нужен отдельный collector.
|
||||
- Канал `aw-ssh-sessions_*` зависит от корректного `utmp/who` на хосте и может быть неполным в отдельных окружениях.
|
||||
- Полные системные audit-события (`execve` всех пользователей) требуют root-level `auditd`/journald integration и в этот user-space сценарий не входят.
|
||||
+20
-1
@@ -1,5 +1,21 @@
|
||||
# Deployment
|
||||
|
||||
## 0. Ansible full-stack вариант (рекомендуется)
|
||||
|
||||
```sh
|
||||
cd /home/igor/tmp/AWatch-rus/ansible
|
||||
ansible-playbook -i inventory.ini provision_proxmox_ct_and_deploy_aw.yml
|
||||
```
|
||||
|
||||
Этот playbook создаёт CT в Proxmox и полностью разворачивает ActivityWatch внутри контейнера.
|
||||
|
||||
Массовый вариант по матрице CT:
|
||||
|
||||
```sh
|
||||
cd /home/igor/tmp/AWatch-rus/ansible
|
||||
ansible-playbook -i inventory.ini provision_proxmox_ct_matrix_and_deploy_aw.yml
|
||||
```
|
||||
|
||||
## 1. Подготовить env-файлы
|
||||
|
||||
На рабочей машине оператора:
|
||||
@@ -78,7 +94,8 @@ systemctl restart activitywatch-server.service
|
||||
- копирует `aw-ru-patch.js` и `aw-sw-cleanup.js`;
|
||||
- делает backup `index.html`;
|
||||
- добавляет include в `index.html`;
|
||||
- заменяет `service-worker.js` cleanup-версией для сброса старого cache.
|
||||
- заменяет `service-worker.js` cleanup-версией для сброса старого cache;
|
||||
- включает DLP Web UI override: верхнее меню `DLP`, центр `DLP review и правила`, buckets `aw-dlp-review_<HOST>` и `aw-dlp-rules_<HOST>`.
|
||||
|
||||
## 6. Проверить сервис
|
||||
|
||||
@@ -103,6 +120,8 @@ grep -n 'aw-ru-patch\|aw-sw-cleanup' /opt/activitywatch/webui-ru/index.html
|
||||
- открывается `/`;
|
||||
- API доступен по ожидаемому URL;
|
||||
- после hard refresh видна русификация;
|
||||
- в верхнем меню доступен `DLP` и он открывает `#/buckets/aw-dlp-endpoint-signals_<HOST>`;
|
||||
- на DLP bucket-странице доступны секции `DLP review и правила`, `DLP Rules`, `DLP Review`;
|
||||
- reverse proxy не кэширует старую статику.
|
||||
|
||||
## 8. Что зафиксировать после ввода
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# DLP gap analysis: AWatch-rus vs enterprise DLP class
|
||||
|
||||
## Текущий контур AWatch-rus
|
||||
|
||||
- Endpoint activity tracking (`aw-watcher-afk`, `aw-watcher-window`).
|
||||
- Browser URL/domain collection (native UIAutomation collector).
|
||||
- Rule-based категоризация web-активности.
|
||||
- Phase-1 DLP policy: rule match + incident bucket `aw-dlp-incidents_<host>` + локальный incident log.
|
||||
- Автоматизированный deployment (PowerShell, Ansible, Proxmox).
|
||||
|
||||
## Разрыв до enterprise DLP уровня
|
||||
|
||||
1. **Каналы перехвата**: почта, USB/MTP, печать, clipboard, мессенджеры, облака, file transfer.
|
||||
2. **Контент-анализ**: PII/dictionaries/EDM/IDM, advanced OCR, document fingerprinting.
|
||||
3. **Реагирование**: block/quarantine/workflow approvals, исключения, эскалации.
|
||||
4. **Расследования**: case-management, evidence chain, immutable audit.
|
||||
5. **Управление**: RBAC/SoD, policy lifecycle, multi-tenant admin model.
|
||||
6. **Интеграции**: SIEM/SOAR/ITSM, AD/IdP, ticketing.
|
||||
|
||||
## Реалистичный roadmap
|
||||
|
||||
### Phase 1 (сделано)
|
||||
|
||||
- DLP policy JSON + rules.
|
||||
- Incident generation в отдельный AW bucket.
|
||||
- Incident cooldown/dedup.
|
||||
|
||||
### Phase 2 (внедрено частично)
|
||||
|
||||
- USB/print/clipboard collectors (endpoint signals) — внедрено.
|
||||
- Incident pipeline расширен на endpoint события — внедрено.
|
||||
- File-operation telemetry (create/copy/archive/upload hints) — в backlog.
|
||||
- Central incident aggregation/export — в backlog.
|
||||
|
||||
### Phase 3
|
||||
|
||||
- Policy engine service (server-side), versioned policies, approval workflow.
|
||||
- Correlation engine (user + channel + object + time).
|
||||
- SIEM connector (CEF/JSON over syslog/HTTP).
|
||||
|
||||
### Phase 4
|
||||
|
||||
- Advanced detectors (dictionary packs, regex packs, OCR pipeline).
|
||||
- Risk scoring / UEBA.
|
||||
- Compliance reports (152-ФЗ / PCI DSS / ISO 27001-aligned evidence views).
|
||||
|
||||
## Reference links (product capability benchmark)
|
||||
|
||||
- https://www.infowatch.ru/products/dlp-sistema-traffic-monitor/vozmozhnosti-dlp-sistemy
|
||||
- https://www.infowatch.ru/products/dlp-sistema-traffic-monitor/sistemnye-trebovaniya-dlp
|
||||
- https://www.infowatch.ru/company/presscenter/news/zapatentovana-tekhnologiya-dlya-raspoznavaniya-teksta-na-izobrazheniyakh
|
||||
Executable
+70
@@ -0,0 +1,70 @@
|
||||
# Linux client deployment
|
||||
|
||||
## Назначение
|
||||
|
||||
Этот сценарий ставит `ActivityWatch` в user-space Linux-хоста и направляет watcher'ы на уже существующий `AW server`, не поднимая локальный `aw-server-rust`.
|
||||
|
||||
Подтвержденный целевой кейс:
|
||||
|
||||
- Linux desktop/admin host: `10.10.10.2`
|
||||
- удаленный `AW server`: `10.10.10.13:5600`
|
||||
|
||||
## Что делает скрипт
|
||||
|
||||
- скачивает официальный Linux ZIP `ActivityWatch`;
|
||||
- распаковывает bundle в `~/.local/opt/activitywatch/v<version>`;
|
||||
- обновляет symlink `~/.local/opt/activitywatch/current`;
|
||||
- пишет `aw-client.toml` с указанием удаленного `AW server`;
|
||||
- пишет `aw-qt.toml`, где оставляет только `aw-watcher-afk` и `aw-watcher-window`;
|
||||
- создает launcher `~/.local/bin/activitywatch-remote-aw`;
|
||||
- создает XDG autostart entry `~/.config/autostart/activitywatch-remote-aw.desktop`.
|
||||
|
||||
## Установка
|
||||
|
||||
```bash
|
||||
cd /path/to/AWatch-rus
|
||||
sh ./scripts/install_aw_linux_client.sh \
|
||||
--server-host 10.10.10.13 \
|
||||
--server-port 5600
|
||||
```
|
||||
|
||||
Если нужно переустановить ту же версию:
|
||||
|
||||
```bash
|
||||
sh ./scripts/install_aw_linux_client.sh --force
|
||||
```
|
||||
|
||||
## Ключевые файлы
|
||||
|
||||
- `~/.config/activitywatch/aw-client/aw-client.toml`
|
||||
- `~/.config/activitywatch/aw-qt/aw-qt.toml`
|
||||
- `~/.local/bin/activitywatch-remote-aw`
|
||||
- `~/.config/autostart/activitywatch-remote-aw.desktop`
|
||||
|
||||
## Ручной запуск
|
||||
|
||||
```bash
|
||||
~/.local/bin/activitywatch-remote-aw
|
||||
```
|
||||
|
||||
## Проверка
|
||||
|
||||
Проверка bundle и конфигурации:
|
||||
|
||||
```bash
|
||||
~/.local/opt/activitywatch/current/aw-qt --help
|
||||
~/.local/opt/activitywatch/current/aw-watcher-afk/aw-watcher-afk --help
|
||||
~/.local/opt/activitywatch/current/aw-watcher-window/aw-watcher-window --help
|
||||
cat ~/.config/activitywatch/aw-client/aw-client.toml
|
||||
cat ~/.config/activitywatch/aw-qt/aw-qt.toml
|
||||
```
|
||||
|
||||
Проверка на сервере:
|
||||
|
||||
```bash
|
||||
curl -fsS http://10.10.10.13:5600/api/0/buckets | jq -r 'keys[]' | grep '^aw-watcher-'
|
||||
```
|
||||
|
||||
## Ограничение
|
||||
|
||||
`aw-watcher-window` и `aw-watcher-afk` требуют реальную пользовательскую desktop-сессию (`X11`/поддерживаемый GUI login). Если на хосте нет активного GUI-сеанса, установка пройдет, но событий от watcher'ов не будет до первого нормального графического входа пользователя.
|
||||
@@ -8,6 +8,7 @@
|
||||
- LXC-контейнера с Debian 12;
|
||||
- `ActivityWatch Server` на Rust;
|
||||
- Web UI override с RU patch;
|
||||
- DLP Web UI overlay для review/rules поверх bucket `aw-dlp-endpoint-signals_<HOST>`;
|
||||
- systemd unit `activitywatch-server.service`.
|
||||
|
||||
Базовые правила:
|
||||
@@ -49,6 +50,7 @@
|
||||
- `/etc/systemd/system/activitywatch-server.service`;
|
||||
- `/opt/activitywatch/webui-ru/`;
|
||||
- `/opt/activitywatch/releases/`;
|
||||
- buckets `aw-dlp-review_*` и `aw-dlp-rules_*` через API export, если review/rule-классификация уже ведётся в UI;
|
||||
- конфиг reverse proxy, если он есть.
|
||||
|
||||
Пример:
|
||||
@@ -57,6 +59,9 @@
|
||||
vzdump <CT_ID> --mode snapshot --compress zstd --storage <BACKUP_STORAGE>
|
||||
pct exec <CT_ID> -- tar -C / -czf /root/activitywatch-config-backup.tgz \
|
||||
etc/activitywatch etc/systemd/system/activitywatch-server.service opt/activitywatch/webui-ru
|
||||
|
||||
curl -sS http://127.0.0.1:5600/api/0/buckets/aw-dlp-review_<HOST>/events?limit=500 > /root/aw-dlp-review-<HOST>.json
|
||||
curl -sS http://127.0.0.1:5600/api/0/buckets/aw-dlp-rules_<HOST>/events?limit=500 > /root/aw-dlp-rules-<HOST>.json
|
||||
```
|
||||
|
||||
## Rollback
|
||||
@@ -68,6 +73,8 @@ cp /opt/activitywatch/webui-ru/index.html.bak.<timestamp> /opt/activitywatch/web
|
||||
systemctl restart activitywatch-server.service
|
||||
```
|
||||
|
||||
Примечание: после rollback или повторного деплоя открыть UI с hard refresh, так как браузер может держать старую версию `ru-patch-v5.js`.
|
||||
|
||||
### Rollback server release
|
||||
|
||||
1. Остановить сервис.
|
||||
@@ -95,6 +102,12 @@ systemctl restart activitywatch-server.service
|
||||
6. Проверить API/UI.
|
||||
7. Зафиксировать результат.
|
||||
|
||||
После обновления UI отдельно проверить:
|
||||
|
||||
- `#/home` — есть один корректный пункт `DLP`;
|
||||
- `#/buckets/aw-dlp-endpoint-signals_<HOST>` — работают сохранение review/rule и списки `DLP Rules` / `DLP Review`;
|
||||
- API создаёт/читает buckets `aw-dlp-review_<HOST>` и `aw-dlp-rules_<HOST>` без ошибок `304/409`.
|
||||
|
||||
## Эскалация
|
||||
|
||||
Эскалировать сразу, если:
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# pfSense -> ActivityWatch
|
||||
|
||||
Для `pfSense` используется внешний poller на `Debian/Ubuntu` utility VM. Сам firewall не трогаем Windows-агентами и не превращаем в desktop-client.
|
||||
|
||||
## Схема
|
||||
|
||||
- `pfSense` отвечает по API.
|
||||
- `Debian/Ubuntu` poller VM опрашивает его с интервалом.
|
||||
- poller отправляет события в общий `AW server`.
|
||||
|
||||
## Подтвержденный runtime
|
||||
|
||||
Проверенный на `2026-04-27` рабочий контур:
|
||||
|
||||
- `10.10.10.2` (`pve-detmir`, admin host) запускает `AW server`:
|
||||
- `/usr/local/bin/aw-server-rust --host 0.0.0.0 --port 5600 --webpath /opt/aw-webui-ru`
|
||||
- тот же `10.10.10.2` запускает внешний `pfSense poller`:
|
||||
- `/usr/bin/python3 /opt/aw-pfsense/pfsense-aw-poller.py --config /etc/aw-pfsense/poller.json`
|
||||
- `10.10.10.1` используется как API-цель для poller'а; на сам `pfSense` агент или сервер `AW` не ставятся.
|
||||
|
||||
## Bucket'ы
|
||||
|
||||
Базовая конфигурация пишет:
|
||||
|
||||
- `aw-pfsense-health_<HOST>`
|
||||
- `aw-pfsense-interfaces_<HOST>`
|
||||
- `aw-pfsense-gateways_<HOST>`
|
||||
|
||||
## Файлы
|
||||
|
||||
- [pfsense-aw-poller.py](/home/igor/tmp/AWatch-rus/pfsense/pfsense-aw-poller.py)
|
||||
- [pfsense-aw-poller.service](/home/igor/tmp/AWatch-rus/pfsense/pfsense-aw-poller.service)
|
||||
- [pfsense-aw-poller.example.json](/home/igor/tmp/AWatch-rus/pfsense/pfsense-aw-poller.example.json)
|
||||
- [deploy_aw_pfsense_poller.yml](/home/igor/tmp/AWatch-rus/ansible/deploy_aw_pfsense_poller.yml)
|
||||
- [pfsense-poller.example.yml](/home/igor/tmp/AWatch-rus/ansible/group_vars/pfsense-poller.example.yml)
|
||||
|
||||
## Ручной запуск
|
||||
|
||||
```bash
|
||||
sudo install -d /opt/aw-pfsense /etc/aw-pfsense
|
||||
sudo cp /path/to/pfsense-aw-poller.py /opt/aw-pfsense/
|
||||
sudo chmod 0755 /opt/aw-pfsense/pfsense-aw-poller.py
|
||||
sudo cp /path/to/poller.json /etc/aw-pfsense/poller.json
|
||||
python3 /opt/aw-pfsense/pfsense-aw-poller.py --config /etc/aw-pfsense/poller.json --once
|
||||
```
|
||||
|
||||
## Через Ansible
|
||||
|
||||
```bash
|
||||
cd /home/igor/tmp/AWatch-rus/ansible
|
||||
cp group_vars/pfsense-poller.example.yml group_vars/pfsense-poller.yml
|
||||
ansible-playbook -i inventory.ini deploy_aw_pfsense_poller.yml
|
||||
```
|
||||
|
||||
Inventory:
|
||||
|
||||
```ini
|
||||
[aw_pfsense_pollers]
|
||||
aw-poller-01 ansible_host=10.10.10.50
|
||||
```
|
||||
|
||||
## AW Web
|
||||
|
||||
На `#/home` добавлен отдельный блок:
|
||||
|
||||
- `Windows RDP`
|
||||
- `Virtual servers + Proxmox`
|
||||
|
||||
Для `pfSense` задавай hostname вида `PFSENSE-EDGE01`, тогда он попадёт во вторую группу.
|
||||
+119
@@ -11,6 +11,21 @@ pct exec <CT_ID> -- systemctl is-active activitywatch-server.service
|
||||
pct exec <CT_ID> -- curl -fsS http://127.0.0.1:5600/api/0/info
|
||||
```
|
||||
|
||||
### На host-based инсталляции
|
||||
|
||||
Для подтвержденного размещения на `10.10.10.2`:
|
||||
|
||||
```sh
|
||||
ps -ef | grep -E 'aw-server-rust|pfsense-aw-poller' | grep -v grep
|
||||
curl -fsS http://127.0.0.1:5600/api/0/info
|
||||
ss -ltnp | grep 5600
|
||||
```
|
||||
|
||||
Ожидаемо должны быть видны:
|
||||
|
||||
- `aw-server-rust` с `--webpath /opt/aw-webui-ru`;
|
||||
- `pfsense-aw-poller.py --config /etc/aw-pfsense/poller.json`.
|
||||
|
||||
### Внутри CT
|
||||
|
||||
```sh
|
||||
@@ -34,8 +49,112 @@ ls -l /opt/activitywatch/webui-ru/js/
|
||||
- `index.html` содержит оба include;
|
||||
- `service-worker.js` заменён cleanup-версией.
|
||||
|
||||
## Рабочее время (worktime)
|
||||
|
||||
Если в деплое включен `aw_apply_worktime_settings: true`, playbook применяет базовую категоризацию (classes) и view `worktime`.
|
||||
|
||||
Контрольные AQL-шаблоны для расчета рабочего времени: `docs/worktime_aql_detmir.md`.
|
||||
|
||||
Период рабочего времени задаётся переменными:
|
||||
|
||||
- `aw_worktime_from` (например `08:00`)
|
||||
- `aw_worktime_to` (например `17:00`)
|
||||
|
||||
Playbook вычисляет `durationDefault` автоматически (включая смены через полночь) и выставляет:
|
||||
`/api/0/settings/startOfDay` и `/api/0/settings/durationDefault`.
|
||||
|
||||
## Типовые инциденты
|
||||
|
||||
### DLP не виден в вебе
|
||||
|
||||
Быстрый чек сервера:
|
||||
|
||||
```sh
|
||||
curl -fsS http://127.0.0.1:5600/api/0/buckets | jq -r 'keys[] | select(test("^aw-dlp-"))'
|
||||
curl -fsS http://127.0.0.1:5600/api/0/buckets/aw-dlp-incidents_SHARKON2025 | jq '{end:.metadata.end}'
|
||||
```
|
||||
|
||||
Контролируемый тест ingest:
|
||||
|
||||
```sh
|
||||
TS=$(date -u +%Y-%m-%dT%H:%M:%S.000Z)
|
||||
PAYLOAD=$(jq -nc --arg ts "$TS" '{timestamp:$ts,duration:0,data:{ruleId:"selftest-dlp-incident",action:"alert",severity:"low",message:"Self-test DLP incident from runbook",signalType:"self_test",username:"AUTOTEST",sessionId:0,hostname:"SHARKON2025",source:"self-test"}}')
|
||||
curl -fsS -X POST 'http://127.0.0.1:5600/api/0/buckets/aw-dlp-incidents_SHARKON2025/heartbeat?pulsetime=60' -H 'Content-Type: application/json' --data "$PAYLOAD"
|
||||
curl -fsS 'http://127.0.0.1:5600/api/0/buckets/aw-dlp-incidents_SHARKON2025/events?limit=5' | jq '.[0].data'
|
||||
```
|
||||
|
||||
Если API видит событие, а bucket-страница в UI показывает старые `First/last event`, нажать `Обновить` на странице bucket и раскрыть `Events`.
|
||||
|
||||
### У пользователей всплывает окно PowerShell
|
||||
|
||||
Ожидаемое поведение collector-ов: запуск hidden (`-WindowStyle Hidden`).
|
||||
Проверка на Windows хосте:
|
||||
|
||||
```powershell
|
||||
Get-CimInstance Win32_Process |
|
||||
Where-Object { $_.CommandLine -and ($_.CommandLine -match 'browser-domains-native-collector.ps1' -or $_.CommandLine -match 'dlp-endpoint-signals-collector.ps1') } |
|
||||
Select-Object SessionId, ProcessId, CommandLine
|
||||
```
|
||||
|
||||
Если нужно экстренно убрать снимки инцидентов:
|
||||
|
||||
1. Поставить `incidentCapture.screenshotEnabled = false` в `deployment-config.json` (для каждого StateRoot).
|
||||
2. Запустить `Start-ScheduledTask -TaskName 'ActivityWatch Recovery'`.
|
||||
|
||||
### SHARKON2025: `Активное время = 0s`, хотя `window`-события есть
|
||||
|
||||
Симптом:
|
||||
|
||||
- в Activity view за день видно `Worktime = 0s`;
|
||||
- `Top Window Titles / Top Categories / Category Tree` пустые;
|
||||
- при этом bucket `aw-watcher-window_SHARKON2025` содержит свежие события.
|
||||
|
||||
Подтвержденная причина:
|
||||
|
||||
- watcher `afk` "залип" в `status=afk` без `not-afk`;
|
||||
- из-за этого дневная сводка не считает подтвержденную активность.
|
||||
|
||||
Быстрый recovery (с Linux admin host):
|
||||
|
||||
1. Проверить учетку входа. Для этого кейса рабочая учетная запись: `SHARKON2025\Администратор` (не `Administrator`).
|
||||
2. Поднять remote execution через `wmiexec.py` с auth-file:
|
||||
|
||||
```sh
|
||||
cat > /tmp/sharkon_ru.auth << 'EOF'
|
||||
username = Администратор
|
||||
password = <PASSWORD>
|
||||
domain = SHARKON2025
|
||||
EOF
|
||||
chmod 600 /tmp/sharkon_ru.auth
|
||||
```
|
||||
|
||||
3. Запустить recovery task:
|
||||
|
||||
```sh
|
||||
wmiexec.py -nooutput -A /tmp/sharkon_ru.auth 192.168.100.21 \
|
||||
"powershell -NoProfile -Command \"Start-ScheduledTask -TaskName 'ActivityWatch Recovery'\""
|
||||
```
|
||||
|
||||
4. Запустить все launch tasks:
|
||||
|
||||
```sh
|
||||
wmiexec.py -nooutput -A /tmp/sharkon_ru.auth 192.168.100.21 \
|
||||
"powershell -NoProfile -Command \"Get-ScheduledTask | Where-Object TaskName -like 'ActivityWatch Launch *' | ForEach-Object { Start-ScheduledTask -TaskName \$_.TaskName }\""
|
||||
```
|
||||
|
||||
5. Подождать 10-20 секунд и проверить API на AW server (`10.10.10.13:5600`):
|
||||
|
||||
```sh
|
||||
curl -fsS 'http://10.10.10.13:5600/api/0/buckets/aw-watcher-afk_SHARKON2025/events?limit=30' \
|
||||
| jq '{latest:.[0].timestamp, statuses:(group_by(.data.status)|map({status:.[0].data.status,count:length}))}'
|
||||
```
|
||||
|
||||
Ожидаемо после фикса:
|
||||
|
||||
- в свежих AFK-событиях появляется `status=not-afk`;
|
||||
- `aw-watcher-window_SHARKON2025` продолжает обновляться;
|
||||
- после обновления страницы UI дневная сводка перестает быть `0s`.
|
||||
|
||||
### Сервис не стартует
|
||||
|
||||
```sh
|
||||
|
||||
@@ -8,16 +8,29 @@
|
||||
- `windows/hardening-recovery.ps1` — повторная регистрация задач, ACL и recovery-loop.
|
||||
- `windows/validate-deployment.ps1` — машинная проверка состояния и JSON-отчёт.
|
||||
- `windows/browser-domains-native-collector.ps1` — native collector доменов браузера с категоризацией.
|
||||
- `windows/dlp-endpoint-signals-collector.ps1` — phase-2 collector (clipboard/USB/print signals).
|
||||
- `windows/web-category-rules.example.json` — пример кастомных правил категоризации.
|
||||
- `windows/dlp-policy.example.json` — пример DLP-политики (phase-1: alerting incidents).
|
||||
|
||||
## Что делает пакет
|
||||
|
||||
- Ставит `aw-watcher-afk` и `aw-watcher-window` из официального Windows ZIP ActivityWatch.
|
||||
- Копирует browser-domain collector в `C:\ProgramData\ActivityWatch`.
|
||||
- Копирует DLP policy в `C:\ProgramData\ActivityWatch\dlp-policy.json`.
|
||||
- Включает `incidentCapture` в `deployment-config.json` для DLP-инцидентов:
|
||||
- `incidentCapture.screenshotEnabled = true`
|
||||
- `incidentCapture.artifactsRoot = <StateRoot>\incident-artifacts`
|
||||
- Создаёт per-user задачи `ActivityWatch Launch [...]` с запуском при логоне.
|
||||
- Создаёт системную задачу `ActivityWatch Recovery`, которая циклически перезапускает per-user launch tasks.
|
||||
- Применяет ACL к `C:\Program Files\ActivityWatch`, `C:\ProgramData\ActivityWatch` и каталогу логов.
|
||||
- Не содержит хардкодов инфраструктуры: сервер, домен, список пользователей и правила передаются параметрами.
|
||||
- Корректно регистрирует задачи через `-LogonType Interactive` (совместимо с Windows Server, где `InteractiveToken` не поддерживается).
|
||||
- Поддерживает отключение шумных watcher'ов через `-AfkEnabled:$false` и `-WindowEnabled:$false`.
|
||||
|
||||
Важно:
|
||||
|
||||
- Скриншот делается только при DLP-инциденте (`Send-DlpIncidentHeartbeat`), не по таймеру и не на обычной активности.
|
||||
- Для экстренного отключения снимков можно выставить `incidentCapture.screenshotEnabled = false` в `deployment-config.json` и запустить `ActivityWatch Recovery`.
|
||||
|
||||
## Предпосылки
|
||||
|
||||
@@ -36,7 +49,33 @@ Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope Process
|
||||
-ServerHost aw.example.local `
|
||||
-ServerPort 5600 `
|
||||
-TargetUser 'CONTOSO\svc.activity.user01' `
|
||||
-CustomRulesPath .\windows\web-category-rules.example.json
|
||||
-CustomRulesPath .\windows\web-category-rules.example.json `
|
||||
-CustomPolicyPath .\windows\dlp-policy.example.json
|
||||
```
|
||||
|
||||
Чтобы убрать `afkstatus` из веб-интерфейса, отключите AFK watcher:
|
||||
|
||||
```powershell
|
||||
.\windows\deploy-single-user.ps1 `
|
||||
-ServerHost aw.example.local `
|
||||
-ServerPort 5600 `
|
||||
-TargetUser 'CONTOSO\svc.activity.user01' `
|
||||
-AfkEnabled:$false `
|
||||
-CustomRulesPath .\windows\web-category-rules.example.json `
|
||||
-CustomPolicyPath .\windows\dlp-policy.example.json
|
||||
```
|
||||
|
||||
Если нужен только `window`-сигнал без AFK:
|
||||
|
||||
```powershell
|
||||
.\windows\deploy-single-user.ps1 `
|
||||
-ServerHost aw.example.local `
|
||||
-ServerPort 5600 `
|
||||
-TargetUser 'CONTOSO\svc.activity.user01' `
|
||||
-AfkEnabled:$false `
|
||||
-WindowEnabled:$true `
|
||||
-CustomRulesPath .\windows\web-category-rules.example.json `
|
||||
-CustomPolicyPath .\windows\dlp-policy.example.json
|
||||
```
|
||||
|
||||
Локальный пользователь:
|
||||
@@ -85,11 +124,54 @@ CSV-формат: колонка `User`, `Username`, `SamAccountName` или `Lo
|
||||
-ServerPort 5600 `
|
||||
-Domain CONTOSO `
|
||||
-UserListPath C:\Temp\aw-users.txt `
|
||||
-CustomRulesPath C:\Temp\web-category-rules.json
|
||||
-CustomRulesPath C:\Temp\web-category-rules.json `
|
||||
-CustomPolicyPath C:\Temp\dlp-policy.json
|
||||
```
|
||||
|
||||
Для quiet-профиля без `afkstatus`:
|
||||
|
||||
```powershell
|
||||
.\windows\deploy-domain-users.ps1 `
|
||||
-ServerHost aw.example.local `
|
||||
-ServerPort 5600 `
|
||||
-Domain CONTOSO `
|
||||
-UserListPath C:\Temp\aw-users.txt `
|
||||
-AfkEnabled:$false `
|
||||
-CustomRulesPath C:\Temp\web-category-rules.json `
|
||||
-CustomPolicyPath C:\Temp\dlp-policy.json
|
||||
```
|
||||
|
||||
Если список уже содержит `DOMAIN\user`, параметр `-Domain` не нужен.
|
||||
|
||||
## Рекомендуемый phased rollout (изолированный профиль)
|
||||
|
||||
Для безопасного параллельного запуска рядом с legacy-инсталляцией используйте отдельные пути:
|
||||
|
||||
```powershell
|
||||
.\windows\deploy-domain-users.ps1 `
|
||||
-ServerHost 10.10.10.13 `
|
||||
-ServerPort 5600 `
|
||||
-Domain SHARKON2025 `
|
||||
-Users user2,user3,user4,user5 `
|
||||
-InstallRoot 'C:\Program Files\ActivityWatch-Phase2-u2u5' `
|
||||
-StateRoot 'C:\ProgramData\ActivityWatch-Phase2-u2u5' `
|
||||
-CustomRulesPath C:\Deploy\AWatch-rus\windows\web-category-rules.example.json `
|
||||
-CustomPolicyPath C:\Deploy\AWatch-rus\windows\dlp-policy.example.json
|
||||
```
|
||||
|
||||
Single-user pilot в таком же стиле:
|
||||
|
||||
```powershell
|
||||
.\windows\deploy-single-user.ps1 `
|
||||
-ServerHost 10.10.10.13 `
|
||||
-ServerPort 5600 `
|
||||
-TargetUser 'SHARKON2025\user1' `
|
||||
-InstallRoot 'C:\Program Files\ActivityWatch-Phase2' `
|
||||
-StateRoot 'C:\ProgramData\ActivityWatch-Phase2-user1' `
|
||||
-CustomRulesPath C:\Deploy\AWatch-rus\windows\web-category-rules.example.json `
|
||||
-CustomPolicyPath C:\Deploy\AWatch-rus\windows\dlp-policy.example.json
|
||||
```
|
||||
|
||||
## Ensemble deploy (production workflow)
|
||||
|
||||
```powershell
|
||||
@@ -98,6 +180,7 @@ CSV-формат: колонка `User`, `Username`, `SamAccountName` или `Lo
|
||||
-ServerPort 5600 `
|
||||
-Domain CONTOSO `
|
||||
-Users user1,user2,user3,user4,user5 `
|
||||
-CustomPolicyPath C:\Temp\dlp-policy.json `
|
||||
-ValidateAfterDeploy
|
||||
```
|
||||
|
||||
@@ -116,11 +199,16 @@ CSV-формат: колонка `User`, `Username`, `SamAccountName` или `Lo
|
||||
|
||||
- `C:\Program Files\ActivityWatch` — бинарники watcher'ов.
|
||||
- `C:\ProgramData\ActivityWatch\deployment-config.json` — итоговая конфигурация.
|
||||
- `C:\ProgramData\ActivityWatch\incident-artifacts\` — скриншоты DLP-инцидентов (если `incidentCapture.screenshotEnabled=true`).
|
||||
- `C:\ProgramData\ActivityWatch\launch-watchers.ps1` — per-user launcher.
|
||||
- `C:\ProgramData\ActivityWatch\recovery-loop.ps1` — system recovery loop.
|
||||
- `C:\ProgramData\ActivityWatch\browser-domains-native-collector.ps1` — runtime collector.
|
||||
- `C:\ProgramData\ActivityWatch\dlp-endpoint-signals-collector.ps1` — runtime endpoint collector.
|
||||
- `C:\ProgramData\ActivityWatch\dlp-policy.json` — активная DLP-политика.
|
||||
- `C:\ProgramData\ActivityWatch\logs\` — логи collector'а.
|
||||
|
||||
Для phased rollout те же файлы формируются в каталоге `StateRoot`, переданном параметром.
|
||||
|
||||
## Повторный прогон
|
||||
|
||||
- Скрипты идемпотентны: переустанавливают задачи и обновляют runtime-файлы.
|
||||
|
||||
@@ -20,8 +20,12 @@ Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope Process
|
||||
C:\Deploy\AWatch-rus\windows\deploy-ensemble.ps1 `
|
||||
-ServerHost 10.10.10.13 `
|
||||
-ServerPort 5600 `
|
||||
-Domain AD `
|
||||
-Domain SHARKON2025 `
|
||||
-Users user1,user2,user3,user4,user5 `
|
||||
-InstallRoot 'C:\Program Files\ActivityWatch-Phase2' `
|
||||
-StateRoot 'C:\ProgramData\ActivityWatch-Phase2' `
|
||||
-AfkEnabled:$false `
|
||||
-CustomPolicyPath C:\Deploy\AWatch-rus\windows\dlp-policy.example.json `
|
||||
-ValidateAfterDeploy
|
||||
```
|
||||
|
||||
@@ -31,13 +35,15 @@ C:\Deploy\AWatch-rus\windows\deploy-ensemble.ps1 `
|
||||
2. Вызывает массовый деплой `deploy-domain-users.ps1`.
|
||||
3. Применяет hardening/recovery (`hardening-recovery.ps1`), если не задан `-SkipHardening`.
|
||||
4. Опционально запускает контроль (`validate-deployment.ps1`) при `-ValidateAfterDeploy`.
|
||||
5. Пишет итоговый JSON-отчёт в `C:\ProgramData\ActivityWatch\ensemble-report-*.json`.
|
||||
5. Пишет итоговый JSON-отчёт в `<StateRoot>\ensemble-report-*.json`.
|
||||
|
||||
Для quiet-профиля без `afkstatus` используйте `-AfkEnabled:$false`; если не нужен window watcher, добавьте `-WindowEnabled:$false`.
|
||||
|
||||
## Быстрый health-check
|
||||
|
||||
```powershell
|
||||
$report = C:\Deploy\AWatch-rus\windows\validate-deployment.ps1 `
|
||||
-ConfigPath C:\ProgramData\ActivityWatch\deployment-config.json
|
||||
-ConfigPath C:\ProgramData\ActivityWatch-Phase2\deployment-config.json
|
||||
$report | ConvertTo-Json -Depth 12
|
||||
```
|
||||
|
||||
|
||||
@@ -64,6 +64,28 @@ Start-ScheduledTask -TaskName 'ActivityWatch Launch [CONTOSO_user01]'
|
||||
|
||||
- Если сервер и пользователи уже есть в `deployment-config.json`, дополнительные параметры не нужны.
|
||||
|
||||
### Ошибка `InvalidVariableReferenceWithDrive` (в строке `Server: http://...`)
|
||||
|
||||
Симптом:
|
||||
|
||||
- деплой падает на выводе URL сервера с ошибкой о недопустимой ссылке на переменную.
|
||||
|
||||
Решение:
|
||||
|
||||
- обновить скрипты до версии, где используется `${ServerScheme}` в строке вывода;
|
||||
- перезалить `deploy-single-user.ps1` / `deploy-domain-users.ps1` на целевой хост.
|
||||
|
||||
### Ошибка `LogonType InteractiveToken` при регистрации задач
|
||||
|
||||
Симптом:
|
||||
|
||||
- `Register-ActivityWatchUserTasks` падает с ошибкой преобразования enum для `LogonType`.
|
||||
|
||||
Решение:
|
||||
|
||||
- использовать версию `ActivityWatch.Windows.Common.psm1`, где `LogonType` задан как `Interactive`;
|
||||
- повторно запустить deploy/hardening после обновления модуля.
|
||||
|
||||
## Диагностика
|
||||
|
||||
Проверить задачи:
|
||||
|
||||
@@ -8,6 +8,8 @@ $report = .\windows\validate-deployment.ps1 `
|
||||
$report | ConvertTo-Json -Depth 12
|
||||
```
|
||||
|
||||
Примечание: валидатор проверяет задачи через полный список Task Scheduler и корректно обрабатывает имена с квадратными скобками (`ActivityWatch Launch [DOMAIN_user]`).
|
||||
|
||||
Критерий:
|
||||
|
||||
- `overallOk = true`
|
||||
@@ -20,6 +22,7 @@ $report | ConvertTo-Json -Depth 12
|
||||
Test-Path 'C:\Program Files\ActivityWatch\aw-watcher-afk\aw-watcher-afk.exe'
|
||||
Test-Path 'C:\Program Files\ActivityWatch\aw-watcher-window\aw-watcher-window.exe'
|
||||
Test-Path 'C:\ProgramData\ActivityWatch\browser-domains-native-collector.ps1'
|
||||
Test-Path 'C:\ProgramData\ActivityWatch\dlp-policy.json'
|
||||
Test-Path 'C:\ProgramData\ActivityWatch\deployment-config.json'
|
||||
```
|
||||
|
||||
@@ -37,6 +40,26 @@ Get-ScheduledTask -TaskName 'ActivityWatch*' |
|
||||
- по одной задаче `ActivityWatch Launch [...]` на пользователя;
|
||||
- одна задача `ActivityWatch Recovery`.
|
||||
|
||||
Точечная проверка:
|
||||
|
||||
```powershell
|
||||
Get-ScheduledTask | Where-Object TaskName -eq 'ActivityWatch Launch [SHARKON2025_user1]'
|
||||
Get-ScheduledTask | Where-Object TaskName -eq 'ActivityWatch Recovery'
|
||||
```
|
||||
|
||||
### 2.1 Проверить incidentCapture в конфиге
|
||||
|
||||
```powershell
|
||||
$cfg = Get-Content 'C:\ProgramData\ActivityWatch\deployment-config.json' -Raw | ConvertFrom-Json
|
||||
$cfg.incidentCapture
|
||||
```
|
||||
|
||||
Ожидаемо:
|
||||
|
||||
- `enabled = True`
|
||||
- `screenshotEnabled = True` (или `False`, если временно отключали)
|
||||
- `artifactsRoot` указывает на `<StateRoot>\incident-artifacts`
|
||||
|
||||
### 3. Проверить процессы после логина пользователя
|
||||
|
||||
```powershell
|
||||
@@ -72,6 +95,8 @@ Invoke-WebRequest https://aw.example.local/api/0/info
|
||||
- `aw-watcher-window_<hostname>`
|
||||
- `aw-watcher-web-edge_<hostname>` или другой browser bucket
|
||||
- `aw-detmir-web-category_<hostname>`
|
||||
- `aw-dlp-endpoint-signals_<hostname>`
|
||||
- `aw-dlp-incidents_<hostname>` (при срабатывании policy rule с `action=alert|block|quarantine`)
|
||||
|
||||
Проверка через API:
|
||||
|
||||
@@ -86,6 +111,86 @@ Invoke-WebRequest http://aw.example.local:5600/api/0/buckets | Select-Object -Ex
|
||||
3. Проверьте category bucket на сервере.
|
||||
4. Убедитесь, что поля `domain`, `rootDomain`, `category`, `categoryGroup`, `categoryRule` заполнены.
|
||||
|
||||
## Проверка DLP phase-1
|
||||
|
||||
1. В `dlp-policy.json` задайте правило на тестовый домен.
|
||||
2. Откройте этот домен в браузере.
|
||||
3. Проверьте `aw-dlp-incidents_<hostname>` через API.
|
||||
4. Проверьте локальный лог:
|
||||
|
||||
```powershell
|
||||
Get-Content "C:\ProgramData\ActivityWatch\logs\dlp-incidents-$env:USERNAME.log" -Tail 50
|
||||
```
|
||||
|
||||
Если `screenshotEnabled = True`, проверьте наличие скриншота в инциденте:
|
||||
|
||||
```powershell
|
||||
Invoke-WebRequest http://aw.example.local:5600/api/0/buckets/aw-dlp-incidents_<hostname>/events?limit=5 |
|
||||
Select-Object -ExpandProperty Content
|
||||
```
|
||||
|
||||
Проверьте поля:
|
||||
|
||||
- `data.screenshotPath`
|
||||
- `data.screenshotSha256`
|
||||
- `data.screenshotWidth`
|
||||
- `data.screenshotHeight`
|
||||
|
||||
И что файл реально существует:
|
||||
|
||||
```powershell
|
||||
Test-Path '<path from data.screenshotPath>'
|
||||
```
|
||||
|
||||
## Контролируемый self-test DLP ingest
|
||||
|
||||
Этот тест проверяет канал `AW API -> aw-dlp-incidents_<hostname> -> Web UI`, не затрагивая пользовательские действия.
|
||||
|
||||
```powershell
|
||||
$ts = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.000Z')
|
||||
$body = @{
|
||||
timestamp = $ts
|
||||
duration = 0
|
||||
data = @{
|
||||
ruleId = 'selftest-dlp-incident'
|
||||
action = 'alert'
|
||||
severity = 'low'
|
||||
message = 'Self-test DLP incident from validation'
|
||||
signalType= 'self_test'
|
||||
username = 'AUTOTEST'
|
||||
sessionId = 0
|
||||
hostname = '<hostname>'
|
||||
source = 'self-test'
|
||||
}
|
||||
} | ConvertTo-Json -Depth 6
|
||||
|
||||
Invoke-RestMethod -Method Post `
|
||||
-Uri "http://aw.example.local:5600/api/0/buckets/aw-dlp-incidents_<hostname>/heartbeat?pulsetime=60" `
|
||||
-ContentType 'application/json; charset=utf-8' `
|
||||
-Body ([Text.Encoding]::UTF8.GetBytes($body))
|
||||
```
|
||||
|
||||
Проверка:
|
||||
|
||||
- в API появился `ruleId=selftest-dlp-incident`;
|
||||
- в UI (`#/buckets/aw-dlp-incidents_<hostname>`) событие видно в `Events`.
|
||||
|
||||
## Проверка phase-2 endpoint signals
|
||||
|
||||
1. Скопируйте любой текст в буфер обмена.
|
||||
2. Отправьте тестовую печать (любой принтер/виртуальный PDF).
|
||||
3. Проверьте endpoint bucket:
|
||||
|
||||
```powershell
|
||||
Invoke-WebRequest http://aw.example.local:5600/api/0/buckets/aw-dlp-endpoint-signals_<hostname>/events?limit=20
|
||||
```
|
||||
|
||||
4. Проверьте локальный лог:
|
||||
|
||||
```powershell
|
||||
Get-Content "C:\ProgramData\ActivityWatch\logs\endpoint-signals-$env:USERNAME.log" -Tail 50
|
||||
```
|
||||
|
||||
## Проверка восстановления
|
||||
|
||||
1. Завершите `aw-watcher-afk.exe` и `aw-watcher-window.exe` у тестового пользователя.
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
# DetMir: Worktime (AQL templates)
|
||||
|
||||
Цель: получить «рабочее время» по данным ActivityWatch с учетом:
|
||||
|
||||
- активного времени (`not-afk`);
|
||||
- категоризации (classes) для оконных событий;
|
||||
- отдельного категоризованного веб-потока `aw-detmir-web-category_<HOST>` для доменов.
|
||||
|
||||
Ниже — шаблоны AQL для страницы `Query` в AW Web UI.
|
||||
|
||||
## Рабочее время по приложениям (окна)
|
||||
|
||||
Подходит для расчета рабочего времени в толстых клиентах (1С, документы, админка).
|
||||
|
||||
```javascript
|
||||
events = flood(query_bucket("aw-watcher-window_SHARKON2025"));
|
||||
not_afk = flood(query_bucket("aw-watcher-afk_SHARKON2025"));
|
||||
not_afk = filter_keyvals(not_afk, "status", ["not-afk"]);
|
||||
|
||||
events = filter_period_intersect(events, not_afk);
|
||||
events = categorize(events, __CATEGORIES__);
|
||||
|
||||
work = filter_keyvals(events, "$category", [
|
||||
["Работа", "1С"],
|
||||
["Работа", "Документы"],
|
||||
["Работа", "Коммуникации"],
|
||||
["Работа", "Администрирование"]
|
||||
]);
|
||||
|
||||
work = merge_events_by_keys(work, ["$category", "app"]);
|
||||
RETURN = sort_by_duration(work);
|
||||
```
|
||||
|
||||
## Рабочее время в вебе (по доменам)
|
||||
|
||||
Требует, чтобы на клиенте работал browser collector и писал в:
|
||||
`aw-detmir-web-category_<HOST>` поля `categoryGroup`, `rootDomain`.
|
||||
|
||||
```javascript
|
||||
web = flood(query_bucket("aw-detmir-web-category_SHARKON2025"));
|
||||
not_afk = flood(query_bucket("aw-watcher-afk_SHARKON2025"));
|
||||
not_afk = filter_keyvals(not_afk, "status", ["not-afk"]);
|
||||
|
||||
web = filter_period_intersect(web, not_afk);
|
||||
web = filter_keyvals(web, "categoryGroup", ["work"]);
|
||||
|
||||
web = merge_events_by_keys(web, ["rootDomain", "category"]);
|
||||
RETURN = sort_by_duration(web);
|
||||
```
|
||||
|
||||
## Sanity-check: «куда уходит время»
|
||||
|
||||
```javascript
|
||||
events = flood(query_bucket("aw-watcher-window_SHARKON2025"));
|
||||
not_afk = flood(query_bucket("aw-watcher-afk_SHARKON2025"));
|
||||
not_afk = filter_keyvals(not_afk, "status", ["not-afk"]);
|
||||
|
||||
events = filter_period_intersect(events, not_afk);
|
||||
events = categorize(events, __CATEGORIES__);
|
||||
|
||||
events = merge_events_by_keys(events, ["$category"]);
|
||||
RETURN = sort_by_duration(events);
|
||||
```
|
||||
|
||||
## Замечания
|
||||
|
||||
- Для других хостов замените суффикс `_SHARKON2025` на нужный hostname.
|
||||
- Если web-поток пустой, рабочее время в браузере корректно посчитать по доменам не получится. Тогда либо:
|
||||
- чинить/запускать browser collector;
|
||||
- либо временно считать браузер в `window` как «Интернет/Браузер» без разделения на work/personal.
|
||||
Executable
+7
@@ -0,0 +1,7 @@
|
||||
COMPOSE_PROJECT_NAME=awrus-1c
|
||||
GRAFANA_ADMIN_USER=admin
|
||||
GRAFANA_ADMIN_PASSWORD=change_me_now
|
||||
GRAFANA_PORT=3000
|
||||
PROMETHEUS_PORT=9090
|
||||
SQL_EXPORTER_PORT=9399
|
||||
ONEC_DSN=postgres://onec_reader:change_me@10.10.10.20:5432/onec_db?sslmode=disable
|
||||
Executable
+1
@@ -0,0 +1 @@
|
||||
.env
|
||||
Executable
+94
@@ -0,0 +1,94 @@
|
||||
# 1С-Бухгалтерия → Grafana (production scaffold)
|
||||
|
||||
Готовый каркас для непрерывного сбора KPI из 1С и анализа в Grafana:
|
||||
|
||||
- `sql-exporter` читает SQL-представления KPI из БД 1С;
|
||||
- `prometheus` собирает метрики и применяет alert-rules;
|
||||
- `grafana` поднимает datasource и дашборд автоматически.
|
||||
|
||||
## Полные пути
|
||||
|
||||
- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/.env.example`
|
||||
- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/docker-compose.yml`
|
||||
- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/sql-exporter/sql_exporter.yml`
|
||||
- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/sql-exporter/collectors/onec_accounting_kpi.collector.yml`
|
||||
- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/prometheus/prometheus.yml`
|
||||
- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/prometheus/alerts.yml`
|
||||
- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/prometheus/recording_rules.yml`
|
||||
- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/grafana/dashboards/1c-accounting-overview.json`
|
||||
- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/grafana/dashboards/1c-accounting-sre.json`
|
||||
- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/sql/postgres_views_template.sql`
|
||||
- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/sql/mssql_views_template.sql`
|
||||
- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/tools/discover_postgres_1c.sh`
|
||||
- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/tools/validate_kpi_views.sh`
|
||||
- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/tools/check_pipeline.sh`
|
||||
|
||||
## Быстрый запуск
|
||||
|
||||
1. Подготовьте env:
|
||||
|
||||
```bash
|
||||
cd /mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
2. В `.env` задайте:
|
||||
|
||||
- `GRAFANA_ADMIN_USER`, `GRAFANA_ADMIN_PASSWORD`;
|
||||
- `ONEC_DSN` (DSN read-only пользователя в БД 1С).
|
||||
|
||||
3. В БД 1С создайте KPI-представления:
|
||||
|
||||
- для PostgreSQL возьмите `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/sql/postgres_views_template.sql`;
|
||||
- для MS SQL возьмите `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/sql/mssql_views_template.sql`.
|
||||
|
||||
4. Поднимите стек:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
5. Проверка:
|
||||
|
||||
```bash
|
||||
curl -fsS http://127.0.0.1:9399/metrics | head
|
||||
curl -fsS http://127.0.0.1:9090/-/healthy
|
||||
```
|
||||
|
||||
Откройте Grafana: `http://<host>:3000`.
|
||||
|
||||
## Быстрая диагностика
|
||||
|
||||
Профилирование структуры 1С (PostgreSQL):
|
||||
|
||||
```bash
|
||||
sh /mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/tools/discover_postgres_1c.sh \
|
||||
"postgres://user:pass@db-host:5432/db?sslmode=disable"
|
||||
```
|
||||
|
||||
Проверка KPI views:
|
||||
|
||||
```bash
|
||||
sh /mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/tools/validate_kpi_views.sh \
|
||||
"postgres://user:pass@db-host:5432/db?sslmode=disable"
|
||||
```
|
||||
|
||||
Проверка end-to-end пайплайна:
|
||||
|
||||
```bash
|
||||
sh /mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/tools/check_pipeline.sh
|
||||
```
|
||||
|
||||
## Что контролируется
|
||||
|
||||
- Непроведенные документы (`onec_unposted_documents_total`)
|
||||
- Продажи текущего дня (`onec_sales_amount_today`)
|
||||
- Просроченная дебиторка (`onec_overdue_receivables_total`)
|
||||
- Ошибки проведения за 24ч (`onec_posting_errors_total`)
|
||||
- Свежесть данных из 1С (`onec_data_freshness_seconds`)
|
||||
|
||||
## Принципы безопасности
|
||||
|
||||
- только read-only аккаунт к БД 1С;
|
||||
- минимальные SQL-права только на KPI views;
|
||||
- секреты только в `.env`, не в git.
|
||||
Executable
+58
@@ -0,0 +1,58 @@
|
||||
services:
|
||||
sql-exporter:
|
||||
image: ghcr.io/burningalchemist/sql_exporter:latest
|
||||
container_name: awrus-sql-exporter
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
command:
|
||||
- "-config.file=/etc/sql_exporter/sql_exporter.yml"
|
||||
volumes:
|
||||
- ./sql-exporter/sql_exporter.yml:/etc/sql_exporter/sql_exporter.yml:ro
|
||||
- ./sql-exporter/collectors:/etc/sql_exporter/collectors:ro
|
||||
ports:
|
||||
- "${SQL_EXPORTER_PORT:-9399}:9399"
|
||||
|
||||
prometheus:
|
||||
image: prom/prometheus:v2.54.1
|
||||
container_name: awrus-prometheus
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
||||
- ./prometheus/alerts.yml:/etc/prometheus/alerts.yml:ro
|
||||
- ./prometheus/recording_rules.yml:/etc/prometheus/recording_rules.yml:ro
|
||||
- prometheus-data:/prometheus
|
||||
command:
|
||||
- "--config.file=/etc/prometheus/prometheus.yml"
|
||||
- "--storage.tsdb.path=/prometheus"
|
||||
- "--storage.tsdb.retention.time=30d"
|
||||
- "--web.enable-lifecycle"
|
||||
ports:
|
||||
- "${PROMETHEUS_PORT:-9090}:9090"
|
||||
depends_on:
|
||||
- sql-exporter
|
||||
|
||||
grafana:
|
||||
image: grafana/grafana-oss:11.2.2
|
||||
container_name: awrus-grafana
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
GF_SECURITY_ADMIN_USER: "${GRAFANA_ADMIN_USER}"
|
||||
GF_SECURITY_ADMIN_PASSWORD: "${GRAFANA_ADMIN_PASSWORD}"
|
||||
GF_USERS_DEFAULT_THEME: "dark"
|
||||
GF_INSTALL_PLUGINS: ""
|
||||
volumes:
|
||||
- grafana-data:/var/lib/grafana
|
||||
- ./grafana/provisioning/datasources:/etc/grafana/provisioning/datasources:ro
|
||||
- ./grafana/provisioning/dashboards:/etc/grafana/provisioning/dashboards:ro
|
||||
- ./grafana/dashboards:/var/lib/grafana/dashboards:ro
|
||||
ports:
|
||||
- "${GRAFANA_PORT:-3000}:3000"
|
||||
depends_on:
|
||||
- prometheus
|
||||
|
||||
volumes:
|
||||
prometheus-data:
|
||||
grafana-data:
|
||||
+342
@@ -0,0 +1,342 @@
|
||||
{
|
||||
"annotations": {
|
||||
"list": []
|
||||
},
|
||||
"editable": true,
|
||||
"fiscalYearStartMonth": 0,
|
||||
"graphTooltip": 0,
|
||||
"id": null,
|
||||
"links": [],
|
||||
"panels": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
"value": 100
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 6,
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"id": 1,
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "none",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
"lastNotNull"
|
||||
],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"textMode": "auto"
|
||||
},
|
||||
"pluginVersion": "11.2.2",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum(onec_unposted_documents_total)",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Непроведенные документы",
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
"value": 500000
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 6,
|
||||
"x": 6,
|
||||
"y": 0
|
||||
},
|
||||
"id": 2,
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "none",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
"lastNotNull"
|
||||
],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"textMode": "auto"
|
||||
},
|
||||
"pluginVersion": "11.2.2",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum(onec_overdue_receivables_total)",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Просроченная дебиторка",
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 6,
|
||||
"x": 12,
|
||||
"y": 0
|
||||
},
|
||||
"id": 3,
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "none",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
"lastNotNull"
|
||||
],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"textMode": "auto"
|
||||
},
|
||||
"pluginVersion": "11.2.2",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum(onec_sales_amount_today)",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Продажи за день",
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 6,
|
||||
"x": 18,
|
||||
"y": 0
|
||||
},
|
||||
"id": 4,
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "none",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
"lastNotNull"
|
||||
],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"textMode": "auto"
|
||||
},
|
||||
"pluginVersion": "11.2.2",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum(onec_posting_errors_total)",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Ошибки проведения (24ч)",
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 9,
|
||||
"w": 12,
|
||||
"x": 0,
|
||||
"y": 8
|
||||
},
|
||||
"id": 5,
|
||||
"options": {
|
||||
"legend": {
|
||||
"calcs": [],
|
||||
"displayMode": "list",
|
||||
"placement": "bottom",
|
||||
"showLegend": true
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "single",
|
||||
"sort": "none"
|
||||
}
|
||||
},
|
||||
"pluginVersion": "11.2.2",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "onec_sales_amount_today",
|
||||
"legendFormat": "{{company}} {{currency}}",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Динамика продаж за день",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 9,
|
||||
"w": 12,
|
||||
"x": 12,
|
||||
"y": 8
|
||||
},
|
||||
"id": 6,
|
||||
"options": {
|
||||
"legend": {
|
||||
"calcs": [],
|
||||
"displayMode": "list",
|
||||
"placement": "bottom",
|
||||
"showLegend": true
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "single",
|
||||
"sort": "none"
|
||||
}
|
||||
},
|
||||
"pluginVersion": "11.2.2",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "onec_overdue_receivables_total",
|
||||
"legendFormat": "{{company}} {{aging_bucket}} {{currency}}",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Просроченная дебиторка по корзинам",
|
||||
"type": "timeseries"
|
||||
}
|
||||
],
|
||||
"refresh": "30s",
|
||||
"schemaVersion": 39,
|
||||
"style": "dark",
|
||||
"tags": [
|
||||
"1c",
|
||||
"buh"
|
||||
],
|
||||
"templating": {
|
||||
"list": []
|
||||
},
|
||||
"time": {
|
||||
"from": "now-24h",
|
||||
"to": "now"
|
||||
},
|
||||
"timepicker": {},
|
||||
"timezone": "",
|
||||
"title": "1C Бухгалтерия — Обзор",
|
||||
"uid": "onec-accounting-overview",
|
||||
"version": 1,
|
||||
"weekStart": ""
|
||||
}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
{
|
||||
"annotations": { "list": [] },
|
||||
"editable": true,
|
||||
"graphTooltip": 0,
|
||||
"id": null,
|
||||
"links": [],
|
||||
"panels": [
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "green", "value": null },
|
||||
{ "color": "red", "value": 7200 }
|
||||
]
|
||||
},
|
||||
"unit": "s"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": { "h": 8, "w": 8, "x": 0, "y": 0 },
|
||||
"id": 1,
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "none",
|
||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
|
||||
"textMode": "auto"
|
||||
},
|
||||
"targets": [{ "expr": "max(onec_data_freshness_seconds)", "refId": "A" }],
|
||||
"title": "Свежесть данных (сек)",
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"mappings": [
|
||||
{
|
||||
"options": {
|
||||
"0": { "text": "DOWN" },
|
||||
"1": { "text": "UP" }
|
||||
},
|
||||
"type": "value"
|
||||
}
|
||||
]
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": { "h": 8, "w": 8, "x": 8, "y": 0 },
|
||||
"id": 2,
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "none",
|
||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
|
||||
"textMode": "auto"
|
||||
},
|
||||
"targets": [{ "expr": "max(up{job=\"onec_sql_exporter\"})", "refId": "A" }],
|
||||
"title": "SQL Exporter",
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": { "defaults": {}, "overrides": [] },
|
||||
"gridPos": { "h": 8, "w": 8, "x": 16, "y": 0 },
|
||||
"id": 3,
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "none",
|
||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
|
||||
"textMode": "auto"
|
||||
},
|
||||
"targets": [{ "expr": "sum(ALERTS{alertstate=\"firing\",service=\"onec\"})", "refId": "A" }],
|
||||
"title": "Активные алерты 1С",
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": { "defaults": {}, "overrides": [] },
|
||||
"gridPos": { "h": 10, "w": 24, "x": 0, "y": 8 },
|
||||
"id": 4,
|
||||
"options": {
|
||||
"legend": { "displayMode": "list", "placement": "bottom", "showLegend": true },
|
||||
"tooltip": { "mode": "single", "sort": "none" }
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"expr": "onec_data_freshness_seconds",
|
||||
"legendFormat": "{{company}} {{source}}",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Свежесть источников 1С",
|
||||
"type": "timeseries"
|
||||
}
|
||||
],
|
||||
"refresh": "30s",
|
||||
"schemaVersion": 39,
|
||||
"style": "dark",
|
||||
"tags": ["1c", "sre"],
|
||||
"templating": { "list": [] },
|
||||
"time": { "from": "now-24h", "to": "now" },
|
||||
"timezone": "",
|
||||
"title": "1C Бухгалтерия — SRE",
|
||||
"uid": "onec-accounting-sre",
|
||||
"version": 1,
|
||||
"weekStart": ""
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
apiVersion: 1
|
||||
|
||||
providers:
|
||||
- name: 1C-Buhgalteria
|
||||
orgId: 1
|
||||
folder: "1C"
|
||||
type: file
|
||||
disableDeletion: true
|
||||
editable: false
|
||||
options:
|
||||
path: /var/lib/grafana/dashboards
|
||||
@@ -0,0 +1,10 @@
|
||||
apiVersion: 1
|
||||
|
||||
datasources:
|
||||
- name: Prometheus
|
||||
uid: prometheus
|
||||
type: prometheus
|
||||
access: proxy
|
||||
url: http://prometheus:9090
|
||||
isDefault: true
|
||||
editable: false
|
||||
Executable
+42
@@ -0,0 +1,42 @@
|
||||
groups:
|
||||
- name: onec_accounting_alerts
|
||||
rules:
|
||||
- alert: OneCUnpostedDocumentsHigh
|
||||
expr: onec_unposted_documents_total > 100
|
||||
for: 15m
|
||||
labels:
|
||||
severity: warning
|
||||
service: onec
|
||||
annotations:
|
||||
summary: "Высокий объем непроведенных документов ({{ $labels.company }})"
|
||||
description: "В базе 1С накопилось {{ $value }} непроведенных документов."
|
||||
|
||||
- alert: OneCOverdueReceivablesCritical
|
||||
expr: sum by (company, currency) (onec_overdue_receivables_total) > 500000
|
||||
for: 15m
|
||||
labels:
|
||||
severity: critical
|
||||
service: onec
|
||||
annotations:
|
||||
summary: "Критичная просроченная дебиторка ({{ $labels.company }})"
|
||||
description: "Просроченная дебиторская задолженность превысила порог: {{ $value }} {{ $labels.currency }}."
|
||||
|
||||
- alert: OneCSQLExporterDown
|
||||
expr: up{job="onec_sql_exporter"} == 0
|
||||
for: 3m
|
||||
labels:
|
||||
severity: critical
|
||||
service: monitoring
|
||||
annotations:
|
||||
summary: "SQL Exporter недоступен"
|
||||
description: "Prometheus не может получить метрики 1С от sql-exporter."
|
||||
|
||||
- alert: OneCDataFreshnessCritical
|
||||
expr: onec_data_freshness_seconds > 7200
|
||||
for: 10m
|
||||
labels:
|
||||
severity: critical
|
||||
service: onec
|
||||
annotations:
|
||||
summary: "Данные 1С устарели ({{ $labels.company }})"
|
||||
description: "Последнее обновление источника {{ $labels.source }} старше 2 часов: {{ $value }} сек."
|
||||
Executable
+16
@@ -0,0 +1,16 @@
|
||||
global:
|
||||
scrape_interval: 15s
|
||||
evaluation_interval: 15s
|
||||
|
||||
rule_files:
|
||||
- /etc/prometheus/alerts.yml
|
||||
- /etc/prometheus/recording_rules.yml
|
||||
|
||||
scrape_configs:
|
||||
- job_name: prometheus
|
||||
static_configs:
|
||||
- targets: ["localhost:9090"]
|
||||
|
||||
- job_name: onec_sql_exporter
|
||||
static_configs:
|
||||
- targets: ["sql-exporter:9399"]
|
||||
Executable
+11
@@ -0,0 +1,11 @@
|
||||
groups:
|
||||
- name: onec_accounting_recording
|
||||
rules:
|
||||
- record: onec_overdue_receivables_total:company_currency
|
||||
expr: sum by (company, currency) (onec_overdue_receivables_total)
|
||||
|
||||
- record: onec_unposted_documents_total:company
|
||||
expr: sum by (company) (onec_unposted_documents_total)
|
||||
|
||||
- record: onec_sales_amount_today:company_currency
|
||||
expr: sum by (company, currency) (onec_sales_amount_today)
|
||||
@@ -0,0 +1,55 @@
|
||||
collector_name: onec_accounting_kpi
|
||||
metrics:
|
||||
- metric_name: onec_unposted_documents_total
|
||||
type: gauge
|
||||
help: "Количество непроведенных документов в 1С."
|
||||
key_labels:
|
||||
- company
|
||||
values: [value]
|
||||
query: |
|
||||
SELECT company, value
|
||||
FROM onec_kpi_unposted_documents;
|
||||
|
||||
- metric_name: onec_sales_amount_today
|
||||
type: gauge
|
||||
help: "Сумма продаж за текущие сутки (валюта учета)."
|
||||
key_labels:
|
||||
- company
|
||||
- currency
|
||||
values: [value]
|
||||
query: |
|
||||
SELECT company, currency, value
|
||||
FROM onec_kpi_sales_today;
|
||||
|
||||
- metric_name: onec_overdue_receivables_total
|
||||
type: gauge
|
||||
help: "Сумма просроченной дебиторской задолженности."
|
||||
key_labels:
|
||||
- company
|
||||
- aging_bucket
|
||||
- currency
|
||||
values: [value]
|
||||
query: |
|
||||
SELECT company, aging_bucket, currency, value
|
||||
FROM onec_kpi_overdue_receivables;
|
||||
|
||||
- metric_name: onec_posting_errors_total
|
||||
type: gauge
|
||||
help: "Количество ошибок проведения документов за последние 24 часа."
|
||||
key_labels:
|
||||
- company
|
||||
values: [value]
|
||||
query: |
|
||||
SELECT company, value
|
||||
FROM onec_kpi_posting_errors_24h;
|
||||
|
||||
- metric_name: onec_data_freshness_seconds
|
||||
type: gauge
|
||||
help: "Возраст последних данных 1С в секундах."
|
||||
key_labels:
|
||||
- company
|
||||
- source
|
||||
values: [value]
|
||||
query: |
|
||||
SELECT company, source, value
|
||||
FROM onec_kpi_data_freshness;
|
||||
Executable
+13
@@ -0,0 +1,13 @@
|
||||
global:
|
||||
scrape_timeout_offset: 500ms
|
||||
min_interval: 15s
|
||||
max_connections: 3
|
||||
max_idle_connections: 3
|
||||
|
||||
target:
|
||||
data_source_name: "${ONEC_DSN}"
|
||||
collectors:
|
||||
- onec_accounting_kpi
|
||||
|
||||
collector_files:
|
||||
- "/etc/sql_exporter/collectors/*.collector.yml"
|
||||
Executable
+86
@@ -0,0 +1,86 @@
|
||||
-- Шаблон для MS SQL-базы 1С.
|
||||
-- ВНИМАНИЕ: замените dbo.onec_raw_* на реальные объекты вашей конфигурации 1С.
|
||||
|
||||
IF OBJECT_ID('dbo.onec_kpi_unposted_documents', 'V') IS NOT NULL DROP VIEW dbo.onec_kpi_unposted_documents;
|
||||
GO
|
||||
CREATE VIEW dbo.onec_kpi_unposted_documents AS
|
||||
SELECT
|
||||
CAST('main' AS nvarchar(64)) AS company,
|
||||
CAST(COUNT(*) AS float) AS value
|
||||
FROM dbo.onec_raw_documents d
|
||||
WHERE d.posted = 0;
|
||||
GO
|
||||
|
||||
IF OBJECT_ID('dbo.onec_kpi_sales_today', 'V') IS NOT NULL DROP VIEW dbo.onec_kpi_sales_today;
|
||||
GO
|
||||
CREATE VIEW dbo.onec_kpi_sales_today AS
|
||||
SELECT
|
||||
CAST('main' AS nvarchar(64)) AS company,
|
||||
CAST(ISNULL(d.currency, 'RUB') AS nvarchar(16)) AS currency,
|
||||
CAST(ISNULL(SUM(d.amount), 0) AS float) AS value
|
||||
FROM dbo.onec_raw_sales_documents d
|
||||
WHERE d.posted = 1
|
||||
AND CAST(d.doc_date AS date) = CAST(GETDATE() AS date)
|
||||
GROUP BY ISNULL(d.currency, 'RUB');
|
||||
GO
|
||||
|
||||
IF OBJECT_ID('dbo.onec_kpi_overdue_receivables', 'V') IS NOT NULL DROP VIEW dbo.onec_kpi_overdue_receivables;
|
||||
GO
|
||||
CREATE VIEW dbo.onec_kpi_overdue_receivables AS
|
||||
SELECT
|
||||
CAST('main' AS nvarchar(64)) AS company,
|
||||
CAST(
|
||||
CASE
|
||||
WHEN age_days BETWEEN 1 AND 7 THEN '1-7'
|
||||
WHEN age_days BETWEEN 8 AND 30 THEN '8-30'
|
||||
WHEN age_days BETWEEN 31 AND 90 THEN '31-90'
|
||||
ELSE '90+'
|
||||
END AS nvarchar(16)
|
||||
) AS aging_bucket,
|
||||
CAST(ISNULL(currency, 'RUB') AS nvarchar(16)) AS currency,
|
||||
CAST(SUM(amount_due) AS float) AS value
|
||||
FROM (
|
||||
SELECT
|
||||
r.currency,
|
||||
r.amount_due,
|
||||
CASE WHEN DATEDIFF(day, CAST(r.due_date AS date), CAST(GETDATE() AS date)) > 0
|
||||
THEN DATEDIFF(day, CAST(r.due_date AS date), CAST(GETDATE() AS date))
|
||||
ELSE 0 END AS age_days
|
||||
FROM dbo.onec_raw_receivables r
|
||||
WHERE r.amount_due > 0
|
||||
) x
|
||||
WHERE age_days > 0
|
||||
GROUP BY
|
||||
CASE
|
||||
WHEN age_days BETWEEN 1 AND 7 THEN '1-7'
|
||||
WHEN age_days BETWEEN 8 AND 30 THEN '8-30'
|
||||
WHEN age_days BETWEEN 31 AND 90 THEN '31-90'
|
||||
ELSE '90+'
|
||||
END,
|
||||
ISNULL(currency, 'RUB');
|
||||
GO
|
||||
|
||||
IF OBJECT_ID('dbo.onec_kpi_posting_errors_24h', 'V') IS NOT NULL DROP VIEW dbo.onec_kpi_posting_errors_24h;
|
||||
GO
|
||||
CREATE VIEW dbo.onec_kpi_posting_errors_24h AS
|
||||
SELECT
|
||||
CAST('main' AS nvarchar(64)) AS company,
|
||||
CAST(COUNT(*) AS float) AS value
|
||||
FROM dbo.onec_raw_posting_errors e
|
||||
WHERE e.error_at >= DATEADD(hour, -24, GETDATE());
|
||||
GO
|
||||
|
||||
IF OBJECT_ID('dbo.onec_kpi_data_freshness', 'V') IS NOT NULL DROP VIEW dbo.onec_kpi_data_freshness;
|
||||
GO
|
||||
CREATE VIEW dbo.onec_kpi_data_freshness AS
|
||||
SELECT
|
||||
CAST('main' AS nvarchar(64)) AS company,
|
||||
CAST('documents' AS nvarchar(64)) AS source,
|
||||
CAST(
|
||||
CASE
|
||||
WHEN MAX(d.updated_at) IS NULL THEN 999999
|
||||
ELSE DATEDIFF(second, MAX(d.updated_at), GETDATE())
|
||||
END AS float
|
||||
) AS value
|
||||
FROM dbo.onec_raw_documents d;
|
||||
GO
|
||||
Executable
+62
@@ -0,0 +1,62 @@
|
||||
-- Шаблон для PostgreSQL-базы 1С.
|
||||
-- ВНИМАНИЕ: замените onec_raw.* на реальные таблицы/представления вашей конфигурации 1С.
|
||||
|
||||
CREATE OR REPLACE VIEW onec_kpi_unposted_documents AS
|
||||
SELECT
|
||||
'main'::text AS company,
|
||||
COUNT(*)::double precision AS value
|
||||
FROM onec_raw.documents d
|
||||
WHERE d.posted = false;
|
||||
|
||||
CREATE OR REPLACE VIEW onec_kpi_sales_today AS
|
||||
SELECT
|
||||
'main'::text AS company,
|
||||
COALESCE(d.currency, 'RUB')::text AS currency,
|
||||
COALESCE(SUM(d.amount), 0)::double precision AS value
|
||||
FROM onec_raw.sales_documents d
|
||||
WHERE d.posted = true
|
||||
AND d.doc_date::date = CURRENT_DATE
|
||||
GROUP BY COALESCE(d.currency, 'RUB');
|
||||
|
||||
CREATE OR REPLACE VIEW onec_kpi_overdue_receivables AS
|
||||
SELECT
|
||||
'main'::text AS company,
|
||||
CASE
|
||||
WHEN age_days BETWEEN 1 AND 7 THEN '1-7'
|
||||
WHEN age_days BETWEEN 8 AND 30 THEN '8-30'
|
||||
WHEN age_days BETWEEN 31 AND 90 THEN '31-90'
|
||||
ELSE '90+'
|
||||
END::text AS aging_bucket,
|
||||
COALESCE(currency, 'RUB')::text AS currency,
|
||||
COALESCE(SUM(amount_due), 0)::double precision AS value
|
||||
FROM (
|
||||
SELECT
|
||||
r.currency,
|
||||
r.amount_due,
|
||||
GREATEST((CURRENT_DATE - r.due_date::date), 0) AS age_days
|
||||
FROM onec_raw.receivables r
|
||||
WHERE r.amount_due > 0
|
||||
) x
|
||||
WHERE age_days > 0
|
||||
GROUP BY
|
||||
CASE
|
||||
WHEN age_days BETWEEN 1 AND 7 THEN '1-7'
|
||||
WHEN age_days BETWEEN 8 AND 30 THEN '8-30'
|
||||
WHEN age_days BETWEEN 31 AND 90 THEN '31-90'
|
||||
ELSE '90+'
|
||||
END,
|
||||
COALESCE(currency, 'RUB');
|
||||
|
||||
CREATE OR REPLACE VIEW onec_kpi_posting_errors_24h AS
|
||||
SELECT
|
||||
'main'::text AS company,
|
||||
COUNT(*)::double precision AS value
|
||||
FROM onec_raw.posting_errors e
|
||||
WHERE e.error_at >= NOW() - INTERVAL '24 hours';
|
||||
|
||||
CREATE OR REPLACE VIEW onec_kpi_data_freshness AS
|
||||
SELECT
|
||||
'main'::text AS company,
|
||||
'documents'::text AS source,
|
||||
COALESCE(EXTRACT(EPOCH FROM (NOW() - MAX(d.updated_at))), 999999)::double precision AS value
|
||||
FROM onec_raw.documents d;
|
||||
Executable
+20
@@ -0,0 +1,20 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
STACK_DIR="${1:-/mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c}"
|
||||
|
||||
echo "[*] Checking endpoints"
|
||||
curl -fsS http://127.0.0.1:9399/metrics >/tmp/awrus-onec-metrics.out
|
||||
curl -fsS http://127.0.0.1:9090/-/healthy >/tmp/awrus-prom-healthy.out
|
||||
curl -fsS "http://127.0.0.1:9090/api/v1/query?query=up%7Bjob%3D%22onec_sql_exporter%22%7D" >/tmp/awrus-prom-up.json
|
||||
curl -fsS "http://127.0.0.1:9090/api/v1/query?query=onec_data_freshness_seconds" >/tmp/awrus-prom-freshness.json
|
||||
|
||||
echo "[*] Checking container status"
|
||||
cd "$STACK_DIR"
|
||||
docker compose ps
|
||||
|
||||
echo "[+] Pipeline health artifacts:"
|
||||
echo " /tmp/awrus-onec-metrics.out"
|
||||
echo " /tmp/awrus-prom-healthy.out"
|
||||
echo " /tmp/awrus-prom-up.json"
|
||||
echo " /tmp/awrus-prom-freshness.json"
|
||||
Executable
+46
@@ -0,0 +1,46 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
if ! command -v psql >/dev/null 2>&1; then
|
||||
echo "psql not found" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "${1:-}" = "" ]; then
|
||||
echo "Usage: $0 \"postgres://user:pass@host:5432/db?sslmode=disable\"" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
DSN="$1"
|
||||
OUT_DIR="${2:-/tmp/onec-schema-$(date +%Y%m%d-%H%M%S)}"
|
||||
mkdir -p "$OUT_DIR"
|
||||
|
||||
export PGPASSWORD=""
|
||||
|
||||
echo "[*] Dumping schemas, tables, columns, candidate accounting objects into $OUT_DIR"
|
||||
|
||||
psql "$DSN" -X -v ON_ERROR_STOP=1 -c "\dn+" >"$OUT_DIR/schemas.txt"
|
||||
psql "$DSN" -X -v ON_ERROR_STOP=1 -c "\dt+ *.*" >"$OUT_DIR/tables.txt"
|
||||
psql "$DSN" -X -v ON_ERROR_STOP=1 -c "
|
||||
SELECT table_schema, table_name, column_name, data_type
|
||||
FROM information_schema.columns
|
||||
ORDER BY table_schema, table_name, ordinal_position;
|
||||
" >"$OUT_DIR/columns.tsv"
|
||||
|
||||
psql "$DSN" -X -v ON_ERROR_STOP=1 -c "
|
||||
SELECT table_schema, table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_type='BASE TABLE'
|
||||
AND (
|
||||
table_name ILIKE '%doc%' OR
|
||||
table_name ILIKE '%sale%' OR
|
||||
table_name ILIKE '%realiz%' OR
|
||||
table_name ILIKE '%debt%' OR
|
||||
table_name ILIKE '%debitor%' OR
|
||||
table_name ILIKE '%error%' OR
|
||||
table_name ILIKE '%post%'
|
||||
)
|
||||
ORDER BY table_schema, table_name;
|
||||
" >"$OUT_DIR/candidate_tables.tsv"
|
||||
|
||||
echo "[+] Done: $OUT_DIR"
|
||||
Executable
+44
@@ -0,0 +1,44 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
if ! command -v psql >/dev/null 2>&1; then
|
||||
echo "psql not found" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "${1:-}" = "" ]; then
|
||||
echo "Usage: $0 \"postgres://user:pass@host:5432/db?sslmode=disable\"" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
DSN="$1"
|
||||
|
||||
VIEWS="
|
||||
onec_kpi_unposted_documents
|
||||
onec_kpi_sales_today
|
||||
onec_kpi_overdue_receivables
|
||||
onec_kpi_posting_errors_24h
|
||||
onec_kpi_data_freshness
|
||||
"
|
||||
|
||||
echo "[*] Checking required KPI views"
|
||||
for view_name in $VIEWS; do
|
||||
exists="$(psql "$DSN" -X -A -t -v ON_ERROR_STOP=1 -c "SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.views
|
||||
WHERE table_name='${view_name}'
|
||||
);")"
|
||||
if [ "$exists" != "t" ]; then
|
||||
echo "[-] Missing view: $view_name" >&2
|
||||
exit 2
|
||||
fi
|
||||
done
|
||||
|
||||
echo "[*] Running test queries"
|
||||
psql "$DSN" -X -v ON_ERROR_STOP=1 -c "SELECT * FROM onec_kpi_unposted_documents LIMIT 5;"
|
||||
psql "$DSN" -X -v ON_ERROR_STOP=1 -c "SELECT * FROM onec_kpi_sales_today LIMIT 5;"
|
||||
psql "$DSN" -X -v ON_ERROR_STOP=1 -c "SELECT * FROM onec_kpi_overdue_receivables LIMIT 5;"
|
||||
psql "$DSN" -X -v ON_ERROR_STOP=1 -c "SELECT * FROM onec_kpi_posting_errors_24h LIMIT 5;"
|
||||
psql "$DSN" -X -v ON_ERROR_STOP=1 -c "SELECT * FROM onec_kpi_data_freshness LIMIT 5;"
|
||||
|
||||
echo "[+] KPI views are valid"
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,37 @@
|
||||
6e1f304f468d77f12df67face6afaaca5bbcfb1496f43df4e4fa0557cf847829 install-kit-awindows-20260427-211240/README-INSTALL-KIT.txt
|
||||
a11f41827769be915f73d0de2c5503b05f61ccf56f70a50845771bcb79c5ebb7 install-kit-awindows-20260427-211240/ansible/README.md
|
||||
02ca96f5ecc6abf89ab3271bd08add5795dbba2281168f158d96166f557e33f0 install-kit-awindows-20260427-211240/ansible/deploy_aw_pfsense_poller.yml
|
||||
7c1ad9363412e802f4272f2e91a1d9f26be722eaf22e654c0a2512b0cebbbfd0 install-kit-awindows-20260427-211240/ansible/deploy_aw_server.yml
|
||||
d5c42e6fe49c14a0769517ff28184139e467d40f22d4a632630c42ed1ff34ce5 install-kit-awindows-20260427-211240/ansible/deploy_aw_windows_phase2.yml
|
||||
bc791462b9c00adc8c68ed81e2a7697c560bdbc46f156b4840c7bca1dee2157d 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
|
||||
69368b7adb7711fa81304866373e61ed464bcc23a08e4509fa54655b05f95790 install-kit-awindows-20260427-211240/ansible/group_vars/windows.example.yml
|
||||
00064ce5187569fb3221ddd45c2ad7eb37cd50b2a0a832ddf7843e4ff461849a install-kit-awindows-20260427-211240/ansible/inventory.example.ini
|
||||
bbef175cb77dd53aa07452dbb2fe8797f38b58f42372005c3404c8dc9d6f8e13 install-kit-awindows-20260427-211240/ansible/provision_proxmox_ct_and_deploy_aw.yml
|
||||
b8f8b6bc504a51cd87db3f46c35a27295b395ea516533068f96af35f8b720434 install-kit-awindows-20260427-211240/ansible/provision_proxmox_ct_matrix_and_deploy_aw.yml
|
||||
d35bc97b6de18f0006cbbad4adf8a8a5db8ed912997d8fc47c7f11fa9247e907 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
|
||||
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
|
||||
59307d284caa74eb3dc129765f9db93b6e8dfd5b1d960b98f347a332b23f82dc install-kit-awindows-20260427-211240/server-configs-192.168.100.21/phase2-admin.deployment-config.json
|
||||
f2cee1872bf274f15dcfb8fb595fb20a11d228a4bae0930dc6cb918a6f800756 install-kit-awindows-20260427-211240/server-configs-192.168.100.21/phase2-u2u5.deployment-config.json
|
||||
6aefedcdac8c1d3823c9f4065b051a67a221a3e9424c913a673667b2a23ea1e7 install-kit-awindows-20260427-211240/server-configs-192.168.100.21/phase2-user1.deployment-config.json
|
||||
33aa34b89246d6c079ef9afe2f5cd153bd9d5946b69a175ff6fd678c77f61da5 install-kit-awindows-20260427-211240/windows/ActivityWatch.Windows.Common.psd1
|
||||
d506614168227fa01fa481289079b432b6d8846d5ee8961cff8c21fd0bf7ea8f install-kit-awindows-20260427-211240/windows/ActivityWatch.Windows.Common.psm1
|
||||
2a0b94ddad43a6bc684037243e636a54c168d8d4ad25b29778c4d78180da2532 install-kit-awindows-20260427-211240/windows/browser-domains-native-collector.ps1
|
||||
98bfcf5dca972f1ba1845bbca546133c192824ec40dd2e55f4e6d59c24a834d1 install-kit-awindows-20260427-211240/windows/deploy-domain-users.ps1
|
||||
a19c2a98e6483eb921457f472d1cf62a76e344f2ef3621f04fafa3d7dc2df353 install-kit-awindows-20260427-211240/windows/deploy-ensemble.ps1
|
||||
2542425e02acd8a8b02ed701ca2382cf7ae612be28441d1a10a40e48c6a13ad0 install-kit-awindows-20260427-211240/windows/deploy-single-user.ps1
|
||||
a2f963927c8b263a21aaffc0a926a058dcec65a04a5fa57c271d3dbe59f9347c install-kit-awindows-20260427-211240/windows/dlp-endpoint-signals-collector.ps1
|
||||
aef0032edd9b1e0c54f7b575664ed511dfc6cb53364e7496cbc95e137678e11a install-kit-awindows-20260427-211240/windows/dlp-policy.example.json
|
||||
ade74a55ce00d9295f2efa0fd72f987688154c93f1142ce0eb6e982f07271be7 install-kit-awindows-20260427-211240/windows/hardening-recovery.ps1
|
||||
88ffe06093ef5f7247bd2b990b0c8f801c706a26dc87725bb1194504fab7e306 install-kit-awindows-20260427-211240/windows/validate-deployment.ps1
|
||||
731098681d89b9af6f3872abd586ac3b1faba2d7f9340211e503f52ad0243b3f install-kit-awindows-20260427-211240/windows/web-category-rules.example.json
|
||||
@@ -0,0 +1,10 @@
|
||||
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 Phase2 config snapshots)
|
||||
|
||||
Source:
|
||||
- Local project snapshot at build time.
|
||||
@@ -0,0 +1,119 @@
|
||||
# Ansible ensemble for AWatch-rus
|
||||
|
||||
Эта директория содержит Ansible-ensemble для двух сценариев:
|
||||
|
||||
- деплой на уже существующий Debian host/CT;
|
||||
- полный цикл с нуля в Proxmox: создание CT + bootstrap + установка ActivityWatch + RU patch.
|
||||
- централизованный деплой Windows phase-2 collectors по WinRM.
|
||||
- deployment внешнего pfSense poller'а на Debian/Ubuntu utility VM.
|
||||
|
||||
## Файлы
|
||||
|
||||
- `/home/igor/tmp/AWatch-rus/ansible/deploy_aw_server.yml` — основной playbook.
|
||||
- `/home/igor/tmp/AWatch-rus/ansible/provision_proxmox_ct_and_deploy_aw.yml` — full-stack playbook для Proxmox.
|
||||
- `/home/igor/tmp/AWatch-rus/ansible/provision_proxmox_ct_matrix_and_deploy_aw.yml` — массовый full-stack playbook (несколько CT).
|
||||
- `/home/igor/tmp/AWatch-rus/ansible/deploy_aw_windows_phase2.yml` — WinRM playbook для развёртывания phase-2 Windows collector'ов.
|
||||
- `/home/igor/tmp/AWatch-rus/ansible/deploy_aw_pfsense_poller.yml` — deployment pfSense poller'а.
|
||||
- `/home/igor/tmp/AWatch-rus/ansible/inventory.example.ini` — шаблон inventory.
|
||||
- `/home/igor/tmp/AWatch-rus/ansible/group_vars/all.example.yml` — шаблон переменных.
|
||||
- `/home/igor/tmp/AWatch-rus/ansible/group_vars/proxmox.example.yml` — шаблон переменных CT в Proxmox.
|
||||
- `/home/igor/tmp/AWatch-rus/ansible/group_vars/proxmox-matrix.example.yml` — шаблон матрицы CT.
|
||||
- `/home/igor/tmp/AWatch-rus/ansible/group_vars/windows.example.yml` — шаблон переменных Windows phase-2.
|
||||
- `/home/igor/tmp/AWatch-rus/ansible/group_vars/pfsense-poller.example.yml` — шаблон переменных pfSense poller'а.
|
||||
|
||||
## Быстрый запуск
|
||||
|
||||
1. Скопируйте шаблоны:
|
||||
- `cp /home/igor/tmp/AWatch-rus/ansible/inventory.example.ini /home/igor/tmp/AWatch-rus/ansible/inventory.ini`
|
||||
- `cp /home/igor/tmp/AWatch-rus/ansible/group_vars/all.example.yml /home/igor/tmp/AWatch-rus/ansible/group_vars/all.yml`
|
||||
2. Заполните значения в `inventory.ini` и `group_vars/all.yml`.
|
||||
3. Запустите:
|
||||
|
||||
```bash
|
||||
cd /home/igor/tmp/AWatch-rus/ansible
|
||||
ansible-playbook -i inventory.ini deploy_aw_server.yml
|
||||
```
|
||||
|
||||
## Полный запуск с нуля в Proxmox
|
||||
|
||||
1. Подготовьте inventory и vars:
|
||||
- `cp /home/igor/tmp/AWatch-rus/ansible/inventory.example.ini /home/igor/tmp/AWatch-rus/ansible/inventory.ini`
|
||||
- `cp /home/igor/tmp/AWatch-rus/ansible/group_vars/all.example.yml /home/igor/tmp/AWatch-rus/ansible/group_vars/all.yml`
|
||||
- `cp /home/igor/tmp/AWatch-rus/ansible/group_vars/proxmox.example.yml /home/igor/tmp/AWatch-rus/ansible/group_vars/proxmox.yml`
|
||||
2. Заполните `group_vars/proxmox.yml` и `group_vars/all.yml`.
|
||||
3. Запустите playbook:
|
||||
|
||||
```bash
|
||||
cd /home/igor/tmp/AWatch-rus/ansible
|
||||
ansible-playbook -i inventory.ini provision_proxmox_ct_and_deploy_aw.yml
|
||||
```
|
||||
|
||||
## Массовый запуск (матрица CT)
|
||||
|
||||
1. Подготовьте матрицу:
|
||||
- `cp /home/igor/tmp/AWatch-rus/ansible/group_vars/proxmox-matrix.example.yml /home/igor/tmp/AWatch-rus/ansible/group_vars/proxmox-matrix.yml`
|
||||
2. Заполните `proxmox-matrix.yml`.
|
||||
3. Запустите:
|
||||
|
||||
```bash
|
||||
cd /home/igor/tmp/AWatch-rus/ansible
|
||||
ansible-playbook -i inventory.ini provision_proxmox_ct_matrix_and_deploy_aw.yml
|
||||
```
|
||||
|
||||
## Windows phase-2 rollout (WinRM)
|
||||
|
||||
1. Подготовьте inventory и vars:
|
||||
- `cp /home/igor/tmp/AWatch-rus/ansible/inventory.example.ini /home/igor/tmp/AWatch-rus/ansible/inventory.ini`
|
||||
- `cp /home/igor/tmp/AWatch-rus/ansible/group_vars/windows.example.yml /home/igor/tmp/AWatch-rus/ansible/group_vars/windows.yml`
|
||||
2. Заполните `inventory.ini` (секция `[aw_windows]`) и `group_vars/windows.yml`.
|
||||
3. Запустите:
|
||||
|
||||
```bash
|
||||
cd /home/igor/tmp/AWatch-rus/ansible
|
||||
ansible-playbook -i inventory.ini deploy_aw_windows_phase2.yml
|
||||
```
|
||||
|
||||
Playbook:
|
||||
|
||||
- выгружает `windows/*` toolkit на целевой хост в `C:\Deploy\AWatch-rus\windows`;
|
||||
- выполняет `deploy-ensemble.ps1` (deploy + hardening/recovery) с phase-2 policy/rules;
|
||||
- запускает `validate-deployment.ps1`;
|
||||
- забирает JSON-отчёт в локальную директорию (`/tmp/aw-rus-validation` по умолчанию).
|
||||
|
||||
Дополнительные флаги:
|
||||
|
||||
- `aw_windows_afk_enabled: false` — не запускать `aw-watcher-afk`;
|
||||
- `aw_windows_window_enabled: false` — не запускать `aw-watcher-window`;
|
||||
- `aw_windows_incident_capture_enabled: false` — отключить блок incidentCapture;
|
||||
- `aw_windows_incident_screenshot_enabled: false` — не делать скриншот при DLP-инциденте;
|
||||
- `aw_windows_incident_artifacts_root: 'C:\...\incident-artifacts'` — переопределить путь артефактов;
|
||||
- `aw_windows_skip_hardening: true` — пропустить `hardening-recovery.ps1` внутри ensemble-скрипта.
|
||||
|
||||
## pfSense poller rollout
|
||||
|
||||
1. Подготовьте vars:
|
||||
- `cp /home/igor/tmp/AWatch-rus/ansible/group_vars/pfsense-poller.example.yml /home/igor/tmp/AWatch-rus/ansible/group_vars/pfsense-poller.yml`
|
||||
2. Добавьте inventory group `[aw_pfsense_pollers]`.
|
||||
3. Запустите:
|
||||
|
||||
```bash
|
||||
cd /home/igor/tmp/AWatch-rus/ansible
|
||||
ansible-playbook -i inventory.ini deploy_aw_pfsense_poller.yml
|
||||
```
|
||||
|
||||
Playbook:
|
||||
|
||||
- ставит `python3`;
|
||||
- копирует `pfsense-aw-poller.py`;
|
||||
- пишет `/etc/aw-pfsense/poller.json`;
|
||||
- поднимает `aw-pfsense-poller.service`.
|
||||
|
||||
## Результат
|
||||
|
||||
- Установлен ActivityWatch Server.
|
||||
- Создан systemd-unit `activitywatch-server.service`.
|
||||
- Установлен RU Web UI patch.
|
||||
- Для Web UI используется checksum-based cache-bust для `ru-patch-v5.js` и `sw-cleanup.js`, чтобы браузер не держал старую DLP/русскую статику после деплоя.
|
||||
- На `#/home` Web UI делит хосты на `Windows RDP` и `Virtual servers + Proxmox`.
|
||||
- Выполнена валидация API `http://127.0.0.1:5600/api/0/info`.
|
||||
- Для full-stack сценария CT создаётся автоматически через `pct create`.
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
- name: Deploy pfSense ActivityWatch poller
|
||||
hosts: aw_pfsense_pollers
|
||||
become: true
|
||||
gather_facts: true
|
||||
|
||||
vars:
|
||||
aw_pfsense_install_root: "/opt/aw-pfsense"
|
||||
aw_pfsense_config_dir: "/etc/aw-pfsense"
|
||||
aw_pfsense_service_name: "aw-pfsense-poller.service"
|
||||
|
||||
tasks:
|
||||
- name: Install required packages
|
||||
ansible.builtin.apt:
|
||||
name:
|
||||
- python3
|
||||
state: present
|
||||
update_cache: true
|
||||
|
||||
- name: Ensure directories exist
|
||||
ansible.builtin.file:
|
||||
path: "{{ item }}"
|
||||
state: directory
|
||||
mode: "0755"
|
||||
loop:
|
||||
- "{{ aw_pfsense_install_root }}"
|
||||
- "{{ aw_pfsense_config_dir }}"
|
||||
|
||||
- name: Install pfSense poller script
|
||||
ansible.builtin.copy:
|
||||
src: "{{ aw_repo_root }}/pfsense/pfsense-aw-poller.py"
|
||||
dest: "{{ aw_pfsense_install_root }}/pfsense-aw-poller.py"
|
||||
mode: "0755"
|
||||
|
||||
- name: Install systemd service
|
||||
ansible.builtin.copy:
|
||||
src: "{{ aw_repo_root }}/pfsense/pfsense-aw-poller.service"
|
||||
dest: "/etc/systemd/system/{{ aw_pfsense_service_name }}"
|
||||
mode: "0644"
|
||||
notify:
|
||||
- Reload systemd
|
||||
|
||||
- name: Write pfSense poller config
|
||||
ansible.builtin.copy:
|
||||
dest: "{{ aw_pfsense_config_dir }}/poller.json"
|
||||
mode: "0600"
|
||||
content: "{{ aw_pfsense_poller_config | to_nice_json }}"
|
||||
notify:
|
||||
- Restart pfSense poller
|
||||
|
||||
- name: Enable and start pfSense poller
|
||||
ansible.builtin.systemd:
|
||||
name: "{{ aw_pfsense_service_name }}"
|
||||
enabled: true
|
||||
state: restarted
|
||||
daemon_reload: true
|
||||
|
||||
handlers:
|
||||
- name: Reload systemd
|
||||
ansible.builtin.systemd:
|
||||
daemon_reload: true
|
||||
|
||||
- name: Restart pfSense poller
|
||||
ansible.builtin.systemd:
|
||||
name: "{{ aw_pfsense_service_name }}"
|
||||
state: restarted
|
||||
@@ -0,0 +1,229 @@
|
||||
---
|
||||
- name: Deploy AWatch-rus server
|
||||
hosts: aw_server
|
||||
become: true
|
||||
gather_facts: true
|
||||
|
||||
vars:
|
||||
aw_release_root: "/opt/activitywatch/releases"
|
||||
aw_release_dir: "{{ aw_release_root }}/{{ aw_server_version }}"
|
||||
aw_archive_path: "/tmp/activitywatch-{{ aw_server_version }}.zip"
|
||||
aw_bootstrap_dir: "/tmp/aw-rus-bootstrap"
|
||||
aw_ru_patch_cache_bust: "{{ lookup('file', aw_repo_root + '/aw-server/aw-ru-patch.js') | hash('sha1') | truncate(12, true, '') }}"
|
||||
aw_sw_cleanup_cache_bust: "{{ lookup('file', aw_repo_root + '/aw-server/aw-sw-cleanup.js') | hash('sha1') | truncate(12, true, '') }}"
|
||||
aw_host_groups_cache_bust: "{{ lookup('file', aw_repo_root + '/aw-server/aw-host-groups.json') | hash('sha1') | truncate(12, true, '') }}"
|
||||
aw_worktime_classes: "{{ lookup('file', aw_repo_root + '/aw-server/settings/classes-worktime.json') | from_json }}"
|
||||
aw_default_views: "{{ lookup('file', aw_repo_root + '/aw-server/settings/views-default.json') | from_json }}"
|
||||
|
||||
tasks:
|
||||
- name: Install base packages
|
||||
ansible.builtin.apt:
|
||||
name:
|
||||
- curl
|
||||
- unzip
|
||||
state: present
|
||||
update_cache: true
|
||||
|
||||
- name: Ensure service account exists
|
||||
ansible.builtin.user:
|
||||
name: "{{ aw_server_user }}"
|
||||
group: "{{ aw_server_group }}"
|
||||
home: "{{ aw_server_data_dir }}"
|
||||
shell: /usr/sbin/nologin
|
||||
system: true
|
||||
create_home: false
|
||||
|
||||
- name: Ensure required directories
|
||||
ansible.builtin.file:
|
||||
path: "{{ item }}"
|
||||
state: directory
|
||||
owner: "{{ aw_server_user }}"
|
||||
group: "{{ aw_server_group }}"
|
||||
mode: "0755"
|
||||
loop:
|
||||
- "{{ aw_release_root }}"
|
||||
- "{{ aw_release_dir }}"
|
||||
- "{{ aw_server_webui_dir }}"
|
||||
- "{{ aw_server_data_dir }}"
|
||||
- "{{ aw_server_log_dir }}"
|
||||
- /etc/activitywatch
|
||||
- "{{ aw_bootstrap_dir }}"
|
||||
|
||||
- name: Download ActivityWatch release archive
|
||||
ansible.builtin.get_url:
|
||||
url: "{{ aw_server_download_url }}"
|
||||
dest: "{{ aw_archive_path }}"
|
||||
mode: "0644"
|
||||
|
||||
- name: Unpack ActivityWatch release
|
||||
ansible.builtin.unarchive:
|
||||
src: "{{ aw_archive_path }}"
|
||||
dest: "{{ aw_release_dir }}"
|
||||
remote_src: true
|
||||
extra_opts: ["-o"]
|
||||
|
||||
- name: Discover extracted AW directory
|
||||
ansible.builtin.find:
|
||||
paths: "{{ aw_release_dir }}"
|
||||
file_type: directory
|
||||
patterns: "activitywatch*"
|
||||
register: aw_release_find
|
||||
|
||||
- name: Set release extracted path
|
||||
ansible.builtin.set_fact:
|
||||
aw_release_extracted: "{{ (aw_release_find.files | sort(attribute='path') | map(attribute='path') | list | first) }}"
|
||||
|
||||
- name: Verify extracted directory exists
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- aw_release_extracted is defined
|
||||
- aw_release_extracted | length > 0
|
||||
fail_msg: "Cannot locate extracted ActivityWatch release directory."
|
||||
|
||||
- name: Sync release content to /opt/activitywatch
|
||||
ansible.builtin.command:
|
||||
cmd: "rsync -a --delete {{ aw_release_extracted }}/ /opt/activitywatch/"
|
||||
|
||||
- name: Copy bootstrap files from repository
|
||||
ansible.builtin.copy:
|
||||
src: "{{ item.src }}"
|
||||
dest: "{{ item.dest }}"
|
||||
mode: "{{ item.mode }}"
|
||||
loop:
|
||||
- { src: "{{ aw_repo_root }}/aw-server/activitywatch-server.service", dest: "/etc/systemd/system/activitywatch-server.service", mode: "0644" }
|
||||
- { src: "{{ aw_repo_root }}/aw-server/aw-ru-patch.js", dest: "{{ aw_server_webui_dir }}/js/ru-patch-v5.js", mode: "0644" }
|
||||
- { src: "{{ aw_repo_root }}/aw-server/aw-sw-cleanup.js", dest: "{{ aw_server_webui_dir }}/js/sw-cleanup.js", mode: "0644" }
|
||||
- { src: "{{ aw_repo_root }}/aw-server/aw-host-groups.json", dest: "{{ aw_server_webui_dir }}/js/aw-host-groups.json", mode: "0644" }
|
||||
notify:
|
||||
- Reload systemd
|
||||
- Restart activitywatch
|
||||
|
||||
- name: Copy WebUI index template from installed distribution
|
||||
ansible.builtin.copy:
|
||||
remote_src: true
|
||||
src: "/opt/activitywatch/aw-webui/index.html"
|
||||
dest: "{{ aw_server_webui_dir }}/index.html"
|
||||
mode: "0644"
|
||||
|
||||
- name: Insert RU patch scripts into index.html
|
||||
ansible.builtin.replace:
|
||||
path: "{{ aw_server_webui_dir }}/index.html"
|
||||
regexp: '</head>'
|
||||
replace: '<script src="/js/sw-cleanup.js?v={{ aw_sw_cleanup_cache_bust }}"></script></head>'
|
||||
|
||||
- name: Insert RU patch loader before body end
|
||||
ansible.builtin.replace:
|
||||
path: "{{ aw_server_webui_dir }}/index.html"
|
||||
regexp: '</body>'
|
||||
replace: '<script defer="defer" src="/js/ru-patch-v5.js?v={{ aw_ru_patch_cache_bust }}"></script></body>'
|
||||
|
||||
- name: Write /etc/activitywatch/aw-server.env
|
||||
ansible.builtin.copy:
|
||||
dest: /etc/activitywatch/aw-server.env
|
||||
mode: "0640"
|
||||
content: |
|
||||
AW_SERVER_HOST={{ aw_server_bind_host }}
|
||||
AW_SERVER_PORT={{ aw_server_port }}
|
||||
AW_DATA_DIR={{ aw_server_data_dir }}
|
||||
AW_LOG_DIR={{ aw_server_log_dir }}
|
||||
AW_WEBUI_DIR={{ aw_server_webui_dir }}
|
||||
AW_SERVER_USER={{ aw_server_user }}
|
||||
AW_SERVER_GROUP={{ aw_server_group }}
|
||||
|
||||
- name: Enable and start service
|
||||
ansible.builtin.systemd:
|
||||
name: activitywatch-server.service
|
||||
enabled: true
|
||||
state: restarted
|
||||
daemon_reload: true
|
||||
|
||||
- name: Wait for API
|
||||
ansible.builtin.uri:
|
||||
url: "http://127.0.0.1:{{ aw_server_port }}/api/0/info"
|
||||
method: GET
|
||||
status_code: 200
|
||||
register: aw_api
|
||||
retries: 10
|
||||
delay: 3
|
||||
until: aw_api.status == 200
|
||||
|
||||
- name: Apply baseline worktime settings (classes)
|
||||
ansible.builtin.uri:
|
||||
url: "http://127.0.0.1:{{ aw_server_port }}/api/0/settings/classes"
|
||||
method: POST
|
||||
body: "{{ aw_worktime_classes }}"
|
||||
body_format: json
|
||||
status_code: 201
|
||||
when: aw_apply_worktime_settings | default(false) | bool
|
||||
|
||||
- name: Apply baseline views (include DLP and worktime)
|
||||
ansible.builtin.uri:
|
||||
url: "http://127.0.0.1:{{ aw_server_port }}/api/0/settings/views"
|
||||
method: POST
|
||||
body: "{{ aw_default_views }}"
|
||||
body_format: json
|
||||
status_code: 201
|
||||
when: aw_apply_worktime_settings | default(false) | bool
|
||||
|
||||
- name: Derive worktime durationDefault from aw_worktime_from/to
|
||||
ansible.builtin.set_fact:
|
||||
aw_worktime_from_h: "{{ (aw_worktime_from | default('08:00')).split(':')[0] | int }}"
|
||||
aw_worktime_from_m: "{{ (aw_worktime_from | default('08:00')).split(':')[1] | int }}"
|
||||
aw_worktime_to_h: "{{ (aw_worktime_to | default('17:00')).split(':')[0] | int }}"
|
||||
aw_worktime_to_m: "{{ (aw_worktime_to | default('17:00')).split(':')[1] | int }}"
|
||||
aw_worktime_duration_default_derived: >-
|
||||
{{
|
||||
(
|
||||
(
|
||||
((aw_worktime_to_h | int) * 60 + (aw_worktime_to_m | int)) -
|
||||
((aw_worktime_from_h | int) * 60 + (aw_worktime_from_m | int))
|
||||
) * 60
|
||||
)
|
||||
}}
|
||||
when: aw_apply_worktime_settings | default(false) | bool
|
||||
|
||||
- name: Normalize derived durationDefault for overnight shifts
|
||||
ansible.builtin.set_fact:
|
||||
aw_worktime_duration_default_effective: >-
|
||||
{{
|
||||
(aw_worktime_duration_default_derived | int)
|
||||
if (aw_worktime_duration_default_derived | int) > 0
|
||||
else ((aw_worktime_duration_default_derived | int) + 86400)
|
||||
}}
|
||||
when: aw_apply_worktime_settings | default(false) | bool
|
||||
|
||||
- name: Validate derived durationDefault is sane
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- aw_worktime_duration_default_effective | int > 0
|
||||
- aw_worktime_duration_default_effective | int <= 86400
|
||||
fail_msg: "Invalid worktime window: {{ aw_worktime_from }}..{{ aw_worktime_to }}"
|
||||
when: aw_apply_worktime_settings | default(false) | bool
|
||||
|
||||
- name: Apply baseline worktime period (startOfDay)
|
||||
ansible.builtin.uri:
|
||||
url: "http://127.0.0.1:{{ aw_server_port }}/api/0/settings/startOfDay"
|
||||
method: POST
|
||||
body: "{{ aw_worktime_start_of_day }}"
|
||||
body_format: json
|
||||
status_code: 200
|
||||
when: aw_apply_worktime_settings | default(false) | bool
|
||||
|
||||
- name: Apply baseline worktime period (durationDefault seconds)
|
||||
ansible.builtin.uri:
|
||||
url: "http://127.0.0.1:{{ aw_server_port }}/api/0/settings/durationDefault"
|
||||
method: POST
|
||||
body: "{{ aw_worktime_duration_default_effective }}"
|
||||
body_format: json
|
||||
status_code: 200
|
||||
when: aw_apply_worktime_settings | default(false) | bool
|
||||
|
||||
handlers:
|
||||
- name: Reload systemd
|
||||
ansible.builtin.systemd:
|
||||
daemon_reload: true
|
||||
|
||||
- name: Restart activitywatch
|
||||
ansible.builtin.systemd:
|
||||
name: activitywatch-server.service
|
||||
state: restarted
|
||||
@@ -0,0 +1,133 @@
|
||||
---
|
||||
- name: Deploy AWatch-rus Windows phase2 collectors
|
||||
hosts: aw_windows
|
||||
gather_facts: false
|
||||
|
||||
vars:
|
||||
aw_windows_repo_root: "/home/igor/tmp/AWatch-rus"
|
||||
aw_windows_deploy_root: "C:\\Deploy\\AWatch-rus"
|
||||
aw_windows_server_host: "10.10.10.13"
|
||||
aw_windows_server_port: 5600
|
||||
aw_windows_domain: "SHARKON2025"
|
||||
aw_windows_users:
|
||||
- user1
|
||||
- user2
|
||||
- user3
|
||||
- user4
|
||||
- user5
|
||||
aw_windows_extra_users: []
|
||||
aw_windows_users_effective: "{{ (aw_windows_users + aw_windows_extra_users) | unique }}"
|
||||
aw_windows_install_root: "C:\\Program Files\\ActivityWatch-Phase2"
|
||||
aw_windows_state_root: "C:\\ProgramData\\ActivityWatch"
|
||||
aw_windows_afk_enabled: true
|
||||
aw_windows_window_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_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: "C:\\Windows\\Temp\\aw_validate_phase2_ansible.json"
|
||||
aw_windows_validation_local_dir: "/tmp/aw-rus-validation"
|
||||
|
||||
tasks:
|
||||
- name: Validate required variables
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- aw_windows_server_host is defined
|
||||
- aw_windows_server_port is defined
|
||||
- aw_windows_domain is defined
|
||||
- aw_windows_users_effective | length > 0
|
||||
- aw_windows_install_root is defined
|
||||
- aw_windows_state_root is defined
|
||||
fail_msg: "Missing required Windows deployment variables."
|
||||
|
||||
- name: Ensure deploy directories exist
|
||||
ansible.windows.win_file:
|
||||
path: "{{ item }}"
|
||||
state: directory
|
||||
loop:
|
||||
- "{{ aw_windows_deploy_root }}"
|
||||
- "{{ aw_windows_deploy_root }}\\windows"
|
||||
|
||||
- name: Upload Windows deployment toolkit
|
||||
ansible.windows.win_copy:
|
||||
src: "{{ aw_windows_repo_root }}/windows/{{ item }}"
|
||||
dest: "{{ aw_windows_deploy_root }}\\windows\\{{ item }}"
|
||||
loop:
|
||||
- ActivityWatch.Windows.Common.psd1
|
||||
- ActivityWatch.Windows.Common.psm1
|
||||
- browser-domains-native-collector.ps1
|
||||
- dlp-endpoint-signals-collector.ps1
|
||||
- deploy-domain-users.ps1
|
||||
- deploy-ensemble.ps1
|
||||
- hardening-recovery.ps1
|
||||
- validate-deployment.ps1
|
||||
- web-category-rules.example.json
|
||||
- dlp-policy.example.json
|
||||
|
||||
- name: Upload user list for domain deploy
|
||||
ansible.windows.win_copy:
|
||||
dest: "{{ aw_windows_deploy_root }}\\windows\\users.txt"
|
||||
content: |
|
||||
{% for user in aw_windows_users -%}
|
||||
{{ user }}
|
||||
{% endfor -%}
|
||||
{% for user in aw_windows_extra_users -%}
|
||||
{{ user }}
|
||||
{% endfor -%}
|
||||
|
||||
- name: Run phase2 ensemble deployment
|
||||
ansible.windows.win_powershell:
|
||||
script: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$params = @{
|
||||
ServerHost = "{{ aw_windows_server_host }}"
|
||||
ServerPort = {{ aw_windows_server_port }}
|
||||
Domain = "{{ aw_windows_domain }}"
|
||||
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' }}
|
||||
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 }}"
|
||||
LogonMarkerEnabled = {{ '$true' if (aw_windows_logon_marker_enabled | bool) else '$false' }}
|
||||
CustomRulesPath = "{{ aw_windows_rules_path }}"
|
||||
CustomPolicyPath = "{{ aw_windows_policy_path }}"
|
||||
}
|
||||
{% if aw_windows_skip_hardening | bool %}
|
||||
$params.SkipHardening = $true
|
||||
{% endif %}
|
||||
& "{{ aw_windows_deploy_root }}\windows\deploy-ensemble.ps1" @params
|
||||
|
||||
- name: Run validation and store report on target
|
||||
ansible.windows.win_powershell:
|
||||
script: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$report = & "{{ 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
|
||||
|
||||
- name: Ensure local validation directory exists
|
||||
ansible.builtin.file:
|
||||
path: "{{ aw_windows_validation_local_dir }}"
|
||||
state: directory
|
||||
mode: "0755"
|
||||
delegate_to: localhost
|
||||
|
||||
- name: Fetch validation report
|
||||
ansible.builtin.fetch:
|
||||
src: "{{ aw_windows_validation_remote_path }}"
|
||||
dest: "{{ aw_windows_validation_local_dir }}/"
|
||||
flat: false
|
||||
|
||||
- name: Show report location
|
||||
ansible.builtin.debug:
|
||||
msg:
|
||||
- "Windows phase2 deploy completed on {{ inventory_hostname }}."
|
||||
- "Validation report: {{ aw_windows_validation_local_dir }}/{{ inventory_hostname }}/C$/Windows/Temp/aw_validate_phase2_ansible.json"
|
||||
@@ -0,0 +1,24 @@
|
||||
aw_server_version: "v0.13.2"
|
||||
aw_server_download_url: "https://github.com/ActivityWatch/activitywatch/releases/download/v0.13.2/activitywatch-v0.13.2-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_log_dir: "/var/log/activitywatch"
|
||||
aw_server_user: "activitywatch"
|
||||
aw_server_group: "activitywatch"
|
||||
|
||||
aw_repo_root: "/home/igor/tmp/AWatch-rus"
|
||||
|
||||
# Optional: apply a baseline worktime-focused categorization and views via AW settings API.
|
||||
# WARNING: this overwrites existing server-side settings/classes/views.
|
||||
aw_apply_worktime_settings: false
|
||||
|
||||
# Optional defaults for the worktime period in Web UI.
|
||||
# startOfDay controls day-boundary and default report window start.
|
||||
# durationDefault controls default time range (seconds) shown in UI.
|
||||
#
|
||||
# Recommended: set worktime window explicitly and let the playbook derive duration.
|
||||
aw_worktime_from: "08:00"
|
||||
aw_worktime_to: "17:00"
|
||||
aw_worktime_start_of_day: "{{ aw_worktime_from }}"
|
||||
@@ -0,0 +1,30 @@
|
||||
aw_pfsense_poller_config:
|
||||
poll_interval_seconds: 60
|
||||
aw:
|
||||
server_host: "10.10.10.13"
|
||||
server_port: 5600
|
||||
hostname: "PFSENSE-EDGE01"
|
||||
pulse_time_seconds: 120
|
||||
timeout_seconds: 15
|
||||
pfsense:
|
||||
name: "pfSense Edge 01"
|
||||
host: "10.10.10.1"
|
||||
scheme: "https"
|
||||
verify_tls: false
|
||||
timeout_seconds: 15
|
||||
headers:
|
||||
X-API-Key: "replace-me"
|
||||
X-API-Secret: "replace-me"
|
||||
endpoints:
|
||||
- name: "system-status"
|
||||
path: "/api/v2/status/system"
|
||||
bucket_prefix: "aw-pfsense-health"
|
||||
bucket_type: "aw.pfsense.health"
|
||||
- name: "interfaces"
|
||||
path: "/api/v2/interface"
|
||||
bucket_prefix: "aw-pfsense-interfaces"
|
||||
bucket_type: "aw.pfsense.interfaces"
|
||||
- name: "gateways"
|
||||
path: "/api/v2/status/gateways"
|
||||
bucket_prefix: "aw-pfsense-gateways"
|
||||
bucket_type: "aw.pfsense.gateways"
|
||||
@@ -0,0 +1,38 @@
|
||||
proxmox_ct_matrix:
|
||||
- id: "203"
|
||||
hostname: "activitywatch-user1"
|
||||
storage: "local-lvm"
|
||||
template: "local:vztmpl/debian-12-standard_12.7-1_amd64.tar.zst"
|
||||
rootfs_size: "8G"
|
||||
cores: "2"
|
||||
memory: "2048"
|
||||
swap: "512"
|
||||
bridge: "vmbr10"
|
||||
ip: "10.20.30.13/24"
|
||||
gw: "10.20.30.1"
|
||||
vlan: ""
|
||||
nameserver: "1.1.1.1 8.8.8.8"
|
||||
searchdomain: "example.internal"
|
||||
password: "CHANGE_ME"
|
||||
unprivileged: "1"
|
||||
onboot: "1"
|
||||
features: "nesting=1,keyctl=1"
|
||||
|
||||
- id: "204"
|
||||
hostname: "activitywatch-user2"
|
||||
storage: "local-lvm"
|
||||
template: "local:vztmpl/debian-12-standard_12.7-1_amd64.tar.zst"
|
||||
rootfs_size: "8G"
|
||||
cores: "2"
|
||||
memory: "2048"
|
||||
swap: "512"
|
||||
bridge: "vmbr10"
|
||||
ip: "10.20.30.14/24"
|
||||
gw: "10.20.30.1"
|
||||
vlan: ""
|
||||
nameserver: "1.1.1.1 8.8.8.8"
|
||||
searchdomain: "example.internal"
|
||||
password: "CHANGE_ME"
|
||||
unprivileged: "1"
|
||||
onboot: "1"
|
||||
features: "nesting=1,keyctl=1"
|
||||
@@ -0,0 +1,18 @@
|
||||
proxmox_ct_id: "203"
|
||||
proxmox_ct_hostname: "activitywatch-server"
|
||||
proxmox_ct_storage: "local-lvm"
|
||||
proxmox_ct_template: "local:vztmpl/debian-12-standard_12.7-1_amd64.tar.zst"
|
||||
proxmox_ct_rootfs_size: "8G"
|
||||
proxmox_ct_cores: "2"
|
||||
proxmox_ct_memory: "2048"
|
||||
proxmox_ct_swap: "512"
|
||||
proxmox_ct_bridge: "vmbr10"
|
||||
proxmox_ct_ip: "10.20.30.13/24"
|
||||
proxmox_ct_gw: "10.20.30.1"
|
||||
proxmox_ct_vlan: ""
|
||||
proxmox_ct_nameserver: "1.1.1.1 8.8.8.8"
|
||||
proxmox_ct_searchdomain: "example.internal"
|
||||
proxmox_ct_password: "CHANGE_ME"
|
||||
proxmox_ct_unprivileged: "1"
|
||||
proxmox_ct_onboot: "1"
|
||||
proxmox_ct_features: "nesting=1,keyctl=1"
|
||||
@@ -0,0 +1,33 @@
|
||||
aw_windows_repo_root: "/home/igor/tmp/AWatch-rus"
|
||||
aw_windows_deploy_root: "C:\\Deploy\\AWatch-rus"
|
||||
aw_windows_server_host: "10.10.10.13"
|
||||
aw_windows_server_port: 5600
|
||||
aw_windows_domain: "SHARKON2025"
|
||||
aw_windows_users:
|
||||
- user1
|
||||
- user2
|
||||
- user3
|
||||
- user4
|
||||
- user5
|
||||
aw_windows_extra_users: []
|
||||
# Например:
|
||||
# aw_windows_extra_users:
|
||||
# - Администратор
|
||||
|
||||
# Рекомендуемый изолированный профиль для фазового раската.
|
||||
aw_windows_install_root: "C:\\Program Files\\ActivityWatch-Phase2"
|
||||
aw_windows_state_root: "C:\\ProgramData\\ActivityWatch"
|
||||
aw_windows_afk_enabled: true
|
||||
aw_windows_window_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_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: "C:\\Windows\\Temp\\aw_validate_phase2_ansible.json"
|
||||
aw_windows_validation_local_dir: "/tmp/aw-rus-validation"
|
||||
@@ -0,0 +1,8 @@
|
||||
[proxmox]
|
||||
pve-main ansible_host=192.168.10.2 ansible_user=root ansible_port=22
|
||||
|
||||
[aw_server]
|
||||
aw-ct ansible_host=10.20.30.13 ansible_user=root ansible_port=22
|
||||
|
||||
[aw_windows]
|
||||
win-node1 ansible_host=192.168.100.21 ansible_user=Administrator ansible_password=CHANGE_ME ansible_connection=winrm ansible_winrm_transport=ntlm ansible_port=5985 ansible_winrm_server_cert_validation=ignore
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
- name: Provision single Proxmox CT and deploy AWatch-rus
|
||||
hosts: proxmox
|
||||
gather_facts: false
|
||||
|
||||
vars:
|
||||
proxmox_bootstrap_dir: "/tmp/aw-rus-bootstrap"
|
||||
aw_bootstrap_files:
|
||||
- install_aw_server.sh
|
||||
- apply_webui_ru_patch.sh
|
||||
- activitywatch-server.service
|
||||
- aw-server.env.example
|
||||
- aw-ru-patch.js
|
||||
- aw-sw-cleanup.js
|
||||
- aw-host-groups.json
|
||||
- settings/classes-worktime.json
|
||||
- settings/views-default.json
|
||||
|
||||
tasks:
|
||||
- name: Execute single-CT provisioning workflow
|
||||
ansible.builtin.include_tasks: tasks/provision_ct_and_deploy_aw.yml
|
||||
vars:
|
||||
ct_id: "{{ proxmox_ct_id }}"
|
||||
ct_hostname: "{{ proxmox_ct_hostname }}"
|
||||
ct_storage: "{{ proxmox_ct_storage }}"
|
||||
ct_template: "{{ proxmox_ct_template }}"
|
||||
ct_rootfs_size: "{{ proxmox_ct_rootfs_size }}"
|
||||
ct_cores: "{{ proxmox_ct_cores }}"
|
||||
ct_memory: "{{ proxmox_ct_memory }}"
|
||||
ct_swap: "{{ proxmox_ct_swap }}"
|
||||
ct_bridge: "{{ proxmox_ct_bridge }}"
|
||||
ct_ip: "{{ proxmox_ct_ip }}"
|
||||
ct_gw: "{{ proxmox_ct_gw }}"
|
||||
ct_vlan: "{{ proxmox_ct_vlan | default('') }}"
|
||||
ct_nameserver: "{{ proxmox_ct_nameserver | default('') }}"
|
||||
ct_searchdomain: "{{ proxmox_ct_searchdomain | default('') }}"
|
||||
ct_password: "{{ proxmox_ct_password }}"
|
||||
ct_unprivileged: "{{ proxmox_ct_unprivileged }}"
|
||||
ct_onboot: "{{ proxmox_ct_onboot }}"
|
||||
ct_features: "{{ proxmox_ct_features }}"
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
---
|
||||
- name: Provision Proxmox CT matrix and deploy AWatch-rus with RU patch
|
||||
hosts: proxmox
|
||||
gather_facts: false
|
||||
|
||||
vars:
|
||||
proxmox_bootstrap_dir: "/tmp/aw-rus-bootstrap"
|
||||
aw_bootstrap_files:
|
||||
- install_aw_server.sh
|
||||
- apply_webui_ru_patch.sh
|
||||
- activitywatch-server.service
|
||||
- aw-server.env.example
|
||||
- aw-ru-patch.js
|
||||
- aw-sw-cleanup.js
|
||||
- aw-host-groups.json
|
||||
- settings/classes-worktime.json
|
||||
- settings/views-default.json
|
||||
|
||||
tasks:
|
||||
- name: Validate CT matrix is provided
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- proxmox_ct_matrix is defined
|
||||
- proxmox_ct_matrix | length > 0
|
||||
fail_msg: "Define proxmox_ct_matrix in group_vars/proxmox-matrix.yml"
|
||||
|
||||
- name: Execute provisioning workflow for each CT
|
||||
ansible.builtin.include_tasks: tasks/provision_ct_and_deploy_aw.yml
|
||||
vars:
|
||||
ct_id: "{{ item.id }}"
|
||||
ct_hostname: "{{ item.hostname }}"
|
||||
ct_storage: "{{ item.storage }}"
|
||||
ct_template: "{{ item.template }}"
|
||||
ct_rootfs_size: "{{ item.rootfs_size }}"
|
||||
ct_cores: "{{ item.cores }}"
|
||||
ct_memory: "{{ item.memory }}"
|
||||
ct_swap: "{{ item.swap }}"
|
||||
ct_bridge: "{{ item.bridge }}"
|
||||
ct_ip: "{{ item.ip }}"
|
||||
ct_gw: "{{ item.gw }}"
|
||||
ct_vlan: "{{ item.vlan | default('') }}"
|
||||
ct_nameserver: "{{ item.nameserver | default('') }}"
|
||||
ct_searchdomain: "{{ item.searchdomain | default('') }}"
|
||||
ct_password: "{{ item.password }}"
|
||||
ct_unprivileged: "{{ item.unprivileged }}"
|
||||
ct_onboot: "{{ item.onboot }}"
|
||||
ct_features: "{{ item.features }}"
|
||||
loop: "{{ proxmox_ct_matrix }}"
|
||||
loop_control:
|
||||
label: "ct={{ item.id }} host={{ item.hostname }} ip={{ item.ip }}"
|
||||
@@ -0,0 +1,217 @@
|
||||
---
|
||||
- name: Validate required per-CT variables
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- ct_id is defined
|
||||
- ct_hostname is defined
|
||||
- ct_storage is defined
|
||||
- ct_template is defined
|
||||
- ct_rootfs_size is defined
|
||||
- ct_cores is defined
|
||||
- ct_memory is defined
|
||||
- ct_swap is defined
|
||||
- ct_bridge is defined
|
||||
- ct_ip is defined
|
||||
- ct_gw is defined
|
||||
- ct_password is defined
|
||||
- ct_unprivileged is defined
|
||||
- ct_onboot is defined
|
||||
- ct_features is defined
|
||||
- aw_repo_root is defined
|
||||
- aw_server_version is defined
|
||||
- aw_server_download_url is defined
|
||||
- aw_server_bind_host is defined
|
||||
- aw_server_port is defined
|
||||
- aw_server_webui_dir is defined
|
||||
- aw_server_data_dir is defined
|
||||
- aw_server_log_dir is defined
|
||||
- aw_server_user is defined
|
||||
- aw_server_group is defined
|
||||
fail_msg: "Missing required variables for CT provisioning/deploy."
|
||||
|
||||
- name: Build CT network string
|
||||
ansible.builtin.set_fact:
|
||||
ct_net0: >-
|
||||
name=eth0,bridge={{ ct_bridge }},ip={{ ct_ip }},gw={{ ct_gw }}{% if (ct_vlan | default('') | string | length) > 0 %},tag={{ ct_vlan }}{% endif %}
|
||||
|
||||
- name: Check whether CT already exists
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- pct
|
||||
- status
|
||||
- "{{ ct_id }}"
|
||||
register: ct_status_check
|
||||
failed_when: false
|
||||
changed_when: false
|
||||
|
||||
- name: Create CT when absent
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- pct
|
||||
- create
|
||||
- "{{ ct_id }}"
|
||||
- "{{ ct_template }}"
|
||||
- --hostname
|
||||
- "{{ ct_hostname }}"
|
||||
- --cores
|
||||
- "{{ ct_cores }}"
|
||||
- --memory
|
||||
- "{{ ct_memory }}"
|
||||
- --swap
|
||||
- "{{ ct_swap }}"
|
||||
- --rootfs
|
||||
- "{{ ct_storage }}:{{ ct_rootfs_size }}"
|
||||
- --password
|
||||
- "{{ ct_password }}"
|
||||
- --unprivileged
|
||||
- "{{ ct_unprivileged }}"
|
||||
- --onboot
|
||||
- "{{ ct_onboot }}"
|
||||
- --features
|
||||
- "{{ ct_features }}"
|
||||
- --net0
|
||||
- "{{ ct_net0 }}"
|
||||
- --nameserver
|
||||
- "{{ ct_nameserver | default('') }}"
|
||||
- --searchdomain
|
||||
- "{{ ct_searchdomain | default('') }}"
|
||||
- --ostype
|
||||
- debian
|
||||
when: ct_status_check.rc != 0
|
||||
|
||||
- name: Check current CT runtime state
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- pct
|
||||
- status
|
||||
- "{{ ct_id }}"
|
||||
register: ct_runtime_status
|
||||
changed_when: false
|
||||
|
||||
- name: Start CT when stopped
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- pct
|
||||
- start
|
||||
- "{{ ct_id }}"
|
||||
when: "'stopped' in ct_runtime_status.stdout"
|
||||
|
||||
- name: Ensure bootstrap directory on Proxmox host
|
||||
ansible.builtin.file:
|
||||
path: "{{ proxmox_bootstrap_dir }}"
|
||||
state: directory
|
||||
mode: "0700"
|
||||
|
||||
- name: Copy AW bootstrap files to Proxmox host temp
|
||||
ansible.builtin.copy:
|
||||
src: "{{ aw_repo_root }}/aw-server/{{ item }}"
|
||||
dest: "{{ proxmox_bootstrap_dir }}/{{ item }}"
|
||||
mode: "0644"
|
||||
loop: "{{ aw_bootstrap_files }}"
|
||||
|
||||
- name: Bootstrap CT OS dependencies
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- pct
|
||||
- exec
|
||||
- "{{ ct_id }}"
|
||||
- --
|
||||
- bash
|
||||
- -lc
|
||||
- |
|
||||
set -euo pipefail
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update
|
||||
apt-get install -y curl ca-certificates bash unzip xz-utils jq rsync openssh-server
|
||||
mkdir -p /root/bootstrap /etc/activitywatch
|
||||
systemctl enable ssh || true
|
||||
systemctl restart ssh || true
|
||||
|
||||
- name: Push bootstrap files into CT
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- pct
|
||||
- push
|
||||
- "{{ ct_id }}"
|
||||
- "{{ proxmox_bootstrap_dir }}/{{ item }}"
|
||||
- "/root/bootstrap/{{ item }}"
|
||||
loop: "{{ aw_bootstrap_files }}"
|
||||
|
||||
- name: Write AW server env file on Proxmox host temp
|
||||
ansible.builtin.copy:
|
||||
dest: "{{ proxmox_bootstrap_dir }}/aw-server.env"
|
||||
mode: "0600"
|
||||
content: |
|
||||
AW_SERVER_VERSION={{ aw_server_version }}
|
||||
AW_SERVER_DOWNLOAD_URL={{ aw_server_download_url }}
|
||||
AW_SERVER_BIND_HOST={{ aw_server_bind_host }}
|
||||
AW_SERVER_PORT={{ aw_server_port }}
|
||||
AW_SERVER_WEBUI_DIR={{ aw_server_webui_dir }}
|
||||
AW_SERVER_DATA_DIR={{ aw_server_data_dir }}
|
||||
AW_SERVER_LOG_DIR={{ aw_server_log_dir }}
|
||||
AW_SERVER_USER={{ aw_server_user }}
|
||||
AW_SERVER_GROUP={{ aw_server_group }}
|
||||
|
||||
- name: Push AW server env into CT
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- pct
|
||||
- push
|
||||
- "{{ ct_id }}"
|
||||
- "{{ proxmox_bootstrap_dir }}/aw-server.env"
|
||||
- /etc/activitywatch/aw-server.env
|
||||
|
||||
- name: Set mode for env inside CT
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- pct
|
||||
- exec
|
||||
- "{{ ct_id }}"
|
||||
- --
|
||||
- chmod
|
||||
- "0600"
|
||||
- /etc/activitywatch/aw-server.env
|
||||
|
||||
- name: Install server and apply RU patch inside CT
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- pct
|
||||
- exec
|
||||
- "{{ ct_id }}"
|
||||
- --
|
||||
- bash
|
||||
- -lc
|
||||
- |
|
||||
set -euo pipefail
|
||||
chmod +x /root/bootstrap/install_aw_server.sh /root/bootstrap/apply_webui_ru_patch.sh
|
||||
bash /root/bootstrap/install_aw_server.sh
|
||||
bash /root/bootstrap/apply_webui_ru_patch.sh
|
||||
systemctl restart activitywatch-server.service
|
||||
|
||||
- name: Validate AW API from inside CT
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- pct
|
||||
- exec
|
||||
- "{{ ct_id }}"
|
||||
- --
|
||||
- bash
|
||||
- -lc
|
||||
- "curl -fsS http://127.0.0.1:{{ aw_server_port }}/api/0/info >/dev/null"
|
||||
|
||||
- name: Validate RU patch hooks in index
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- pct
|
||||
- exec
|
||||
- "{{ ct_id }}"
|
||||
- --
|
||||
- bash
|
||||
- -lc
|
||||
- "grep -q 'ru-patch-v5.js' {{ aw_server_webui_dir }}/index.html && grep -q 'sw-cleanup.js' {{ aw_server_webui_dir }}/index.html"
|
||||
|
||||
- name: Show final endpoint
|
||||
ansible.builtin.debug:
|
||||
msg:
|
||||
- "CT {{ ct_id }} is provisioned and configured."
|
||||
- "ActivityWatch endpoint: http://{{ ct_ip | regex_replace('/[0-9]+$', '') }}:{{ aw_server_port }}"
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
[Unit]
|
||||
Description=ActivityWatch Server (Rust)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
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"'
|
||||
Restart=on-failure
|
||||
RestartSec=5s
|
||||
StateDirectory=activitywatch
|
||||
LogsDirectory=activitywatch
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=full
|
||||
ProtectHome=true
|
||||
LimitNOFILE=65535
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,111 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
ENV_FILE="/etc/activitywatch/aw-server.env"
|
||||
if [[ ! -f "$ENV_FILE" ]]; then
|
||||
echo "missing env file: $ENV_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
source "$ENV_FILE"
|
||||
|
||||
WEBUI_DIR="${AW_SERVER_WEBUI_DIR:-${AW_WEBUI_DIR:-/opt/activitywatch/webui-ru}}"
|
||||
PATCH_JS_SRC="/root/bootstrap/aw-ru-patch.js"
|
||||
SW_CLEANUP_SRC="/root/bootstrap/aw-sw-cleanup.js"
|
||||
HOST_GROUPS_SRC="/root/bootstrap/aw-host-groups.json"
|
||||
INDEX_HTML="$WEBUI_DIR/index.html"
|
||||
SERVICE_WORKER="$WEBUI_DIR/service-worker.js"
|
||||
TS=$(date +%Y%m%d%H%M%S)
|
||||
PATCH_TARGET="$WEBUI_DIR/js/ru-patch-v5.js"
|
||||
SW_TARGET="$WEBUI_DIR/js/sw-cleanup.js"
|
||||
HOST_GROUPS_TARGET="$WEBUI_DIR/js/aw-host-groups.json"
|
||||
TRENDS_NEEDLE='this.activityStore.query_category_time_by_period(r)'
|
||||
TRENDS_REPLACEMENT='this.activityStore.ensure_loaded(r)'
|
||||
TIMESPIRAL_NEEDLE='start:new Date("2022-08-08")'
|
||||
TIMESPIRAL_REPLACEMENT='start:new Date(Date.now()-12*36e5)'
|
||||
CATEGORY_HELPER_NEEDLE='hostname:t.hostnameChoices[0]'
|
||||
CATEGORY_HELPER_REPLACEMENT='hostname:t.hostnameChoices.filter((function(t){return"unknown"!==t}))[0]||t.hostnameChoices[0]'
|
||||
|
||||
[[ -f "$PATCH_JS_SRC" ]] || { echo "missing $PATCH_JS_SRC" >&2; exit 1; }
|
||||
[[ -f "$SW_CLEANUP_SRC" ]] || { echo "missing $SW_CLEANUP_SRC" >&2; exit 1; }
|
||||
[[ -f "$HOST_GROUPS_SRC" ]] || { echo "missing $HOST_GROUPS_SRC" >&2; exit 1; }
|
||||
[[ -f "$INDEX_HTML" ]] || { echo "missing $INDEX_HTML" >&2; exit 1; }
|
||||
|
||||
install -d "$WEBUI_DIR/js"
|
||||
install -m 0644 "$PATCH_JS_SRC" "$PATCH_TARGET"
|
||||
install -m 0644 "$SW_CLEANUP_SRC" "$SW_TARGET"
|
||||
install -m 0644 "$HOST_GROUPS_SRC" "$HOST_GROUPS_TARGET"
|
||||
cp "$INDEX_HTML" "$INDEX_HTML.bak.$TS"
|
||||
|
||||
patch_hash="$(sha1sum "$PATCH_TARGET" | awk '{print substr($1,1,12)}')"
|
||||
sw_hash="$(sha1sum "$SW_TARGET" | awk '{print substr($1,1,12)}')"
|
||||
|
||||
sed -i '/ru-patch-v5.js/d;/sw-cleanup.js/d;/aw-ru-patch.js/d;/aw-sw-cleanup.js/d' "$INDEX_HTML"
|
||||
sed -i "s#</head>#<script src=\"/js/sw-cleanup.js?v=$sw_hash\"></script></head>#" "$INDEX_HTML"
|
||||
sed -i "s#</body>#<script defer=\"defer\" src=\"/js/ru-patch-v5.js?v=$patch_hash\"></script></body>#" "$INDEX_HTML"
|
||||
cp "$SW_CLEANUP_SRC" "$SERVICE_WORKER"
|
||||
|
||||
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"
|
||||
python3 - "$trends_chunk" "$TRENDS_NEEDLE" "$TRENDS_REPLACEMENT" <<'PY'
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
path = Path(sys.argv[1])
|
||||
old = sys.argv[2]
|
||||
new = sys.argv[3]
|
||||
content = path.read_text()
|
||||
if old in content:
|
||||
path.write_text(content.replace(old, new, 1))
|
||||
print(f"Trends hotfix applied to {path}")
|
||||
else:
|
||||
print(f"Trends hotfix already present in {path}")
|
||||
PY
|
||||
else
|
||||
echo "Trends hotfix skipped: chunk not found"
|
||||
fi
|
||||
|
||||
timespiral_chunk="$(grep -Rsl "$TIMESPIRAL_NEEDLE" "$WEBUI_DIR/js"/*.js 2>/dev/null | head -n 1 || true)"
|
||||
if [[ -n "$timespiral_chunk" ]]; then
|
||||
cp "$timespiral_chunk" "$timespiral_chunk.bak.$TS"
|
||||
python3 - "$timespiral_chunk" "$TIMESPIRAL_NEEDLE" "$TIMESPIRAL_REPLACEMENT" <<'PY'
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
path = Path(sys.argv[1])
|
||||
old = sys.argv[2]
|
||||
new = sys.argv[3]
|
||||
content = path.read_text()
|
||||
if old in content:
|
||||
path.write_text(content.replace(old, new, 1))
|
||||
print(f"Timespiral hotfix applied to {path}")
|
||||
else:
|
||||
print(f"Timespiral hotfix already present in {path}")
|
||||
PY
|
||||
else
|
||||
echo "Timespiral hotfix skipped: chunk not found"
|
||||
fi
|
||||
|
||||
category_helper_chunk="$(grep -Rsl "$CATEGORY_HELPER_NEEDLE" "$WEBUI_DIR/js"/*.js 2>/dev/null | head -n 1 || true)"
|
||||
if [[ -n "$category_helper_chunk" ]]; then
|
||||
cp "$category_helper_chunk" "$category_helper_chunk.bak.$TS"
|
||||
python3 - "$category_helper_chunk" "$CATEGORY_HELPER_NEEDLE" "$CATEGORY_HELPER_REPLACEMENT" <<'PY'
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
path = Path(sys.argv[1])
|
||||
old = sys.argv[2]
|
||||
new = sys.argv[3]
|
||||
content = path.read_text()
|
||||
if old in content:
|
||||
path.write_text(content.replace(old, new, 1))
|
||||
print(f"Category helper host hotfix applied to {path}")
|
||||
else:
|
||||
print(f"Category helper host hotfix already present in {path}")
|
||||
PY
|
||||
else
|
||||
echo "Category helper host hotfix skipped: chunk not found"
|
||||
fi
|
||||
|
||||
echo "RU patch applied to $WEBUI_DIR (ru-patch-v5.js?v=$patch_hash)"
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"groups": [
|
||||
{
|
||||
"id": "pve-detmir",
|
||||
"name": "pve-detmir",
|
||||
"description": "Выделенный клиент DetMir в разделе Активность.",
|
||||
"patterns": [
|
||||
"^pve-detmir$"
|
||||
],
|
||||
"links": [
|
||||
{ "label": "Активность", "type": "activity", "view": "pve_audit" },
|
||||
{ "label": "Web-admin аудит", "type": "bucket", "bucket_prefix": "aw-pve-webadmin-events_" },
|
||||
{ "label": "PVE tasks", "type": "bucket", "bucket_prefix": "aw-pve-task-events_" },
|
||||
{ "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": "windows-rdp",
|
||||
"name": "Windows RDP",
|
||||
"description": "Пользовательские Windows/RDP хосты с активностью, DLP и рабочим временем.",
|
||||
"patterns": [
|
||||
"^(SHARKON|WIN|RDP|TERM|TS-|WS-)"
|
||||
],
|
||||
"links": [
|
||||
{ "label": "Активность", "type": "activity" },
|
||||
{ "label": "DLP", "type": "bucket", "bucket_prefix": "aw-dlp-endpoint-signals_" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "virtual-infra",
|
||||
"name": "Virtual servers + Proxmox",
|
||||
"description": "Инфраструктурные VM и сетевые узлы. Здесь должны лежать Proxmox, pfSense, Debian и Ubuntu серверы.",
|
||||
"patterns": [
|
||||
"^(PFSENSE|PVE|PROXMOX|DEBIAN|UBUNTU|LINUX|VM-|SRV-|INFRA-)"
|
||||
],
|
||||
"links": [
|
||||
{ "label": "pfSense health", "type": "bucket", "bucket_prefix": "aw-pfsense-health_" },
|
||||
{ "label": "pfSense gateways", "type": "bucket", "bucket_prefix": "aw-pfsense-gateways_" },
|
||||
{ "label": "Все бакеты", "type": "buckets" }
|
||||
]
|
||||
}
|
||||
],
|
||||
"ungrouped_name": "Прочие хосты"
|
||||
}
|
||||
+1627
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,11 @@
|
||||
# Copy to /etc/activitywatch/aw-server.env and fill with real values.
|
||||
|
||||
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_LOG_DIR=/var/log/activitywatch
|
||||
AW_SERVER_USER=activitywatch
|
||||
AW_SERVER_GROUP=activitywatch
|
||||
@@ -0,0 +1,18 @@
|
||||
self.addEventListener("install", function (event) {
|
||||
self.skipWaiting();
|
||||
event.waitUntil((async function () {
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(keys.map(function (key) { return caches.delete(key); }));
|
||||
})());
|
||||
});
|
||||
|
||||
self.addEventListener("activate", function (event) {
|
||||
event.waitUntil((async function () {
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(keys.map(function (key) { return caches.delete(key); }));
|
||||
await self.clients.claim();
|
||||
await self.registration.unregister();
|
||||
})());
|
||||
});
|
||||
|
||||
self.addEventListener("fetch", function () {});
|
||||
@@ -0,0 +1,117 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
ENV_FILE="/etc/activitywatch/aw-server.env"
|
||||
if [[ ! -f "$ENV_FILE" ]]; then
|
||||
echo "missing env file: $ENV_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
source "$ENV_FILE"
|
||||
|
||||
required_vars=(
|
||||
AW_SERVER_VERSION
|
||||
AW_SERVER_DOWNLOAD_URL
|
||||
AW_SERVER_BIND_HOST
|
||||
AW_SERVER_PORT
|
||||
AW_SERVER_WEBUI_DIR
|
||||
AW_SERVER_DATA_DIR
|
||||
AW_SERVER_LOG_DIR
|
||||
AW_SERVER_USER
|
||||
AW_SERVER_GROUP
|
||||
)
|
||||
|
||||
BOOTSTRAP_DIR="/root/bootstrap"
|
||||
VIEWS_JSON="$BOOTSTRAP_DIR/settings/views-default.json"
|
||||
CLASSES_JSON="$BOOTSTRAP_DIR/settings/classes-worktime.json"
|
||||
|
||||
for var_name in "${required_vars[@]}"; do
|
||||
if [[ -z "${!var_name:-}" ]]; then
|
||||
echo "missing required variable: $var_name" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update
|
||||
apt-get install -y curl ca-certificates unzip jq
|
||||
|
||||
if ! getent group "$AW_SERVER_GROUP" >/dev/null; then
|
||||
groupadd --system "$AW_SERVER_GROUP"
|
||||
fi
|
||||
|
||||
if ! id "$AW_SERVER_USER" >/dev/null 2>&1; then
|
||||
useradd --system --gid "$AW_SERVER_GROUP" --home-dir "$AW_SERVER_DATA_DIR" --shell /usr/sbin/nologin "$AW_SERVER_USER"
|
||||
fi
|
||||
|
||||
install -d -o "$AW_SERVER_USER" -g "$AW_SERVER_GROUP" /opt/activitywatch/bin
|
||||
install -d -o "$AW_SERVER_USER" -g "$AW_SERVER_GROUP" /opt/activitywatch/releases
|
||||
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"
|
||||
|
||||
tmp_dir=$(mktemp -d)
|
||||
trap 'rm -rf "$tmp_dir"' EXIT
|
||||
|
||||
curl -fL "$AW_SERVER_DOWNLOAD_URL" -o "$tmp_dir/aw-server.zip"
|
||||
unzip -q "$tmp_dir/aw-server.zip" -d "$tmp_dir/unpacked"
|
||||
|
||||
server_bin=$(find "$tmp_dir/unpacked" -type f \( -name 'aw-server-rust' -o -name 'aw-server' \) | head -n 1)
|
||||
webui_dir=$(find "$tmp_dir/unpacked" -type d \( -name 'webui' -o -name 'aw-webui' \) | head -n 1 || true)
|
||||
|
||||
if [[ -z "$server_bin" || ! -f "$server_bin" ]]; then
|
||||
echo "aw-server binary not found in archive" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
release_dir="/opt/activitywatch/releases/aw-server-rust-v${AW_SERVER_VERSION}"
|
||||
rm -rf "$release_dir"
|
||||
install -d -o "$AW_SERVER_USER" -g "$AW_SERVER_GROUP" "$release_dir"
|
||||
install -m 0755 -o "$AW_SERVER_USER" -g "$AW_SERVER_GROUP" "$server_bin" "$release_dir/aw-server-rust"
|
||||
ln -sfn "$release_dir/aw-server-rust" /opt/activitywatch/bin/aw-server-rust
|
||||
|
||||
if [[ -n "$webui_dir" && -d "$webui_dir" ]]; then
|
||||
rm -rf "$AW_SERVER_WEBUI_DIR"
|
||||
mkdir -p "$AW_SERVER_WEBUI_DIR"
|
||||
cp -a "$webui_dir"/. "$AW_SERVER_WEBUI_DIR"/
|
||||
chown -R "$AW_SERVER_USER:$AW_SERVER_GROUP" "$AW_SERVER_WEBUI_DIR"
|
||||
fi
|
||||
|
||||
sed \
|
||||
-e "s#__AW_SERVER_USER__#$AW_SERVER_USER#g" \
|
||||
-e "s#__AW_SERVER_GROUP__#$AW_SERVER_GROUP#g" \
|
||||
-e "s#__AW_SERVER_DATA_DIR__#$AW_SERVER_DATA_DIR#g" \
|
||||
/root/bootstrap/activitywatch-server.service > /etc/systemd/system/activitywatch-server.service
|
||||
chmod 0644 /etc/systemd/system/activitywatch-server.service
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable activitywatch-server.service
|
||||
systemctl restart activitywatch-server.service
|
||||
systemctl --no-pager --full status activitywatch-server.service || true
|
||||
|
||||
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
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
if [[ -f "$CLASSES_JSON" ]]; then
|
||||
curl -fsS -X POST \
|
||||
-H 'Content-Type: application/json' \
|
||||
--data-binary @"$CLASSES_JSON" \
|
||||
"http://127.0.0.1:${AW_SERVER_PORT}/api/0/settings/classes" >/dev/null
|
||||
echo "Applied worktime classes from $CLASSES_JSON"
|
||||
else
|
||||
echo "Worktime classes bootstrap not found, skipped: $CLASSES_JSON"
|
||||
fi
|
||||
|
||||
if [[ -f "$VIEWS_JSON" ]]; then
|
||||
curl -fsS -X POST \
|
||||
-H 'Content-Type: application/json' \
|
||||
--data-binary @"$VIEWS_JSON" \
|
||||
"http://127.0.0.1:${AW_SERVER_PORT}/api/0/settings/views" >/dev/null
|
||||
echo "Applied baseline views from $VIEWS_JSON"
|
||||
else
|
||||
echo "Views bootstrap not found, skipped: $VIEWS_JSON"
|
||||
fi
|
||||
@@ -0,0 +1,90 @@
|
||||
[
|
||||
{
|
||||
"id": 0,
|
||||
"name": ["Работа"],
|
||||
"rule": { "type": "none" },
|
||||
"data": {}
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"name": ["Работа", "1С"],
|
||||
"rule": {
|
||||
"type": "regex",
|
||||
"regex": "\\b(1cv8s?|1cv8c|1cestart)\\.exe\\b|1С:Предприятие|Запуск 1С:Предприятия|Загрузка конфигурационной информации|Доступ к информационной базе",
|
||||
"ignore_case": true
|
||||
},
|
||||
"data": { "color": "#194D33" }
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": ["Работа", "Документы"],
|
||||
"rule": {
|
||||
"type": "regex",
|
||||
"regex": "\\b(winword|excel|powerpnt|outlook|acrord32|acrord64)\\.exe\\b|Adobe Reader|Acrobat",
|
||||
"ignore_case": true
|
||||
},
|
||||
"data": { "color": "#2E7D32" }
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"name": ["Работа", "Коммуникации"],
|
||||
"rule": {
|
||||
"type": "regex",
|
||||
"regex": "\\b(teams|telegram|slack|thunderbird|zoom|skype|whatsapp|viber|discord)\\.exe\\b|Mattermost|Element|Riot",
|
||||
"ignore_case": true
|
||||
},
|
||||
"data": { "color": "#1E88E5" }
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"name": ["Работа", "Администрирование"],
|
||||
"rule": {
|
||||
"type": "regex",
|
||||
"regex": "\\b(mstsc|putty|kitty|winscp|anydesk|teamviewer|vncviewer|mmc|regedit|services|control|powershell|cmd)\\.exe\\b",
|
||||
"ignore_case": true
|
||||
},
|
||||
"data": { "color": "#6D4C41" }
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"name": ["Интернет"],
|
||||
"rule": { "type": "none" },
|
||||
"data": {}
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"name": ["Интернет", "Браузер"],
|
||||
"rule": {
|
||||
"type": "regex",
|
||||
"regex": "\\b(chrome|msedge|firefox|opera|brave|vivaldi|browser)\\.exe\\b",
|
||||
"ignore_case": true
|
||||
},
|
||||
"data": { "color": "#00897B" }
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"name": ["Система"],
|
||||
"rule": { "type": "none" },
|
||||
"data": {}
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"name": ["Система", "Windows"],
|
||||
"rule": {
|
||||
"type": "regex",
|
||||
"regex": "\\b(SearchHost|explorer|ShellExperienceHost|ApplicationFrameHost|RuntimeBroker|sihost|dwm|svchost|fontdrvhost|userinit)\\.exe\\b|\\\\Windows\\\\System32",
|
||||
"ignore_case": true
|
||||
},
|
||||
"data": { "color": "#607D8B" }
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"name": ["ActivityWatch"],
|
||||
"rule": {
|
||||
"type": "regex",
|
||||
"regex": "ActivityWatch|\\baw-(watcher|qt)\\.exe\\b",
|
||||
"ignore_case": true
|
||||
},
|
||||
"data": {}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,39 @@
|
||||
[
|
||||
{
|
||||
"id": "summary",
|
||||
"name": "Summary",
|
||||
"elements": [
|
||||
{ "type": "top_titles", "size": 3 },
|
||||
{ "type": "timeline_barchart", "size": 3 },
|
||||
{ "type": "top_categories", "size": 3 },
|
||||
{ "type": "category_tree", "size": 3 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "window",
|
||||
"name": "Window",
|
||||
"elements": [
|
||||
{ "type": "top_apps", "size": 3, "props": {} }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "DLP",
|
||||
"name": "DLP",
|
||||
"elements": []
|
||||
},
|
||||
{
|
||||
"id": "worktime",
|
||||
"name": "Worktime",
|
||||
"elements": [
|
||||
{ "type": "top_categories", "size": 3, "props": {} },
|
||||
{ "type": "timeline_barchart", "size": 3, "props": {} },
|
||||
{ "type": "category_tree", "size": 3, "props": {} },
|
||||
{ "type": "top_apps", "size": 3, "props": {} }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "pve_audit",
|
||||
"name": "PVE Audit",
|
||||
"elements": []
|
||||
}
|
||||
]
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"version": 1,
|
||||
"generatedAtUtc": "2026-04-27T01:14:21.9184268Z",
|
||||
"server": {
|
||||
"host": "10.10.10.13",
|
||||
"port": 5600,
|
||||
"scheme": "http"
|
||||
},
|
||||
"paths": {
|
||||
"installRoot": "C:\\Program Files\\ActivityWatch-Phase2-admin",
|
||||
"stateRoot": "C:\\ProgramData\\ActivityWatch\\phase2-admin",
|
||||
"logsRoot": "C:\\ProgramData\\ActivityWatch\\phase2-admin\\logs",
|
||||
"collectorScript": "C:\\ProgramData\\ActivityWatch\\phase2-admin\\browser-domains-native-collector.ps1",
|
||||
"endpointCollectorScript": "C:\\ProgramData\\ActivityWatch\\phase2-admin\\dlp-endpoint-signals-collector.ps1",
|
||||
"rulesPath": "C:\\ProgramData\\ActivityWatch\\phase2-admin\\web-category-rules.json",
|
||||
"policyPath": "C:\\ProgramData\\ActivityWatch\\phase2-admin\\dlp-policy.json",
|
||||
"launchScript": "C:\\ProgramData\\ActivityWatch\\phase2-admin\\launch-watchers.ps1",
|
||||
"recoveryScript": "C:\\ProgramData\\ActivityWatch\\phase2-admin\\recovery-loop.ps1"
|
||||
},
|
||||
"collector": {
|
||||
"pollSeconds": 5,
|
||||
"pulseSeconds": 30
|
||||
},
|
||||
"collectors": {
|
||||
"afkEnabled": true,
|
||||
"windowEnabled": true
|
||||
},
|
||||
"logging": {
|
||||
"localAgentLogsEnabled": false
|
||||
},
|
||||
"incidentCapture": {
|
||||
"enabled": true,
|
||||
"screenshotEnabled": true,
|
||||
"artifactsRoot": "C:\\ProgramData\\ActivityWatch\\phase2-admin\\incident-artifacts"
|
||||
},
|
||||
"sessionEvents": {
|
||||
"logonEnabled": true,
|
||||
"bucketPrefix": "aw-session-events"
|
||||
},
|
||||
"recovery": {
|
||||
"intervalSeconds": 180,
|
||||
"taskName": "ActivityWatch Recovery"
|
||||
},
|
||||
"dlp": {
|
||||
"incidentBucketPrefix": "aw-dlp-incidents",
|
||||
"enabled": true
|
||||
},
|
||||
"package": {
|
||||
"version": "v0.13.2"
|
||||
},
|
||||
"userTasks": [
|
||||
{
|
||||
"UserId": "SHARKON2025\\Администратор",
|
||||
"LaunchTaskName": "ActivityWatch Launch [SHARKON2025_РђРґРјРёРЅРёСЃС_СЂР_С_РѕСЂ]"
|
||||
}
|
||||
]
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"version": 1,
|
||||
"generatedAtUtc": "2026-04-27T01:09:42.4193209Z",
|
||||
"server": {
|
||||
"host": "10.10.10.13",
|
||||
"port": 5600,
|
||||
"scheme": "http"
|
||||
},
|
||||
"paths": {
|
||||
"installRoot": "C:\\Program Files\\ActivityWatch-Phase2-u2u5",
|
||||
"stateRoot": "C:\\ProgramData\\ActivityWatch\\phase2-u2u5",
|
||||
"logsRoot": "C:\\ProgramData\\ActivityWatch\\phase2-u2u5\\logs",
|
||||
"collectorScript": "C:\\ProgramData\\ActivityWatch\\phase2-u2u5\\browser-domains-native-collector.ps1",
|
||||
"endpointCollectorScript": "C:\\ProgramData\\ActivityWatch\\phase2-u2u5\\dlp-endpoint-signals-collector.ps1",
|
||||
"rulesPath": "C:\\ProgramData\\ActivityWatch\\phase2-u2u5\\web-category-rules.json",
|
||||
"policyPath": "C:\\ProgramData\\ActivityWatch\\phase2-u2u5\\dlp-policy.json",
|
||||
"launchScript": "C:\\ProgramData\\ActivityWatch\\phase2-u2u5\\launch-watchers.ps1",
|
||||
"recoveryScript": "C:\\ProgramData\\ActivityWatch\\phase2-u2u5\\recovery-loop.ps1"
|
||||
},
|
||||
"collector": {
|
||||
"pollSeconds": 5,
|
||||
"pulseSeconds": 30
|
||||
},
|
||||
"collectors": {
|
||||
"afkEnabled": true,
|
||||
"windowEnabled": true
|
||||
},
|
||||
"logging": {
|
||||
"localAgentLogsEnabled": false
|
||||
},
|
||||
"incidentCapture": {
|
||||
"enabled": true,
|
||||
"screenshotEnabled": true,
|
||||
"artifactsRoot": "C:\\ProgramData\\ActivityWatch\\phase2-u2u5\\incident-artifacts"
|
||||
},
|
||||
"sessionEvents": {
|
||||
"logonEnabled": true,
|
||||
"bucketPrefix": "aw-session-events"
|
||||
},
|
||||
"recovery": {
|
||||
"intervalSeconds": 180,
|
||||
"taskName": "ActivityWatch Recovery"
|
||||
},
|
||||
"dlp": {
|
||||
"incidentBucketPrefix": "aw-dlp-incidents",
|
||||
"enabled": true
|
||||
},
|
||||
"package": {
|
||||
"version": "v0.13.2"
|
||||
},
|
||||
"userTasks": [
|
||||
{
|
||||
"UserId": "SHARKON2025\\user2",
|
||||
"LaunchTaskName": "ActivityWatch Launch [SHARKON2025_user2]"
|
||||
},
|
||||
{
|
||||
"UserId": "SHARKON2025\\user3",
|
||||
"LaunchTaskName": "ActivityWatch Launch [SHARKON2025_user3]"
|
||||
},
|
||||
{
|
||||
"UserId": "SHARKON2025\\user4",
|
||||
"LaunchTaskName": "ActivityWatch Launch [SHARKON2025_user4]"
|
||||
},
|
||||
{
|
||||
"UserId": "SHARKON2025\\user5",
|
||||
"LaunchTaskName": "ActivityWatch Launch [SHARKON2025_user5]"
|
||||
}
|
||||
]
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"version": 1,
|
||||
"generatedAtUtc": "2026-04-27T01:09:38.9519788Z",
|
||||
"server": {
|
||||
"host": "10.10.10.13",
|
||||
"port": 5600,
|
||||
"scheme": "http"
|
||||
},
|
||||
"paths": {
|
||||
"installRoot": "C:\\Program Files\\ActivityWatch-Phase2",
|
||||
"stateRoot": "C:\\ProgramData\\ActivityWatch\\phase2-user1",
|
||||
"logsRoot": "C:\\ProgramData\\ActivityWatch\\phase2-user1\\logs",
|
||||
"collectorScript": "C:\\ProgramData\\ActivityWatch\\phase2-user1\\browser-domains-native-collector.ps1",
|
||||
"endpointCollectorScript": "C:\\ProgramData\\ActivityWatch\\phase2-user1\\dlp-endpoint-signals-collector.ps1",
|
||||
"rulesPath": "C:\\ProgramData\\ActivityWatch\\phase2-user1\\web-category-rules.json",
|
||||
"policyPath": "C:\\ProgramData\\ActivityWatch\\phase2-user1\\dlp-policy.json",
|
||||
"launchScript": "C:\\ProgramData\\ActivityWatch\\phase2-user1\\launch-watchers.ps1",
|
||||
"recoveryScript": "C:\\ProgramData\\ActivityWatch\\phase2-user1\\recovery-loop.ps1"
|
||||
},
|
||||
"collector": {
|
||||
"pollSeconds": 5,
|
||||
"pulseSeconds": 30
|
||||
},
|
||||
"collectors": {
|
||||
"afkEnabled": true,
|
||||
"windowEnabled": true
|
||||
},
|
||||
"logging": {
|
||||
"localAgentLogsEnabled": false
|
||||
},
|
||||
"incidentCapture": {
|
||||
"enabled": true,
|
||||
"screenshotEnabled": true,
|
||||
"artifactsRoot": "C:\\ProgramData\\ActivityWatch\\phase2-user1\\incident-artifacts"
|
||||
},
|
||||
"sessionEvents": {
|
||||
"logonEnabled": true,
|
||||
"bucketPrefix": "aw-session-events"
|
||||
},
|
||||
"recovery": {
|
||||
"intervalSeconds": 180,
|
||||
"taskName": "ActivityWatch Recovery"
|
||||
},
|
||||
"dlp": {
|
||||
"incidentBucketPrefix": "aw-dlp-incidents",
|
||||
"enabled": true
|
||||
},
|
||||
"package": {
|
||||
"version": "v0.13.2"
|
||||
},
|
||||
"userTasks": [
|
||||
{
|
||||
"UserId": "SHARKON2025\\user1",
|
||||
"LaunchTaskName": "ActivityWatch Launch [SHARKON2025_user1]"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
@{
|
||||
RootModule = 'ActivityWatch.Windows.Common.psm1'
|
||||
ModuleVersion = '1.0.0'
|
||||
GUID = '90b3fcf6-df9f-4f9b-9ee0-8a7de4dc0ee2'
|
||||
Author = 'igor04091968'
|
||||
CompanyName = 'Private'
|
||||
Description = 'Common PowerShell functions for ActivityWatch Windows deployment, hardening and recovery.'
|
||||
PowerShellVersion = '5.1'
|
||||
FunctionsToExport = @(
|
||||
'*-ActivityWatch*',
|
||||
'Assert-Administrator',
|
||||
'Normalize-ActivityWatchUsers',
|
||||
'Get-ActivityWatchPackageUrl',
|
||||
'Remove-LegacyActivityWatchEntries'
|
||||
)
|
||||
CmdletsToExport = @()
|
||||
VariablesToExport = '*'
|
||||
AliasesToExport = @()
|
||||
PrivateData = @{
|
||||
PSData = @{
|
||||
Tags = @('ActivityWatch', 'Windows', 'Deployment', 'Recovery')
|
||||
ProjectUri = 'https://github.com/igor04091968/AWatch-rus'
|
||||
}
|
||||
}
|
||||
}
|
||||
+982
@@ -0,0 +1,982 @@
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
function Assert-Administrator {
|
||||
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
|
||||
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
|
||||
throw 'Run this script from an elevated PowerShell session.'
|
||||
}
|
||||
}
|
||||
|
||||
function New-ActivityWatchDirectory {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Path
|
||||
)
|
||||
|
||||
if (-not (Test-Path -LiteralPath $Path)) {
|
||||
New-Item -Path $Path -ItemType Directory -Force | Out-Null
|
||||
}
|
||||
}
|
||||
|
||||
function Get-ActivityWatchPackageUrl {
|
||||
param(
|
||||
[string]$Version = 'v0.13.2'
|
||||
)
|
||||
|
||||
return "https://github.com/ActivityWatch/activitywatch/releases/download/$Version/activitywatch-$Version-windows-x86_64.zip"
|
||||
}
|
||||
|
||||
function Get-ActivityWatchArchive {
|
||||
param(
|
||||
[string]$PackageZipPath,
|
||||
[string]$PackageUrl,
|
||||
[string]$Version = 'v0.13.2',
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$WorkingRoot
|
||||
)
|
||||
|
||||
New-ActivityWatchDirectory -Path $WorkingRoot
|
||||
|
||||
if ($PackageZipPath) {
|
||||
$resolved = Resolve-Path -LiteralPath $PackageZipPath -ErrorAction Stop
|
||||
return $resolved.Path
|
||||
}
|
||||
|
||||
if (-not $PackageUrl) {
|
||||
$PackageUrl = Get-ActivityWatchPackageUrl -Version $Version
|
||||
}
|
||||
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||
$archivePath = Join-Path $WorkingRoot ("activitywatch-{0}.zip" -f $Version.TrimStart('v'))
|
||||
Invoke-WebRequest -Uri $PackageUrl -OutFile $archivePath
|
||||
return $archivePath
|
||||
}
|
||||
|
||||
function Get-ActivityWatchPackageRoot {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ExpandedRoot
|
||||
)
|
||||
|
||||
$afkBinary = Get-ChildItem -Path $ExpandedRoot -Filter 'aw-watcher-afk.exe' -File -Recurse |
|
||||
Select-Object -First 1
|
||||
|
||||
if (-not $afkBinary) {
|
||||
throw "Cannot find aw-watcher-afk.exe under $ExpandedRoot."
|
||||
}
|
||||
|
||||
return (Split-Path -Path (Split-Path -Path $afkBinary.FullName -Parent) -Parent)
|
||||
}
|
||||
|
||||
function Install-ActivityWatchPackage {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ArchivePath,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$InstallRoot,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$WorkingRoot,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$BackupRoot
|
||||
)
|
||||
|
||||
New-ActivityWatchDirectory -Path $WorkingRoot
|
||||
New-ActivityWatchDirectory -Path $BackupRoot
|
||||
|
||||
$extractRoot = Join-Path $WorkingRoot ('extract-' + [guid]::NewGuid().Guid)
|
||||
if (Test-Path -LiteralPath $extractRoot) {
|
||||
Remove-Item -LiteralPath $extractRoot -Recurse -Force
|
||||
}
|
||||
New-ActivityWatchDirectory -Path $extractRoot
|
||||
|
||||
Expand-Archive -Path $ArchivePath -DestinationPath $extractRoot -Force
|
||||
$packageRoot = Get-ActivityWatchPackageRoot -ExpandedRoot $extractRoot
|
||||
|
||||
if (Test-Path -LiteralPath $InstallRoot) {
|
||||
$existingItems = Get-ChildItem -LiteralPath $InstallRoot -Force -ErrorAction SilentlyContinue
|
||||
if ($existingItems) {
|
||||
$stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
|
||||
$backupPath = Join-Path $BackupRoot ("install-$stamp")
|
||||
New-ActivityWatchDirectory -Path $backupPath
|
||||
Copy-Item -Path (Join-Path $InstallRoot '*') -Destination $backupPath -Recurse -Force
|
||||
Get-ChildItem -LiteralPath $InstallRoot -Force | Remove-Item -Recurse -Force
|
||||
}
|
||||
}
|
||||
else {
|
||||
New-ActivityWatchDirectory -Path $InstallRoot
|
||||
}
|
||||
|
||||
Copy-Item -Path (Join-Path $packageRoot '*') -Destination $InstallRoot -Recurse -Force
|
||||
|
||||
return [pscustomobject]@{
|
||||
PackageRoot = $packageRoot
|
||||
ExtractRoot = $extractRoot
|
||||
BackupRoot = $BackupRoot
|
||||
}
|
||||
}
|
||||
|
||||
function Get-ActivityWatchExecutableMap {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$InstallRoot
|
||||
)
|
||||
|
||||
$map = [ordered]@{
|
||||
Afk = Join-Path $InstallRoot 'aw-watcher-afk\aw-watcher-afk.exe'
|
||||
Window = Join-Path $InstallRoot 'aw-watcher-window\aw-watcher-window.exe'
|
||||
}
|
||||
|
||||
foreach ($entry in $map.GetEnumerator()) {
|
||||
if (-not (Test-Path -LiteralPath $entry.Value)) {
|
||||
throw "Missing required ActivityWatch binary: $($entry.Value)"
|
||||
}
|
||||
}
|
||||
|
||||
return [pscustomobject]$map
|
||||
}
|
||||
|
||||
function Normalize-ActivityWatchUsers {
|
||||
param(
|
||||
[string[]]$Users,
|
||||
[string]$UserListPath,
|
||||
[string]$Domain
|
||||
)
|
||||
|
||||
$collected = New-Object System.Collections.Generic.List[string]
|
||||
|
||||
if ($Users) {
|
||||
foreach ($user in $Users) {
|
||||
if (-not [string]::IsNullOrWhiteSpace($user)) {
|
||||
$collected.Add($user.Trim())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($UserListPath) {
|
||||
$resolved = Resolve-Path -LiteralPath $UserListPath -ErrorAction Stop
|
||||
$extension = [IO.Path]::GetExtension($resolved.Path)
|
||||
if ($extension -ieq '.csv') {
|
||||
$rows = Import-Csv -LiteralPath $resolved.Path
|
||||
foreach ($row in $rows) {
|
||||
foreach ($column in 'User', 'Username', 'SamAccountName', 'Login') {
|
||||
if ($row.PSObject.Properties.Name -contains $column) {
|
||||
$value = [string]$row.$column
|
||||
if (-not [string]::IsNullOrWhiteSpace($value)) {
|
||||
$collected.Add($value.Trim())
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
Get-Content -LiteralPath $resolved.Path | ForEach-Object {
|
||||
$line = $_.Trim()
|
||||
if ($line -and -not $line.StartsWith('#')) {
|
||||
$collected.Add($line)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$normalized = $collected |
|
||||
Where-Object { -not [string]::IsNullOrWhiteSpace($_) } |
|
||||
ForEach-Object {
|
||||
if ($Domain -and ($_ -notmatch '[\\@]')) {
|
||||
'{0}\{1}' -f $Domain, $_
|
||||
}
|
||||
else {
|
||||
$_
|
||||
}
|
||||
} |
|
||||
Sort-Object -Unique
|
||||
|
||||
if (-not $normalized -or $normalized.Count -eq 0) {
|
||||
throw 'No target users resolved. Provide -Users or -UserListPath.'
|
||||
}
|
||||
|
||||
return @($normalized)
|
||||
}
|
||||
|
||||
function Get-ActivityWatchTaskNameToken {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$UserId
|
||||
)
|
||||
|
||||
$buffer = [Text.StringBuilder]::new()
|
||||
foreach ($character in $UserId.ToCharArray()) {
|
||||
if ([char]::IsLetterOrDigit($character)) {
|
||||
[void]$buffer.Append($character)
|
||||
}
|
||||
else {
|
||||
[void]$buffer.Append('_')
|
||||
}
|
||||
}
|
||||
|
||||
return $buffer.ToString().Trim('_')
|
||||
}
|
||||
|
||||
function New-ActivityWatchUserTaskDefinitions {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string[]]$Users
|
||||
)
|
||||
|
||||
$result = foreach ($user in $Users) {
|
||||
$token = Get-ActivityWatchTaskNameToken -UserId $user
|
||||
[pscustomobject]@{
|
||||
UserId = $user
|
||||
LaunchTaskName = "ActivityWatch Launch [$token]"
|
||||
}
|
||||
}
|
||||
|
||||
return @($result)
|
||||
}
|
||||
|
||||
function Copy-ActivityWatchCollectorAssets {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$CollectorScriptSource,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$EndpointCollectorScriptSource,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ExampleRulesSource,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ExamplePolicySource,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$StateRoot,
|
||||
[string]$CustomRulesSource,
|
||||
[string]$CustomPolicySource
|
||||
)
|
||||
|
||||
New-ActivityWatchDirectory -Path $StateRoot
|
||||
|
||||
$collectorTarget = Join-Path $StateRoot 'browser-domains-native-collector.ps1'
|
||||
$endpointCollectorTarget = Join-Path $StateRoot 'dlp-endpoint-signals-collector.ps1'
|
||||
$exampleRulesTarget = Join-Path $StateRoot 'web-category-rules.example.json'
|
||||
$rulesTarget = Join-Path $StateRoot 'web-category-rules.json'
|
||||
$examplePolicyTarget = Join-Path $StateRoot 'dlp-policy.example.json'
|
||||
$policyTarget = Join-Path $StateRoot 'dlp-policy.json'
|
||||
|
||||
Copy-Item -LiteralPath $CollectorScriptSource -Destination $collectorTarget -Force
|
||||
Copy-Item -LiteralPath $EndpointCollectorScriptSource -Destination $endpointCollectorTarget -Force
|
||||
Copy-Item -LiteralPath $ExampleRulesSource -Destination $exampleRulesTarget -Force
|
||||
Copy-Item -LiteralPath $ExamplePolicySource -Destination $examplePolicyTarget -Force
|
||||
|
||||
if ($CustomRulesSource) {
|
||||
$resolvedRules = Resolve-Path -LiteralPath $CustomRulesSource -ErrorAction Stop
|
||||
Copy-Item -LiteralPath $resolvedRules.Path -Destination $rulesTarget -Force
|
||||
}
|
||||
|
||||
if ($CustomPolicySource) {
|
||||
$resolvedPolicy = Resolve-Path -LiteralPath $CustomPolicySource -ErrorAction Stop
|
||||
Copy-Item -LiteralPath $resolvedPolicy.Path -Destination $policyTarget -Force
|
||||
}
|
||||
else {
|
||||
Copy-Item -LiteralPath $examplePolicyTarget -Destination $policyTarget -Force
|
||||
}
|
||||
|
||||
return [pscustomobject]@{
|
||||
CollectorScript = $collectorTarget
|
||||
EndpointCollectorScript = $endpointCollectorTarget
|
||||
ExampleRules = $exampleRulesTarget
|
||||
ActiveRules = $rulesTarget
|
||||
ExamplePolicy = $examplePolicyTarget
|
||||
ActivePolicy = $policyTarget
|
||||
}
|
||||
}
|
||||
|
||||
function New-ActivityWatchDeploymentConfig {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ServerHost,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[int]$ServerPort,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ServerScheme,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$InstallRoot,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$StateRoot,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$LogsRoot,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$CollectorScript,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$EndpointCollectorScript,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$RulesPath,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$PolicyPath,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[int]$PollSeconds,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[int]$PulseSeconds,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[int]$RecoveryIntervalSeconds,
|
||||
[bool]$AfkEnabled = $true,
|
||||
[bool]$WindowEnabled = $true,
|
||||
[bool]$LocalAgentLogsEnabled = $true,
|
||||
[bool]$IncidentCaptureEnabled = $true,
|
||||
[bool]$IncidentScreenshotEnabled = $true,
|
||||
[string]$IncidentArtifactsRoot,
|
||||
[bool]$LogonMarkerEnabled = $true,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$LaunchScriptPath,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$RecoveryScriptPath,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[pscustomobject[]]$UserTasks,
|
||||
[string]$PackageVersion = 'v0.13.2'
|
||||
)
|
||||
|
||||
$effectiveIncidentArtifactsRoot = if ($IncidentArtifactsRoot) { $IncidentArtifactsRoot } else { Join-Path $StateRoot 'incident-artifacts' }
|
||||
|
||||
return [pscustomobject]@{
|
||||
version = 1
|
||||
generatedAtUtc = (Get-Date).ToUniversalTime().ToString('o')
|
||||
server = [pscustomobject]@{
|
||||
host = $ServerHost
|
||||
port = $ServerPort
|
||||
scheme = $ServerScheme
|
||||
}
|
||||
paths = [pscustomobject]@{
|
||||
installRoot = $InstallRoot
|
||||
stateRoot = $StateRoot
|
||||
logsRoot = $LogsRoot
|
||||
collectorScript = $CollectorScript
|
||||
endpointCollectorScript = $EndpointCollectorScript
|
||||
rulesPath = $RulesPath
|
||||
policyPath = $PolicyPath
|
||||
launchScript = $LaunchScriptPath
|
||||
recoveryScript = $RecoveryScriptPath
|
||||
}
|
||||
collector = [pscustomobject]@{
|
||||
pollSeconds = $PollSeconds
|
||||
pulseSeconds = $PulseSeconds
|
||||
}
|
||||
collectors = [pscustomobject]@{
|
||||
afkEnabled = $AfkEnabled
|
||||
windowEnabled = $WindowEnabled
|
||||
}
|
||||
logging = [pscustomobject]@{
|
||||
localAgentLogsEnabled = $LocalAgentLogsEnabled
|
||||
}
|
||||
incidentCapture = [pscustomobject]@{
|
||||
enabled = $IncidentCaptureEnabled
|
||||
screenshotEnabled = $IncidentScreenshotEnabled
|
||||
artifactsRoot = $effectiveIncidentArtifactsRoot
|
||||
}
|
||||
sessionEvents = [pscustomobject]@{
|
||||
logonEnabled = $LogonMarkerEnabled
|
||||
bucketPrefix = 'aw-session-events'
|
||||
}
|
||||
recovery = [pscustomobject]@{
|
||||
intervalSeconds = $RecoveryIntervalSeconds
|
||||
taskName = 'ActivityWatch Recovery'
|
||||
}
|
||||
dlp = [pscustomobject]@{
|
||||
incidentBucketPrefix = 'aw-dlp-incidents'
|
||||
enabled = $true
|
||||
}
|
||||
package = [pscustomobject]@{
|
||||
version = $PackageVersion
|
||||
}
|
||||
userTasks = @($UserTasks)
|
||||
}
|
||||
}
|
||||
|
||||
function Write-ActivityWatchDeploymentConfig {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[pscustomobject]$Config,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Path
|
||||
)
|
||||
|
||||
$directory = Split-Path -Path $Path -Parent
|
||||
if ($directory) {
|
||||
New-ActivityWatchDirectory -Path $directory
|
||||
}
|
||||
|
||||
$json = $Config | ConvertTo-Json -Depth 8
|
||||
Set-Content -LiteralPath $Path -Value $json -Encoding UTF8
|
||||
}
|
||||
|
||||
function Read-ActivityWatchDeploymentConfig {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Path
|
||||
)
|
||||
|
||||
if (-not (Test-Path -LiteralPath $Path)) {
|
||||
throw "Deployment config not found: $Path"
|
||||
}
|
||||
|
||||
return Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json
|
||||
}
|
||||
|
||||
function Write-ActivityWatchLaunchScript {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Path,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ConfigPath
|
||||
)
|
||||
|
||||
$content = @"
|
||||
param(
|
||||
[string]`$ConfigPath = '$ConfigPath'
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
`$ErrorActionPreference = 'Stop'
|
||||
|
||||
function Get-DeploymentConfig {
|
||||
param([string]`$Path)
|
||||
return Get-Content -LiteralPath `$Path -Raw | ConvertFrom-Json
|
||||
}
|
||||
|
||||
function Test-ProcessInSession {
|
||||
param(
|
||||
[string]`$Name,
|
||||
[int]`$SessionId
|
||||
)
|
||||
|
||||
return [bool](Get-Process -Name `$Name -ErrorAction SilentlyContinue | Where-Object { `$_.SessionId -eq `$SessionId } | Select-Object -First 1)
|
||||
}
|
||||
|
||||
function Test-CollectorRunning {
|
||||
param(
|
||||
[string]`$ScriptPath,
|
||||
[int]`$SessionId
|
||||
)
|
||||
|
||||
`$escapedCollector = [Regex]::Escape(`$ScriptPath)
|
||||
`$processes = Get-CimInstance Win32_Process -ErrorAction SilentlyContinue |
|
||||
Where-Object {
|
||||
(`$_.Name -ieq 'powershell.exe' -or `$_.Name -ieq 'pwsh.exe') -and
|
||||
`$_.SessionId -eq `$SessionId -and
|
||||
`$_.CommandLine -match `$escapedCollector
|
||||
}
|
||||
|
||||
return [bool](`$processes | Select-Object -First 1)
|
||||
}
|
||||
|
||||
function Invoke-AwJsonPost {
|
||||
param(
|
||||
[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
|
||||
}
|
||||
|
||||
function Ensure-Bucket {
|
||||
param(
|
||||
[string]`$BucketId,
|
||||
[string]`$ClientName,
|
||||
[string]`$BucketType
|
||||
)
|
||||
|
||||
if (`$script:KnownBuckets.ContainsKey(`$BucketId)) {
|
||||
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
|
||||
|
||||
try {
|
||||
Invoke-AwJsonPost -Uri "`$(`$script:ApiBase)/buckets/`$BucketId" -Json `$body
|
||||
}
|
||||
catch {
|
||||
try {
|
||||
Invoke-RestMethod -Method Get -Uri "`$(`$script:ApiBase)/buckets/`$BucketId" | Out-Null
|
||||
}
|
||||
catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
`$script:KnownBuckets[`$BucketId] = `$true
|
||||
}
|
||||
|
||||
function Send-LogonMarkerIfNeeded {
|
||||
param(
|
||||
[pscustomobject]`$Config,
|
||||
[int]`$SessionId
|
||||
)
|
||||
|
||||
`$sessionEvents = if (`$Config.PSObject.Properties.Name -contains 'sessionEvents') { `$Config.sessionEvents } else { `$null }
|
||||
`$logging = if (`$Config.PSObject.Properties.Name -contains 'logging') { `$Config.logging } else { `$null }
|
||||
`$logonEnabled = if (`$sessionEvents -and `$sessionEvents.PSObject.Properties.Name -contains 'logonEnabled') { [bool]`$sessionEvents.logonEnabled } else { `$false }
|
||||
if (-not `$logonEnabled) {
|
||||
return
|
||||
}
|
||||
|
||||
`$bucketPrefix = if (`$sessionEvents -and `$sessionEvents.PSObject.Properties.Name -contains 'bucketPrefix' -and -not [string]::IsNullOrWhiteSpace([string]`$sessionEvents.bucketPrefix)) {
|
||||
[string]`$sessionEvents.bucketPrefix
|
||||
}
|
||||
else {
|
||||
'aw-session-events'
|
||||
}
|
||||
|
||||
`$stateRoot = [string]`$Config.paths.stateRoot
|
||||
`$markerRoots = New-Object System.Collections.Generic.List[string]
|
||||
if (-not [string]::IsNullOrWhiteSpace(`$env:LOCALAPPDATA)) {
|
||||
`$markerRoots.Add((Join-Path `$env:LOCALAPPDATA 'ActivityWatch-Phase2\markers'))
|
||||
}
|
||||
if (-not [string]::IsNullOrWhiteSpace(`$stateRoot)) {
|
||||
`$markerRoots.Add((Join-Path `$stateRoot 'markers'))
|
||||
}
|
||||
|
||||
`$markerDir = `$null
|
||||
foreach (`$candidate in `$markerRoots) {
|
||||
try {
|
||||
if (-not (Test-Path -LiteralPath `$candidate)) {
|
||||
New-Item -Path `$candidate -ItemType Directory -Force | Out-Null
|
||||
}
|
||||
|
||||
`$probePath = Join-Path `$candidate 'write-test.tmp'
|
||||
Set-Content -LiteralPath `$probePath -Value 'ok' -Encoding ASCII
|
||||
Remove-Item -LiteralPath `$probePath -Force -ErrorAction SilentlyContinue
|
||||
`$markerDir = `$candidate
|
||||
break
|
||||
}
|
||||
catch {
|
||||
}
|
||||
}
|
||||
|
||||
if (-not `$markerDir) {
|
||||
return
|
||||
}
|
||||
|
||||
`$markerFile = Join-Path `$markerDir ("logon-{0}-{1}.marker" -f `$env:USERNAME, `$SessionId)
|
||||
if (Test-Path -LiteralPath `$markerFile) {
|
||||
return
|
||||
}
|
||||
|
||||
Set-Content -LiteralPath `$markerFile -Value ((Get-Date).ToUniversalTime().ToString('o')) -Encoding UTF8
|
||||
|
||||
`$bucketId = ('{0}_{1}' -f `$bucketPrefix, `$script:Hostname)
|
||||
Ensure-Bucket -BucketId `$bucketId -ClientName 'aw-session-events' -BucketType 'aw.session.event'
|
||||
|
||||
`$payload = @{
|
||||
timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
|
||||
duration = 0
|
||||
data = @{
|
||||
eventType = 'logon'
|
||||
username = `$env:USERNAME
|
||||
userId = "`$(`$env:USERDOMAIN)\`$(`$env:USERNAME)"
|
||||
sessionId = `$SessionId
|
||||
hostname = `$script:Hostname
|
||||
source = 'launch-watchers-phase2'
|
||||
}
|
||||
} | ConvertTo-Json -Depth 5 -Compress
|
||||
|
||||
try {
|
||||
Invoke-AwJsonPost -Uri "`$(`$script:ApiBase)/buckets/`$bucketId/heartbeat?pulsetime=1" -Json `$payload
|
||||
}
|
||||
catch {
|
||||
Remove-Item -LiteralPath `$markerFile -Force -ErrorAction SilentlyContinue
|
||||
throw
|
||||
}
|
||||
}
|
||||
|
||||
function Start-CollectorScriptIfNeeded {
|
||||
param(
|
||||
[string]`$ScriptPath,
|
||||
[string]`$ConfigPath,
|
||||
[string]`$PowerShellExe,
|
||||
[int]`$SessionId
|
||||
)
|
||||
|
||||
if (-not (Test-Path -LiteralPath `$ScriptPath)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (Test-CollectorRunning -ScriptPath `$ScriptPath -SessionId `$SessionId) {
|
||||
return
|
||||
}
|
||||
|
||||
Start-Process -FilePath `$PowerShellExe -ArgumentList @(
|
||||
'-NoProfile',
|
||||
'-WindowStyle', 'Hidden',
|
||||
'-ExecutionPolicy', 'Bypass',
|
||||
'-File', `$ScriptPath,
|
||||
'-ConfigPath', `$ConfigPath
|
||||
) -WindowStyle Hidden
|
||||
}
|
||||
|
||||
`$config = Get-DeploymentConfig -Path `$ConfigPath
|
||||
`$sessionId = (Get-Process -Id `$PID).SessionId
|
||||
`$installRoot = [string]`$config.paths.installRoot
|
||||
`$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:KnownBuckets = @{}
|
||||
`$collectorScript = [string]`$config.paths.collectorScript
|
||||
`$endpointCollectorScript = if (`$config.paths.PSObject.Properties.Name -contains 'endpointCollectorScript') { [string]`$config.paths.endpointCollectorScript } else { '' }
|
||||
`$afkExe = Join-Path `$installRoot 'aw-watcher-afk\aw-watcher-afk.exe'
|
||||
`$windowExe = Join-Path `$installRoot 'aw-watcher-window\aw-watcher-window.exe'
|
||||
`$serverArgs = @('--host', [string]`$config.server.host, '--port', [string]`$config.server.port)
|
||||
`$powershellExe = Join-Path `$env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'
|
||||
`$afkEnabled = if (`$config.PSObject.Properties.Name -contains 'collectors' -and `$config.collectors.PSObject.Properties.Name -contains 'afkEnabled') { [bool]`$config.collectors.afkEnabled } else { `$true }
|
||||
`$windowEnabled = if (`$config.PSObject.Properties.Name -contains 'collectors' -and `$config.collectors.PSObject.Properties.Name -contains 'windowEnabled') { [bool]`$config.collectors.windowEnabled } else { `$true }
|
||||
|
||||
if (`$afkEnabled -and -not (Test-Path -LiteralPath `$afkExe)) {
|
||||
throw "Missing aw-watcher-afk.exe: `$afkExe"
|
||||
}
|
||||
|
||||
if (`$windowEnabled -and -not (Test-Path -LiteralPath `$windowExe)) {
|
||||
throw "Missing aw-watcher-window.exe: `$windowExe"
|
||||
}
|
||||
|
||||
if (`$afkEnabled -and -not (Test-ProcessInSession -Name 'aw-watcher-afk' -SessionId `$sessionId)) {
|
||||
Start-Process -FilePath `$afkExe -ArgumentList `$serverArgs -WindowStyle Hidden
|
||||
}
|
||||
|
||||
if (`$windowEnabled -and -not (Test-ProcessInSession -Name 'aw-watcher-window' -SessionId `$sessionId)) {
|
||||
Start-Process -FilePath `$windowExe -ArgumentList `$serverArgs -WindowStyle Hidden
|
||||
}
|
||||
|
||||
try {
|
||||
Send-LogonMarkerIfNeeded -Config `$config -SessionId `$sessionId
|
||||
}
|
||||
catch {
|
||||
}
|
||||
Start-CollectorScriptIfNeeded -ScriptPath `$collectorScript -ConfigPath `$ConfigPath -PowerShellExe `$powershellExe -SessionId `$sessionId
|
||||
Start-CollectorScriptIfNeeded -ScriptPath `$endpointCollectorScript -ConfigPath `$ConfigPath -PowerShellExe `$powershellExe -SessionId `$sessionId
|
||||
"@
|
||||
|
||||
Set-Content -LiteralPath $Path -Value $content -Encoding UTF8
|
||||
}
|
||||
|
||||
function Write-ActivityWatchRecoveryScript {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Path,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ConfigPath
|
||||
)
|
||||
|
||||
$content = @"
|
||||
param(
|
||||
[string]`$ConfigPath = '$ConfigPath'
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
`$ErrorActionPreference = 'Continue'
|
||||
|
||||
function Get-DeploymentConfig {
|
||||
param([string]`$Path)
|
||||
return Get-Content -LiteralPath `$Path -Raw | ConvertFrom-Json
|
||||
}
|
||||
|
||||
function Get-RecoveryConfigPaths {
|
||||
param([string]`$PrimaryConfigPath)
|
||||
|
||||
`$paths = New-Object System.Collections.Generic.List[string]
|
||||
if (`$PrimaryConfigPath -and (Test-Path -LiteralPath `$PrimaryConfigPath)) {
|
||||
`$paths.Add((Resolve-Path -LiteralPath `$PrimaryConfigPath).Path)
|
||||
}
|
||||
|
||||
`$searchRoot = `$env:ProgramData
|
||||
if (`$PrimaryConfigPath) {
|
||||
`$stateRoot = Split-Path -Path `$PrimaryConfigPath -Parent
|
||||
`$candidateRoot = Split-Path -Path `$stateRoot -Parent
|
||||
if (`$candidateRoot -and (Test-Path -LiteralPath `$candidateRoot)) {
|
||||
`$searchRoot = `$candidateRoot
|
||||
}
|
||||
}
|
||||
|
||||
if (Test-Path -LiteralPath `$searchRoot) {
|
||||
Get-ChildItem -LiteralPath `$searchRoot -Directory -ErrorAction SilentlyContinue |
|
||||
Where-Object { `$_.Name -like 'ActivityWatch*' } |
|
||||
ForEach-Object {
|
||||
`$candidate = Join-Path `$_.FullName 'deployment-config.json'
|
||||
if (Test-Path -LiteralPath `$candidate) {
|
||||
`$paths.Add(`$candidate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return @(`$paths | Sort-Object -Unique)
|
||||
}
|
||||
|
||||
function Get-RecoveryTaskNames {
|
||||
param([string[]]`$ConfigPaths)
|
||||
|
||||
`$taskNames = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
}
|
||||
}
|
||||
|
||||
return @(`$taskNames)
|
||||
}
|
||||
|
||||
while (`$true) {
|
||||
`$sleepSeconds = 180
|
||||
try {
|
||||
`$configPaths = Get-RecoveryConfigPaths -PrimaryConfigPath `$ConfigPath
|
||||
foreach (`$taskName in Get-RecoveryTaskNames -ConfigPaths `$configPaths) {
|
||||
Start-ScheduledTask -TaskName `$taskName -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
`$config = Get-DeploymentConfig -Path `$ConfigPath
|
||||
if (`$config -and `$config.recovery -and `$config.recovery.intervalSeconds) {
|
||||
`$sleepSeconds = [Math]::Max([int]`$config.recovery.intervalSeconds, 30)
|
||||
}
|
||||
}
|
||||
catch {
|
||||
}
|
||||
|
||||
Start-Sleep -Seconds `$sleepSeconds
|
||||
}
|
||||
"@
|
||||
|
||||
Set-Content -LiteralPath $Path -Value $content -Encoding UTF8
|
||||
}
|
||||
|
||||
function Get-ActivityWatchHiddenLauncherPath {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ScriptPath
|
||||
)
|
||||
|
||||
$directory = Split-Path -Path $ScriptPath -Parent
|
||||
$baseName = [IO.Path]::GetFileNameWithoutExtension($ScriptPath)
|
||||
return Join-Path $directory ("{0}-hidden.vbs" -f $baseName)
|
||||
}
|
||||
|
||||
function Write-ActivityWatchHiddenPowerShellWrapper {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Path,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ScriptPath,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ConfigPath
|
||||
)
|
||||
|
||||
$directory = Split-Path -Path $Path -Parent
|
||||
if ($directory) {
|
||||
New-ActivityWatchDirectory -Path $directory
|
||||
}
|
||||
|
||||
$powershellExe = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'
|
||||
$escapedPowerShellExe = $powershellExe.Replace('"', '""')
|
||||
$escapedScriptPath = $ScriptPath.Replace('"', '""')
|
||||
$escapedConfigPath = $ConfigPath.Replace('"', '""')
|
||||
|
||||
$content = @"
|
||||
Set shell = CreateObject("WScript.Shell")
|
||||
shell.Run """$escapedPowerShellExe"" -NoProfile -ExecutionPolicy Bypass -File ""$escapedScriptPath"" -ConfigPath ""$escapedConfigPath""", 0, False
|
||||
"@
|
||||
|
||||
Set-Content -LiteralPath $Path -Value $content -Encoding ASCII
|
||||
}
|
||||
|
||||
function Remove-LegacyActivityWatchEntries {
|
||||
$legacyTaskNames = @(
|
||||
'ActivityWatch Watchers',
|
||||
'ActivityWatch Guard',
|
||||
'ActivityWatch Heal'
|
||||
)
|
||||
|
||||
foreach ($taskName in $legacyTaskNames) {
|
||||
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
$runKey = 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Run'
|
||||
foreach ($name in 'ActivityWatchAFK', 'ActivityWatchWindow', 'ActivityWatchBrowserCollector') {
|
||||
Remove-ItemProperty -Path $runKey -Name $name -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
function Remove-ActivityWatchScheduledTask {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$TaskName
|
||||
)
|
||||
|
||||
Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue
|
||||
& cmd.exe /c "schtasks /Delete /TN `"$TaskName`" /F >nul 2>&1" | Out-Null
|
||||
|
||||
for ($attempt = 0; $attempt -lt 10; $attempt++) {
|
||||
$task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
|
||||
if (-not $task) {
|
||||
return
|
||||
}
|
||||
|
||||
Start-Sleep -Milliseconds 300
|
||||
}
|
||||
}
|
||||
|
||||
function Set-ActivityWatchScheduledTaskAction {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$TaskName,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Execute,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[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 failed for $TaskName"
|
||||
}
|
||||
}
|
||||
|
||||
function Get-ActivityWatchScheduledTaskByCommand {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$TaskName,
|
||||
[string]$CommandMatch
|
||||
)
|
||||
|
||||
$task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
|
||||
if ($task) {
|
||||
return $task
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($CommandMatch)) {
|
||||
return $null
|
||||
}
|
||||
|
||||
foreach ($candidate in @(Get-ScheduledTask | Where-Object { $_.TaskName -like 'ActivityWatch Launch*' })) {
|
||||
foreach ($action in @($candidate.Actions)) {
|
||||
if ([string]$action.Arguments -like "*$CommandMatch*") {
|
||||
return $candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
function Register-ActivityWatchUserTasks {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[pscustomobject[]]$TaskDefinitions,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$LaunchScriptPath,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ConfigPath
|
||||
)
|
||||
|
||||
$wscriptExe = Join-Path $env:SystemRoot 'System32\wscript.exe'
|
||||
$launcherPath = Get-ActivityWatchHiddenLauncherPath -ScriptPath $LaunchScriptPath
|
||||
Write-ActivityWatchHiddenPowerShellWrapper -Path $launcherPath -ScriptPath $LaunchScriptPath -ConfigPath $ConfigPath
|
||||
|
||||
foreach ($definition in $TaskDefinitions) {
|
||||
$action = New-ScheduledTaskAction -Execute $wscriptExe -Argument "//B //NoLogo `"$launcherPath`""
|
||||
$trigger = New-ScheduledTaskTrigger -AtLogOn -User $definition.UserId
|
||||
$principal = New-ScheduledTaskPrincipal -UserId $definition.UserId -LogonType Interactive -RunLevel Highest
|
||||
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -StartWhenAvailable -MultipleInstances IgnoreNew -ExecutionTimeLimit (New-TimeSpan -Hours 0)
|
||||
$existingTask = Get-ActivityWatchScheduledTaskByCommand -TaskName $definition.LaunchTaskName -CommandMatch $ConfigPath
|
||||
|
||||
if ($existingTask) {
|
||||
Set-ActivityWatchScheduledTaskAction -TaskName $existingTask.TaskName -Execute $wscriptExe -Arguments $action.Arguments
|
||||
continue
|
||||
}
|
||||
|
||||
Remove-ActivityWatchScheduledTask -TaskName $definition.LaunchTaskName
|
||||
Register-ScheduledTask -TaskName $definition.LaunchTaskName -Action $action -Trigger $trigger -Principal $principal -Settings $settings | Out-Null
|
||||
}
|
||||
}
|
||||
|
||||
function Register-ActivityWatchRecoveryTask {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$TaskName,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$RecoveryScriptPath,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ConfigPath
|
||||
)
|
||||
|
||||
Remove-ActivityWatchScheduledTask -TaskName $TaskName
|
||||
|
||||
$wscriptExe = Join-Path $env:SystemRoot 'System32\wscript.exe'
|
||||
$launcherPath = Get-ActivityWatchHiddenLauncherPath -ScriptPath $RecoveryScriptPath
|
||||
Write-ActivityWatchHiddenPowerShellWrapper -Path $launcherPath -ScriptPath $RecoveryScriptPath -ConfigPath $ConfigPath
|
||||
$action = New-ScheduledTaskAction -Execute $wscriptExe -Argument "//B //NoLogo `"$launcherPath`""
|
||||
$trigger = New-ScheduledTaskTrigger -AtStartup
|
||||
$principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest
|
||||
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -StartWhenAvailable -Hidden -MultipleInstances IgnoreNew -ExecutionTimeLimit (New-TimeSpan -Hours 0)
|
||||
|
||||
Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger -Principal $principal -Settings $settings | Out-Null
|
||||
}
|
||||
|
||||
function Set-ActivityWatchAcl {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$InstallRoot,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$StateRoot,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$LogsRoot
|
||||
)
|
||||
|
||||
foreach ($path in $InstallRoot, $StateRoot, $LogsRoot) {
|
||||
New-ActivityWatchDirectory -Path $path
|
||||
}
|
||||
|
||||
& icacls $InstallRoot /inheritance:r /grant:r '*S-1-5-18:(OI)(CI)(F)' '*S-1-5-32-544:(OI)(CI)(F)' '*S-1-5-32-545:(OI)(CI)(RX)' | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "icacls failed for $InstallRoot"
|
||||
}
|
||||
|
||||
& icacls $StateRoot /inheritance:r /grant:r '*S-1-5-18:(OI)(CI)(F)' '*S-1-5-32-544:(OI)(CI)(F)' '*S-1-5-32-545:(OI)(CI)(RX)' | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "icacls failed for $StateRoot"
|
||||
}
|
||||
|
||||
& icacls $LogsRoot /inheritance:r /grant:r '*S-1-5-18:(OI)(CI)(F)' '*S-1-5-32-544:(OI)(CI)(F)' '*S-1-5-32-545:(OI)(CI)(M)' | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "icacls failed for $LogsRoot"
|
||||
}
|
||||
}
|
||||
|
||||
function Start-ActivityWatchTasks {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[pscustomobject[]]$TaskDefinitions,
|
||||
[string]$RecoveryTaskName = 'ActivityWatch Recovery'
|
||||
)
|
||||
|
||||
foreach ($definition in $TaskDefinitions) {
|
||||
Start-ScheduledTask -TaskName $definition.LaunchTaskName -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
Start-ScheduledTask -TaskName $RecoveryTaskName -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
Export-ModuleMember -Function *-ActivityWatch*, Assert-Administrator, Normalize-ActivityWatchUsers, Get-ActivityWatchPackageUrl, Remove-LegacyActivityWatchEntries
|
||||
+822
@@ -0,0 +1,822 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$ConfigPath = 'C:\ProgramData\ActivityWatch\deployment-config.json',
|
||||
[string]$ServerHost,
|
||||
[int]$ServerPort,
|
||||
[ValidateSet('http', 'https')]
|
||||
[string]$ServerScheme,
|
||||
[string]$RulesPath,
|
||||
[string]$PolicyPath,
|
||||
[string]$LogPath,
|
||||
[string]$IncidentLogPath,
|
||||
[int]$PollSeconds,
|
||||
[int]$PulseSeconds
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
Add-Type -AssemblyName UIAutomationClient
|
||||
Add-Type -AssemblyName UIAutomationTypes
|
||||
|
||||
Add-Type @"
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
|
||||
public static class NativeAwMethods {
|
||||
[DllImport("user32.dll")]
|
||||
public static extern IntPtr GetForegroundWindow();
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId);
|
||||
|
||||
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
|
||||
public static extern int GetWindowText(IntPtr hWnd, StringBuilder lpString, int nMaxCount);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
public static extern int GetWindowTextLength(IntPtr hWnd);
|
||||
}
|
||||
"@
|
||||
|
||||
function Get-DeploymentConfig {
|
||||
param([string]$Path)
|
||||
if ($Path -and (Test-Path -LiteralPath $Path)) {
|
||||
return Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
$deploymentConfig = Get-DeploymentConfig -Path $ConfigPath
|
||||
$resolvedServerHost = if ($ServerHost) { $ServerHost } elseif ($deploymentConfig) { [string]$deploymentConfig.server.host } else { throw 'ServerHost is required.' }
|
||||
$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' }
|
||||
$resolvedRulesPath = if ($RulesPath) { $RulesPath } elseif ($deploymentConfig) { [string]$deploymentConfig.paths.rulesPath } else { 'C:\ProgramData\ActivityWatch\web-category-rules.json' }
|
||||
$resolvedPolicyPath = if ($PolicyPath) { $PolicyPath } elseif ($deploymentConfig) { [string]$deploymentConfig.paths.policyPath } else { 'C:\ProgramData\ActivityWatch\dlp-policy.json' }
|
||||
$resolvedPollSeconds = if ($PSBoundParameters.ContainsKey('PollSeconds')) { $PollSeconds } elseif ($deploymentConfig) { [int]$deploymentConfig.collector.pollSeconds } else { 5 }
|
||||
$resolvedPulseSeconds = if ($PSBoundParameters.ContainsKey('PulseSeconds')) { $PulseSeconds } elseif ($deploymentConfig) { [int]$deploymentConfig.collector.pulseSeconds } else { 30 }
|
||||
$resolvedLogsRoot = if ($deploymentConfig) { [string]$deploymentConfig.paths.logsRoot } else { 'C:\ProgramData\ActivityWatch\logs' }
|
||||
$resolvedLogPath = if ($LogPath) { $LogPath } else { Join-Path $resolvedLogsRoot ("browser-domains-{0}.log" -f $env:USERNAME) }
|
||||
$resolvedIncidentLogPath = if ($IncidentLogPath) { $IncidentLogPath } else { Join-Path $resolvedLogsRoot ("dlp-incidents-{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 'ActivityWatch-Phase2\\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 }
|
||||
|
||||
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:SessionId = (Get-Process -Id $PID).SessionId
|
||||
$script:KnownBuckets = @{}
|
||||
$script:LocalAgentLogsEnabled = $resolvedLocalAgentLogsEnabled
|
||||
$script:LogPath = $resolvedLogPath
|
||||
$script:IncidentLogPath = $resolvedIncidentLogPath
|
||||
$script:IncidentArtifactsRoot = $resolvedIncidentArtifactsRoot
|
||||
$script:IncidentScreenshotEnabled = $resolvedIncidentScreenshotEnabled
|
||||
$script:ScreenshotTypesLoaded = $false
|
||||
$script:IncidentState = @{}
|
||||
$script:DlpRules = @()
|
||||
$script:DlpDefaults = [ordered]@{
|
||||
enabled = $false
|
||||
cooldownSeconds = 300
|
||||
action = 'log'
|
||||
severity = 'low'
|
||||
}
|
||||
$script:BrowserMap = @{
|
||||
msedge = 'edge'
|
||||
chrome = 'chrome'
|
||||
brave = 'brave'
|
||||
vivaldi = 'vivaldi'
|
||||
opera = 'opera'
|
||||
firefox = 'firefox'
|
||||
}
|
||||
$script:CategoryRules = @(
|
||||
@{ Name = 'work_business_systems'; Group = 'work'; Domains = @('bitrix24.ru', '1c.ru', 'sbis.ru', 'kontur.ru', 'diadoc.ru', 'nalog.gov.ru', 'gosuslugi.ru') }
|
||||
@{ Name = 'work_docs_collab'; Group = 'work'; Domains = @('office.com', 'sharepoint.com', 'docs.google.com', 'drive.google.com', 'notion.so', 'miro.com') }
|
||||
@{ Name = 'work_dev'; Group = 'work'; Domains = @('github.com', 'gitlab.com', 'bitbucket.org', 'youtrack.cloud', 'atlassian.net') }
|
||||
@{ Name = 'work_communication'; Group = 'work'; Domains = @('teams.microsoft.com', 'outlook.office.com', 'web.telegram.org', 'slack.com', 'zoom.us') }
|
||||
@{ Name = 'neutral_search_reference'; Group = 'neutral'; Domains = @('google.com', 'google.ru', 'yandex.ru', 'bing.com', 'duckduckgo.com', 'wikipedia.org') }
|
||||
@{ Name = 'neutral_news'; Group = 'neutral'; Domains = @('rbc.ru', 'tass.ru', 'ria.ru', 'kommersant.ru', 'vedomosti.ru') }
|
||||
@{ Name = 'personal_social'; Group = 'personal'; Domains = @('vk.com', 'ok.ru', 'facebook.com', 'instagram.com', 'tiktok.com', 'x.com', 'twitter.com') }
|
||||
@{ Name = 'personal_video'; Group = 'personal'; Domains = @('youtube.com', 'youtu.be', 'rutube.ru', 'twitch.tv', 'kinopoisk.ru') }
|
||||
@{ Name = 'personal_marketplace'; Group = 'personal'; Domains = @('ozon.ru', 'wildberries.ru', 'avito.ru', 'aliexpress.com', 'market.yandex.ru') }
|
||||
@{ Name = 'personal_entertainment'; Group = 'personal'; Domains = @('dzen.ru', 'pikabu.ru', 'dtf.ru', 'playground.ru') }
|
||||
)
|
||||
|
||||
function Write-CollectorLog {
|
||||
param([string]$Message)
|
||||
|
||||
if (-not $script:LocalAgentLogsEnabled) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
Add-Content -LiteralPath $script:LogPath -Value ('{0} {1}' -f (Get-Date -Format s), $Message)
|
||||
}
|
||||
catch {
|
||||
}
|
||||
}
|
||||
|
||||
function Write-DlpIncidentLog {
|
||||
param([string]$Message)
|
||||
|
||||
if (-not $script:LocalAgentLogsEnabled) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
Add-Content -LiteralPath $script:IncidentLogPath -Value ('{0} {1}' -f (Get-Date -Format s), $Message)
|
||||
}
|
||||
catch {
|
||||
}
|
||||
}
|
||||
|
||||
function Test-DomainMatch {
|
||||
param(
|
||||
[string]$DomainHost,
|
||||
[string]$RuleDomain
|
||||
)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($DomainHost) -or [string]::IsNullOrWhiteSpace($RuleDomain)) {
|
||||
return $false
|
||||
}
|
||||
|
||||
$left = $DomainHost.ToLowerInvariant()
|
||||
$right = $RuleDomain.ToLowerInvariant()
|
||||
return $left -eq $right -or $left.EndsWith('.' + $right)
|
||||
}
|
||||
|
||||
function Get-HostFromUrl {
|
||||
param([string]$Url)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($Url)) {
|
||||
return $null
|
||||
}
|
||||
|
||||
try {
|
||||
$uri = [Uri]$Url
|
||||
$host = $uri.Host.ToLowerInvariant()
|
||||
if ($host.StartsWith('www.')) {
|
||||
return $host.Substring(4)
|
||||
}
|
||||
|
||||
return $host
|
||||
}
|
||||
catch {
|
||||
return $null
|
||||
}
|
||||
}
|
||||
|
||||
function Get-RootDomain {
|
||||
param([string]$DomainHost)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($DomainHost)) {
|
||||
return $null
|
||||
}
|
||||
|
||||
$parts = $DomainHost.Split('.')
|
||||
if ($parts.Count -le 2) {
|
||||
return $DomainHost
|
||||
}
|
||||
|
||||
$suffix = ('{0}.{1}' -f $parts[$parts.Count - 2], $parts[$parts.Count - 1]).ToLowerInvariant()
|
||||
$compoundTlds = @('co.uk', 'com.au', 'co.jp', 'com.br', 'co.in', 'com.tr', 'com.cn')
|
||||
if (($compoundTlds -contains $suffix) -and $parts.Count -ge 3) {
|
||||
return ('{0}.{1}' -f $parts[$parts.Count - 3], $suffix).ToLowerInvariant()
|
||||
}
|
||||
|
||||
return $suffix
|
||||
}
|
||||
|
||||
function ConvertTo-NormalizedUrl {
|
||||
param([AllowNull()][string]$Value)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($Value)) {
|
||||
return $null
|
||||
}
|
||||
|
||||
$candidate = $Value.Trim()
|
||||
if ($candidate.Length -lt 4) {
|
||||
return $null
|
||||
}
|
||||
|
||||
if ($candidate -match '^(?i)(search|find|address and search|search with|новая вкладка|new tab)') {
|
||||
return $null
|
||||
}
|
||||
|
||||
if ($candidate -match '^(?i)(https?|file|ftp|chrome|edge|about|view-source)://') {
|
||||
return $candidate
|
||||
}
|
||||
|
||||
if ($candidate -match '^(?i)localhost([/:]|$)') {
|
||||
return "http://$candidate"
|
||||
}
|
||||
|
||||
if ($candidate -match '^[a-z0-9.-]+\.[a-z]{2,}([/:?#].*)?$') {
|
||||
return "https://$candidate"
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
function Load-CustomCategoryRules {
|
||||
param([string]$Path)
|
||||
|
||||
if (-not $Path -or -not (Test-Path -LiteralPath $Path)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
$parsed = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json
|
||||
$rules = @()
|
||||
|
||||
if ($parsed.rules) {
|
||||
$sourceRules = @($parsed.rules)
|
||||
}
|
||||
elseif ($parsed -is [System.Collections.IEnumerable]) {
|
||||
$sourceRules = @($parsed)
|
||||
}
|
||||
else {
|
||||
$sourceRules = @()
|
||||
}
|
||||
|
||||
foreach ($rule in $sourceRules) {
|
||||
if (-not $rule) {
|
||||
continue
|
||||
}
|
||||
|
||||
$name = [string]$rule.name
|
||||
$group = [string]$rule.group
|
||||
$domains = @($rule.domains | ForEach-Object { ([string]$_).Trim().ToLowerInvariant() } | Where-Object { $_ })
|
||||
|
||||
if ($name -and $group -and $domains.Count -gt 0) {
|
||||
$rules += @{
|
||||
Name = $name
|
||||
Group = $group
|
||||
Domains = $domains
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($rules.Count -gt 0) {
|
||||
$script:CategoryRules = @($rules) + @($script:CategoryRules)
|
||||
Write-CollectorLog ("custom rules loaded: {0}" -f $rules.Count)
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-CollectorLog ("custom rules load failed: {0}" -f $_.Exception.Message)
|
||||
}
|
||||
}
|
||||
|
||||
function Get-WebCategory {
|
||||
param([string]$DomainHost)
|
||||
|
||||
foreach ($rule in $script:CategoryRules) {
|
||||
foreach ($domain in $rule.Domains) {
|
||||
if (Test-DomainMatch -DomainHost $DomainHost -RuleDomain $domain) {
|
||||
return [pscustomobject]@{
|
||||
Name = [string]$rule.Name
|
||||
Group = [string]$rule.Group
|
||||
Rule = [string]$domain
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [pscustomobject]@{
|
||||
Name = 'uncategorized'
|
||||
Group = 'neutral'
|
||||
Rule = 'none'
|
||||
}
|
||||
}
|
||||
|
||||
function Test-DomainListMatch {
|
||||
param(
|
||||
[string]$DomainHost,
|
||||
[string[]]$Domains
|
||||
)
|
||||
|
||||
if (-not $Domains -or $Domains.Count -eq 0) {
|
||||
return $false
|
||||
}
|
||||
|
||||
foreach ($domain in $Domains) {
|
||||
if (Test-DomainMatch -DomainHost $DomainHost -RuleDomain $domain) {
|
||||
return $true
|
||||
}
|
||||
}
|
||||
|
||||
return $false
|
||||
}
|
||||
|
||||
function Test-DlpRuleTimeWindow {
|
||||
param(
|
||||
[int]$CurrentHour,
|
||||
[AllowNull()][int]$HourFrom,
|
||||
[AllowNull()][int]$HourTo
|
||||
)
|
||||
|
||||
if ($null -eq $HourFrom -or $null -eq $HourTo) {
|
||||
return $true
|
||||
}
|
||||
|
||||
if ($HourFrom -eq $HourTo) {
|
||||
return $true
|
||||
}
|
||||
|
||||
if ($HourFrom -lt $HourTo) {
|
||||
return ($CurrentHour -ge $HourFrom -and $CurrentHour -lt $HourTo)
|
||||
}
|
||||
|
||||
return ($CurrentHour -ge $HourFrom -or $CurrentHour -lt $HourTo)
|
||||
}
|
||||
|
||||
function Load-DlpPolicy {
|
||||
param([string]$Path)
|
||||
|
||||
if (-not $Path -or -not (Test-Path -LiteralPath $Path)) {
|
||||
Write-CollectorLog ("dlp policy not found, disabled: {0}" -f $Path)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
$parsed = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json
|
||||
$defaults = $parsed.defaults
|
||||
if ($defaults) {
|
||||
if ($defaults.PSObject.Properties.Name -contains 'enabled') {
|
||||
$script:DlpDefaults.enabled = [bool]$defaults.enabled
|
||||
}
|
||||
if ($defaults.cooldownSeconds) {
|
||||
$script:DlpDefaults.cooldownSeconds = [int]$defaults.cooldownSeconds
|
||||
}
|
||||
if ($defaults.action) {
|
||||
$script:DlpDefaults.action = [string]$defaults.action
|
||||
}
|
||||
if ($defaults.severity) {
|
||||
$script:DlpDefaults.severity = [string]$defaults.severity
|
||||
}
|
||||
}
|
||||
|
||||
$loaded = @()
|
||||
foreach ($rule in @($parsed.rules)) {
|
||||
if (-not $rule) { continue }
|
||||
$when = $rule.when
|
||||
if (-not $when) {
|
||||
$when = [pscustomobject]@{}
|
||||
}
|
||||
$loaded += [pscustomobject]@{
|
||||
id = [string]$rule.id
|
||||
enabled = if ($rule.PSObject.Properties.Name -contains 'enabled') { [bool]$rule.enabled } else { $true }
|
||||
action = if ($rule.action) { [string]$rule.action } else { [string]$script:DlpDefaults.action }
|
||||
severity = if ($rule.severity) { [string]$rule.severity } else { [string]$script:DlpDefaults.severity }
|
||||
message = if ($rule.message) { [string]$rule.message } else { "DLP rule matched: $($rule.id)" }
|
||||
cooldownSeconds = if ($rule.cooldownSeconds) { [int]$rule.cooldownSeconds } else { [int]$script:DlpDefaults.cooldownSeconds }
|
||||
when = [pscustomobject]@{
|
||||
domains = if ($when.PSObject.Properties.Name -contains 'domains') { @($when.domains | ForEach-Object { ([string]$_).Trim().ToLowerInvariant() } | Where-Object { $_ }) } else { @() }
|
||||
categoryGroups = if ($when.PSObject.Properties.Name -contains 'categoryGroups') { @($when.categoryGroups | ForEach-Object { ([string]$_).Trim().ToLowerInvariant() } | Where-Object { $_ }) } else { @() }
|
||||
categories = if ($when.PSObject.Properties.Name -contains 'categories') { @($when.categories | ForEach-Object { ([string]$_).Trim().ToLowerInvariant() } | Where-Object { $_ }) } else { @() }
|
||||
browsers = if ($when.PSObject.Properties.Name -contains 'browsers') { @($when.browsers | ForEach-Object { ([string]$_).Trim().ToLowerInvariant() } | Where-Object { $_ }) } else { @() }
|
||||
urlRegex = if ($when.PSObject.Properties.Name -contains 'urlRegex' -and $when.urlRegex) { [string]$when.urlRegex } else { $null }
|
||||
titleRegex = if ($when.PSObject.Properties.Name -contains 'titleRegex' -and $when.titleRegex) { [string]$when.titleRegex } else { $null }
|
||||
hourFrom = if ($when.PSObject.Properties.Name -contains 'hourFrom') { [int]$when.hourFrom } else { $null }
|
||||
hourTo = if ($when.PSObject.Properties.Name -contains 'hourTo') { [int]$when.hourTo } else { $null }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$script:DlpRules = @($loaded)
|
||||
Write-CollectorLog ("dlp policy loaded: enabled={0}, rules={1}" -f $script:DlpDefaults.enabled, $script:DlpRules.Count)
|
||||
}
|
||||
catch {
|
||||
Write-CollectorLog ("dlp policy parse failed: {0}" -f $_.Exception.Message)
|
||||
}
|
||||
}
|
||||
|
||||
function Test-DlpRuleMatch {
|
||||
param(
|
||||
[pscustomobject]$Rule,
|
||||
[string]$Domain,
|
||||
[string]$RootDomain,
|
||||
[string]$Url,
|
||||
[string]$Title,
|
||||
[string]$BrowserKey,
|
||||
[string]$Category,
|
||||
[string]$CategoryGroup
|
||||
)
|
||||
|
||||
if (-not $Rule.enabled) {
|
||||
return $false
|
||||
}
|
||||
|
||||
$when = $Rule.when
|
||||
$currentHour = (Get-Date).Hour
|
||||
if (-not (Test-DlpRuleTimeWindow -CurrentHour $currentHour -HourFrom $when.hourFrom -HourTo $when.hourTo)) {
|
||||
return $false
|
||||
}
|
||||
|
||||
if ($when.domains.Count -gt 0) {
|
||||
$domainMatched = (Test-DomainListMatch -DomainHost $Domain -Domains $when.domains) -or (Test-DomainListMatch -DomainHost $RootDomain -Domains $when.domains)
|
||||
if (-not $domainMatched) {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
if ($when.categoryGroups.Count -gt 0 -and ($when.categoryGroups -notcontains $CategoryGroup.ToLowerInvariant())) {
|
||||
return $false
|
||||
}
|
||||
|
||||
if ($when.categories.Count -gt 0 -and ($when.categories -notcontains $Category.ToLowerInvariant())) {
|
||||
return $false
|
||||
}
|
||||
|
||||
if ($when.browsers.Count -gt 0 -and ($when.browsers -notcontains $BrowserKey.ToLowerInvariant())) {
|
||||
return $false
|
||||
}
|
||||
|
||||
if ($when.urlRegex) {
|
||||
if (-not ($Url -match $when.urlRegex)) {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
if ($when.titleRegex) {
|
||||
if (-not ($Title -match $when.titleRegex)) {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
return $true
|
||||
}
|
||||
|
||||
function Get-DlpDecision {
|
||||
param(
|
||||
[string]$Domain,
|
||||
[string]$RootDomain,
|
||||
[string]$Url,
|
||||
[string]$Title,
|
||||
[string]$BrowserKey,
|
||||
[string]$Category,
|
||||
[string]$CategoryGroup
|
||||
)
|
||||
|
||||
if (-not $script:DlpDefaults.enabled) {
|
||||
return $null
|
||||
}
|
||||
|
||||
foreach ($rule in $script:DlpRules) {
|
||||
if (Test-DlpRuleMatch -Rule $rule -Domain $Domain -RootDomain $RootDomain -Url $Url -Title $Title -BrowserKey $BrowserKey -Category $Category -CategoryGroup $CategoryGroup) {
|
||||
return $rule
|
||||
}
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
function Should-EmitIncident {
|
||||
param(
|
||||
[string]$Fingerprint,
|
||||
[int]$CooldownSeconds
|
||||
)
|
||||
|
||||
$now = (Get-Date).ToUniversalTime()
|
||||
if ($script:IncidentState.ContainsKey($Fingerprint)) {
|
||||
$last = [datetime]$script:IncidentState[$Fingerprint]
|
||||
if ((New-TimeSpan -Start $last -End $now).TotalSeconds -lt $CooldownSeconds) {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
$script:IncidentState[$Fingerprint] = $now
|
||||
return $true
|
||||
}
|
||||
|
||||
function Send-DlpIncidentHeartbeat {
|
||||
param(
|
||||
[pscustomobject]$Decision,
|
||||
[string]$Url,
|
||||
[string]$Title,
|
||||
[string]$BrowserKey,
|
||||
[string]$ProcessName,
|
||||
[string]$Domain,
|
||||
[string]$RootDomain,
|
||||
[string]$Category,
|
||||
[string]$CategoryGroup
|
||||
)
|
||||
|
||||
$bucketId = 'aw-dlp-incidents_' + $script:Hostname
|
||||
Ensure-Bucket -BucketId $bucketId -ClientName 'aw-dlp-incidents' -BucketType 'aw.dlp.incident'
|
||||
|
||||
$captureData = @{}
|
||||
if ($script:IncidentScreenshotEnabled) {
|
||||
try {
|
||||
$captureData = Capture-IncidentScreenshot -RuleId ([string]$Decision.id) -SignalType 'web'
|
||||
}
|
||||
catch {
|
||||
}
|
||||
}
|
||||
|
||||
$event = @{
|
||||
timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
|
||||
duration = 0
|
||||
data = @{
|
||||
ruleId = [string]$Decision.id
|
||||
action = [string]$Decision.action
|
||||
severity = [string]$Decision.severity
|
||||
message = [string]$Decision.message
|
||||
url = $Url
|
||||
title = $Title
|
||||
browser = $BrowserKey
|
||||
app = "$ProcessName.exe"
|
||||
domain = $Domain
|
||||
rootDomain = $RootDomain
|
||||
category = $Category
|
||||
categoryGroup = $CategoryGroup
|
||||
username = $env:USERNAME
|
||||
hostname = $script:Hostname
|
||||
sessionId = $script:SessionId
|
||||
source = 'uia-native-dlp'
|
||||
} + $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
|
||||
}
|
||||
|
||||
function Get-FileSha256Hex {
|
||||
param([Parameter(Mandatory = $true)][string]$Path)
|
||||
try {
|
||||
$sha = [Security.Cryptography.SHA256]::Create()
|
||||
$stream = [IO.File]::OpenRead($Path)
|
||||
try {
|
||||
($sha.ComputeHash($stream) | ForEach-Object { $_.ToString('x2') }) -join ''
|
||||
}
|
||||
finally {
|
||||
$stream.Dispose()
|
||||
$sha.Dispose()
|
||||
}
|
||||
}
|
||||
catch {
|
||||
return $null
|
||||
}
|
||||
}
|
||||
|
||||
function Ensure-Directory {
|
||||
param([Parameter(Mandatory = $true)][string]$Path)
|
||||
if (-not (Test-Path -LiteralPath $Path)) {
|
||||
New-Item -Path $Path -ItemType Directory -Force | Out-Null
|
||||
}
|
||||
}
|
||||
|
||||
function Get-IncidentScreenshotPath {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$RuleId,
|
||||
[Parameter(Mandatory = $true)][string]$SignalType
|
||||
)
|
||||
|
||||
$safeUser = ($env:USERNAME -replace '[^A-Za-z0-9_.-]', '_')
|
||||
$safeRule = ($RuleId -replace '[^A-Za-z0-9_.-]', '_')
|
||||
$safeType = ($SignalType -replace '[^A-Za-z0-9_.-]', '_')
|
||||
$stamp = (Get-Date).ToUniversalTime().ToString('yyyyMMdd_HHmmss_fff')
|
||||
$file = '{0}_{1}_sid{2}_{3}_{4}.png' -f $script:Hostname, $safeUser, $script:SessionId, $safeType, $safeRule
|
||||
$file = '{0}_{1}' -f $stamp, $file
|
||||
return (Join-Path $script:IncidentArtifactsRoot $file)
|
||||
}
|
||||
|
||||
function Ensure-ScreenshotTypesLoaded {
|
||||
if ($script:ScreenshotTypesLoaded) {
|
||||
return
|
||||
}
|
||||
Add-Type -AssemblyName System.Windows.Forms | Out-Null
|
||||
Add-Type -AssemblyName System.Drawing | Out-Null
|
||||
$script:ScreenshotTypesLoaded = $true
|
||||
}
|
||||
|
||||
function Capture-IncidentScreenshot {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$RuleId,
|
||||
[Parameter(Mandatory = $true)][string]$SignalType
|
||||
)
|
||||
|
||||
try {
|
||||
Ensure-Directory -Path $script:IncidentArtifactsRoot
|
||||
Ensure-ScreenshotTypesLoaded
|
||||
|
||||
$vs = [System.Windows.Forms.SystemInformation]::VirtualScreen
|
||||
$bmp = New-Object System.Drawing.Bitmap ([int]$vs.Width), ([int]$vs.Height)
|
||||
$gfx = [System.Drawing.Graphics]::FromImage($bmp)
|
||||
try {
|
||||
$gfx.CopyFromScreen([int]$vs.Left, [int]$vs.Top, 0, 0, $bmp.Size)
|
||||
$path = Get-IncidentScreenshotPath -RuleId $RuleId -SignalType $SignalType
|
||||
$bmp.Save($path, [System.Drawing.Imaging.ImageFormat]::Png)
|
||||
}
|
||||
finally {
|
||||
$gfx.Dispose()
|
||||
$bmp.Dispose()
|
||||
}
|
||||
|
||||
return @{
|
||||
screenshotPath = $path
|
||||
screenshotFormat = 'png'
|
||||
screenshotWidth = [int]$vs.Width
|
||||
screenshotHeight = [int]$vs.Height
|
||||
screenshotSha256 = (Get-FileSha256Hex -Path $path)
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-CollectorLog ("screenshot capture failed: {0}" -f $_.Exception.Message)
|
||||
return @{}
|
||||
}
|
||||
}
|
||||
|
||||
function Get-ForegroundWindowContext {
|
||||
$handle = [NativeAwMethods]::GetForegroundWindow()
|
||||
if ($handle -eq [IntPtr]::Zero) {
|
||||
return $null
|
||||
}
|
||||
|
||||
$processId = [uint32]0
|
||||
[void][NativeAwMethods]::GetWindowThreadProcessId($handle, [ref]$processId)
|
||||
if (-not $processId) {
|
||||
return $null
|
||||
}
|
||||
|
||||
$process = Get-Process -Id ([int]$processId) -ErrorAction SilentlyContinue
|
||||
if (-not $process) {
|
||||
return $null
|
||||
}
|
||||
|
||||
$textLength = [NativeAwMethods]::GetWindowTextLength($handle)
|
||||
$builder = [Text.StringBuilder]::new([Math]::Max($textLength + 1, 260))
|
||||
[void][NativeAwMethods]::GetWindowText($handle, $builder, $builder.Capacity)
|
||||
|
||||
return [pscustomobject]@{
|
||||
Handle = $handle
|
||||
ProcessName = $process.ProcessName.ToLowerInvariant()
|
||||
Title = $builder.ToString()
|
||||
}
|
||||
}
|
||||
|
||||
function Get-BrowserUrlFromWindow {
|
||||
param([IntPtr]$Handle)
|
||||
|
||||
$root = [System.Windows.Automation.AutomationElement]::FromHandle($Handle)
|
||||
if (-not $root) {
|
||||
return $null
|
||||
}
|
||||
|
||||
$editCondition = [System.Windows.Automation.PropertyCondition]::new(
|
||||
[System.Windows.Automation.AutomationElement]::ControlTypeProperty,
|
||||
[System.Windows.Automation.ControlType]::Edit
|
||||
)
|
||||
|
||||
$edits = $root.FindAll([System.Windows.Automation.TreeScope]::Descendants, $editCondition)
|
||||
foreach ($edit in $edits) {
|
||||
$valuePattern = $null
|
||||
if ($edit.TryGetCurrentPattern([System.Windows.Automation.ValuePattern]::Pattern, [ref]$valuePattern)) {
|
||||
$candidate = ConvertTo-NormalizedUrl -Value $valuePattern.Current.Value
|
||||
if ($candidate) {
|
||||
return $candidate
|
||||
}
|
||||
}
|
||||
|
||||
$candidateFromName = ConvertTo-NormalizedUrl -Value $edit.Current.Name
|
||||
if ($candidateFromName) {
|
||||
return $candidateFromName
|
||||
}
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
function Ensure-Bucket {
|
||||
param(
|
||||
[string]$BucketId,
|
||||
[string]$ClientName,
|
||||
[string]$BucketType = 'web.tab.current'
|
||||
)
|
||||
|
||||
if ($script:KnownBuckets.ContainsKey($BucketId)) {
|
||||
return
|
||||
}
|
||||
|
||||
$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
|
||||
$script:KnownBuckets[$BucketId] = $true
|
||||
}
|
||||
|
||||
function Send-Heartbeat {
|
||||
param(
|
||||
[string]$BucketId,
|
||||
[string]$Url,
|
||||
[string]$Title,
|
||||
[string]$BrowserKey,
|
||||
[string]$ProcessName
|
||||
)
|
||||
|
||||
$event = @{
|
||||
timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
|
||||
duration = 0
|
||||
data = @{
|
||||
url = $Url
|
||||
title = $Title
|
||||
browser = $BrowserKey
|
||||
app = "$ProcessName.exe"
|
||||
source = 'uia-native'
|
||||
sessionId = $script:SessionId
|
||||
}
|
||||
} | ConvertTo-Json -Depth 4 -Compress
|
||||
|
||||
Invoke-RestMethod -Method Post -Uri "$($script:ApiBase)/buckets/$BucketId/heartbeat?pulsetime=$resolvedPulseSeconds" -ContentType 'application/json' -Body $event | Out-Null
|
||||
}
|
||||
|
||||
function Send-CategoryHeartbeat {
|
||||
param(
|
||||
[string]$Url,
|
||||
[string]$Title,
|
||||
[string]$BrowserKey,
|
||||
[string]$ProcessName,
|
||||
[string]$Domain,
|
||||
[string]$RootDomain,
|
||||
[string]$Category,
|
||||
[string]$CategoryGroup,
|
||||
[string]$CategoryRule
|
||||
)
|
||||
|
||||
$bucketId = 'aw-detmir-web-category_' + $script:Hostname
|
||||
Ensure-Bucket -BucketId $bucketId -ClientName 'aw-detmir-web-category' -BucketType 'aw.web.category'
|
||||
|
||||
$event = @{
|
||||
timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
|
||||
duration = 0
|
||||
data = @{
|
||||
url = $Url
|
||||
title = $Title
|
||||
browser = $BrowserKey
|
||||
app = "$ProcessName.exe"
|
||||
domain = $Domain
|
||||
rootDomain = $RootDomain
|
||||
category = $Category
|
||||
categoryGroup = $CategoryGroup
|
||||
categoryRule = $CategoryRule
|
||||
source = 'uia-native'
|
||||
sessionId = $script:SessionId
|
||||
}
|
||||
} | ConvertTo-Json -Depth 4 -Compress
|
||||
|
||||
Invoke-RestMethod -Method Post -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$resolvedPulseSeconds" -ContentType 'application/json' -Body $event | Out-Null
|
||||
}
|
||||
|
||||
Load-CustomCategoryRules -Path $resolvedRulesPath
|
||||
Load-DlpPolicy -Path $resolvedPolicyPath
|
||||
Write-CollectorLog ("collector started against {0}" -f $script:ApiBase)
|
||||
|
||||
while ($true) {
|
||||
try {
|
||||
$context = Get-ForegroundWindowContext
|
||||
if ($context -and $script:BrowserMap.ContainsKey($context.ProcessName)) {
|
||||
$url = Get-BrowserUrlFromWindow -Handle $context.Handle
|
||||
if ($url) {
|
||||
$browserKey = $script:BrowserMap[$context.ProcessName]
|
||||
$domain = Get-HostFromUrl -Url $url
|
||||
if (-not $domain) {
|
||||
$domain = 'unknown'
|
||||
}
|
||||
|
||||
$rootDomain = Get-RootDomain -DomainHost $domain
|
||||
if (-not $rootDomain) {
|
||||
$rootDomain = $domain
|
||||
}
|
||||
|
||||
$category = Get-WebCategory -DomainHost $domain
|
||||
$bucketId = 'aw-watcher-web-{0}_{1}' -f $browserKey, $script:Hostname
|
||||
Ensure-Bucket -BucketId $bucketId -ClientName ('aw-watcher-web-' + $browserKey)
|
||||
Send-Heartbeat -BucketId $bucketId -Url $url -Title $context.Title -BrowserKey $browserKey -ProcessName $context.ProcessName
|
||||
Send-CategoryHeartbeat -Url $url -Title $context.Title -BrowserKey $browserKey -ProcessName $context.ProcessName -Domain $domain -RootDomain $rootDomain -Category $category.Name -CategoryGroup $category.Group -CategoryRule $category.Rule
|
||||
|
||||
$decision = Get-DlpDecision -Domain $domain -RootDomain $rootDomain -Url $url -Title $context.Title -BrowserKey $browserKey -Category $category.Name -CategoryGroup $category.Group
|
||||
if ($decision) {
|
||||
$fingerprint = '{0}|{1}|{2}|{3}' -f $decision.id, $browserKey, $rootDomain, $env:USERNAME
|
||||
$cooldown = [Math]::Max([int]$decision.cooldownSeconds, 30)
|
||||
if (Should-EmitIncident -Fingerprint $fingerprint -CooldownSeconds $cooldown) {
|
||||
Write-DlpIncidentLog ("{0} {1} {2} {3}" -f $decision.severity, $decision.action, $decision.id, $url)
|
||||
if (@('alert', 'block', 'quarantine') -contains ([string]$decision.action).ToLowerInvariant()) {
|
||||
Send-DlpIncidentHeartbeat -Decision $decision -Url $url -Title $context.Title -BrowserKey $browserKey -ProcessName $context.ProcessName -Domain $domain -RootDomain $rootDomain -Category $category.Name -CategoryGroup $category.Group
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-CollectorLog ("collector error: {0}" -f $_.Exception.Message)
|
||||
}
|
||||
|
||||
Start-Sleep -Seconds $resolvedPollSeconds
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ServerHost,
|
||||
[string[]]$Users,
|
||||
[string]$UserListPath,
|
||||
[string]$Domain,
|
||||
[int]$ServerPort = 5600,
|
||||
[ValidateSet('http', 'https')]
|
||||
[string]$ServerScheme = 'http',
|
||||
[string]$Version = 'v0.13.2',
|
||||
[string]$PackageUrl,
|
||||
[string]$PackageZipPath,
|
||||
[string]$InstallRoot = 'C:\Program Files\ActivityWatch',
|
||||
[string]$StateRoot = 'C:\ProgramData\ActivityWatch',
|
||||
[int]$PollSeconds = 5,
|
||||
[int]$PulseSeconds = 30,
|
||||
[int]$RecoveryIntervalSeconds = 180,
|
||||
[bool]$AfkEnabled = $true,
|
||||
[bool]$WindowEnabled = $true,
|
||||
[bool]$LocalAgentLogsEnabled = $false,
|
||||
[bool]$IncidentCaptureEnabled = $true,
|
||||
[bool]$IncidentScreenshotEnabled = $true,
|
||||
[string]$IncidentArtifactsRoot,
|
||||
[bool]$LogonMarkerEnabled = $true,
|
||||
[string]$CustomRulesPath,
|
||||
[string]$CustomPolicyPath
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$modulePath = Join-Path $PSScriptRoot 'ActivityWatch.Windows.Common.psm1'
|
||||
Import-Module $modulePath -Force
|
||||
|
||||
Assert-Administrator
|
||||
|
||||
$targetUsers = Normalize-ActivityWatchUsers -Users $Users -UserListPath $UserListPath -Domain $Domain
|
||||
$workingRoot = Join-Path $env:TEMP 'activitywatch-windows-deploy'
|
||||
$backupRoot = Join-Path $StateRoot 'backups'
|
||||
$logsRoot = Join-Path $StateRoot 'logs'
|
||||
$configPath = Join-Path $StateRoot 'deployment-config.json'
|
||||
$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'
|
||||
$exampleRulesSource = Join-Path $PSScriptRoot 'web-category-rules.example.json'
|
||||
$examplePolicySource = Join-Path $PSScriptRoot 'dlp-policy.example.json'
|
||||
|
||||
New-ActivityWatchDirectory -Path $StateRoot
|
||||
New-ActivityWatchDirectory -Path $logsRoot
|
||||
|
||||
$archivePath = Get-ActivityWatchArchive -PackageZipPath $PackageZipPath -PackageUrl $PackageUrl -Version $Version -WorkingRoot $workingRoot
|
||||
Install-ActivityWatchPackage -ArchivePath $archivePath -InstallRoot $InstallRoot -WorkingRoot $workingRoot -BackupRoot $backupRoot | Out-Null
|
||||
Get-ActivityWatchExecutableMap -InstallRoot $InstallRoot | Out-Null
|
||||
|
||||
$assetResult = Copy-ActivityWatchCollectorAssets `
|
||||
-CollectorScriptSource $collectorSource `
|
||||
-EndpointCollectorScriptSource $endpointCollectorSource `
|
||||
-ExampleRulesSource $exampleRulesSource `
|
||||
-ExamplePolicySource $examplePolicySource `
|
||||
-StateRoot $StateRoot `
|
||||
-CustomRulesSource $CustomRulesPath `
|
||||
-CustomPolicySource $CustomPolicyPath
|
||||
$taskDefinitions = New-ActivityWatchUserTaskDefinitions -Users $targetUsers
|
||||
|
||||
Write-ActivityWatchLaunchScript -Path $launchScriptPath -ConfigPath $configPath
|
||||
Write-ActivityWatchRecoveryScript -Path $recoveryScriptPath -ConfigPath $configPath
|
||||
|
||||
$config = New-ActivityWatchDeploymentConfig `
|
||||
-ServerHost $ServerHost `
|
||||
-ServerPort $ServerPort `
|
||||
-ServerScheme $ServerScheme `
|
||||
-InstallRoot $InstallRoot `
|
||||
-StateRoot $StateRoot `
|
||||
-LogsRoot $logsRoot `
|
||||
-CollectorScript $assetResult.CollectorScript `
|
||||
-EndpointCollectorScript $assetResult.EndpointCollectorScript `
|
||||
-RulesPath $assetResult.ActiveRules `
|
||||
-PolicyPath $assetResult.ActivePolicy `
|
||||
-PollSeconds $PollSeconds `
|
||||
-PulseSeconds $PulseSeconds `
|
||||
-RecoveryIntervalSeconds $RecoveryIntervalSeconds `
|
||||
-AfkEnabled $AfkEnabled `
|
||||
-WindowEnabled $WindowEnabled `
|
||||
-LocalAgentLogsEnabled $LocalAgentLogsEnabled `
|
||||
-IncidentCaptureEnabled $IncidentCaptureEnabled `
|
||||
-IncidentScreenshotEnabled $IncidentScreenshotEnabled `
|
||||
-IncidentArtifactsRoot $IncidentArtifactsRoot `
|
||||
-LogonMarkerEnabled $LogonMarkerEnabled `
|
||||
-LaunchScriptPath $launchScriptPath `
|
||||
-RecoveryScriptPath $recoveryScriptPath `
|
||||
-UserTasks $taskDefinitions `
|
||||
-PackageVersion $Version
|
||||
|
||||
Write-ActivityWatchDeploymentConfig -Config $config -Path $configPath
|
||||
Remove-LegacyActivityWatchEntries
|
||||
Set-ActivityWatchAcl -InstallRoot $InstallRoot -StateRoot $StateRoot -LogsRoot $logsRoot
|
||||
Register-ActivityWatchUserTasks -TaskDefinitions $taskDefinitions -LaunchScriptPath $launchScriptPath -ConfigPath $configPath
|
||||
Register-ActivityWatchRecoveryTask -TaskName $config.recovery.taskName -RecoveryScriptPath $recoveryScriptPath -ConfigPath $configPath
|
||||
Start-ActivityWatchTasks -TaskDefinitions $taskDefinitions -RecoveryTaskName $config.recovery.taskName
|
||||
|
||||
Write-Host 'ActivityWatch deployed for users:'
|
||||
$targetUsers | ForEach-Object { Write-Host " - $_" }
|
||||
Write-Host "Server: ${ServerScheme}://$ServerHost`:$ServerPort"
|
||||
Write-Host "State root: $StateRoot"
|
||||
Write-Host "Policy file: $($assetResult.ActivePolicy)"
|
||||
@@ -0,0 +1,137 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ServerHost,
|
||||
[string[]]$Users,
|
||||
[string]$UserListPath,
|
||||
[string]$Domain,
|
||||
[int]$ServerPort = 5600,
|
||||
[ValidateSet('http', 'https')]
|
||||
[string]$ServerScheme = 'http',
|
||||
[string]$Version = 'v0.13.2',
|
||||
[string]$PackageUrl,
|
||||
[string]$PackageZipPath,
|
||||
[string]$InstallRoot = 'C:\Program Files\ActivityWatch',
|
||||
[string]$StateRoot = 'C:\ProgramData\ActivityWatch',
|
||||
[int]$PollSeconds = 5,
|
||||
[int]$PulseSeconds = 30,
|
||||
[int]$RecoveryIntervalSeconds = 180,
|
||||
[bool]$AfkEnabled = $true,
|
||||
[bool]$WindowEnabled = $true,
|
||||
[bool]$LocalAgentLogsEnabled = $false,
|
||||
[bool]$IncidentCaptureEnabled = $true,
|
||||
[bool]$IncidentScreenshotEnabled = $true,
|
||||
[string]$IncidentArtifactsRoot,
|
||||
[bool]$LogonMarkerEnabled = $true,
|
||||
[string]$CustomRulesPath,
|
||||
[string]$CustomPolicyPath,
|
||||
[string]$ReportPath,
|
||||
[switch]$SkipHardening,
|
||||
[switch]$ValidateAfterDeploy
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$modulePath = Join-Path $PSScriptRoot 'ActivityWatch.Windows.Common.psm1'
|
||||
Import-Module $modulePath -Force
|
||||
|
||||
Assert-Administrator
|
||||
|
||||
$resolvedUsers = Normalize-ActivityWatchUsers -Users $Users -UserListPath $UserListPath -Domain $Domain
|
||||
$timestamp = Get-Date -Format 'yyyyMMdd-HHmmss'
|
||||
$effectiveReportPath = if ($ReportPath) { $ReportPath } else { Join-Path $StateRoot "ensemble-report-$timestamp.json" }
|
||||
$deployScript = Join-Path $PSScriptRoot 'deploy-domain-users.ps1'
|
||||
$hardeningScript = Join-Path $PSScriptRoot 'hardening-recovery.ps1'
|
||||
$validationScript = Join-Path $PSScriptRoot 'validate-deployment.ps1'
|
||||
|
||||
if (-not (Test-Path -LiteralPath $deployScript)) {
|
||||
throw "Missing script: $deployScript"
|
||||
}
|
||||
|
||||
& $deployScript `
|
||||
-ServerHost $ServerHost `
|
||||
-Users $resolvedUsers `
|
||||
-ServerPort $ServerPort `
|
||||
-ServerScheme $ServerScheme `
|
||||
-Version $Version `
|
||||
-PackageUrl $PackageUrl `
|
||||
-PackageZipPath $PackageZipPath `
|
||||
-InstallRoot $InstallRoot `
|
||||
-StateRoot $StateRoot `
|
||||
-PollSeconds $PollSeconds `
|
||||
-PulseSeconds $PulseSeconds `
|
||||
-RecoveryIntervalSeconds $RecoveryIntervalSeconds `
|
||||
-AfkEnabled $AfkEnabled `
|
||||
-WindowEnabled $WindowEnabled `
|
||||
-LocalAgentLogsEnabled $LocalAgentLogsEnabled `
|
||||
-IncidentCaptureEnabled $IncidentCaptureEnabled `
|
||||
-IncidentScreenshotEnabled $IncidentScreenshotEnabled `
|
||||
-IncidentArtifactsRoot $IncidentArtifactsRoot `
|
||||
-LogonMarkerEnabled $LogonMarkerEnabled `
|
||||
-CustomRulesPath $CustomRulesPath `
|
||||
-CustomPolicyPath $CustomPolicyPath
|
||||
|
||||
if (-not $SkipHardening) {
|
||||
& $hardeningScript `
|
||||
-ConfigPath (Join-Path $StateRoot 'deployment-config.json') `
|
||||
-ServerHost $ServerHost `
|
||||
-ServerPort $ServerPort `
|
||||
-ServerScheme $ServerScheme `
|
||||
-Users $resolvedUsers `
|
||||
-InstallRoot $InstallRoot `
|
||||
-StateRoot $StateRoot `
|
||||
-PollSeconds $PollSeconds `
|
||||
-PulseSeconds $PulseSeconds `
|
||||
-RecoveryIntervalSeconds $RecoveryIntervalSeconds `
|
||||
-AfkEnabled $AfkEnabled `
|
||||
-WindowEnabled $WindowEnabled `
|
||||
-LocalAgentLogsEnabled $LocalAgentLogsEnabled `
|
||||
-IncidentCaptureEnabled $IncidentCaptureEnabled `
|
||||
-IncidentScreenshotEnabled $IncidentScreenshotEnabled `
|
||||
-IncidentArtifactsRoot $IncidentArtifactsRoot `
|
||||
-LogonMarkerEnabled $LogonMarkerEnabled `
|
||||
-CustomRulesPath $CustomRulesPath `
|
||||
-CustomPolicyPath $CustomPolicyPath
|
||||
}
|
||||
|
||||
$report = [ordered]@{
|
||||
generatedAtUtc = (Get-Date).ToUniversalTime().ToString('o')
|
||||
server = [ordered]@{
|
||||
host = $ServerHost
|
||||
port = $ServerPort
|
||||
scheme = $ServerScheme
|
||||
}
|
||||
packageVersion = $Version
|
||||
users = @($resolvedUsers)
|
||||
paths = [ordered]@{
|
||||
installRoot = $InstallRoot
|
||||
stateRoot = $StateRoot
|
||||
configPath = Join-Path $StateRoot 'deployment-config.json'
|
||||
}
|
||||
collectors = [ordered]@{
|
||||
afkEnabled = $AfkEnabled
|
||||
windowEnabled = $WindowEnabled
|
||||
}
|
||||
hardeningApplied = (-not $SkipHardening)
|
||||
}
|
||||
|
||||
if ($ValidateAfterDeploy) {
|
||||
if (-not (Test-Path -LiteralPath $validationScript)) {
|
||||
throw "Missing script: $validationScript"
|
||||
}
|
||||
|
||||
$validation = & $validationScript -ConfigPath (Join-Path $StateRoot 'deployment-config.json')
|
||||
$report.validation = $validation
|
||||
}
|
||||
|
||||
$reportDirectory = Split-Path -Path $effectiveReportPath -Parent
|
||||
if ($reportDirectory) {
|
||||
New-ActivityWatchDirectory -Path $reportDirectory
|
||||
}
|
||||
|
||||
$report | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $effectiveReportPath -Encoding UTF8
|
||||
|
||||
Write-Host 'ActivityWatch ensemble deploy completed.'
|
||||
Write-Host "Users: $($resolvedUsers -join ', ')"
|
||||
Write-Host "Report: $effectiveReportPath"
|
||||
@@ -0,0 +1,106 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ServerHost,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$TargetUser,
|
||||
[int]$ServerPort = 5600,
|
||||
[ValidateSet('http', 'https')]
|
||||
[string]$ServerScheme = 'http',
|
||||
[string]$Version = 'v0.13.2',
|
||||
[string]$PackageUrl,
|
||||
[string]$PackageZipPath,
|
||||
[string]$InstallRoot = 'C:\Program Files\ActivityWatch',
|
||||
[string]$StateRoot = 'C:\ProgramData\ActivityWatch',
|
||||
[int]$PollSeconds = 5,
|
||||
[int]$PulseSeconds = 30,
|
||||
[int]$RecoveryIntervalSeconds = 180,
|
||||
[bool]$AfkEnabled = $true,
|
||||
[bool]$WindowEnabled = $true,
|
||||
[bool]$LocalAgentLogsEnabled = $false,
|
||||
[bool]$IncidentCaptureEnabled = $true,
|
||||
[bool]$IncidentScreenshotEnabled = $true,
|
||||
[string]$IncidentArtifactsRoot,
|
||||
[bool]$LogonMarkerEnabled = $true,
|
||||
[string]$CustomRulesPath,
|
||||
[string]$CustomPolicyPath
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$modulePath = Join-Path $PSScriptRoot 'ActivityWatch.Windows.Common.psm1'
|
||||
Import-Module $modulePath -Force
|
||||
|
||||
Assert-Administrator
|
||||
|
||||
$workingRoot = Join-Path $env:TEMP 'activitywatch-windows-deploy'
|
||||
$backupRoot = Join-Path $StateRoot 'backups'
|
||||
$logsRoot = Join-Path $StateRoot 'logs'
|
||||
$configPath = Join-Path $StateRoot 'deployment-config.json'
|
||||
$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'
|
||||
$exampleRulesSource = Join-Path $PSScriptRoot 'web-category-rules.example.json'
|
||||
$examplePolicySource = Join-Path $PSScriptRoot 'dlp-policy.example.json'
|
||||
|
||||
New-ActivityWatchDirectory -Path $StateRoot
|
||||
New-ActivityWatchDirectory -Path $logsRoot
|
||||
|
||||
$archivePath = Get-ActivityWatchArchive -PackageZipPath $PackageZipPath -PackageUrl $PackageUrl -Version $Version -WorkingRoot $workingRoot
|
||||
Install-ActivityWatchPackage -ArchivePath $archivePath -InstallRoot $InstallRoot -WorkingRoot $workingRoot -BackupRoot $backupRoot | Out-Null
|
||||
Get-ActivityWatchExecutableMap -InstallRoot $InstallRoot | Out-Null
|
||||
|
||||
$assetResult = Copy-ActivityWatchCollectorAssets `
|
||||
-CollectorScriptSource $collectorSource `
|
||||
-EndpointCollectorScriptSource $endpointCollectorSource `
|
||||
-ExampleRulesSource $exampleRulesSource `
|
||||
-ExamplePolicySource $examplePolicySource `
|
||||
-StateRoot $StateRoot `
|
||||
-CustomRulesSource $CustomRulesPath `
|
||||
-CustomPolicySource $CustomPolicyPath
|
||||
$taskDefinitions = New-ActivityWatchUserTaskDefinitions -Users @($TargetUser)
|
||||
|
||||
Write-ActivityWatchLaunchScript -Path $launchScriptPath -ConfigPath $configPath
|
||||
Write-ActivityWatchRecoveryScript -Path $recoveryScriptPath -ConfigPath $configPath
|
||||
|
||||
$config = New-ActivityWatchDeploymentConfig `
|
||||
-ServerHost $ServerHost `
|
||||
-ServerPort $ServerPort `
|
||||
-ServerScheme $ServerScheme `
|
||||
-InstallRoot $InstallRoot `
|
||||
-StateRoot $StateRoot `
|
||||
-LogsRoot $logsRoot `
|
||||
-CollectorScript $assetResult.CollectorScript `
|
||||
-EndpointCollectorScript $assetResult.EndpointCollectorScript `
|
||||
-RulesPath $assetResult.ActiveRules `
|
||||
-PolicyPath $assetResult.ActivePolicy `
|
||||
-PollSeconds $PollSeconds `
|
||||
-PulseSeconds $PulseSeconds `
|
||||
-RecoveryIntervalSeconds $RecoveryIntervalSeconds `
|
||||
-AfkEnabled $AfkEnabled `
|
||||
-WindowEnabled $WindowEnabled `
|
||||
-LocalAgentLogsEnabled $LocalAgentLogsEnabled `
|
||||
-IncidentCaptureEnabled $IncidentCaptureEnabled `
|
||||
-IncidentScreenshotEnabled $IncidentScreenshotEnabled `
|
||||
-IncidentArtifactsRoot $IncidentArtifactsRoot `
|
||||
-LogonMarkerEnabled $LogonMarkerEnabled `
|
||||
-LaunchScriptPath $launchScriptPath `
|
||||
-RecoveryScriptPath $recoveryScriptPath `
|
||||
-UserTasks $taskDefinitions `
|
||||
-PackageVersion $Version
|
||||
|
||||
Write-ActivityWatchDeploymentConfig -Config $config -Path $configPath
|
||||
Remove-LegacyActivityWatchEntries
|
||||
Set-ActivityWatchAcl -InstallRoot $InstallRoot -StateRoot $StateRoot -LogsRoot $logsRoot
|
||||
Register-ActivityWatchUserTasks -TaskDefinitions $taskDefinitions -LaunchScriptPath $launchScriptPath -ConfigPath $configPath
|
||||
Register-ActivityWatchRecoveryTask -TaskName $config.recovery.taskName -RecoveryScriptPath $recoveryScriptPath -ConfigPath $configPath
|
||||
Start-ActivityWatchTasks -TaskDefinitions $taskDefinitions -RecoveryTaskName $config.recovery.taskName
|
||||
|
||||
Write-Host "ActivityWatch deployed for $TargetUser"
|
||||
Write-Host "Server: ${ServerScheme}://$ServerHost`:$ServerPort"
|
||||
Write-Host "Install root: $InstallRoot"
|
||||
Write-Host "State root: $StateRoot"
|
||||
Write-Host "Rules file: $($assetResult.ActiveRules)"
|
||||
Write-Host "Policy file: $($assetResult.ActivePolicy)"
|
||||
@@ -0,0 +1,796 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$ConfigPath = 'C:\ProgramData\ActivityWatch\deployment-config.json',
|
||||
[string]$ServerHost,
|
||||
[int]$ServerPort,
|
||||
[ValidateSet('http', 'https')]
|
||||
[string]$ServerScheme,
|
||||
[string]$PolicyPath,
|
||||
[string]$LogPath,
|
||||
[int]$PollSeconds
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
function Get-DeploymentConfig {
|
||||
param([string]$Path)
|
||||
if ($Path -and (Test-Path -LiteralPath $Path)) {
|
||||
return Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
function Write-EndpointLog {
|
||||
param([string]$Message)
|
||||
if (-not $script:LocalAgentLogsEnabled) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
Add-Content -LiteralPath $script:LogPath -Value ('{0} {1}' -f (Get-Date -Format s), $Message)
|
||||
}
|
||||
catch {
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-AwJsonPost {
|
||||
param(
|
||||
[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
|
||||
}
|
||||
|
||||
function Ensure-Bucket {
|
||||
param(
|
||||
[string]$BucketId,
|
||||
[string]$ClientName,
|
||||
[string]$BucketType
|
||||
)
|
||||
|
||||
if ($script:KnownBuckets.ContainsKey($BucketId)) {
|
||||
return
|
||||
}
|
||||
|
||||
$body = @{
|
||||
client = $ClientName
|
||||
type = $BucketType
|
||||
hostname = $script:Hostname
|
||||
} | ConvertTo-Json -Compress
|
||||
|
||||
Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$BucketId" -Json $body
|
||||
$script:KnownBuckets[$BucketId] = $true
|
||||
}
|
||||
|
||||
function Send-EndpointSignalHeartbeat {
|
||||
param(
|
||||
[string]$SignalType,
|
||||
[hashtable]$Data
|
||||
)
|
||||
|
||||
$bucketId = 'aw-dlp-endpoint-signals_' + $script:Hostname
|
||||
Ensure-Bucket -BucketId $bucketId -ClientName 'aw-dlp-endpoint-signals' -BucketType 'aw.dlp.endpoint.signal'
|
||||
|
||||
$payload = @{
|
||||
timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
|
||||
duration = 0
|
||||
data = @{
|
||||
signalType = $SignalType
|
||||
username = $env:USERNAME
|
||||
sessionId = $script:SessionId
|
||||
hostname = $script:Hostname
|
||||
source = 'endpoint-signals-phase2'
|
||||
} + $Data
|
||||
} | ConvertTo-Json -Depth 6 -Compress
|
||||
|
||||
Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$script:PulseSeconds" -Json $payload
|
||||
}
|
||||
|
||||
function Send-DlpIncidentHeartbeat {
|
||||
param(
|
||||
[string]$RuleId,
|
||||
[string]$Action,
|
||||
[string]$Severity,
|
||||
[string]$Message,
|
||||
[string]$SignalType,
|
||||
[hashtable]$Data
|
||||
)
|
||||
|
||||
$bucketId = 'aw-dlp-incidents_' + $script:Hostname
|
||||
Ensure-Bucket -BucketId $bucketId -ClientName 'aw-dlp-incidents' -BucketType 'aw.dlp.incident'
|
||||
|
||||
$captureData = @{}
|
||||
if ($script:IncidentScreenshotEnabled) {
|
||||
try {
|
||||
$captureData = Capture-IncidentScreenshot -RuleId $RuleId -SignalType $SignalType
|
||||
}
|
||||
catch {
|
||||
}
|
||||
}
|
||||
|
||||
$payload = @{
|
||||
timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
|
||||
duration = 0
|
||||
data = @{
|
||||
ruleId = $RuleId
|
||||
action = $Action
|
||||
severity = $Severity
|
||||
message = $Message
|
||||
signalType = $SignalType
|
||||
username = $env:USERNAME
|
||||
sessionId = $script:SessionId
|
||||
hostname = $script:Hostname
|
||||
source = 'endpoint-signals-phase2'
|
||||
} + $Data + $captureData
|
||||
} | ConvertTo-Json -Depth 7 -Compress
|
||||
|
||||
Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$script:PulseSeconds" -Json $payload
|
||||
}
|
||||
|
||||
function Get-FileSha256Hex {
|
||||
param([Parameter(Mandatory = $true)][string]$Path)
|
||||
try {
|
||||
$sha = [Security.Cryptography.SHA256]::Create()
|
||||
$stream = [IO.File]::OpenRead($Path)
|
||||
try {
|
||||
($sha.ComputeHash($stream) | ForEach-Object { $_.ToString('x2') }) -join ''
|
||||
}
|
||||
finally {
|
||||
$stream.Dispose()
|
||||
$sha.Dispose()
|
||||
}
|
||||
}
|
||||
catch {
|
||||
return $null
|
||||
}
|
||||
}
|
||||
|
||||
function Ensure-Directory {
|
||||
param([Parameter(Mandatory = $true)][string]$Path)
|
||||
if (-not (Test-Path -LiteralPath $Path)) {
|
||||
New-Item -Path $Path -ItemType Directory -Force | Out-Null
|
||||
}
|
||||
}
|
||||
|
||||
function Get-IncidentScreenshotPath {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$RuleId,
|
||||
[Parameter(Mandatory = $true)][string]$SignalType
|
||||
)
|
||||
|
||||
$safeUser = ($env:USERNAME -replace '[^A-Za-z0-9_.-]', '_')
|
||||
$safeRule = ($RuleId -replace '[^A-Za-z0-9_.-]', '_')
|
||||
$safeType = ($SignalType -replace '[^A-Za-z0-9_.-]', '_')
|
||||
$stamp = (Get-Date).ToUniversalTime().ToString('yyyyMMdd_HHmmss_fff')
|
||||
$file = '{0}_{1}_sid{2}_{3}_{4}.png' -f $script:Hostname, $safeUser, $script:SessionId, $safeType, $safeRule
|
||||
$file = '{0}_{1}' -f $stamp, $file
|
||||
return (Join-Path $script:IncidentArtifactsRoot $file)
|
||||
}
|
||||
|
||||
function Ensure-ScreenshotTypesLoaded {
|
||||
if ($script:ScreenshotTypesLoaded) {
|
||||
return
|
||||
}
|
||||
Add-Type -AssemblyName System.Windows.Forms | Out-Null
|
||||
Add-Type -AssemblyName System.Drawing | Out-Null
|
||||
$script:ScreenshotTypesLoaded = $true
|
||||
}
|
||||
|
||||
function Capture-IncidentScreenshot {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$RuleId,
|
||||
[Parameter(Mandatory = $true)][string]$SignalType
|
||||
)
|
||||
|
||||
try {
|
||||
Ensure-Directory -Path $script:IncidentArtifactsRoot
|
||||
Ensure-ScreenshotTypesLoaded
|
||||
|
||||
$vs = [System.Windows.Forms.SystemInformation]::VirtualScreen
|
||||
$bmp = New-Object System.Drawing.Bitmap ([int]$vs.Width), ([int]$vs.Height)
|
||||
$gfx = [System.Drawing.Graphics]::FromImage($bmp)
|
||||
try {
|
||||
$gfx.CopyFromScreen([int]$vs.Left, [int]$vs.Top, 0, 0, $bmp.Size)
|
||||
$path = Get-IncidentScreenshotPath -RuleId $RuleId -SignalType $SignalType
|
||||
$bmp.Save($path, [System.Drawing.Imaging.ImageFormat]::Png)
|
||||
}
|
||||
finally {
|
||||
$gfx.Dispose()
|
||||
$bmp.Dispose()
|
||||
}
|
||||
|
||||
return @{
|
||||
screenshotPath = $path
|
||||
screenshotFormat = 'png'
|
||||
screenshotWidth = [int]$vs.Width
|
||||
screenshotHeight = [int]$vs.Height
|
||||
screenshotSha256 = (Get-FileSha256Hex -Path $path)
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-EndpointLog ("screenshot capture failed: {0}" -f $_.Exception.Message)
|
||||
return @{}
|
||||
}
|
||||
}
|
||||
|
||||
function Get-StringHash {
|
||||
param([AllowNull()][string]$Value)
|
||||
if ($null -eq $Value) { return $null }
|
||||
$bytes = [Text.Encoding]::UTF8.GetBytes($Value)
|
||||
$sha = [Security.Cryptography.SHA256]::Create()
|
||||
try {
|
||||
($sha.ComputeHash($bytes) | ForEach-Object { $_.ToString('x2') }) -join ''
|
||||
}
|
||||
finally {
|
||||
$sha.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
function Load-DlpPolicy {
|
||||
param([string]$Path)
|
||||
|
||||
$script:Policy = [ordered]@{
|
||||
defaults = [ordered]@{
|
||||
enabled = $true
|
||||
cooldownSeconds = 300
|
||||
action = 'alert'
|
||||
severity = 'medium'
|
||||
}
|
||||
endpoint = [ordered]@{
|
||||
clipboard = @()
|
||||
usb = @()
|
||||
print = @()
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $Path -or -not (Test-Path -LiteralPath $Path)) {
|
||||
Write-EndpointLog ("policy not found, using defaults: {0}" -f $Path)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
$raw = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json
|
||||
if ($raw.defaults) {
|
||||
if ($raw.defaults.PSObject.Properties.Name -contains 'enabled') { $script:Policy.defaults.enabled = [bool]$raw.defaults.enabled }
|
||||
if ($raw.defaults.cooldownSeconds) { $script:Policy.defaults.cooldownSeconds = [int]$raw.defaults.cooldownSeconds }
|
||||
if ($raw.defaults.action) { $script:Policy.defaults.action = [string]$raw.defaults.action }
|
||||
if ($raw.defaults.severity) { $script:Policy.defaults.severity = [string]$raw.defaults.severity }
|
||||
}
|
||||
|
||||
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) }
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-EndpointLog ("policy parse failed: {0}" -f $_.Exception.Message)
|
||||
}
|
||||
}
|
||||
|
||||
function Should-EmitByCooldown {
|
||||
param(
|
||||
[string]$Fingerprint,
|
||||
[int]$CooldownSeconds
|
||||
)
|
||||
|
||||
$now = (Get-Date).ToUniversalTime()
|
||||
if ($script:Cooldown.ContainsKey($Fingerprint)) {
|
||||
$last = [datetime]$script:Cooldown[$Fingerprint]
|
||||
if ((New-TimeSpan -Start $last -End $now).TotalSeconds -lt $CooldownSeconds) {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
$script:Cooldown[$Fingerprint] = $now
|
||||
return $true
|
||||
}
|
||||
|
||||
function Evaluate-ClipboardRules {
|
||||
param(
|
||||
[string]$ClipboardText,
|
||||
[string]$ClipboardHash
|
||||
)
|
||||
|
||||
foreach ($rule in @($script:Policy.endpoint.clipboard)) {
|
||||
if (-not $rule) { continue }
|
||||
if ($rule.PSObject.Properties.Name -contains 'enabled' -and -not [bool]$rule.enabled) { continue }
|
||||
$ruleId = [string]$rule.id
|
||||
if (-not $ruleId) { continue }
|
||||
$minLength = if ($rule.minLength) { [int]$rule.minLength } else { 0 }
|
||||
$regexPatterns = if ($rule.regexPatterns) { @($rule.regexPatterns) } else { @() }
|
||||
if ($ClipboardText.Length -lt $minLength) { continue }
|
||||
|
||||
$matched = $false
|
||||
foreach ($pattern in $regexPatterns) {
|
||||
if ($ClipboardText -match [string]$pattern) {
|
||||
$matched = $true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $matched) { continue }
|
||||
|
||||
$cooldown = if ($rule.cooldownSeconds) { [int]$rule.cooldownSeconds } else { [int]$script:Policy.defaults.cooldownSeconds }
|
||||
$fingerprint = "clipboard|$ruleId|$ClipboardHash|$env:USERNAME"
|
||||
if (-not (Should-EmitByCooldown -Fingerprint $fingerprint -CooldownSeconds ([Math]::Max($cooldown, 30)))) { continue }
|
||||
|
||||
$action = if ($rule.action) { [string]$rule.action } else { [string]$script:Policy.defaults.action }
|
||||
$severity = if ($rule.severity) { [string]$rule.severity } else { [string]$script:Policy.defaults.severity }
|
||||
$message = if ($rule.message) { [string]$rule.message } else { "Clipboard rule matched: $ruleId" }
|
||||
|
||||
Send-DlpIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -SignalType 'clipboard' -Data @{
|
||||
clipboardHash = $ClipboardHash
|
||||
clipboardLength = $ClipboardText.Length
|
||||
}
|
||||
Write-EndpointLog ("incident clipboard rule={0} action={1} severity={2}" -f $ruleId, $action, $severity)
|
||||
}
|
||||
}
|
||||
|
||||
function Evaluate-UsbRules {
|
||||
param(
|
||||
[string]$DriveLetter,
|
||||
[string]$VolumeName
|
||||
)
|
||||
|
||||
foreach ($rule in @($script:Policy.endpoint.usb)) {
|
||||
if (-not $rule) { continue }
|
||||
if ($rule.PSObject.Properties.Name -contains 'enabled' -and -not [bool]$rule.enabled) { continue }
|
||||
$ruleId = [string]$rule.id
|
||||
if (-not $ruleId) { continue }
|
||||
|
||||
$cooldown = if ($rule.cooldownSeconds) { [int]$rule.cooldownSeconds } else { [int]$script:Policy.defaults.cooldownSeconds }
|
||||
$fingerprint = "usb|$ruleId|$DriveLetter|$env:USERNAME"
|
||||
if (-not (Should-EmitByCooldown -Fingerprint $fingerprint -CooldownSeconds ([Math]::Max($cooldown, 30)))) { continue }
|
||||
|
||||
$action = if ($rule.action) { [string]$rule.action } else { [string]$script:Policy.defaults.action }
|
||||
$severity = if ($rule.severity) { [string]$rule.severity } else { [string]$script:Policy.defaults.severity }
|
||||
$message = if ($rule.message) { [string]$rule.message } else { "USB rule matched: $ruleId" }
|
||||
|
||||
Send-DlpIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -SignalType 'usb_insert' -Data @{
|
||||
driveLetter = $DriveLetter
|
||||
volumeName = $VolumeName
|
||||
}
|
||||
Write-EndpointLog ("incident usb rule={0} action={1} severity={2} drive={3}" -f $ruleId, $action, $severity, $DriveLetter)
|
||||
}
|
||||
}
|
||||
|
||||
function Evaluate-PrintRules {
|
||||
param(
|
||||
[string]$PrinterName,
|
||||
[string]$DocumentName,
|
||||
[string]$Owner
|
||||
)
|
||||
|
||||
foreach ($rule in @($script:Policy.endpoint.print)) {
|
||||
if (-not $rule) { continue }
|
||||
if ($rule.PSObject.Properties.Name -contains 'enabled' -and -not [bool]$rule.enabled) { continue }
|
||||
$ruleId = [string]$rule.id
|
||||
if (-not $ruleId) { continue }
|
||||
|
||||
$match = $true
|
||||
if ($rule.printerRegex) {
|
||||
$match = $match -and ($PrinterName -match [string]$rule.printerRegex)
|
||||
}
|
||||
if ($rule.documentRegex) {
|
||||
$match = $match -and ($DocumentName -match [string]$rule.documentRegex)
|
||||
}
|
||||
if (-not $match) { continue }
|
||||
|
||||
$cooldown = if ($rule.cooldownSeconds) { [int]$rule.cooldownSeconds } else { [int]$script:Policy.defaults.cooldownSeconds }
|
||||
$fingerprint = "print|$ruleId|$PrinterName|$Owner|$env:USERNAME"
|
||||
if (-not (Should-EmitByCooldown -Fingerprint $fingerprint -CooldownSeconds ([Math]::Max($cooldown, 30)))) { continue }
|
||||
|
||||
$action = if ($rule.action) { [string]$rule.action } else { [string]$script:Policy.defaults.action }
|
||||
$severity = if ($rule.severity) { [string]$rule.severity } else { [string]$script:Policy.defaults.severity }
|
||||
$message = if ($rule.message) { [string]$rule.message } else { "Print rule matched: $ruleId" }
|
||||
|
||||
Send-DlpIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -SignalType 'print_job' -Data @{
|
||||
printerName = $PrinterName
|
||||
documentName = $DocumentName
|
||||
owner = $Owner
|
||||
}
|
||||
Write-EndpointLog ("incident print rule={0} action={1} severity={2} printer={3}" -f $ruleId, $action, $severity, $PrinterName)
|
||||
}
|
||||
}
|
||||
|
||||
function Test-LooksLikeMojibakeQuestionMarks {
|
||||
param([AllowNull()][string]$Value)
|
||||
if ([string]::IsNullOrWhiteSpace($Value)) { return $true }
|
||||
return $Value -match '\?{2,}'
|
||||
}
|
||||
|
||||
function Normalize-OwnerForMatch {
|
||||
param([AllowNull()][string]$Value)
|
||||
if ([string]::IsNullOrWhiteSpace($Value)) { return '' }
|
||||
$normalized = $Value.Trim().ToLowerInvariant()
|
||||
if ($normalized -match '[\\/]') {
|
||||
$parts = $normalized -split '[\\/]'
|
||||
if ($parts.Count -gt 0) {
|
||||
$normalized = [string]$parts[$parts.Count - 1]
|
||||
}
|
||||
}
|
||||
if ($normalized -match '@') {
|
||||
$parts = $normalized -split '@'
|
||||
if ($parts.Count -gt 0) {
|
||||
$normalized = [string]$parts[0]
|
||||
}
|
||||
}
|
||||
return $normalized
|
||||
}
|
||||
|
||||
function Test-OwnerLooseMatch {
|
||||
param(
|
||||
[string]$Expected,
|
||||
[string]$Actual
|
||||
)
|
||||
$expectedNorm = Normalize-OwnerForMatch -Value $Expected
|
||||
$actualNorm = Normalize-OwnerForMatch -Value $Actual
|
||||
if ([string]::IsNullOrWhiteSpace($expectedNorm) -or [string]::IsNullOrWhiteSpace($actualNorm)) {
|
||||
return $false
|
||||
}
|
||||
return ($actualNorm -eq $expectedNorm) -or $actualNorm.Contains($expectedNorm) -or $expectedNorm.Contains($actualNorm)
|
||||
}
|
||||
|
||||
function Normalize-PrinterForMatch {
|
||||
param([AllowNull()][string]$Value)
|
||||
if ([string]::IsNullOrWhiteSpace($Value)) { return '' }
|
||||
$normalized = $Value.Trim().ToLowerInvariant()
|
||||
if ($normalized.Contains(',')) {
|
||||
$normalized = ($normalized -split ',', 2)[0].Trim()
|
||||
}
|
||||
if ($normalized -match '\son\s') {
|
||||
$normalized = ($normalized -split '\son\s', 2)[0].Trim()
|
||||
}
|
||||
return $normalized
|
||||
}
|
||||
|
||||
function Test-PrinterLooseMatch {
|
||||
param(
|
||||
[string]$Expected,
|
||||
[string]$Actual
|
||||
)
|
||||
$expectedNorm = Normalize-PrinterForMatch -Value $Expected
|
||||
$actualNorm = Normalize-PrinterForMatch -Value $Actual
|
||||
if ([string]::IsNullOrWhiteSpace($expectedNorm) -or [string]::IsNullOrWhiteSpace($actualNorm)) {
|
||||
return $false
|
||||
}
|
||||
return ($actualNorm -eq $expectedNorm) -or $actualNorm.Contains($expectedNorm) -or $expectedNorm.Contains($actualNorm)
|
||||
}
|
||||
|
||||
function Get-PrintServiceEventSummary {
|
||||
param([Parameter(Mandatory = $true)]$Event)
|
||||
|
||||
$props = @($Event.Properties)
|
||||
$propertyValues = @()
|
||||
foreach ($prop in $props) {
|
||||
$propertyValues += [string]$prop.Value
|
||||
}
|
||||
|
||||
[pscustomobject]@{
|
||||
RecordId = [string]$Event.RecordId
|
||||
TimeCreated = if ($Event.TimeCreated) { $Event.TimeCreated.ToString('o') } else { '' }
|
||||
PropertyCount = $props.Count
|
||||
DocumentName = if ($props.Count -ge 1) { [string]$props[0].Value } else { '' }
|
||||
Owner = if ($props.Count -ge 2) { [string]$props[1].Value } else { '' }
|
||||
PrinterName = if ($props.Count -ge 4) { [string]$props[3].Value } else { '' }
|
||||
PropertyValues = $propertyValues
|
||||
}
|
||||
}
|
||||
|
||||
function Get-PrintServiceDocumentFallback {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]$EventSummary,
|
||||
[string]$Owner,
|
||||
[string]$PrinterName
|
||||
)
|
||||
|
||||
$preferred = [string]$EventSummary.DocumentName
|
||||
if (-not (Test-LooksLikeMojibakeQuestionMarks -Value $preferred) -and $preferred -notmatch '^[0-9]+$') {
|
||||
return $preferred
|
||||
}
|
||||
|
||||
$pathCandidates = New-Object System.Collections.Generic.List[string]
|
||||
$textCandidates = New-Object System.Collections.Generic.List[string]
|
||||
|
||||
foreach ($value in @($EventSummary.PropertyValues)) {
|
||||
$candidate = [string]$value
|
||||
if ([string]::IsNullOrWhiteSpace($candidate)) { continue }
|
||||
if ($candidate -eq $preferred) { continue }
|
||||
if ($Owner -and $candidate -like "*$Owner*") { continue }
|
||||
if ($PrinterName -and $candidate -like "*$PrinterName*") { continue }
|
||||
if (Test-LooksLikeMojibakeQuestionMarks -Value $candidate) { continue }
|
||||
|
||||
if ($candidate -match '[\\/:]' -and $candidate -match '\.[A-Za-z0-9]{1,8}$') {
|
||||
$pathCandidates.Add($candidate)
|
||||
continue
|
||||
}
|
||||
|
||||
if ($candidate -match '^[0-9]+$') {
|
||||
continue
|
||||
}
|
||||
|
||||
$textCandidates.Add($candidate)
|
||||
}
|
||||
|
||||
foreach ($candidate in @($pathCandidates)) {
|
||||
$leaf = Split-Path -Path $candidate -Leaf
|
||||
if (-not [string]::IsNullOrWhiteSpace($leaf)) {
|
||||
return $leaf
|
||||
}
|
||||
return $candidate
|
||||
}
|
||||
|
||||
foreach ($candidate in @($textCandidates)) {
|
||||
return $candidate
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
function Write-PrintServiceEventTrace {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]$EventSummary,
|
||||
[string]$Phase,
|
||||
[string]$MatchReason,
|
||||
[string]$ResolvedDocument
|
||||
)
|
||||
|
||||
$properties = if ($EventSummary.PropertyValues) {
|
||||
($EventSummary.PropertyValues -join ' | ')
|
||||
}
|
||||
else {
|
||||
''
|
||||
}
|
||||
|
||||
Write-EndpointLog (
|
||||
'printservice-307 phase={0} recordId={1} time={2} owner={3} printer={4} document={5} resolved={6} properties=[{7}] reason={8}' -f
|
||||
$Phase,
|
||||
$EventSummary.RecordId,
|
||||
$EventSummary.TimeCreated,
|
||||
$EventSummary.Owner,
|
||||
$EventSummary.PrinterName,
|
||||
$EventSummary.DocumentName,
|
||||
$ResolvedDocument,
|
||||
$properties,
|
||||
$MatchReason
|
||||
)
|
||||
}
|
||||
|
||||
function Get-BetterDocumentNameFromPrintServiceEvents {
|
||||
param(
|
||||
[string]$Owner,
|
||||
[string]$PrinterName
|
||||
)
|
||||
|
||||
try {
|
||||
$startTime = (Get-Date).AddMinutes(-15)
|
||||
$events = Get-WinEvent -FilterHashtable @{
|
||||
LogName = 'Microsoft-Windows-PrintService/Operational'
|
||||
Id = 307
|
||||
StartTime = $startTime
|
||||
} -MaxEvents 200 -ErrorAction Stop
|
||||
|
||||
foreach ($pass in @('strict', 'relaxed')) {
|
||||
foreach ($event in @($events)) {
|
||||
$summary = Get-PrintServiceEventSummary -Event $event
|
||||
$resolvedDocument = Get-PrintServiceDocumentFallback -EventSummary $summary -Owner $Owner -PrinterName $PrinterName
|
||||
|
||||
$ownerMatches = if ($Owner) { Test-OwnerLooseMatch -Expected $Owner -Actual $summary.Owner } else { $true }
|
||||
$printerMatches = if ($PrinterName) { Test-PrinterLooseMatch -Expected $PrinterName -Actual $summary.PrinterName } else { $true }
|
||||
|
||||
if ($pass -eq 'strict') {
|
||||
if ($Owner -and -not $ownerMatches) {
|
||||
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'owner-mismatch-strict' -ResolvedDocument $resolvedDocument
|
||||
continue
|
||||
}
|
||||
if ($PrinterName -and -not $printerMatches) {
|
||||
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'printer-mismatch-strict' -ResolvedDocument $resolvedDocument
|
||||
continue
|
||||
}
|
||||
}
|
||||
else {
|
||||
if ($Owner -and $PrinterName -and -not $ownerMatches -and -not $printerMatches) {
|
||||
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'owner-and-printer-mismatch-relaxed' -ResolvedDocument $resolvedDocument
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($resolvedDocument)) {
|
||||
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason ('no-document-candidate-' + $pass) -ResolvedDocument ''
|
||||
continue
|
||||
}
|
||||
|
||||
$matchReasonBase = if (Test-LooksLikeMojibakeQuestionMarks -Value $summary.DocumentName) { 'fallback-used' } else { 'direct' }
|
||||
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'selected' -MatchReason ($matchReasonBase + '-' + $pass) -ResolvedDocument $resolvedDocument
|
||||
return $resolvedDocument
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
$deploymentConfig = Get-DeploymentConfig -Path $ConfigPath
|
||||
$resolvedServerHost = if ($ServerHost) { $ServerHost } elseif ($deploymentConfig) { [string]$deploymentConfig.server.host } else { throw 'ServerHost is required.' }
|
||||
$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\ActivityWatch\dlp-policy.json' }
|
||||
$resolvedPollSeconds = if ($PSBoundParameters.ContainsKey('PollSeconds')) { $PollSeconds } elseif ($deploymentConfig) { [int]$deploymentConfig.collector.pollSeconds } else { 5 }
|
||||
$resolvedLogsRoot = if ($deploymentConfig) { [string]$deploymentConfig.paths.logsRoot } else { 'C:\ProgramData\ActivityWatch\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 'ActivityWatch-Phase2\\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 }
|
||||
|
||||
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:SessionId = (Get-Process -Id $PID).SessionId
|
||||
$script:KnownBuckets = @{}
|
||||
$script:Cooldown = @{}
|
||||
$script:SeenUsb = @{}
|
||||
$script:SeenPrintJob = @{}
|
||||
$script:SeenPrintEvent = @{}
|
||||
$script:LastClipboardHash = $null
|
||||
$script:PulseSeconds = [Math]::Max($resolvedPollSeconds * 3, 30)
|
||||
$script:LocalAgentLogsEnabled = $resolvedLocalAgentLogsEnabled
|
||||
$script:LogPath = $resolvedLogPath
|
||||
$script:IncidentArtifactsRoot = $resolvedIncidentArtifactsRoot
|
||||
$script:IncidentScreenshotEnabled = $resolvedIncidentScreenshotEnabled
|
||||
$script:ScreenshotTypesLoaded = $false
|
||||
|
||||
Load-DlpPolicy -Path $resolvedPolicyPath
|
||||
Write-EndpointLog ("endpoint collector started against {0}" -f $script:ApiBase)
|
||||
|
||||
while ($true) {
|
||||
try {
|
||||
if (-not $script:Policy.defaults.enabled) {
|
||||
Start-Sleep -Seconds $resolvedPollSeconds
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
$clipboardText = Get-Clipboard -Raw -ErrorAction SilentlyContinue
|
||||
if ($clipboardText) {
|
||||
$clipboardHash = Get-StringHash -Value $clipboardText
|
||||
if ($clipboardHash -and $clipboardHash -ne $script:LastClipboardHash) {
|
||||
$script:LastClipboardHash = $clipboardHash
|
||||
Send-EndpointSignalHeartbeat -SignalType 'clipboard_change' -Data @{
|
||||
clipboardHash = $clipboardHash
|
||||
clipboardLength = $clipboardText.Length
|
||||
}
|
||||
Evaluate-ClipboardRules -ClipboardText $clipboardText -ClipboardHash $clipboardHash
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
}
|
||||
|
||||
try {
|
||||
$usbDrives = Get-CimInstance Win32_LogicalDisk -Filter "DriveType=2" -ErrorAction SilentlyContinue
|
||||
$currentUsb = @{}
|
||||
foreach ($drive in @($usbDrives)) {
|
||||
$deviceId = [string]$drive.DeviceID
|
||||
if (-not $deviceId) { continue }
|
||||
$currentUsb[$deviceId] = $true
|
||||
if (-not $script:SeenUsb.ContainsKey($deviceId)) {
|
||||
$script:SeenUsb[$deviceId] = (Get-Date).ToUniversalTime()
|
||||
$volumeName = [string]$drive.VolumeName
|
||||
Send-EndpointSignalHeartbeat -SignalType 'usb_insert' -Data @{
|
||||
driveLetter = $deviceId
|
||||
volumeName = $volumeName
|
||||
}
|
||||
Evaluate-UsbRules -DriveLetter $deviceId -VolumeName $volumeName
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($known in @($script:SeenUsb.Keys)) {
|
||||
if (-not $currentUsb.ContainsKey($known)) {
|
||||
$script:SeenUsb.Remove($known)
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
}
|
||||
|
||||
try {
|
||||
$printJobs = Get-CimInstance Win32_PrintJob -ErrorAction SilentlyContinue
|
||||
foreach ($job in @($printJobs)) {
|
||||
$jobId = [string]$job.JobId
|
||||
if (-not $jobId) { continue }
|
||||
if ($script:SeenPrintJob.ContainsKey($jobId)) { continue }
|
||||
$script:SeenPrintJob[$jobId] = (Get-Date).ToUniversalTime()
|
||||
|
||||
$printerName = [string]$job.Name
|
||||
$documentName = [string]$job.Document
|
||||
$owner = [string]$job.Owner
|
||||
$documentNameOriginal = $documentName
|
||||
|
||||
if (Test-LooksLikeMojibakeQuestionMarks -Value $documentName) {
|
||||
$eventDocumentName = Get-BetterDocumentNameFromPrintServiceEvents -Owner $owner -PrinterName $printerName
|
||||
if ($eventDocumentName) {
|
||||
$documentName = $eventDocumentName
|
||||
}
|
||||
}
|
||||
|
||||
Send-EndpointSignalHeartbeat -SignalType 'print_job' -Data @{
|
||||
printerName = $printerName
|
||||
documentName = $documentName
|
||||
documentNameOriginal = $documentNameOriginal
|
||||
owner = $owner
|
||||
}
|
||||
Evaluate-PrintRules -PrinterName $printerName -DocumentName $documentName -Owner $owner
|
||||
}
|
||||
|
||||
$cleanupBefore = (Get-Date).ToUniversalTime().AddHours(-8)
|
||||
foreach ($k in @($script:SeenPrintJob.Keys)) {
|
||||
$ts = [datetime]$script:SeenPrintJob[$k]
|
||||
if ($ts -lt $cleanupBefore) {
|
||||
$script:SeenPrintJob.Remove($k)
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
}
|
||||
|
||||
try {
|
||||
$printEvents = Get-WinEvent -FilterHashtable @{
|
||||
LogName = 'Microsoft-Windows-PrintService/Operational'
|
||||
Id = 307
|
||||
StartTime = (Get-Date).AddMinutes(-20)
|
||||
} -MaxEvents 200 -ErrorAction SilentlyContinue
|
||||
|
||||
foreach ($event in @($printEvents)) {
|
||||
$recordId = [string]$event.RecordId
|
||||
if (-not $recordId) { continue }
|
||||
if ($script:SeenPrintEvent.ContainsKey($recordId)) { continue }
|
||||
$script:SeenPrintEvent[$recordId] = (Get-Date).ToUniversalTime()
|
||||
|
||||
$summary = Get-PrintServiceEventSummary -Event $event
|
||||
$documentName = [string]$summary.DocumentName
|
||||
$owner = [string]$summary.Owner
|
||||
$printerName = [string]$summary.PrinterName
|
||||
$resolvedDocument = Get-PrintServiceDocumentFallback -EventSummary $summary -Owner $owner -PrinterName $printerName
|
||||
|
||||
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'emit' -MatchReason 'raw-scan' -ResolvedDocument $resolvedDocument
|
||||
|
||||
if (-not [string]::IsNullOrWhiteSpace($owner) -and $owner -notlike "*$env:USERNAME*") {
|
||||
continue
|
||||
}
|
||||
|
||||
Send-EndpointSignalHeartbeat -SignalType 'print_job' -Data @{
|
||||
printerName = $printerName
|
||||
documentName = if ($resolvedDocument) { $resolvedDocument } else { $documentName }
|
||||
documentNameOriginal = $documentName
|
||||
owner = $owner
|
||||
eventRecordId = $recordId
|
||||
eventSource = 'printservice-307'
|
||||
}
|
||||
Evaluate-PrintRules -PrinterName $printerName -DocumentName (if ($resolvedDocument) { $resolvedDocument } else { $documentName }) -Owner $owner
|
||||
}
|
||||
|
||||
$cleanupBeforeEvent = (Get-Date).ToUniversalTime().AddHours(-8)
|
||||
foreach ($k in @($script:SeenPrintEvent.Keys)) {
|
||||
$ts = [datetime]$script:SeenPrintEvent[$k]
|
||||
if ($ts -lt $cleanupBeforeEvent) {
|
||||
$script:SeenPrintEvent.Remove($k)
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-EndpointLog ("collector error: {0}" -f $_.Exception.Message)
|
||||
}
|
||||
|
||||
Start-Sleep -Seconds $resolvedPollSeconds
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
{
|
||||
"version": 1,
|
||||
"defaults": {
|
||||
"enabled": true,
|
||||
"cooldownSeconds": 300,
|
||||
"action": "log",
|
||||
"severity": "low"
|
||||
},
|
||||
"rules": [
|
||||
{
|
||||
"id": "personal-web-during-workhours",
|
||||
"enabled": true,
|
||||
"cooldownSeconds": 600,
|
||||
"action": "alert",
|
||||
"severity": "medium",
|
||||
"message": "Личные ресурсы в рабочее время",
|
||||
"when": {
|
||||
"categoryGroups": ["personal"],
|
||||
"hourFrom": 9,
|
||||
"hourTo": 19
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "high-risk-cloud-storage",
|
||||
"enabled": true,
|
||||
"cooldownSeconds": 900,
|
||||
"action": "alert",
|
||||
"severity": "high",
|
||||
"message": "Подозрительный доступ к облачному хранилищу",
|
||||
"when": {
|
||||
"domains": [
|
||||
"dropbox.com",
|
||||
"drive.google.com",
|
||||
"mega.nz",
|
||||
"onedrive.live.com",
|
||||
"disk.yandex.ru"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "anonymizer-and-vpn-web",
|
||||
"enabled": true,
|
||||
"cooldownSeconds": 900,
|
||||
"action": "alert",
|
||||
"severity": "high",
|
||||
"message": "Использование веб-анонимайзеров / VPN-сервисов",
|
||||
"when": {
|
||||
"domains": [
|
||||
"hidemy.name",
|
||||
"2ip.ru",
|
||||
"whoer.net",
|
||||
"protonvpn.com",
|
||||
"nordvpn.com"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"endpoint": {
|
||||
"clipboard": [
|
||||
{
|
||||
"id": "clipboard-sensitive-keywords",
|
||||
"enabled": true,
|
||||
"cooldownSeconds": 300,
|
||||
"action": "alert",
|
||||
"severity": "high",
|
||||
"message": "В буфере обнаружены чувствительные ключевые слова",
|
||||
"minLength": 20,
|
||||
"regexPatterns": [
|
||||
"(?i)парол(ь|и)",
|
||||
"(?i)password",
|
||||
"(?i)secret",
|
||||
"(?i)cvv",
|
||||
"(?i)паспорт"
|
||||
]
|
||||
}
|
||||
],
|
||||
"usb": [
|
||||
{
|
||||
"id": "usb-media-connected",
|
||||
"enabled": true,
|
||||
"cooldownSeconds": 300,
|
||||
"action": "alert",
|
||||
"severity": "medium",
|
||||
"message": "Подключен съемный носитель"
|
||||
}
|
||||
],
|
||||
"print": [
|
||||
{
|
||||
"id": "print-sensitive-docs",
|
||||
"enabled": true,
|
||||
"cooldownSeconds": 300,
|
||||
"action": "alert",
|
||||
"severity": "high",
|
||||
"message": "Печать документа с признаками чувствительных данных",
|
||||
"documentRegex": "(?i)(salary|зарплат|passport|паспорт|договор|contract)"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$ConfigPath = 'C:\ProgramData\ActivityWatch\deployment-config.json',
|
||||
[string]$ServerHost,
|
||||
[int]$ServerPort,
|
||||
[ValidateSet('http', 'https')]
|
||||
[string]$ServerScheme,
|
||||
[string[]]$Users,
|
||||
[string]$UserListPath,
|
||||
[string]$Domain,
|
||||
[string]$InstallRoot,
|
||||
[string]$StateRoot,
|
||||
[int]$PollSeconds,
|
||||
[int]$PulseSeconds,
|
||||
[int]$RecoveryIntervalSeconds,
|
||||
[bool]$AfkEnabled,
|
||||
[bool]$WindowEnabled,
|
||||
[bool]$LocalAgentLogsEnabled,
|
||||
[bool]$IncidentCaptureEnabled,
|
||||
[bool]$IncidentScreenshotEnabled,
|
||||
[string]$IncidentArtifactsRoot,
|
||||
[bool]$LogonMarkerEnabled,
|
||||
[string]$CustomRulesPath,
|
||||
[string]$CustomPolicyPath,
|
||||
[switch]$RepairPackage,
|
||||
[string]$Version,
|
||||
[string]$PackageUrl,
|
||||
[string]$PackageZipPath
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$modulePath = Join-Path $PSScriptRoot 'ActivityWatch.Windows.Common.psm1'
|
||||
Import-Module $modulePath -Force
|
||||
|
||||
Assert-Administrator
|
||||
|
||||
$existingConfig = $null
|
||||
if (Test-Path -LiteralPath $ConfigPath) {
|
||||
$existingConfig = Read-ActivityWatchDeploymentConfig -Path $ConfigPath
|
||||
}
|
||||
|
||||
if (-not $existingConfig -and (-not $ServerHost)) {
|
||||
throw 'deployment-config.json is missing. Provide -ServerHost and user parameters, or run a deploy script first.'
|
||||
}
|
||||
|
||||
$effectiveStateRoot = if ($StateRoot) { $StateRoot } elseif ($existingConfig) { [string]$existingConfig.paths.stateRoot } else { 'C:\ProgramData\ActivityWatch' }
|
||||
$effectiveInstallRoot = if ($InstallRoot) { $InstallRoot } elseif ($existingConfig) { [string]$existingConfig.paths.installRoot } else { 'C:\Program Files\ActivityWatch' }
|
||||
$effectiveLogsRoot = if ($existingConfig) { [string]$existingConfig.paths.logsRoot } else { Join-Path $effectiveStateRoot 'logs' }
|
||||
$effectiveConfigPath = if ($ConfigPath) { $ConfigPath } else { Join-Path $effectiveStateRoot 'deployment-config.json' }
|
||||
$effectiveLaunchScript = Join-Path $effectiveStateRoot 'launch-watchers.ps1'
|
||||
$effectiveRecoveryScript = Join-Path $effectiveStateRoot 'recovery-loop.ps1'
|
||||
$effectiveCollector = Join-Path $effectiveStateRoot 'browser-domains-native-collector.ps1'
|
||||
$effectiveEndpointCollector = if ($existingConfig -and $existingConfig.paths.PSObject.Properties.Name -contains 'endpointCollectorScript') { [string]$existingConfig.paths.endpointCollectorScript } else { Join-Path $effectiveStateRoot 'dlp-endpoint-signals-collector.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' }
|
||||
|
||||
$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 }
|
||||
$effectiveServerScheme = if ($ServerScheme) { $ServerScheme } elseif ($existingConfig) { [string]$existingConfig.server.scheme } else { 'http' }
|
||||
$effectivePollSeconds = if ($PSBoundParameters.ContainsKey('PollSeconds')) { $PollSeconds } elseif ($existingConfig) { [int]$existingConfig.collector.pollSeconds } else { 5 }
|
||||
$effectivePulseSeconds = if ($PSBoundParameters.ContainsKey('PulseSeconds')) { $PulseSeconds } elseif ($existingConfig) { [int]$existingConfig.collector.pulseSeconds } else { 30 }
|
||||
$effectiveRecoveryInterval = if ($PSBoundParameters.ContainsKey('RecoveryIntervalSeconds')) { $RecoveryIntervalSeconds } elseif ($existingConfig) { [int]$existingConfig.recovery.intervalSeconds } else { 180 }
|
||||
$effectiveAfkEnabled = if ($PSBoundParameters.ContainsKey('AfkEnabled')) { [bool]$AfkEnabled } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'collectors' -and $existingConfig.collectors.PSObject.Properties.Name -contains 'afkEnabled') { [bool]$existingConfig.collectors.afkEnabled } else { $true }
|
||||
$effectiveWindowEnabled = if ($PSBoundParameters.ContainsKey('WindowEnabled')) { [bool]$WindowEnabled } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'collectors' -and $existingConfig.collectors.PSObject.Properties.Name -contains 'windowEnabled') { [bool]$existingConfig.collectors.windowEnabled } else { $true }
|
||||
$effectiveLocalAgentLogsEnabled = if ($PSBoundParameters.ContainsKey('LocalAgentLogsEnabled')) { [bool]$LocalAgentLogsEnabled } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'logging' -and $existingConfig.logging.PSObject.Properties.Name -contains 'localAgentLogsEnabled') { [bool]$existingConfig.logging.localAgentLogsEnabled } else { $false }
|
||||
$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' }
|
||||
$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 }
|
||||
$effectiveVersion = if ($Version) { $Version } elseif ($existingConfig) { [string]$existingConfig.package.version } else { 'v0.13.2' }
|
||||
|
||||
$effectiveUsers = if ($Users -or $UserListPath) {
|
||||
Normalize-ActivityWatchUsers -Users $Users -UserListPath $UserListPath -Domain $Domain
|
||||
}
|
||||
elseif ($existingConfig) {
|
||||
@($existingConfig.userTasks | ForEach-Object { [string]$_.userId })
|
||||
}
|
||||
else {
|
||||
throw 'Target users are missing.'
|
||||
}
|
||||
|
||||
New-ActivityWatchDirectory -Path $effectiveStateRoot
|
||||
New-ActivityWatchDirectory -Path $effectiveLogsRoot
|
||||
|
||||
if ($RepairPackage) {
|
||||
$workingRoot = Join-Path $env:TEMP 'activitywatch-windows-deploy'
|
||||
$backupRoot = Join-Path $effectiveStateRoot 'backups'
|
||||
$archivePath = Get-ActivityWatchArchive -PackageZipPath $PackageZipPath -PackageUrl $PackageUrl -Version $effectiveVersion -WorkingRoot $workingRoot
|
||||
Install-ActivityWatchPackage -ArchivePath $archivePath -InstallRoot $effectiveInstallRoot -WorkingRoot $workingRoot -BackupRoot $backupRoot | Out-Null
|
||||
}
|
||||
|
||||
Get-ActivityWatchExecutableMap -InstallRoot $effectiveInstallRoot | Out-Null
|
||||
|
||||
$assetResult = Copy-ActivityWatchCollectorAssets `
|
||||
-CollectorScriptSource (Join-Path $PSScriptRoot 'browser-domains-native-collector.ps1') `
|
||||
-EndpointCollectorScriptSource (Join-Path $PSScriptRoot 'dlp-endpoint-signals-collector.ps1') `
|
||||
-ExampleRulesSource (Join-Path $PSScriptRoot 'web-category-rules.example.json') `
|
||||
-ExamplePolicySource (Join-Path $PSScriptRoot 'dlp-policy.example.json') `
|
||||
-StateRoot $effectiveStateRoot `
|
||||
-CustomRulesSource $CustomRulesPath `
|
||||
-CustomPolicySource $CustomPolicyPath
|
||||
|
||||
$taskDefinitions = New-ActivityWatchUserTaskDefinitions -Users $effectiveUsers
|
||||
Write-ActivityWatchLaunchScript -Path $effectiveLaunchScript -ConfigPath $effectiveConfigPath
|
||||
Write-ActivityWatchRecoveryScript -Path $effectiveRecoveryScript -ConfigPath $effectiveConfigPath
|
||||
|
||||
$config = New-ActivityWatchDeploymentConfig `
|
||||
-ServerHost $effectiveServerHost `
|
||||
-ServerPort $effectiveServerPort `
|
||||
-ServerScheme $effectiveServerScheme `
|
||||
-InstallRoot $effectiveInstallRoot `
|
||||
-StateRoot $effectiveStateRoot `
|
||||
-LogsRoot $effectiveLogsRoot `
|
||||
-CollectorScript $effectiveCollector `
|
||||
-EndpointCollectorScript $effectiveEndpointCollector `
|
||||
-RulesPath $effectiveRules `
|
||||
-PolicyPath $effectivePolicy `
|
||||
-PollSeconds $effectivePollSeconds `
|
||||
-PulseSeconds $effectivePulseSeconds `
|
||||
-RecoveryIntervalSeconds $effectiveRecoveryInterval `
|
||||
-AfkEnabled $effectiveAfkEnabled `
|
||||
-WindowEnabled $effectiveWindowEnabled `
|
||||
-LocalAgentLogsEnabled $effectiveLocalAgentLogsEnabled `
|
||||
-IncidentCaptureEnabled $effectiveIncidentCaptureEnabled `
|
||||
-IncidentScreenshotEnabled $effectiveIncidentScreenshotEnabled `
|
||||
-IncidentArtifactsRoot $effectiveIncidentArtifactsRoot `
|
||||
-LogonMarkerEnabled $effectiveLogonMarkerEnabled `
|
||||
-LaunchScriptPath $effectiveLaunchScript `
|
||||
-RecoveryScriptPath $effectiveRecoveryScript `
|
||||
-UserTasks $taskDefinitions `
|
||||
-PackageVersion $effectiveVersion
|
||||
|
||||
Write-ActivityWatchDeploymentConfig -Config $config -Path $effectiveConfigPath
|
||||
Remove-LegacyActivityWatchEntries
|
||||
Set-ActivityWatchAcl -InstallRoot $effectiveInstallRoot -StateRoot $effectiveStateRoot -LogsRoot $effectiveLogsRoot
|
||||
Register-ActivityWatchUserTasks -TaskDefinitions $taskDefinitions -LaunchScriptPath $effectiveLaunchScript -ConfigPath $effectiveConfigPath
|
||||
Register-ActivityWatchRecoveryTask -TaskName $config.recovery.taskName -RecoveryScriptPath $effectiveRecoveryScript -ConfigPath $effectiveConfigPath
|
||||
Start-ActivityWatchTasks -TaskDefinitions $taskDefinitions -RecoveryTaskName $config.recovery.taskName
|
||||
|
||||
Write-Host 'ActivityWatch hardening/recovery completed.'
|
||||
Write-Host "Config: $effectiveConfigPath"
|
||||
Write-Host "Users repaired: $($effectiveUsers -join ', ')"
|
||||
@@ -0,0 +1,90 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$ConfigPath = 'C:\ProgramData\ActivityWatch\deployment-config.json'
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$modulePath = Join-Path $PSScriptRoot 'ActivityWatch.Windows.Common.psm1'
|
||||
Import-Module $modulePath -Force
|
||||
|
||||
$config = Read-ActivityWatchDeploymentConfig -Path $ConfigPath
|
||||
$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' }
|
||||
$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' }
|
||||
$launchScript = [string]$config.paths.launchScript
|
||||
$recoveryScript = [string]$config.paths.recoveryScript
|
||||
|
||||
$requiredFiles = @(
|
||||
(Join-Path $installRoot 'aw-watcher-afk\aw-watcher-afk.exe'),
|
||||
(Join-Path $installRoot 'aw-watcher-window\aw-watcher-window.exe'),
|
||||
$collectorScript,
|
||||
$endpointCollectorScript,
|
||||
$rulesPath,
|
||||
$policyPath,
|
||||
$launchScript,
|
||||
$recoveryScript,
|
||||
$ConfigPath
|
||||
)
|
||||
|
||||
$missingFiles = @(
|
||||
$requiredFiles | Where-Object { -not (Test-Path -LiteralPath $_) }
|
||||
)
|
||||
|
||||
$processNames = @('aw-watcher-afk', 'aw-watcher-window')
|
||||
$runningProcesses = Get-Process -Name $processNames -ErrorAction SilentlyContinue | Select-Object Name, Id, SessionId
|
||||
|
||||
$taskNames = @()
|
||||
if ($config.userTasks) {
|
||||
$taskNames += @($config.userTasks | ForEach-Object { [string]$_.launchTaskName })
|
||||
}
|
||||
$taskNames += [string]$config.recovery.taskName
|
||||
$taskNames = $taskNames | Sort-Object -Unique
|
||||
|
||||
$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
|
||||
}
|
||||
}
|
||||
else {
|
||||
[pscustomobject]@{
|
||||
taskName = $taskName
|
||||
state = 'Missing'
|
||||
present = $false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$serverUrl = '{0}://{1}:{2}' -f [string]$config.server.scheme, [string]$config.server.host, [int]$config.server.port
|
||||
$result = [ordered]@{
|
||||
generatedAtUtc = (Get-Date).ToUniversalTime().ToString('o')
|
||||
configPath = $ConfigPath
|
||||
serverUrl = $serverUrl
|
||||
installRoot = $installRoot
|
||||
stateRoot = $stateRoot
|
||||
files = [ordered]@{
|
||||
required = $requiredFiles
|
||||
missing = $missingFiles
|
||||
ok = ($missingFiles.Count -eq 0)
|
||||
}
|
||||
tasks = [ordered]@{
|
||||
list = $tasks
|
||||
ok = [bool]($tasks.Count -gt 0 -and -not ($tasks | Where-Object { -not $_.present }))
|
||||
}
|
||||
processes = [ordered]@{
|
||||
list = @($runningProcesses)
|
||||
ok = [bool](($runningProcesses | Select-Object -ExpandProperty Name -Unique).Count -ge 2)
|
||||
}
|
||||
}
|
||||
|
||||
$result.overallOk = [bool]($result.files.ok -and $result.tasks.ok -and $result.processes.ok)
|
||||
|
||||
$result
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"version": 1,
|
||||
"description": "Override or extend built-in ActivityWatch web categorization rules.",
|
||||
"rules": [
|
||||
{
|
||||
"name": "work_crm",
|
||||
"group": "work",
|
||||
"domains": [
|
||||
"crm.example.com",
|
||||
"portal.example.org"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "work_erp",
|
||||
"group": "work",
|
||||
"domains": [
|
||||
"erp.example.com",
|
||||
"bi.example.com"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "neutral_training",
|
||||
"group": "neutral",
|
||||
"domains": [
|
||||
"wiki.example.net",
|
||||
"kb.example.net"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "personal_social",
|
||||
"group": "personal",
|
||||
"domains": [
|
||||
"social.example.net"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"poll_interval_seconds": 60,
|
||||
"aw": {
|
||||
"server_host": "10.10.10.13",
|
||||
"server_port": 5600,
|
||||
"hostname": "PFSENSE-EDGE01",
|
||||
"pulse_time_seconds": 120,
|
||||
"timeout_seconds": 15
|
||||
},
|
||||
"pfsense": {
|
||||
"name": "pfSense Edge 01",
|
||||
"host": "10.10.10.1",
|
||||
"scheme": "https",
|
||||
"verify_tls": false,
|
||||
"timeout_seconds": 15,
|
||||
"headers": {
|
||||
"X-API-Key": "replace-me",
|
||||
"X-API-Secret": "replace-me"
|
||||
}
|
||||
},
|
||||
"endpoints": [
|
||||
{
|
||||
"name": "system-status",
|
||||
"path": "/api/v2/status/system",
|
||||
"bucket_prefix": "aw-pfsense-health",
|
||||
"bucket_type": "aw.pfsense.health"
|
||||
},
|
||||
{
|
||||
"name": "interfaces",
|
||||
"path": "/api/v2/interface",
|
||||
"bucket_prefix": "aw-pfsense-interfaces",
|
||||
"bucket_type": "aw.pfsense.interfaces"
|
||||
},
|
||||
{
|
||||
"name": "gateways",
|
||||
"path": "/api/v2/status/gateways",
|
||||
"bucket_prefix": "aw-pfsense-gateways",
|
||||
"bucket_type": "aw.pfsense.gateways"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import json
|
||||
import ssl
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def utc_now_iso():
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
|
||||
|
||||
|
||||
def load_json(path):
|
||||
return json.loads(Path(path).read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def build_ssl_context(verify_tls):
|
||||
if verify_tls:
|
||||
return None
|
||||
context = ssl.create_default_context()
|
||||
context.check_hostname = False
|
||||
context.verify_mode = ssl.CERT_NONE
|
||||
return context
|
||||
|
||||
|
||||
def http_json(url, method="GET", headers=None, body=None, timeout=15, ssl_context=None):
|
||||
payload = None
|
||||
effective_headers = {"Content-Type": "application/json; charset=utf-8"}
|
||||
if headers:
|
||||
effective_headers.update(headers)
|
||||
if body is not None:
|
||||
payload = json.dumps(body).encode("utf-8")
|
||||
request = urllib.request.Request(url, data=payload, headers=effective_headers, method=method)
|
||||
with urllib.request.urlopen(request, timeout=timeout, context=ssl_context) as response:
|
||||
raw = response.read().decode("utf-8")
|
||||
return json.loads(raw) if raw else None
|
||||
|
||||
|
||||
def ensure_bucket(aw_base_url, bucket_id, client_name, bucket_type, hostname, timeout, ssl_context):
|
||||
http_json(
|
||||
f"{aw_base_url}/buckets/{urllib.parse.quote(bucket_id, safe='')}",
|
||||
method="POST",
|
||||
body={
|
||||
"client": client_name,
|
||||
"type": bucket_type,
|
||||
"hostname": hostname,
|
||||
},
|
||||
timeout=timeout,
|
||||
ssl_context=ssl_context,
|
||||
)
|
||||
|
||||
|
||||
def send_heartbeat(aw_base_url, bucket_id, event, pulse_time, timeout, ssl_context):
|
||||
http_json(
|
||||
f"{aw_base_url}/buckets/{urllib.parse.quote(bucket_id, safe='')}/heartbeat?pulsetime={pulse_time}",
|
||||
method="POST",
|
||||
body=event,
|
||||
timeout=timeout,
|
||||
ssl_context=ssl_context,
|
||||
)
|
||||
|
||||
|
||||
def normalize_headers(config):
|
||||
headers = dict(config.get("headers") or {})
|
||||
auth = config.get("auth") or {}
|
||||
bearer_token = auth.get("bearer_token")
|
||||
basic = auth.get("basic")
|
||||
if bearer_token:
|
||||
headers["Authorization"] = f"Bearer {bearer_token}"
|
||||
elif basic and basic.get("username") and basic.get("password"):
|
||||
import base64
|
||||
token = base64.b64encode(f"{basic['username']}:{basic['password']}".encode("utf-8")).decode("ascii")
|
||||
headers["Authorization"] = f"Basic {token}"
|
||||
return headers
|
||||
|
||||
|
||||
def summarize_payload(payload):
|
||||
if isinstance(payload, dict):
|
||||
return {
|
||||
"keys": sorted(payload.keys())[:50],
|
||||
"size": len(json.dumps(payload, ensure_ascii=False)),
|
||||
}
|
||||
if isinstance(payload, list):
|
||||
return {
|
||||
"items": len(payload),
|
||||
"sample_type": type(payload[0]).__name__ if payload else "none",
|
||||
"size": len(json.dumps(payload, ensure_ascii=False)),
|
||||
}
|
||||
return {
|
||||
"type": type(payload).__name__,
|
||||
"value": str(payload)[:400],
|
||||
}
|
||||
|
||||
|
||||
def poll_endpoint(config, endpoint, aw_base_url, aw_timeout, pf_timeout, ssl_context):
|
||||
pf_host = config["pfsense"]["host"]
|
||||
scheme = config["pfsense"].get("scheme", "https")
|
||||
hostname = config["aw"]["hostname"]
|
||||
path = endpoint["path"]
|
||||
url = f"{scheme}://{pf_host}{path}"
|
||||
headers = normalize_headers(config["pfsense"])
|
||||
|
||||
try:
|
||||
payload = http_json(
|
||||
url,
|
||||
method=endpoint.get("method", "GET"),
|
||||
headers=headers,
|
||||
timeout=pf_timeout,
|
||||
ssl_context=ssl_context,
|
||||
)
|
||||
bucket_prefix = endpoint["bucket_prefix"]
|
||||
bucket_id = f"{bucket_prefix}_{hostname}"
|
||||
ensure_bucket(
|
||||
aw_base_url,
|
||||
bucket_id,
|
||||
endpoint.get("client", bucket_prefix),
|
||||
endpoint.get("bucket_type", "aw.pfsense.metric"),
|
||||
hostname,
|
||||
aw_timeout,
|
||||
ssl_context,
|
||||
)
|
||||
event = {
|
||||
"timestamp": utc_now_iso(),
|
||||
"duration": 0,
|
||||
"data": {
|
||||
"source": "pfsense-aw-poller",
|
||||
"target": {
|
||||
"host": pf_host,
|
||||
"name": config["pfsense"].get("name", hostname),
|
||||
},
|
||||
"endpoint": {
|
||||
"path": path,
|
||||
"method": endpoint.get("method", "GET"),
|
||||
"name": endpoint.get("name", bucket_prefix),
|
||||
},
|
||||
"summary": summarize_payload(payload),
|
||||
"payload": payload,
|
||||
},
|
||||
}
|
||||
send_heartbeat(
|
||||
aw_base_url,
|
||||
bucket_id,
|
||||
event,
|
||||
endpoint.get("pulse_time_seconds", config["aw"].get("pulse_time_seconds", 120)),
|
||||
aw_timeout,
|
||||
ssl_context,
|
||||
)
|
||||
return {
|
||||
"endpoint": path,
|
||||
"bucket_id": bucket_id,
|
||||
"status": "ok",
|
||||
}
|
||||
except urllib.error.HTTPError as error:
|
||||
return {
|
||||
"endpoint": path,
|
||||
"status": "http_error",
|
||||
"code": error.code,
|
||||
"reason": str(error),
|
||||
}
|
||||
except Exception as error:
|
||||
return {
|
||||
"endpoint": path,
|
||||
"status": "error",
|
||||
"reason": str(error),
|
||||
}
|
||||
|
||||
|
||||
def run_once(config):
|
||||
aw = config["aw"]
|
||||
aw_base_url = f"http://{aw['server_host']}:{aw.get('server_port', 5600)}/api/0"
|
||||
aw_timeout = int(aw.get("timeout_seconds", 15))
|
||||
pf_timeout = int(config["pfsense"].get("timeout_seconds", 15))
|
||||
verify_tls = bool(config["pfsense"].get("verify_tls", False))
|
||||
ssl_context = build_ssl_context(verify_tls)
|
||||
|
||||
results = []
|
||||
for endpoint in config.get("endpoints") or []:
|
||||
results.append(poll_endpoint(config, endpoint, aw_base_url, aw_timeout, pf_timeout, ssl_context))
|
||||
return results
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Poll pfSense API and forward selected telemetry to ActivityWatch.")
|
||||
parser.add_argument("--config", required=True, help="Path to JSON config.")
|
||||
parser.add_argument("--once", action="store_true", help="Run one cycle and exit.")
|
||||
args = parser.parse_args()
|
||||
|
||||
config = load_json(args.config)
|
||||
interval = int(config.get("poll_interval_seconds", 60))
|
||||
|
||||
while True:
|
||||
results = run_once(config)
|
||||
print(json.dumps({"timestamp": utc_now_iso(), "results": results}, ensure_ascii=False), flush=True)
|
||||
if args.once:
|
||||
return 0
|
||||
time.sleep(max(interval, 15))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,15 @@
|
||||
[Unit]
|
||||
Description=pfSense ActivityWatch poller
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/bin/python3 /opt/aw-pfsense/pfsense-aw-poller.py --config /etc/aw-pfsense/poller.json
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
User=root
|
||||
Group=root
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -25,11 +25,16 @@ for file_name in \
|
||||
activitywatch-server.service \
|
||||
aw-server.env.example \
|
||||
aw-ru-patch.js \
|
||||
aw-sw-cleanup.js
|
||||
aw-sw-cleanup.js \
|
||||
aw-host-groups.json
|
||||
do
|
||||
pct push "$CT_ID" "$PROJECT_ROOT/aw-server/$file_name" "/root/bootstrap/$file_name"
|
||||
done
|
||||
|
||||
pct exec "$CT_ID" -- mkdir -p /root/bootstrap/settings
|
||||
pct push "$CT_ID" "$PROJECT_ROOT/aw-server/settings/classes-worktime.json" "/root/bootstrap/settings/classes-worktime.json"
|
||||
pct push "$CT_ID" "$PROJECT_ROOT/aw-server/settings/views-default.json" "/root/bootstrap/settings/views-default.json"
|
||||
|
||||
if [ -n "${AW_SERVER_VERSION:-}" ] &&
|
||||
[ -n "${AW_SERVER_DOWNLOAD_URL:-}" ] &&
|
||||
[ -n "${AW_SERVER_BIND_HOST:-}" ] &&
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user