Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d75b3eb05e | ||
|
|
4359f6d5eb | ||
|
|
7ea4ebd463 | ||
|
|
5a4064dc0f | ||
|
|
c97ffe2cbd | ||
|
|
f45ef0038d | ||
|
|
8088b19dc7 | ||
|
|
046aa3ed1d | ||
|
|
b6f019982d | ||
|
|
fae2e2ca14 | ||
|
|
f436950bda | ||
|
|
d7fedde69d | ||
|
|
7f58a49c0a |
@@ -1,11 +1,13 @@
|
|||||||
# Local secrets
|
# Local secrets
|
||||||
secrets/deploy.secrets.env
|
secrets/deploy.secrets.env
|
||||||
|
secrets/runtime.env
|
||||||
|
|
||||||
# Runtime / reports
|
# Runtime / reports
|
||||||
*.log
|
*.log
|
||||||
*.tmp
|
*.tmp
|
||||||
*.bak
|
*.bak
|
||||||
windows/*.report.json
|
windows/*.report.json
|
||||||
|
.rollout-logs/
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
.idea/
|
.idea/
|
||||||
|
|||||||
@@ -13,12 +13,14 @@
|
|||||||
- `docs/linux-remote-worker.md` — полный Linux remote-worker stack: GUI, SSH/console и browser admin UI вроде Proxmox `:8006`.
|
- `docs/linux-remote-worker.md` — полный Linux remote-worker stack: GUI, SSH/console и browser admin UI вроде Proxmox `:8006`.
|
||||||
- `docs/console-ssh-logger.md` — логирование только консольных команд и SSH-сессий в AW.
|
- `docs/console-ssh-logger.md` — логирование только консольных команд и SSH-сессий в AW.
|
||||||
- `docs/dlp-gap-analysis.md` — разрыв до enterprise DLP и roadmap.
|
- `docs/dlp-gap-analysis.md` — разрыв до enterprise DLP и roadmap.
|
||||||
|
- `docs/dlp-aggregator.md` — прототип централизованной агрегации DLP/file-operation событий.
|
||||||
- `proxmox/` — шаблонные скрипты подготовки и наполнения CT на стороне Proxmox.
|
- `proxmox/` — шаблонные скрипты подготовки и наполнения CT на стороне Proxmox.
|
||||||
- `aw-server/` — установочные скрипты, env-шаблон, systemd unit и RU patch для Web UI.
|
- `aw-server/` — установочные скрипты, env-шаблон, systemd unit и RU patch для Web UI.
|
||||||
- `ansible/` — Ansible-ensemble для автоматизированного сервера (Debian/CT).
|
- `ansible/` — Ansible-ensemble для автоматизированного сервера (Debian/CT).
|
||||||
- `pfsense/` — внешний poller для pfSense API и systemd unit под Debian/Ubuntu utility VM.
|
- `pfsense/` — внешний poller для pfSense API и systemd unit под Debian/Ubuntu utility VM.
|
||||||
- `windows/` — PowerShell toolkit: single-user, domain-users, ensemble orchestration, hardening/recovery, validation, Windows/RDP DLP telemetry (`aw-dlp-incidents_*`, `aw-dlp-endpoint-signals_*`) и session-level presence для удалённых Windows/RDP пользователей (`aw-worktime-sessions_*`).
|
- `windows/` — PowerShell toolkit: single-user, domain-users, ensemble orchestration, hardening/recovery, validation, Windows/RDP DLP telemetry (`aw-dlp-incidents_*`, `aw-dlp-endpoint-signals_*`) и session-level presence для удалённых Windows/RDP пользователей (`aw-worktime-sessions_*`).
|
||||||
- `scripts/quality-gate.sh` — локальный preflight-пайплайн проверок.
|
- `scripts/quality-gate.sh` — локальный preflight-пайплайн проверок.
|
||||||
|
- `scripts/aggregate_dlp_events.py` — сбор `aw-file-operations_*` и `aw-dlp-incidents_*` в SQLite/PostgreSQL.
|
||||||
- `scripts/install_aw_linux_client.sh` — установка Linux bundle + autostart для remote AW server.
|
- `scripts/install_aw_linux_client.sh` — установка Linux bundle + autostart для remote AW server.
|
||||||
- `scripts/install_aw_console_ssh_logger.sh` — user-space установка console/ssh logger.
|
- `scripts/install_aw_console_ssh_logger.sh` — user-space установка console/ssh logger.
|
||||||
- `scripts/install_aw_linux_web_category_logger.sh` — user-space классификация browser admin UI по title/class.
|
- `scripts/install_aw_linux_web_category_logger.sh` — user-space классификация browser admin UI по title/class.
|
||||||
|
|||||||
@@ -31,6 +31,15 @@ cd ansible
|
|||||||
ansible-playbook -i inventory.ini deploy_aw_server.yml
|
ansible-playbook -i inventory.ini deploy_aw_server.yml
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Секреты (пароли) безопасно
|
||||||
|
|
||||||
|
Рекомендуемый способ не хранить пароли в репозитории — перед запуском экспортировать их в переменные окружения:
|
||||||
|
|
||||||
|
- Linux `aw_server` (SSH пароль root): `AW_SSH_PASSWORD`
|
||||||
|
- Windows `aw_windows` (WinRM пароль): `AW_WINRM_PASSWORD`
|
||||||
|
|
||||||
|
В `group_vars/aw_server.yml` и `group_vars/windows.yml` они читаются через `lookup('env', ...)`.
|
||||||
|
|
||||||
## Полный установочный playbook (всё за один запуск)
|
## Полный установочный playbook (всё за один запуск)
|
||||||
|
|
||||||
Если нужно прогнать полный цикл одной командой:
|
Если нужно прогнать полный цикл одной командой:
|
||||||
@@ -149,3 +158,13 @@ Playbook:
|
|||||||
- Для полного сценария CT создаётся автоматически через `pct create`.
|
- Для полного сценария CT создаётся автоматически через `pct create`.
|
||||||
- На Windows/RDP host развёрнуты AFK/window watchers, browser domain collector, DLP endpoint collector и worktime session collector.
|
- На Windows/RDP host развёрнуты AFK/window watchers, browser domain collector, DLP endpoint collector и worktime session collector.
|
||||||
- Проверочный JSON-отчёт Windows playbook должен иметь `overallOk=true`.
|
- Проверочный JSON-отчёт Windows playbook должен иметь `overallOk=true`.
|
||||||
|
|
||||||
|
## Prod rollout одной командой
|
||||||
|
|
||||||
|
Для ручного запуска с dry-run и логированием используйте:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/prod_rollout.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Скрипт попросит `AW_SSH_PASSWORD` и `AW_WINRM_PASSWORD` интерактивно (ввод скрыт) и сложит логи в `.rollout-logs/`.
|
||||||
|
|||||||
+312
-20
@@ -54,6 +54,11 @@
|
|||||||
- "{{ aw_server_webui_dir }}"
|
- "{{ aw_server_webui_dir }}"
|
||||||
- "{{ aw_server_webui_dir }}/js"
|
- "{{ aw_server_webui_dir }}/js"
|
||||||
- "{{ aw_server_data_dir }}"
|
- "{{ aw_server_data_dir }}"
|
||||||
|
- "{{ aw_server_db_path | dirname }}"
|
||||||
|
- "{{ aw_server_data_dir }}/.config"
|
||||||
|
- "{{ aw_server_data_dir }}/.config/activitywatch"
|
||||||
|
- "{{ aw_server_data_dir }}/.config/activitywatch/aw-server-rust"
|
||||||
|
- "{{ aw_server_data_dir }}/backups"
|
||||||
- "{{ aw_server_log_dir }}"
|
- "{{ aw_server_log_dir }}"
|
||||||
- /etc/activitywatch
|
- /etc/activitywatch
|
||||||
- "{{ aw_bootstrap_dir }}"
|
- "{{ aw_bootstrap_dir }}"
|
||||||
@@ -74,8 +79,21 @@
|
|||||||
- "{{ aw_server_webui_dir }}"
|
- "{{ aw_server_webui_dir }}"
|
||||||
- "{{ aw_server_webui_dir }}/js"
|
- "{{ aw_server_webui_dir }}/js"
|
||||||
- "{{ aw_server_data_dir }}"
|
- "{{ aw_server_data_dir }}"
|
||||||
|
- "{{ aw_server_db_path | dirname }}"
|
||||||
|
- "{{ aw_server_data_dir }}/.config"
|
||||||
|
- "{{ aw_server_data_dir }}/.config/activitywatch"
|
||||||
|
- "{{ aw_server_data_dir }}/.config/activitywatch/aw-server-rust"
|
||||||
|
- "{{ aw_server_data_dir }}/backups"
|
||||||
- "{{ aw_server_log_dir }}"
|
- "{{ aw_server_log_dir }}"
|
||||||
|
|
||||||
|
- name: (Check mode) Пропустить установку релиза ActivityWatch
|
||||||
|
ansible.builtin.debug:
|
||||||
|
msg: "ansible_check_mode=true: download/unarchive/install of ActivityWatch release is skipped."
|
||||||
|
when: ansible_check_mode
|
||||||
|
|
||||||
|
- name: Установить релиз ActivityWatch (download/unarchive/install)
|
||||||
|
when: not ansible_check_mode
|
||||||
|
block:
|
||||||
- name: Скачать архив релиза ActivityWatch
|
- name: Скачать архив релиза ActivityWatch
|
||||||
ansible.builtin.get_url:
|
ansible.builtin.get_url:
|
||||||
url: "{{ aw_server_download_url }}"
|
url: "{{ aw_server_download_url }}"
|
||||||
@@ -92,6 +110,7 @@
|
|||||||
- name: Найти распакованный каталог ActivityWatch
|
- name: Найти распакованный каталог ActivityWatch
|
||||||
ansible.builtin.find:
|
ansible.builtin.find:
|
||||||
paths: "{{ aw_release_dir }}"
|
paths: "{{ aw_release_dir }}"
|
||||||
|
recurse: true
|
||||||
file_type: directory
|
file_type: directory
|
||||||
patterns: "activitywatch*"
|
patterns: "activitywatch*"
|
||||||
register: aw_release_find
|
register: aw_release_find
|
||||||
@@ -99,26 +118,59 @@
|
|||||||
- name: Найти бинарный файл AW server
|
- name: Найти бинарный файл AW server
|
||||||
ansible.builtin.find:
|
ansible.builtin.find:
|
||||||
paths: "{{ aw_release_dir }}"
|
paths: "{{ aw_release_dir }}"
|
||||||
|
recurse: true
|
||||||
file_type: file
|
file_type: file
|
||||||
patterns:
|
patterns:
|
||||||
- aw-server-rust
|
- aw-server-rust
|
||||||
- aw-server
|
- aw-server
|
||||||
register: aw_server_binary_find
|
register: aw_server_binary_find
|
||||||
|
|
||||||
- name: Найти каталог WebUI
|
- name: Найти index.html WebUI
|
||||||
ansible.builtin.find:
|
ansible.builtin.find:
|
||||||
paths: "{{ aw_release_dir }}"
|
paths: "{{ aw_release_dir }}"
|
||||||
file_type: directory
|
recurse: true
|
||||||
|
file_type: file
|
||||||
patterns:
|
patterns:
|
||||||
- aw-webui
|
- index.html
|
||||||
- webui
|
register: aw_webui_index_find
|
||||||
register: aw_webui_dir_find
|
|
||||||
|
|
||||||
- name: Сохранить пути распакованного релиза
|
- name: Сохранить пути распакованного релиза (binary + webui index)
|
||||||
ansible.builtin.set_fact:
|
ansible.builtin.set_fact:
|
||||||
aw_release_extracted: "{{ (aw_release_find.files | default([]) | sort(attribute='path') | map(attribute='path') | list | first) | default('') }}"
|
aw_release_extracted: "{{ (aw_release_find.files | default([]) | sort(attribute='path') | map(attribute='path') | list | first) | default('') }}"
|
||||||
aw_server_binary_path: "{{ (aw_server_binary_find.files | default([]) | sort(attribute='path') | map(attribute='path') | list | first) | default('') }}"
|
aw_server_binary_path: >-
|
||||||
aw_webui_source_path: "{{ (aw_webui_dir_find.files | default([]) | sort(attribute='path') | map(attribute='path') | list | first) | default('') }}"
|
{{
|
||||||
|
(
|
||||||
|
(
|
||||||
|
(aw_server_binary_find.files | default([]) | sort(attribute='path') | map(attribute='path') | list)
|
||||||
|
| select('match', '.*/aw-server-rust$') | list | first
|
||||||
|
)
|
||||||
|
| default(
|
||||||
|
(
|
||||||
|
(aw_server_binary_find.files | default([]) | sort(attribute='path') | map(attribute='path') | list | first)
|
||||||
|
),
|
||||||
|
true
|
||||||
|
)
|
||||||
|
) | default('')
|
||||||
|
}}
|
||||||
|
aw_webui_index_path: >-
|
||||||
|
{{
|
||||||
|
(
|
||||||
|
(
|
||||||
|
(aw_webui_index_find.files | default([]) | sort(attribute='path') | map(attribute='path') | list)
|
||||||
|
| select('search', '/static/index\\.html$') | list | first
|
||||||
|
)
|
||||||
|
| default(
|
||||||
|
(
|
||||||
|
(aw_webui_index_find.files | default([]) | sort(attribute='path') | map(attribute='path') | list | first)
|
||||||
|
),
|
||||||
|
true
|
||||||
|
)
|
||||||
|
) | default('')
|
||||||
|
}}
|
||||||
|
|
||||||
|
- name: Сохранить каталог WebUI (dirname index.html)
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
aw_webui_source_path: "{{ aw_webui_index_path | dirname }}"
|
||||||
|
|
||||||
- name: Проверить, что компоненты релиза найдены
|
- name: Проверить, что компоненты релиза найдены
|
||||||
ansible.builtin.assert:
|
ansible.builtin.assert:
|
||||||
@@ -184,6 +236,14 @@
|
|||||||
- Перезагрузить systemd
|
- Перезагрузить systemd
|
||||||
- Перезапустить activitywatch
|
- Перезапустить activitywatch
|
||||||
|
|
||||||
|
- name: (Check mode) Пропустить WebUI patch и запуск сервиса
|
||||||
|
ansible.builtin.debug:
|
||||||
|
msg: "ansible_check_mode=true: WebUI patch + service start + API checks are skipped."
|
||||||
|
when: ansible_check_mode
|
||||||
|
|
||||||
|
- name: Применить WebUI RU patch и запустить сервис
|
||||||
|
when: not ansible_check_mode
|
||||||
|
block:
|
||||||
- name: Скопировать RU patch файлы WebUI из репозитория
|
- name: Скопировать RU patch файлы WebUI из репозитория
|
||||||
ansible.builtin.copy:
|
ansible.builtin.copy:
|
||||||
src: "{{ item.src }}"
|
src: "{{ item.src }}"
|
||||||
@@ -235,10 +295,120 @@
|
|||||||
AW_SERVER_BIND_HOST={{ aw_server_bind_host }}
|
AW_SERVER_BIND_HOST={{ aw_server_bind_host }}
|
||||||
AW_SERVER_PORT={{ aw_server_port }}
|
AW_SERVER_PORT={{ aw_server_port }}
|
||||||
AW_SERVER_DATA_DIR={{ aw_server_data_dir }}
|
AW_SERVER_DATA_DIR={{ aw_server_data_dir }}
|
||||||
|
AW_SERVER_DB_PATH={{ aw_server_db_path }}
|
||||||
AW_SERVER_LOG_DIR={{ aw_server_log_dir }}
|
AW_SERVER_LOG_DIR={{ aw_server_log_dir }}
|
||||||
AW_SERVER_WEBUI_DIR={{ aw_server_webui_dir }}
|
AW_SERVER_WEBUI_DIR={{ aw_server_webui_dir }}
|
||||||
AW_SERVER_USER={{ aw_server_user }}
|
AW_SERVER_USER={{ aw_server_user }}
|
||||||
AW_SERVER_GROUP={{ aw_server_group }}
|
AW_SERVER_GROUP={{ aw_server_group }}
|
||||||
|
XDG_DATA_HOME={{ aw_server_data_dir }}/.local/share
|
||||||
|
XDG_CONFIG_HOME={{ aw_server_data_dir }}/.config
|
||||||
|
|
||||||
|
- name: Скопировать merge script AW DB на сервер
|
||||||
|
ansible.builtin.copy:
|
||||||
|
src: "{{ aw_repo_root }}/scripts/merge_aw_server_dbs.py"
|
||||||
|
dest: /usr/local/bin/merge_aw_server_dbs.py
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0755"
|
||||||
|
|
||||||
|
- name: Проверить наличие legacy root DB
|
||||||
|
ansible.builtin.stat:
|
||||||
|
path: /root/.local/share/activitywatch/aw-server-rust/sqlite.db
|
||||||
|
register: aw_legacy_root_db
|
||||||
|
|
||||||
|
- name: Проверить наличие target DB
|
||||||
|
ansible.builtin.stat:
|
||||||
|
path: "{{ aw_server_db_path }}"
|
||||||
|
register: aw_target_db
|
||||||
|
|
||||||
|
- name: Остановить сервис перед merge server DB
|
||||||
|
ansible.builtin.systemd:
|
||||||
|
name: activitywatch-server.service
|
||||||
|
state: stopped
|
||||||
|
when: aw_legacy_root_db.stat.exists | default(false)
|
||||||
|
|
||||||
|
- name: Создать backup каталоги server DB
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ aw_server_data_dir }}/backups/db"
|
||||||
|
state: directory
|
||||||
|
owner: "{{ aw_server_user }}"
|
||||||
|
group: "{{ aw_server_group }}"
|
||||||
|
mode: "0755"
|
||||||
|
when: aw_legacy_root_db.stat.exists | default(false)
|
||||||
|
|
||||||
|
- name: Backup target DB перед merge
|
||||||
|
ansible.builtin.copy:
|
||||||
|
remote_src: true
|
||||||
|
src: "{{ aw_server_db_path }}"
|
||||||
|
dest: "{{ aw_server_data_dir }}/backups/db/target-before-merge-{{ ansible_date_time.iso8601_basic_short }}.sqlite.db"
|
||||||
|
owner: "{{ aw_server_user }}"
|
||||||
|
group: "{{ aw_server_group }}"
|
||||||
|
mode: "0644"
|
||||||
|
when:
|
||||||
|
- aw_legacy_root_db.stat.exists | default(false)
|
||||||
|
- aw_target_db.stat.exists | default(false)
|
||||||
|
|
||||||
|
- name: Backup legacy root DB перед merge
|
||||||
|
ansible.builtin.copy:
|
||||||
|
remote_src: true
|
||||||
|
src: /root/.local/share/activitywatch/aw-server-rust/sqlite.db
|
||||||
|
dest: "{{ aw_server_data_dir }}/backups/db/legacy-root-{{ ansible_date_time.iso8601_basic_short }}.sqlite.db"
|
||||||
|
owner: "{{ aw_server_user }}"
|
||||||
|
group: "{{ aw_server_group }}"
|
||||||
|
mode: "0644"
|
||||||
|
when: aw_legacy_root_db.stat.exists | default(false)
|
||||||
|
|
||||||
|
- name: Merge legacy root DB в target DB
|
||||||
|
ansible.builtin.command:
|
||||||
|
argv:
|
||||||
|
- python3
|
||||||
|
- /usr/local/bin/merge_aw_server_dbs.py
|
||||||
|
- --base
|
||||||
|
- /root/.local/share/activitywatch/aw-server-rust/sqlite.db
|
||||||
|
- --overlay
|
||||||
|
- "{{ aw_server_db_path }}"
|
||||||
|
- --output
|
||||||
|
- "{{ aw_server_db_path }}.merged"
|
||||||
|
when:
|
||||||
|
- aw_legacy_root_db.stat.exists | default(false)
|
||||||
|
- aw_target_db.stat.exists | default(false)
|
||||||
|
|
||||||
|
- name: Install merged DB as active target DB
|
||||||
|
ansible.builtin.copy:
|
||||||
|
remote_src: true
|
||||||
|
src: "{{ aw_server_db_path }}.merged"
|
||||||
|
dest: "{{ aw_server_db_path }}"
|
||||||
|
owner: "{{ aw_server_user }}"
|
||||||
|
group: "{{ aw_server_group }}"
|
||||||
|
mode: "0644"
|
||||||
|
when:
|
||||||
|
- aw_legacy_root_db.stat.exists | default(false)
|
||||||
|
- aw_target_db.stat.exists | default(false)
|
||||||
|
|
||||||
|
- name: Скопировать legacy root DB в target DB если target ещё не существует
|
||||||
|
ansible.builtin.copy:
|
||||||
|
remote_src: true
|
||||||
|
src: /root/.local/share/activitywatch/aw-server-rust/sqlite.db
|
||||||
|
dest: "{{ aw_server_db_path }}"
|
||||||
|
owner: "{{ aw_server_user }}"
|
||||||
|
group: "{{ aw_server_group }}"
|
||||||
|
mode: "0644"
|
||||||
|
when:
|
||||||
|
- aw_legacy_root_db.stat.exists | default(false)
|
||||||
|
- not (aw_target_db.stat.exists | default(false))
|
||||||
|
|
||||||
|
- name: Записать aw-server-rust config.toml с разрешёнными CORS origin
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: "{{ aw_server_data_dir }}/.config/activitywatch/aw-server-rust/config.toml"
|
||||||
|
owner: "{{ aw_server_user }}"
|
||||||
|
group: "{{ aw_server_group }}"
|
||||||
|
mode: "0644"
|
||||||
|
content: |
|
||||||
|
cors = [
|
||||||
|
{% for origin in aw_server_cors_origins | default([]) %}
|
||||||
|
"{{ origin }}"{% if not loop.last %},{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
]
|
||||||
|
|
||||||
- name: Включить и запустить сервис
|
- name: Включить и запустить сервис
|
||||||
ansible.builtin.systemd:
|
ansible.builtin.systemd:
|
||||||
@@ -257,6 +427,106 @@
|
|||||||
delay: 3
|
delay: 3
|
||||||
until: aw_api.status == 200
|
until: aw_api.status == 200
|
||||||
|
|
||||||
|
- name: Считать текущие server-side settings
|
||||||
|
ansible.builtin.uri:
|
||||||
|
url: "http://127.0.0.1:{{ aw_server_port }}/api/0/settings/"
|
||||||
|
method: GET
|
||||||
|
status_code: 200
|
||||||
|
register: aw_settings_current
|
||||||
|
when: aw_apply_worktime_settings | default(false) | bool
|
||||||
|
|
||||||
|
- name: Считать текущие server-side views
|
||||||
|
ansible.builtin.uri:
|
||||||
|
url: "http://127.0.0.1:{{ aw_server_port }}/api/0/settings/views"
|
||||||
|
method: GET
|
||||||
|
status_code: 200
|
||||||
|
register: aw_views_current
|
||||||
|
when: aw_apply_worktime_settings | default(false) | bool
|
||||||
|
|
||||||
|
- name: Считать текущие server-side classes
|
||||||
|
ansible.builtin.uri:
|
||||||
|
url: "http://127.0.0.1:{{ aw_server_port }}/api/0/settings/classes"
|
||||||
|
method: GET
|
||||||
|
status_code: 200
|
||||||
|
register: aw_classes_current
|
||||||
|
when: aw_apply_worktime_settings | default(false) | bool
|
||||||
|
|
||||||
|
- name: Создать backup текущих server-side settings/views/classes
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: "{{ aw_server_data_dir }}/backups/{{ item.name }}-{{ ansible_date_time.iso8601_basic_short }}.json"
|
||||||
|
owner: "{{ aw_server_user }}"
|
||||||
|
group: "{{ aw_server_group }}"
|
||||||
|
mode: "0644"
|
||||||
|
content: "{{ item.payload | to_nice_json }}"
|
||||||
|
loop:
|
||||||
|
- name: settings
|
||||||
|
payload: "{{ aw_settings_current.json | default({}) }}"
|
||||||
|
- name: views
|
||||||
|
payload: "{{ aw_views_current.json | default(none) }}"
|
||||||
|
- name: classes
|
||||||
|
payload: "{{ aw_classes_current.json | default(none) }}"
|
||||||
|
when: aw_apply_worktime_settings | default(false) | bool
|
||||||
|
|
||||||
|
- name: Настроить DLP Aggregator (Phase 2)
|
||||||
|
block:
|
||||||
|
- name: Создать каталог для скриптов
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "/opt/activitywatch/scripts"
|
||||||
|
state: directory
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0755"
|
||||||
|
|
||||||
|
- name: Скопировать агрегатор событий DLP
|
||||||
|
ansible.builtin.copy:
|
||||||
|
src: "{{ aw_repo_root }}/scripts/aggregate_dlp_events.py"
|
||||||
|
dest: "/opt/activitywatch/scripts/aggregate_dlp_events.py"
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0755"
|
||||||
|
|
||||||
|
- name: Установить systemd unit для агрегатора
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: /etc/systemd/system/activitywatch-dlp-aggregator.service
|
||||||
|
content: |
|
||||||
|
[Unit]
|
||||||
|
Description=ActivityWatch DLP Event Aggregator
|
||||||
|
After=activitywatch-server.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
User={{ aw_server_user }}
|
||||||
|
WorkingDirectory={{ aw_server_data_dir }}
|
||||||
|
ExecStart=/usr/bin/python3 /opt/activitywatch/scripts/aggregate_dlp_events.py \
|
||||||
|
--aw-url http://127.0.0.1:{{ aw_server_port }}/api/0 \
|
||||||
|
--sqlite-path {{ aw_server_data_dir }}/dlp_warehouse.sqlite \
|
||||||
|
--state-path {{ aw_server_data_dir }}/dlp-aggregator-state.json
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
|
||||||
|
- name: Установить systemd timer для агрегатора
|
||||||
|
ansible.builtin.copy:
|
||||||
|
dest: /etc/systemd/system/activitywatch-dlp-aggregator.timer
|
||||||
|
content: |
|
||||||
|
[Unit]
|
||||||
|
Description=Run ActivityWatch DLP Aggregator every 5 minutes
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnBootSec=1min
|
||||||
|
OnUnitActiveSec=5min
|
||||||
|
AccuracySec=1s
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
|
|
||||||
|
- name: Включить и запустить таймер агрегатора
|
||||||
|
ansible.builtin.systemd:
|
||||||
|
name: activitywatch-dlp-aggregator.timer
|
||||||
|
enabled: true
|
||||||
|
state: started
|
||||||
|
daemon_reload: true
|
||||||
|
|
||||||
- name: Применить базовые worktime settings (classes)
|
- name: Применить базовые worktime settings (classes)
|
||||||
ansible.builtin.uri:
|
ansible.builtin.uri:
|
||||||
url: "http://127.0.0.1:{{ aw_server_port }}/api/0/settings/classes"
|
url: "http://127.0.0.1:{{ aw_server_port }}/api/0/settings/classes"
|
||||||
@@ -277,16 +547,12 @@
|
|||||||
|
|
||||||
- name: Вычислить worktime durationDefault из aw_worktime_from/to
|
- name: Вычислить worktime durationDefault из aw_worktime_from/to
|
||||||
ansible.builtin.set_fact:
|
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_duration_default_derived: >-
|
||||||
{{
|
{{
|
||||||
(
|
(
|
||||||
(
|
(
|
||||||
((aw_worktime_to_h | int) * 60 + (aw_worktime_to_m | int)) -
|
(((aw_worktime_to | default('17:00')).split(':')[0] | int) * 60 + ((aw_worktime_to | default('17:00')).split(':')[1] | int)) -
|
||||||
((aw_worktime_from_h | int) * 60 + (aw_worktime_from_m | int))
|
(((aw_worktime_from | default('08:00')).split(':')[0] | int) * 60 + ((aw_worktime_from | default('08:00')).split(':')[1] | int))
|
||||||
) * 60
|
) * 60
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
@@ -314,20 +580,46 @@
|
|||||||
ansible.builtin.uri:
|
ansible.builtin.uri:
|
||||||
url: "http://127.0.0.1:{{ aw_server_port }}/api/0/settings/startOfDay"
|
url: "http://127.0.0.1:{{ aw_server_port }}/api/0/settings/startOfDay"
|
||||||
method: POST
|
method: POST
|
||||||
body: "{{ aw_worktime_start_of_day }}"
|
body: "\"{{ aw_worktime_start_of_day }}\""
|
||||||
body_format: json
|
headers:
|
||||||
status_code: 200
|
Content-Type: application/json
|
||||||
|
status_code: [200, 201]
|
||||||
when: aw_apply_worktime_settings | default(false) | bool
|
when: aw_apply_worktime_settings | default(false) | bool
|
||||||
|
|
||||||
- name: Применить базовый период worktime (durationDefault seconds)
|
- name: Применить базовый период worktime (durationDefault seconds)
|
||||||
ansible.builtin.uri:
|
ansible.builtin.uri:
|
||||||
url: "http://127.0.0.1:{{ aw_server_port }}/api/0/settings/durationDefault"
|
url: "http://127.0.0.1:{{ aw_server_port }}/api/0/settings/durationDefault"
|
||||||
method: POST
|
method: POST
|
||||||
body: "{{ aw_worktime_duration_default_effective }}"
|
body: "{{ aw_worktime_duration_default_effective | string }}"
|
||||||
body_format: json
|
headers:
|
||||||
status_code: 200
|
Content-Type: application/json
|
||||||
|
status_code: [200, 201]
|
||||||
when: aw_apply_worktime_settings | default(false) | bool
|
when: aw_apply_worktime_settings | default(false) | bool
|
||||||
|
|
||||||
|
- name: Применить always_active_pattern для fallback без AFK
|
||||||
|
ansible.builtin.uri:
|
||||||
|
url: "http://127.0.0.1:{{ aw_server_port }}/api/0/settings/always_active_pattern"
|
||||||
|
method: POST
|
||||||
|
body: "\"{{ aw_server_always_active_pattern }}\""
|
||||||
|
headers:
|
||||||
|
Content-Type: application/json
|
||||||
|
status_code: [200, 201]
|
||||||
|
when:
|
||||||
|
- aw_apply_worktime_settings | default(false) | bool
|
||||||
|
- (aw_server_always_active_pattern | default('') | string | length) > 0
|
||||||
|
|
||||||
|
- name: Применить landingpage профиля
|
||||||
|
ansible.builtin.uri:
|
||||||
|
url: "http://127.0.0.1:{{ aw_server_port }}/api/0/settings/landingpage"
|
||||||
|
method: POST
|
||||||
|
body: "\"{{ aw_server_landingpage }}\""
|
||||||
|
headers:
|
||||||
|
Content-Type: application/json
|
||||||
|
status_code: [200, 201]
|
||||||
|
when:
|
||||||
|
- aw_apply_worktime_settings | default(false) | bool
|
||||||
|
- (aw_server_landingpage | default('') | string | length) > 0
|
||||||
|
|
||||||
handlers:
|
handlers:
|
||||||
- name: Перезагрузить systemd
|
- name: Перезагрузить systemd
|
||||||
ansible.builtin.systemd:
|
ansible.builtin.systemd:
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
aw_windows_state_root: "C:\\ProgramData\\AWatch-rus"
|
aw_windows_state_root: "C:\\ProgramData\\AWatch-rus"
|
||||||
aw_windows_afk_enabled: true
|
aw_windows_afk_enabled: true
|
||||||
aw_windows_window_enabled: true
|
aw_windows_window_enabled: true
|
||||||
|
aw_windows_file_ops_enabled: true
|
||||||
aw_windows_local_agent_logs_enabled: false
|
aw_windows_local_agent_logs_enabled: false
|
||||||
aw_windows_incident_capture_enabled: true
|
aw_windows_incident_capture_enabled: true
|
||||||
aw_windows_incident_screenshot_enabled: true
|
aw_windows_incident_screenshot_enabled: true
|
||||||
@@ -77,6 +78,7 @@
|
|||||||
- ActivityWatch.Windows.Common.psm1
|
- ActivityWatch.Windows.Common.psm1
|
||||||
- browser-domains-native-collector.ps1
|
- browser-domains-native-collector.ps1
|
||||||
- dlp-endpoint-signals-collector.ps1
|
- dlp-endpoint-signals-collector.ps1
|
||||||
|
- file-operations-collector.ps1
|
||||||
- worktime-session-collector.ps1
|
- worktime-session-collector.ps1
|
||||||
- migrate-awatch-rus-paths.ps1
|
- migrate-awatch-rus-paths.ps1
|
||||||
- deploy-domain-users.ps1
|
- deploy-domain-users.ps1
|
||||||
@@ -86,6 +88,18 @@
|
|||||||
- web-category-rules.example.json
|
- web-category-rules.example.json
|
||||||
- dlp-policy.example.json
|
- dlp-policy.example.json
|
||||||
|
|
||||||
|
- name: Нормализовать кодировку PowerShell файлов (UTF-8 BOM для Windows PowerShell)
|
||||||
|
ansible.windows.win_powershell:
|
||||||
|
script: |
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$toolkitDir = "{{ aw_windows_deploy_root }}\windows"
|
||||||
|
$encIn = New-Object System.Text.UTF8Encoding($false)
|
||||||
|
$encOut = New-Object System.Text.UTF8Encoding($true)
|
||||||
|
Get-ChildItem -LiteralPath $toolkitDir -File -Include *.ps1,*.psm1,*.psd1 | ForEach-Object {
|
||||||
|
$text = [System.IO.File]::ReadAllText($_.FullName, $encIn)
|
||||||
|
[System.IO.File]::WriteAllText($_.FullName, $text, $encOut)
|
||||||
|
}
|
||||||
|
|
||||||
- name: Загрузить список пользователей для доменного развёртывания
|
- name: Загрузить список пользователей для доменного развёртывания
|
||||||
ansible.windows.win_copy:
|
ansible.windows.win_copy:
|
||||||
dest: "{{ aw_windows_deploy_root }}\\windows\\users.txt"
|
dest: "{{ aw_windows_deploy_root }}\\windows\\users.txt"
|
||||||
@@ -130,6 +144,7 @@
|
|||||||
StateRoot = "{{ aw_windows_state_root }}"
|
StateRoot = "{{ aw_windows_state_root }}"
|
||||||
AfkEnabled = {{ '$true' if (aw_windows_afk_enabled | bool) else '$false' }}
|
AfkEnabled = {{ '$true' if (aw_windows_afk_enabled | bool) else '$false' }}
|
||||||
WindowEnabled = {{ '$true' if (aw_windows_window_enabled | bool) else '$false' }}
|
WindowEnabled = {{ '$true' if (aw_windows_window_enabled | bool) else '$false' }}
|
||||||
|
FileOpsEnabled = {{ '$true' if (aw_windows_file_ops_enabled | bool) else '$false' }}
|
||||||
LocalAgentLogsEnabled = {{ '$true' if (aw_windows_local_agent_logs_enabled | bool) else '$false' }}
|
LocalAgentLogsEnabled = {{ '$true' if (aw_windows_local_agent_logs_enabled | bool) else '$false' }}
|
||||||
IncidentCaptureEnabled = {{ '$true' if (aw_windows_incident_capture_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' }}
|
IncidentScreenshotEnabled = {{ '$true' if (aw_windows_incident_screenshot_enabled | bool) else '$false' }}
|
||||||
@@ -149,6 +164,19 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
& "{{ aw_windows_deploy_root }}\windows\deploy-ensemble.ps1" @params
|
& "{{ aw_windows_deploy_root }}\windows\deploy-ensemble.ps1" @params
|
||||||
|
|
||||||
|
- name: Удалить лишние ActivityWatch Launch tasks вне текущего deployment-config
|
||||||
|
ansible.windows.win_powershell:
|
||||||
|
script: |
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$config = Get-Content -Raw -LiteralPath "{{ aw_windows_state_root }}\deployment-config.json" | ConvertFrom-Json
|
||||||
|
$desired = @($config.userTasks | ForEach-Object { [string]$_.LaunchTaskName })
|
||||||
|
foreach ($task in @(Get-ScheduledTask | Where-Object { $_.TaskName -like 'ActivityWatch Launch *' })) {
|
||||||
|
if ($desired -notcontains [string]$task.TaskName) {
|
||||||
|
Unregister-ScheduledTask -TaskName $task.TaskName -Confirm:$false -ErrorAction SilentlyContinue
|
||||||
|
& cmd.exe /c "schtasks /Delete /TN `"$($task.TaskName)`" /F >nul 2>&1" | Out-Null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
- name: Принудительно запустить ActivityWatch recovery и launch tasks
|
- name: Принудительно запустить ActivityWatch recovery и launch tasks
|
||||||
when: aw_windows_force_task_restart | bool
|
when: aw_windows_force_task_restart | bool
|
||||||
ansible.windows.win_powershell:
|
ansible.windows.win_powershell:
|
||||||
@@ -171,62 +199,55 @@
|
|||||||
when:
|
when:
|
||||||
- aw_windows_api_smoke_check_enabled | bool
|
- aw_windows_api_smoke_check_enabled | bool
|
||||||
- aw_windows_afk_enabled | bool
|
- aw_windows_afk_enabled | bool
|
||||||
|
- aw_windows_hostname_result.stdout is defined
|
||||||
ansible.builtin.set_fact:
|
ansible.builtin.set_fact:
|
||||||
aw_windows_api_smoke_check_bucket_effective: >-
|
aw_windows_api_smoke_check_bucket_effective: "aw-watcher-afk_{{ aw_windows_hostname_result.stdout | trim }}"
|
||||||
{{
|
|
||||||
aw_windows_api_smoke_check_bucket
|
|
||||||
if (aw_windows_api_smoke_check_bucket | default('') | string | length) > 0
|
|
||||||
else 'aw-watcher-afk_' ~ (aw_windows_hostname_result.stdout | trim)
|
|
||||||
}}
|
|
||||||
|
|
||||||
- name: Дождаться свежих AFK событий на AW server
|
- name: Выполнить AW API smoke-check (проверка наличия свежих событий в AFK бакете)
|
||||||
when:
|
when:
|
||||||
- aw_windows_api_smoke_check_enabled | bool
|
- aw_windows_api_smoke_check_enabled | bool
|
||||||
- aw_windows_afk_enabled | bool
|
- aw_windows_afk_enabled | bool
|
||||||
delegate_to: localhost
|
|
||||||
ansible.builtin.uri:
|
ansible.builtin.uri:
|
||||||
url: "{{ aw_windows_server_scheme }}://{{ aw_windows_server_host }}:{{ aw_windows_server_port }}/api/0/buckets/{{ aw_windows_api_smoke_check_bucket_effective }}/events?limit={{ aw_windows_api_smoke_check_limit }}"
|
url: "{{ aw_windows_server_scheme }}://{{ aw_windows_server_host }}:{{ aw_windows_server_port }}/api/0/buckets/{{ aw_windows_api_smoke_check_bucket_effective }}/events?limit={{ aw_windows_api_smoke_check_limit }}"
|
||||||
method: GET
|
method: GET
|
||||||
return_content: true
|
status_code: 200
|
||||||
register: aw_windows_api_smoke
|
register: aw_windows_api_smoke_result
|
||||||
until: >
|
until: aw_windows_api_smoke_result.json | length > 0
|
||||||
aw_windows_api_smoke.status == 200 and
|
retries: 5
|
||||||
(aw_windows_api_smoke.json | length) > 0 and
|
delay: 5
|
||||||
(
|
ignore_errors: true
|
||||||
aw_windows_api_smoke.json
|
|
||||||
| selectattr('data.status', 'equalto', 'not-afk')
|
|
||||||
| list
|
|
||||||
| length
|
|
||||||
) > 0
|
|
||||||
retries: 10
|
|
||||||
delay: 6
|
|
||||||
|
|
||||||
- name: Выполнить валидацию и сохранить отчёт на целевом Windows host
|
- name: Валидировать развёртывание на эндпоинте
|
||||||
ansible.windows.win_powershell:
|
ansible.windows.win_powershell:
|
||||||
script: |
|
script: |
|
||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
$report = & "{{ aw_windows_deploy_root }}\windows\validate-deployment.ps1" `
|
$result = & "{{ aw_windows_deploy_root }}\windows\validate-deployment.ps1" `
|
||||||
-ConfigPath "{{ aw_windows_state_root }}\deployment-config.json"
|
-ConfigPath "{{ aw_windows_state_root }}\deployment-config.json"
|
||||||
$report | ConvertTo-Json -Depth 12 | Out-File -FilePath "{{ aw_windows_validation_remote_path }}" -Encoding utf8
|
$result | ConvertTo-Json -Depth 8 | Out-File -FilePath "{{ aw_windows_validation_remote_path }}" -Encoding utf8
|
||||||
if ({{ '$true' if (aw_windows_fail_on_validation_error | bool) else '$false' }} -and -not [bool]$report.overallOk) {
|
return $result
|
||||||
throw "Проверка развёртывания ActivityWatch завершилась ошибкой. Отчёт: {{ aw_windows_validation_remote_path }}"
|
|
||||||
}
|
|
||||||
|
|
||||||
- name: Создать локальный каталог для validation reports
|
- name: Создать локальную директорию для отчётов валидации
|
||||||
ansible.builtin.file:
|
ansible.builtin.file:
|
||||||
path: "{{ aw_windows_validation_local_dir }}"
|
path: "{{ aw_windows_validation_local_dir }}"
|
||||||
state: directory
|
state: directory
|
||||||
mode: "0755"
|
mode: "0755"
|
||||||
delegate_to: localhost
|
delegate_to: localhost
|
||||||
|
|
||||||
- name: Забрать validation report
|
- name: Стянуть отчёт валидации с эндпоинта
|
||||||
ansible.builtin.fetch:
|
ansible.builtin.fetch:
|
||||||
src: "{{ aw_windows_validation_remote_path }}"
|
src: "{{ aw_windows_validation_remote_path }}"
|
||||||
dest: "{{ aw_windows_validation_local_dir }}/{{ inventory_hostname }}-aw_validate_ansible.json"
|
dest: "{{ aw_windows_validation_local_dir }}/{{ inventory_hostname }}-aw_validate_ansible.json"
|
||||||
flat: true
|
flat: true
|
||||||
|
|
||||||
- name: Показать путь к отчёту
|
- name: Проверить статус валидации
|
||||||
ansible.builtin.debug:
|
ansible.builtin.shell: |
|
||||||
msg:
|
python3 - <<'PY'
|
||||||
- "Windows/RDP развёртывание завершено на {{ inventory_hostname }}."
|
import json, sys
|
||||||
- "Отчёт проверки: {{ aw_windows_validation_local_dir }}/{{ inventory_hostname }}-aw_validate_ansible.json"
|
with open('{{ aw_windows_validation_local_dir }}/{{ inventory_hostname }}-aw_validate_ansible.json', 'r') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
if not data.get('overallOk', False):
|
||||||
|
print(f"Validation failed for {{ inventory_hostname }}: {data.get('summary', 'Unknown error')}")
|
||||||
|
sys.exit(1)
|
||||||
|
PY
|
||||||
|
delegate_to: localhost
|
||||||
|
when: aw_windows_fail_on_validation_error | bool
|
||||||
|
|||||||
@@ -4,15 +4,24 @@ aw_server_bind_host: "0.0.0.0"
|
|||||||
aw_server_port: 5600
|
aw_server_port: 5600
|
||||||
aw_server_webui_dir: "/opt/activitywatch/webui-ru"
|
aw_server_webui_dir: "/opt/activitywatch/webui-ru"
|
||||||
aw_server_data_dir: "/var/lib/activitywatch"
|
aw_server_data_dir: "/var/lib/activitywatch"
|
||||||
|
aw_server_db_path: "/var/lib/activitywatch/.local/share/activitywatch/aw-server-rust/sqlite.db"
|
||||||
aw_server_log_dir: "/var/log/activitywatch"
|
aw_server_log_dir: "/var/log/activitywatch"
|
||||||
aw_server_user: "activitywatch"
|
aw_server_user: "activitywatch"
|
||||||
aw_server_group: "activitywatch"
|
aw_server_group: "activitywatch"
|
||||||
|
|
||||||
aw_repo_root: "{{ playbook_dir | dirname }}"
|
aw_repo_root: "{{ playbook_dir | dirname }}"
|
||||||
|
|
||||||
# Опционально: применить базовые категории и views для рабочего времени через AW settings API.
|
# Применить базовые категории и views для рабочего времени через AW settings API.
|
||||||
# Внимание: это перезаписывает существующие server-side settings/classes/views.
|
# При прод-обновлениях это нужно оставлять включённым, иначе UI остаётся без views/classes.
|
||||||
aw_apply_worktime_settings: false
|
aw_apply_worktime_settings: true
|
||||||
|
|
||||||
|
# Дополнительные origin для aw-server-rust CORS.
|
||||||
|
# Обязательно включите тот origin, с которого реально открывается Web UI.
|
||||||
|
aw_server_cors_origins:
|
||||||
|
- "http://127.0.0.1:5600"
|
||||||
|
- "http://localhost:5600"
|
||||||
|
- "http://10.10.10.13:5600"
|
||||||
|
- "http://aw-server:5600"
|
||||||
|
|
||||||
# Опциональные значения периода рабочего времени в Web UI.
|
# Опциональные значения периода рабочего времени в Web UI.
|
||||||
# startOfDay задаёт границу дня и стартовое время окна отчёта.
|
# startOfDay задаёт границу дня и стартовое время окна отчёта.
|
||||||
@@ -22,3 +31,5 @@ aw_apply_worktime_settings: false
|
|||||||
aw_worktime_from: "08:00"
|
aw_worktime_from: "08:00"
|
||||||
aw_worktime_to: "17:00"
|
aw_worktime_to: "17:00"
|
||||||
aw_worktime_start_of_day: "{{ aw_worktime_from }}"
|
aw_worktime_start_of_day: "{{ aw_worktime_from }}"
|
||||||
|
aw_server_always_active_pattern: "aw-watcher-window"
|
||||||
|
aw_server_landingpage: "/activity/SHARKON2025/view/"
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
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_db_path: "/var/lib/activitywatch/.local/share/activitywatch/aw-server-rust/sqlite.db"
|
||||||
|
aw_server_log_dir: "/var/log/activitywatch"
|
||||||
|
aw_server_user: "activitywatch"
|
||||||
|
aw_server_group: "activitywatch"
|
||||||
|
|
||||||
|
aw_repo_root: "{{ playbook_dir | dirname }}"
|
||||||
|
|
||||||
|
# Optional: apply worktime settings via server-side settings API.
|
||||||
|
aw_apply_worktime_settings: true
|
||||||
|
|
||||||
|
aw_server_cors_origins:
|
||||||
|
- "http://127.0.0.1:5600"
|
||||||
|
- "http://localhost:5600"
|
||||||
|
- "http://10.10.10.13:5600"
|
||||||
|
- "http://aw-server:5600"
|
||||||
|
|
||||||
|
aw_worktime_from: "08:00"
|
||||||
|
aw_worktime_to: "17:00"
|
||||||
|
aw_worktime_start_of_day: "{{ aw_worktime_from }}"
|
||||||
|
aw_server_always_active_pattern: "aw-watcher-window"
|
||||||
|
aw_server_landingpage: "/activity/SHARKON2025/view/"
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Secret handling:
|
||||||
|
# - put the real SSH password into env var before running Ansible:
|
||||||
|
# export AW_SSH_PASSWORD='...'
|
||||||
|
ansible_password: "{{ lookup('env', 'AW_SSH_PASSWORD') }}"
|
||||||
|
|
||||||
|
ansible_become: true
|
||||||
|
ansible_become_method: sudo
|
||||||
|
# If sudo password differs, set AW_SUDO_PASSWORD. Otherwise it will reuse AW_SSH_PASSWORD.
|
||||||
|
ansible_become_password: "{{ lookup('env', 'AW_SUDO_PASSWORD') | default(lookup('env', 'AW_SSH_PASSWORD'), true) }}"
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# Secret handling:
|
||||||
|
# - put the real password into env var before running Ansible:
|
||||||
|
# export AW_WINRM_PASSWORD='...'
|
||||||
|
ansible_password: "{{ lookup('env', 'AW_WINRM_PASSWORD') }}"
|
||||||
|
|
||||||
|
aw_windows_repo_root: "{{ playbook_dir | dirname }}"
|
||||||
|
aw_windows_deploy_root: "C:\\Program Files\\AWatch-rus"
|
||||||
|
aw_windows_server_scheme: "http"
|
||||||
|
aw_windows_server_host: "10.10.10.13"
|
||||||
|
aw_windows_server_port: 5600
|
||||||
|
|
||||||
|
aw_windows_package_version: "v0.13.2"
|
||||||
|
aw_windows_package_url: "https://github.com/ActivityWatch/activitywatch/releases/download/v0.13.2/activitywatch-v0.13.2-windows-x86_64.zip"
|
||||||
|
aw_windows_package_zip_path: ""
|
||||||
|
|
||||||
|
aw_windows_domain: "SHARKON2025"
|
||||||
|
aw_windows_users:
|
||||||
|
- user1
|
||||||
|
- user2
|
||||||
|
- user3
|
||||||
|
- user4
|
||||||
|
- user5
|
||||||
|
aw_windows_extra_users: []
|
||||||
|
|
||||||
|
aw_windows_install_root: "C:\\Program Files\\AWatch-rus\\bin"
|
||||||
|
aw_windows_state_root: "C:\\ProgramData\\AWatch-rus"
|
||||||
|
|
||||||
|
aw_windows_afk_enabled: true
|
||||||
|
aw_windows_window_enabled: true
|
||||||
|
aw_windows_file_ops_enabled: true
|
||||||
|
aw_windows_local_agent_logs_enabled: false
|
||||||
|
aw_windows_incident_capture_enabled: true
|
||||||
|
aw_windows_incident_screenshot_enabled: true
|
||||||
|
aw_windows_incident_artifacts_root: "{{ aw_windows_state_root }}\\incident-artifacts"
|
||||||
|
aw_windows_logon_marker_enabled: true
|
||||||
|
aw_windows_skip_hardening: false
|
||||||
|
|
||||||
|
aw_windows_rules_path: "{{ aw_windows_deploy_root }}\\windows\\web-category-rules.example.json"
|
||||||
|
aw_windows_policy_path: "{{ aw_windows_deploy_root }}\\windows\\dlp-policy.example.json"
|
||||||
|
|
||||||
|
aw_windows_validation_remote_path: "{{ aw_windows_state_root }}\\aw_validate_ansible.json"
|
||||||
|
aw_windows_validation_local_dir: "/tmp/aw-rus-validation"
|
||||||
|
aw_windows_fail_on_validation_error: true
|
||||||
|
|
||||||
|
aw_windows_migration_enabled: true
|
||||||
|
aw_windows_legacy_install_root: "C:\\Program Files\\ActivityWatch-Phase2"
|
||||||
|
aw_windows_legacy_state_root: "C:\\ProgramData\\ActivityWatch-Phase2"
|
||||||
|
aw_windows_migration_report_remote_path: "{{ aw_windows_state_root }}\\aw_migration_ansible.json"
|
||||||
|
|
||||||
|
aw_windows_api_smoke_check_enabled: true
|
||||||
|
aw_windows_api_smoke_check_bucket: ""
|
||||||
|
aw_windows_api_smoke_check_limit: 10
|
||||||
|
|
||||||
@@ -23,6 +23,7 @@ aw_windows_install_root: "C:\\Program Files\\AWatch-rus\\bin"
|
|||||||
aw_windows_state_root: "C:\\ProgramData\\AWatch-rus"
|
aw_windows_state_root: "C:\\ProgramData\\AWatch-rus"
|
||||||
aw_windows_afk_enabled: true
|
aw_windows_afk_enabled: true
|
||||||
aw_windows_window_enabled: true
|
aw_windows_window_enabled: true
|
||||||
|
aw_windows_file_ops_enabled: true
|
||||||
aw_windows_local_agent_logs_enabled: false
|
aw_windows_local_agent_logs_enabled: false
|
||||||
aw_windows_incident_capture_enabled: true
|
aw_windows_incident_capture_enabled: true
|
||||||
aw_windows_incident_screenshot_enabled: true
|
aw_windows_incident_screenshot_enabled: true
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
[proxmox]
|
||||||
|
# Optional. Leave empty if you don't use Proxmox provisioning from this repo.
|
||||||
|
# pve-main ansible_host=10.10.10.2 ansible_user=igor ansible_port=22
|
||||||
|
|
||||||
|
[aw_server]
|
||||||
|
aw-server ansible_host=10.10.10.13 ansible_user=igor ansible_port=22
|
||||||
|
|
||||||
|
[aw_windows]
|
||||||
|
# Note: on RU-localized Windows the built-in admin account name is often "Администратор".
|
||||||
|
rdp-prod ansible_host=192.168.100.21 ansible_user=Администратор ansible_connection=winrm ansible_winrm_transport=ntlm ansible_port=5985 ansible_winrm_server_cert_validation=ignore
|
||||||
|
|
||||||
|
[aw_pfsense_pollers]
|
||||||
|
# Optional.
|
||||||
|
# pfsense-poller1 ansible_host=192.168.100.30 ansible_user=root ansible_port=22
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
---
|
||||||
|
- name: Post-deploy validation for Windows/RDP AWatch-rus
|
||||||
|
hosts: aw_windows
|
||||||
|
gather_facts: false
|
||||||
|
|
||||||
|
vars:
|
||||||
|
aw_windows_launch_task_pattern: "ActivityWatch Launch *"
|
||||||
|
aw_windows_recovery_task_name: "ActivityWatch Recovery"
|
||||||
|
aw_windows_force_task_restart: true
|
||||||
|
aw_windows_api_smoke_check_enabled: true
|
||||||
|
aw_windows_api_smoke_check_bucket: ""
|
||||||
|
aw_windows_api_smoke_check_limit: 10
|
||||||
|
|
||||||
|
tasks:
|
||||||
|
- name: Принудительно запустить ActivityWatch recovery и launch tasks
|
||||||
|
when: aw_windows_force_task_restart | bool
|
||||||
|
ansible.windows.win_powershell:
|
||||||
|
script: |
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
Start-ScheduledTask -TaskName "{{ aw_windows_recovery_task_name }}"
|
||||||
|
Get-ScheduledTask |
|
||||||
|
Where-Object TaskName -like "{{ aw_windows_launch_task_pattern }}" |
|
||||||
|
ForEach-Object { Start-ScheduledTask -TaskName $_.TaskName }
|
||||||
|
|
||||||
|
- name: Получить Windows hostname для AW smoke-check bucket
|
||||||
|
when: aw_windows_api_smoke_check_enabled | bool
|
||||||
|
ansible.windows.win_command: powershell.exe -NoProfile -Command "$env:COMPUTERNAME"
|
||||||
|
register: aw_windows_hostname_result
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: Вычислить AW AFK smoke-check bucket
|
||||||
|
when: aw_windows_api_smoke_check_enabled | bool
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
aw_windows_api_smoke_check_bucket_effective: >-
|
||||||
|
{{
|
||||||
|
aw_windows_api_smoke_check_bucket
|
||||||
|
if (aw_windows_api_smoke_check_bucket | default('') | string | length) > 0
|
||||||
|
else 'aw-watcher-afk_' ~ (aw_windows_hostname_result.stdout | trim)
|
||||||
|
}}
|
||||||
|
|
||||||
|
- name: Дождаться свежих AFK событий на AW server
|
||||||
|
when: aw_windows_api_smoke_check_enabled | bool
|
||||||
|
delegate_to: localhost
|
||||||
|
ansible.builtin.uri:
|
||||||
|
url: "{{ aw_windows_server_scheme }}://{{ aw_windows_server_host }}:{{ aw_windows_server_port }}/api/0/buckets/{{ aw_windows_api_smoke_check_bucket_effective }}/events?limit={{ aw_windows_api_smoke_check_limit }}"
|
||||||
|
method: GET
|
||||||
|
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: Выполнить валидацию и сохранить отчёт на целевом Windows host
|
||||||
|
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
|
||||||
|
if ({{ '$true' if (aw_windows_fail_on_validation_error | bool) else '$false' }} -and -not [bool]$report.overallOk) {
|
||||||
|
throw "ActivityWatch validation failed. Report: {{ aw_windows_validation_remote_path }}"
|
||||||
|
}
|
||||||
|
|
||||||
|
- name: Создать локальный каталог для validation reports
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ aw_windows_validation_local_dir }}"
|
||||||
|
state: directory
|
||||||
|
mode: "0755"
|
||||||
|
delegate_to: localhost
|
||||||
|
|
||||||
|
- name: Забрать validation report
|
||||||
|
ansible.builtin.fetch:
|
||||||
|
src: "{{ aw_windows_validation_remote_path }}"
|
||||||
|
dest: "{{ aw_windows_validation_local_dir }}/{{ inventory_hostname }}-aw_validate_ansible.json"
|
||||||
|
flat: true
|
||||||
|
|
||||||
|
- name: Показать путь к отчёту
|
||||||
|
ansible.builtin.debug:
|
||||||
|
msg:
|
||||||
|
- "Validation OK on {{ inventory_hostname }}."
|
||||||
|
- "Report: {{ aw_windows_validation_local_dir }}/{{ inventory_hostname }}-aw_validate_ansible.json"
|
||||||
|
|
||||||
@@ -9,7 +9,7 @@ EnvironmentFile=/etc/activitywatch/aw-server.env
|
|||||||
User=__AW_SERVER_USER__
|
User=__AW_SERVER_USER__
|
||||||
Group=__AW_SERVER_GROUP__
|
Group=__AW_SERVER_GROUP__
|
||||||
WorkingDirectory=__AW_SERVER_DATA_DIR__
|
WorkingDirectory=__AW_SERVER_DATA_DIR__
|
||||||
ExecStart=/bin/sh -lc 'exec /opt/activitywatch/bin/aw-server-rust --host "$AW_SERVER_BIND_HOST" --port "$AW_SERVER_PORT"'
|
ExecStart=/bin/sh -lc 'exec /opt/activitywatch/bin/aw-server-rust --host "$AW_SERVER_BIND_HOST" --port "$AW_SERVER_PORT" --dbpath "$AW_SERVER_DB_PATH" --webpath "$AW_SERVER_WEBUI_DIR"'
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=5s
|
RestartSec=5s
|
||||||
StateDirectory=activitywatch
|
StateDirectory=activitywatch
|
||||||
@@ -17,7 +17,7 @@ LogsDirectory=activitywatch
|
|||||||
NoNewPrivileges=true
|
NoNewPrivileges=true
|
||||||
PrivateTmp=true
|
PrivateTmp=true
|
||||||
ProtectSystem=full
|
ProtectSystem=full
|
||||||
ProtectHome=true
|
ProtectHome=read-only
|
||||||
LimitNOFILE=65535
|
LimitNOFILE=65535
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
# Central DLP aggregator prototype
|
||||||
|
|
||||||
|
`scripts/aggregate_dlp_events.py` collects Phase 2 DLP telemetry from ActivityWatch buckets and stores normalized rows in one database for Grafana/SIEM-style reporting.
|
||||||
|
|
||||||
|
## Streams
|
||||||
|
|
||||||
|
The prototype reads:
|
||||||
|
|
||||||
|
- `aw-file-operations_*` (`aw.file.operation`) — file create/delete/rename telemetry, including `archiveHint`.
|
||||||
|
- `aw-dlp-incidents_*` (`aw.dlp.incident`) — browser/endpoint DLP incidents and screenshot metadata when available.
|
||||||
|
|
||||||
|
## SQLite smoke test
|
||||||
|
|
||||||
|
SQLite is the default so the collector can be tested without deploying PostgreSQL:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/aggregate_dlp_events.py \
|
||||||
|
--aw-url http://10.10.10.13:5600/api/0 \
|
||||||
|
--sqlite-path data/dlp-events.sqlite3 \
|
||||||
|
--lookback-hours 24
|
||||||
|
```
|
||||||
|
|
||||||
|
Useful checks:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sqlite3 data/dlp-events.sqlite3 \
|
||||||
|
"select stream_type, hostname, count(*) from dlp_events group by 1,2 order by 3 desc;"
|
||||||
|
|
||||||
|
sqlite3 data/dlp-events.sqlite3 \
|
||||||
|
"select event_ts, hostname, username, file_path from dlp_file_operations where archive_hint = 1 order by event_ts desc limit 20;"
|
||||||
|
```
|
||||||
|
|
||||||
|
## PostgreSQL mode
|
||||||
|
|
||||||
|
For centralized reporting, pass a DSN through an environment variable instead of committing secrets:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export DLP_AGGREGATOR_POSTGRES_DSN='postgresql://aw_dlp:${PASSWORD}@postgres.internal:5432/aw_dlp'
|
||||||
|
python3 -m pip install 'psycopg[binary]'
|
||||||
|
python3 scripts/aggregate_dlp_events.py \
|
||||||
|
--aw-url http://10.10.10.13:5600/api/0
|
||||||
|
```
|
||||||
|
|
||||||
|
Minimum database bootstrap:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
create database aw_dlp;
|
||||||
|
create user aw_dlp_ingest with password '<strong generated password>';
|
||||||
|
grant connect on database aw_dlp to aw_dlp_ingest;
|
||||||
|
grant usage, create on schema public to aw_dlp_ingest;
|
||||||
|
```
|
||||||
|
|
||||||
|
The script creates:
|
||||||
|
|
||||||
|
- table `dlp_events`
|
||||||
|
- view `dlp_file_operations`
|
||||||
|
- view `dlp_incidents`
|
||||||
|
|
||||||
|
## Incremental state
|
||||||
|
|
||||||
|
By default, the aggregator stores the last successful end timestamp in:
|
||||||
|
|
||||||
|
```text
|
||||||
|
data/dlp-aggregator-state.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Future runs resume from that timestamp with a small overlap window to avoid missing late events. Duplicate inserts are ignored by `(bucket_id, event_id)`.
|
||||||
|
|
||||||
|
## Scheduling example
|
||||||
|
|
||||||
|
Cron every minute:
|
||||||
|
|
||||||
|
```cron
|
||||||
|
* * * * * cd /opt/AWatch-rus && /usr/bin/python3 scripts/aggregate_dlp_events.py --aw-url http://10.10.10.13:5600/api/0 >> /var/log/aw-dlp-aggregator.log 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example Grafana queries
|
||||||
|
|
||||||
|
Archive creation by user:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
select
|
||||||
|
date_trunc('minute', event_ts) as time,
|
||||||
|
hostname,
|
||||||
|
username,
|
||||||
|
count(*) as archives
|
||||||
|
from dlp_file_operations
|
||||||
|
where archive_hint = true
|
||||||
|
group by 1, 2, 3
|
||||||
|
order by 1 desc;
|
||||||
|
```
|
||||||
|
|
||||||
|
DLP incidents by severity:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
select
|
||||||
|
date_trunc('hour', event_ts) as time,
|
||||||
|
severity,
|
||||||
|
count(*) as incidents
|
||||||
|
from dlp_incidents
|
||||||
|
group by 1, 2
|
||||||
|
order by 1 desc;
|
||||||
|
```
|
||||||
@@ -29,8 +29,8 @@
|
|||||||
|
|
||||||
- USB/print/clipboard collectors (endpoint signals) — внедрено.
|
- USB/print/clipboard collectors (endpoint signals) — внедрено.
|
||||||
- Incident pipeline расширен на endpoint события — внедрено.
|
- Incident pipeline расширен на endpoint события — внедрено.
|
||||||
- File-operation telemetry (create/copy/archive/upload hints) — в backlog.
|
- File-operation telemetry (create/delete/rename/archive hints) — прототип внедрён (`windows/file-operations-collector.ps1`).
|
||||||
- Central incident aggregation/export — в backlog.
|
- Central incident aggregation/export — прототип внедрён (`scripts/aggregate_dlp_events.py`, `docs/dlp-aggregator.md`).
|
||||||
|
|
||||||
### Phase 3
|
### Phase 3
|
||||||
|
|
||||||
|
|||||||
Executable
+517
@@ -0,0 +1,517 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from collections.abc import Iterable
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Protocol, TypeAlias
|
||||||
|
|
||||||
|
|
||||||
|
JsonScalar: TypeAlias = str | int | float | bool | None
|
||||||
|
JsonValue: TypeAlias = JsonScalar | list["JsonValue"] | dict[str, "JsonValue"]
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_BUCKET_PREFIXES = ("aw-file-operations_", "aw-dlp-incidents_")
|
||||||
|
DEFAULT_SQLITE_PATH = "data/dlp-events.sqlite3"
|
||||||
|
EVENT_COLUMNS = (
|
||||||
|
"bucket_id",
|
||||||
|
"event_id",
|
||||||
|
"stream_type",
|
||||||
|
"hostname",
|
||||||
|
"username",
|
||||||
|
"event_ts",
|
||||||
|
"duration",
|
||||||
|
"operation",
|
||||||
|
"file_path",
|
||||||
|
"old_file_path",
|
||||||
|
"extension",
|
||||||
|
"archive_hint",
|
||||||
|
"rule_id",
|
||||||
|
"action",
|
||||||
|
"severity",
|
||||||
|
"signal_type",
|
||||||
|
"message",
|
||||||
|
"source",
|
||||||
|
"screenshot_path",
|
||||||
|
"raw_json",
|
||||||
|
"ingested_at",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Bucket:
|
||||||
|
id: str
|
||||||
|
type: str
|
||||||
|
client: str
|
||||||
|
hostname: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AwEvent:
|
||||||
|
bucket_id: str
|
||||||
|
hostname: str
|
||||||
|
stream_type: str
|
||||||
|
event_id: str
|
||||||
|
timestamp: str
|
||||||
|
duration: float
|
||||||
|
data: dict[str, JsonValue]
|
||||||
|
|
||||||
|
|
||||||
|
class PsycopgConnection(Protocol):
|
||||||
|
def cursor(self):
|
||||||
|
...
|
||||||
|
|
||||||
|
def commit(self) -> None:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
def utc_now() -> datetime:
|
||||||
|
return datetime.now(tz=UTC)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_timestamp(value: str) -> datetime:
|
||||||
|
normalized = value.replace("Z", "+00:00")
|
||||||
|
parsed = datetime.fromisoformat(normalized)
|
||||||
|
if parsed.tzinfo is None:
|
||||||
|
return parsed.replace(tzinfo=UTC)
|
||||||
|
return parsed.astimezone(UTC)
|
||||||
|
|
||||||
|
|
||||||
|
def format_aw_timestamp(value: datetime) -> str:
|
||||||
|
return value.astimezone(UTC).isoformat().replace("+00:00", "Z")
|
||||||
|
|
||||||
|
|
||||||
|
def load_state(path: Path) -> dict[str, str]:
|
||||||
|
if not path.exists():
|
||||||
|
return {}
|
||||||
|
return json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def save_state(path: Path, state: dict[str, str]) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(json.dumps(state, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_base_url(base_url: str) -> str:
|
||||||
|
return base_url.rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def aw_get_json(base_url: str, path: str, timeout: int) -> JsonValue:
|
||||||
|
url = normalize_base_url(base_url) + path
|
||||||
|
request = urllib.request.Request(url, headers={"Accept": "application/json"})
|
||||||
|
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||||
|
return json.loads(response.read().decode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def list_buckets(base_url: str, timeout: int) -> list[Bucket]:
|
||||||
|
payload = aw_get_json(base_url, "/buckets", timeout)
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise ValueError("ActivityWatch /buckets response must be a JSON object")
|
||||||
|
buckets: list[Bucket] = []
|
||||||
|
for bucket_id, bucket_data in payload.items():
|
||||||
|
if not isinstance(bucket_data, dict):
|
||||||
|
continue
|
||||||
|
buckets.append(
|
||||||
|
Bucket(
|
||||||
|
id=str(bucket_id),
|
||||||
|
type=str(bucket_data.get("type", "")),
|
||||||
|
client=str(bucket_data.get("client", "")),
|
||||||
|
hostname=str(bucket_data.get("hostname", "")),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return buckets
|
||||||
|
|
||||||
|
|
||||||
|
def bucket_stream_type(bucket: Bucket) -> str | None:
|
||||||
|
if bucket.id.startswith("aw-file-operations_") or bucket.type == "aw.file.operation":
|
||||||
|
return "file_operation"
|
||||||
|
if bucket.id.startswith("aw-dlp-incidents_") or bucket.type == "aw.dlp.incident":
|
||||||
|
return "dlp_incident"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def select_buckets(buckets: Iterable[Bucket], prefixes: tuple[str, ...]) -> list[tuple[Bucket, str]]:
|
||||||
|
selected: list[tuple[Bucket, str]] = []
|
||||||
|
for bucket in buckets:
|
||||||
|
stream_type = bucket_stream_type(bucket)
|
||||||
|
if stream_type and any(bucket.id.startswith(prefix) for prefix in prefixes):
|
||||||
|
selected.append((bucket, stream_type))
|
||||||
|
return selected
|
||||||
|
|
||||||
|
|
||||||
|
def build_events_path(bucket_id: str, start: datetime, end: datetime, limit: int) -> str:
|
||||||
|
query = urllib.parse.urlencode(
|
||||||
|
{
|
||||||
|
"start": format_aw_timestamp(start),
|
||||||
|
"end": format_aw_timestamp(end),
|
||||||
|
"limit": str(limit),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return f"/buckets/{urllib.parse.quote(bucket_id, safe='')}/events?{query}"
|
||||||
|
|
||||||
|
|
||||||
|
def event_key(bucket_id: str, timestamp: str, duration: float, data: dict[str, JsonValue]) -> str:
|
||||||
|
payload = json.dumps(data, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||||
|
return f"{bucket_id}|{timestamp}|{duration}|{payload}"
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_bucket_events(
|
||||||
|
base_url: str,
|
||||||
|
bucket: Bucket,
|
||||||
|
stream_type: str,
|
||||||
|
start: datetime,
|
||||||
|
end: datetime,
|
||||||
|
limit: int,
|
||||||
|
timeout: int,
|
||||||
|
) -> list[AwEvent]:
|
||||||
|
payload = aw_get_json(base_url, build_events_path(bucket.id, start, end, limit), timeout)
|
||||||
|
if not isinstance(payload, list):
|
||||||
|
raise ValueError(f"ActivityWatch events response for {bucket.id} must be a JSON array")
|
||||||
|
events: list[AwEvent] = []
|
||||||
|
for item in payload:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
timestamp = str(item["timestamp"])
|
||||||
|
duration = float(item.get("duration", 0) or 0)
|
||||||
|
data = item.get("data") or {}
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
data = {"raw": data}
|
||||||
|
item_id = str(item.get("id") or event_key(bucket.id, timestamp, duration, data))
|
||||||
|
events.append(
|
||||||
|
AwEvent(
|
||||||
|
bucket_id=bucket.id,
|
||||||
|
hostname=bucket.hostname or str(data.get("hostname") or ""),
|
||||||
|
stream_type=stream_type,
|
||||||
|
event_id=item_id,
|
||||||
|
timestamp=timestamp,
|
||||||
|
duration=duration,
|
||||||
|
data=data,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return events
|
||||||
|
|
||||||
|
|
||||||
|
def connect_sqlite(path: Path) -> sqlite3.Connection:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
connection = sqlite3.connect(str(path))
|
||||||
|
connection.execute("PRAGMA journal_mode=WAL")
|
||||||
|
connection.execute("PRAGMA synchronous=NORMAL")
|
||||||
|
connection.execute("PRAGMA foreign_keys=ON")
|
||||||
|
return connection
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_schema(connection: sqlite3.Connection) -> None:
|
||||||
|
connection.executescript(
|
||||||
|
"""
|
||||||
|
create table if not exists dlp_events (
|
||||||
|
id integer primary key autoincrement,
|
||||||
|
bucket_id text not null,
|
||||||
|
event_id text not null,
|
||||||
|
stream_type text not null,
|
||||||
|
hostname text not null,
|
||||||
|
username text,
|
||||||
|
event_ts text not null,
|
||||||
|
duration real not null default 0,
|
||||||
|
operation text,
|
||||||
|
file_path text,
|
||||||
|
old_file_path text,
|
||||||
|
extension text,
|
||||||
|
archive_hint integer not null default 0,
|
||||||
|
rule_id text,
|
||||||
|
action text,
|
||||||
|
severity text,
|
||||||
|
signal_type text,
|
||||||
|
message text,
|
||||||
|
source text,
|
||||||
|
screenshot_path text,
|
||||||
|
raw_json text not null,
|
||||||
|
ingested_at text not null,
|
||||||
|
unique (bucket_id, event_id)
|
||||||
|
);
|
||||||
|
create index if not exists idx_dlp_events_event_ts on dlp_events(event_ts);
|
||||||
|
create index if not exists idx_dlp_events_host_ts on dlp_events(hostname, event_ts);
|
||||||
|
create index if not exists idx_dlp_events_stream_ts on dlp_events(stream_type, event_ts);
|
||||||
|
create index if not exists idx_dlp_events_archive on dlp_events(archive_hint, event_ts);
|
||||||
|
create index if not exists idx_dlp_events_rule on dlp_events(rule_id, event_ts);
|
||||||
|
|
||||||
|
create view if not exists dlp_file_operations as
|
||||||
|
select *
|
||||||
|
from dlp_events
|
||||||
|
where stream_type = 'file_operation';
|
||||||
|
|
||||||
|
create view if not exists dlp_incidents as
|
||||||
|
select *
|
||||||
|
from dlp_events
|
||||||
|
where stream_type = 'dlp_incident';
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_postgres_schema(connection: PsycopgConnection) -> None:
|
||||||
|
with connection.cursor() as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
create table if not exists dlp_events (
|
||||||
|
id bigserial primary key,
|
||||||
|
bucket_id text not null,
|
||||||
|
event_id text not null,
|
||||||
|
stream_type text not null,
|
||||||
|
hostname text not null,
|
||||||
|
username text,
|
||||||
|
event_ts timestamptz not null,
|
||||||
|
duration double precision not null default 0,
|
||||||
|
operation text,
|
||||||
|
file_path text,
|
||||||
|
old_file_path text,
|
||||||
|
extension text,
|
||||||
|
archive_hint boolean not null default false,
|
||||||
|
rule_id text,
|
||||||
|
action text,
|
||||||
|
severity text,
|
||||||
|
signal_type text,
|
||||||
|
message text,
|
||||||
|
source text,
|
||||||
|
screenshot_path text,
|
||||||
|
raw_json jsonb not null,
|
||||||
|
ingested_at timestamptz not null,
|
||||||
|
unique (bucket_id, event_id)
|
||||||
|
);
|
||||||
|
create index if not exists idx_dlp_events_event_ts on dlp_events(event_ts);
|
||||||
|
create index if not exists idx_dlp_events_host_ts on dlp_events(hostname, event_ts);
|
||||||
|
create index if not exists idx_dlp_events_stream_ts on dlp_events(stream_type, event_ts);
|
||||||
|
create index if not exists idx_dlp_events_archive on dlp_events(archive_hint, event_ts);
|
||||||
|
create index if not exists idx_dlp_events_rule on dlp_events(rule_id, event_ts);
|
||||||
|
|
||||||
|
create or replace view dlp_file_operations as
|
||||||
|
select *
|
||||||
|
from dlp_events
|
||||||
|
where stream_type = 'file_operation';
|
||||||
|
|
||||||
|
create or replace view dlp_incidents as
|
||||||
|
select *
|
||||||
|
from dlp_events
|
||||||
|
where stream_type = 'dlp_incident';
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def first_string(data: dict[str, JsonValue], keys: tuple[str, ...]) -> str | None:
|
||||||
|
for key in keys:
|
||||||
|
value = data.get(key)
|
||||||
|
if value is not None and str(value) != "":
|
||||||
|
return str(value)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def bool_as_int(value: JsonValue) -> int:
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return int(value)
|
||||||
|
if isinstance(value, str):
|
||||||
|
return int(value.lower() in {"1", "true", "yes", "y"})
|
||||||
|
return int(bool(value))
|
||||||
|
|
||||||
|
|
||||||
|
def event_row(event: AwEvent, ingested_at: str) -> tuple[JsonValue, ...]:
|
||||||
|
data = event.data
|
||||||
|
event_id = event.event_id or event_key(event.bucket_id, event.timestamp, event.duration, data)
|
||||||
|
return (
|
||||||
|
event.bucket_id,
|
||||||
|
event_id,
|
||||||
|
event.stream_type,
|
||||||
|
event.hostname,
|
||||||
|
first_string(data, ("username", "user")),
|
||||||
|
event.timestamp,
|
||||||
|
event.duration,
|
||||||
|
first_string(data, ("operation",)),
|
||||||
|
first_string(data, ("path", "filePath")),
|
||||||
|
first_string(data, ("oldPath", "oldFilePath")),
|
||||||
|
first_string(data, ("extension",)),
|
||||||
|
bool_as_int(data.get("archiveHint")),
|
||||||
|
first_string(data, ("ruleId", "rule")),
|
||||||
|
first_string(data, ("action",)),
|
||||||
|
first_string(data, ("severity",)),
|
||||||
|
first_string(data, ("signalType",)),
|
||||||
|
first_string(data, ("message",)),
|
||||||
|
first_string(data, ("source",)),
|
||||||
|
first_string(data, ("screenshotPath", "capturePath", "artifactPath")),
|
||||||
|
json.dumps(data, ensure_ascii=False, sort_keys=True),
|
||||||
|
ingested_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def insert_events(connection: sqlite3.Connection, events: Iterable[AwEvent]) -> int:
|
||||||
|
inserted = 0
|
||||||
|
now = format_aw_timestamp(utc_now())
|
||||||
|
for event in events:
|
||||||
|
cursor = connection.execute(
|
||||||
|
"""
|
||||||
|
insert or ignore into dlp_events (
|
||||||
|
bucket_id,
|
||||||
|
event_id,
|
||||||
|
stream_type,
|
||||||
|
hostname,
|
||||||
|
username,
|
||||||
|
event_ts,
|
||||||
|
duration,
|
||||||
|
operation,
|
||||||
|
file_path,
|
||||||
|
old_file_path,
|
||||||
|
extension,
|
||||||
|
archive_hint,
|
||||||
|
rule_id,
|
||||||
|
action,
|
||||||
|
severity,
|
||||||
|
signal_type,
|
||||||
|
message,
|
||||||
|
source,
|
||||||
|
screenshot_path,
|
||||||
|
raw_json,
|
||||||
|
ingested_at
|
||||||
|
)
|
||||||
|
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
event_row(event, now),
|
||||||
|
)
|
||||||
|
inserted += int(cursor.rowcount > 0)
|
||||||
|
connection.commit()
|
||||||
|
return inserted
|
||||||
|
|
||||||
|
|
||||||
|
def insert_postgres_events(dsn: str, events: Iterable[AwEvent]) -> int:
|
||||||
|
try:
|
||||||
|
import psycopg
|
||||||
|
except ImportError as exc:
|
||||||
|
raise SystemExit("PostgreSQL mode requires psycopg: python3 -m pip install 'psycopg[binary]'") from exc
|
||||||
|
|
||||||
|
inserted = 0
|
||||||
|
now = format_aw_timestamp(utc_now())
|
||||||
|
columns = ", ".join(EVENT_COLUMNS)
|
||||||
|
placeholders = ", ".join(["%s"] * len(EVENT_COLUMNS))
|
||||||
|
sql = f"""
|
||||||
|
insert into dlp_events ({columns})
|
||||||
|
values ({placeholders})
|
||||||
|
on conflict (bucket_id, event_id) do nothing
|
||||||
|
"""
|
||||||
|
with psycopg.connect(dsn) as connection:
|
||||||
|
ensure_postgres_schema(connection)
|
||||||
|
with connection.cursor() as cursor:
|
||||||
|
for event in events:
|
||||||
|
row = list(event_row(event, now))
|
||||||
|
row[EVENT_COLUMNS.index("archive_hint")] = bool(row[EVENT_COLUMNS.index("archive_hint")])
|
||||||
|
cursor.execute(sql, row)
|
||||||
|
inserted += int(cursor.rowcount > 0)
|
||||||
|
connection.commit()
|
||||||
|
return inserted
|
||||||
|
|
||||||
|
|
||||||
|
def get_start_time(args: argparse.Namespace, state: dict[str, str]) -> datetime:
|
||||||
|
if args.since:
|
||||||
|
return parse_timestamp(args.since)
|
||||||
|
if state.get("last_end"):
|
||||||
|
return parse_timestamp(state["last_end"]) - timedelta(seconds=args.overlap_seconds)
|
||||||
|
return utc_now() - timedelta(hours=args.lookback_hours)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_prefixes(value: str) -> tuple[str, ...]:
|
||||||
|
prefixes = tuple(item.strip() for item in value.split(",") if item.strip())
|
||||||
|
if not prefixes:
|
||||||
|
raise argparse.ArgumentTypeError("at least one bucket prefix is required")
|
||||||
|
return prefixes
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(description="Aggregate AWatch-rus DLP buckets into a local warehouse database.")
|
||||||
|
parser.add_argument("--aw-url", default=os.environ.get("AW_URL", "http://127.0.0.1:5600/api/0"))
|
||||||
|
parser.add_argument("--postgres-dsn", default=os.environ.get("DLP_AGGREGATOR_POSTGRES_DSN"))
|
||||||
|
parser.add_argument("--sqlite-path", default=os.environ.get("DLP_AGGREGATOR_SQLITE_PATH", DEFAULT_SQLITE_PATH))
|
||||||
|
parser.add_argument("--state-path", default=os.environ.get("DLP_AGGREGATOR_STATE_PATH", "data/dlp-aggregator-state.json"))
|
||||||
|
parser.add_argument("--bucket-prefixes", type=parse_prefixes, default=DEFAULT_BUCKET_PREFIXES)
|
||||||
|
parser.add_argument("--since", help="UTC ISO timestamp. Overrides saved state, for example 2026-05-02T00:00:00Z.")
|
||||||
|
parser.add_argument("--lookback-hours", type=int, default=24)
|
||||||
|
parser.add_argument("--overlap-seconds", type=int, default=60)
|
||||||
|
parser.add_argument("--limit", type=int, default=10000)
|
||||||
|
parser.add_argument("--timeout", type=int, default=15)
|
||||||
|
parser.add_argument("--dry-run", action="store_true")
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = build_parser().parse_args()
|
||||||
|
state_path = Path(args.state_path)
|
||||||
|
state = load_state(state_path)
|
||||||
|
start = get_start_time(args, state)
|
||||||
|
end = utc_now()
|
||||||
|
|
||||||
|
buckets = select_buckets(list_buckets(args.aw_url, args.timeout), args.bucket_prefixes)
|
||||||
|
all_events: list[AwEvent] = []
|
||||||
|
for bucket, stream_type in buckets:
|
||||||
|
all_events.extend(fetch_bucket_events(args.aw_url, bucket, stream_type, start, end, args.limit, args.timeout))
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"aw_url": args.aw_url,
|
||||||
|
"start": format_aw_timestamp(start),
|
||||||
|
"end": format_aw_timestamp(end),
|
||||||
|
"selected_buckets": [bucket.id for bucket, _stream_type in buckets],
|
||||||
|
"fetched_events": len(all_events),
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if args.postgres_dsn:
|
||||||
|
target = "postgres"
|
||||||
|
target_path = args.postgres_dsn.split("@")[-1]
|
||||||
|
inserted = insert_postgres_events(args.postgres_dsn, all_events)
|
||||||
|
else:
|
||||||
|
target = "sqlite"
|
||||||
|
sqlite_path = Path(args.sqlite_path)
|
||||||
|
target_path = str(sqlite_path)
|
||||||
|
connection = connect_sqlite(sqlite_path)
|
||||||
|
try:
|
||||||
|
ensure_schema(connection)
|
||||||
|
inserted = insert_events(connection, all_events)
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
state["last_end"] = format_aw_timestamp(end)
|
||||||
|
save_state(state_path, state)
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"aw_url": args.aw_url,
|
||||||
|
"target": target,
|
||||||
|
"target_path": target_path,
|
||||||
|
"state_path": str(state_path),
|
||||||
|
"start": format_aw_timestamp(start),
|
||||||
|
"end": format_aw_timestamp(end),
|
||||||
|
"selected_buckets": len(buckets),
|
||||||
|
"fetched_events": len(all_events),
|
||||||
|
"inserted_events": inserted,
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
raise SystemExit(main())
|
||||||
|
except urllib.error.URLError as exc:
|
||||||
|
print(f"ActivityWatch API request failed: {exc}", file=sys.stderr)
|
||||||
|
raise SystemExit(2)
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def connect(path: Path) -> sqlite3.Connection:
|
||||||
|
connection = sqlite3.connect(str(path))
|
||||||
|
connection.execute("PRAGMA journal_mode=WAL")
|
||||||
|
connection.execute("PRAGMA synchronous=NORMAL")
|
||||||
|
return connection
|
||||||
|
|
||||||
|
|
||||||
|
def bucket_key(row: sqlite3.Row) -> tuple[str, str, str, str]:
|
||||||
|
return (
|
||||||
|
str(row["name"]),
|
||||||
|
str(row["type"]),
|
||||||
|
str(row["client"]),
|
||||||
|
str(row["hostname"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_parent(path: Path) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def load_existing_events(connection: sqlite3.Connection, bucketrow: int) -> set[tuple[int, int, str]]:
|
||||||
|
cursor = connection.execute(
|
||||||
|
"select starttime, endtime, data from events where bucketrow = ?",
|
||||||
|
(bucketrow,),
|
||||||
|
)
|
||||||
|
return {(int(start), int(end), str(data)) for start, end, data in cursor.fetchall()}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--base", required=True)
|
||||||
|
parser.add_argument("--output", required=True)
|
||||||
|
parser.add_argument("--overlay")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
base = Path(args.base)
|
||||||
|
output = Path(args.output)
|
||||||
|
overlay = Path(args.overlay) if args.overlay else None
|
||||||
|
|
||||||
|
if not base.exists():
|
||||||
|
raise SystemExit(f"Base DB not found: {base}")
|
||||||
|
|
||||||
|
ensure_parent(output)
|
||||||
|
tmp_output = output.with_suffix(output.suffix + ".tmp")
|
||||||
|
if tmp_output.exists():
|
||||||
|
tmp_output.unlink()
|
||||||
|
shutil.copy2(base, tmp_output)
|
||||||
|
|
||||||
|
dest = connect(tmp_output)
|
||||||
|
dest.row_factory = sqlite3.Row
|
||||||
|
|
||||||
|
inserted_buckets = 0
|
||||||
|
inserted_events = 0
|
||||||
|
|
||||||
|
if overlay and overlay.exists():
|
||||||
|
source = connect(overlay)
|
||||||
|
source.row_factory = sqlite3.Row
|
||||||
|
try:
|
||||||
|
source_buckets = source.execute(
|
||||||
|
"select rowid as bucketrow, id, name, type, client, hostname, created, data_deprecated, data from buckets order by rowid"
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
dest_bucket_map = {
|
||||||
|
bucket_key(row): row["bucketrow"]
|
||||||
|
for row in dest.execute(
|
||||||
|
"select rowid as bucketrow, id, name, type, client, hostname, created, data_deprecated, data from buckets order by rowid"
|
||||||
|
).fetchall()
|
||||||
|
}
|
||||||
|
|
||||||
|
for src_bucket in source_buckets:
|
||||||
|
key = bucket_key(src_bucket)
|
||||||
|
dest_rowid = dest_bucket_map.get(key)
|
||||||
|
if dest_rowid is None:
|
||||||
|
cursor = dest.execute(
|
||||||
|
"""
|
||||||
|
insert into buckets (name, type, client, hostname, created, data_deprecated, data)
|
||||||
|
values (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
src_bucket["name"],
|
||||||
|
src_bucket["type"],
|
||||||
|
src_bucket["client"],
|
||||||
|
src_bucket["hostname"],
|
||||||
|
src_bucket["created"],
|
||||||
|
src_bucket["data_deprecated"],
|
||||||
|
src_bucket["data"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
dest_rowid = int(cursor.lastrowid)
|
||||||
|
dest_bucket_map[key] = dest_rowid
|
||||||
|
inserted_buckets += 1
|
||||||
|
|
||||||
|
existing_events = load_existing_events(dest, dest_rowid)
|
||||||
|
for starttime, endtime, data in source.execute(
|
||||||
|
"select starttime, endtime, data from events where bucketrow = ? order by id",
|
||||||
|
(src_bucket["bucketrow"],),
|
||||||
|
).fetchall():
|
||||||
|
event_key = (int(starttime), int(endtime), str(data))
|
||||||
|
if event_key in existing_events:
|
||||||
|
continue
|
||||||
|
dest.execute(
|
||||||
|
"insert into events (bucketrow, starttime, endtime, data) values (?, ?, ?, ?)",
|
||||||
|
(dest_rowid, int(starttime), int(endtime), str(data)),
|
||||||
|
)
|
||||||
|
existing_events.add(event_key)
|
||||||
|
inserted_events += 1
|
||||||
|
|
||||||
|
dest.commit()
|
||||||
|
finally:
|
||||||
|
source.close()
|
||||||
|
|
||||||
|
dest.close()
|
||||||
|
os.replace(tmp_output, output)
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"base": str(base),
|
||||||
|
"overlay": str(overlay) if overlay else None,
|
||||||
|
"output": str(output),
|
||||||
|
"inserted_buckets": inserted_buckets,
|
||||||
|
"inserted_events": inserted_events,
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
|
if [[ -f "${ROOT_DIR}/secrets/runtime.env" ]]; then
|
||||||
|
set -a
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
source "${ROOT_DIR}/secrets/runtime.env"
|
||||||
|
set +a
|
||||||
|
fi
|
||||||
|
|
||||||
|
: "${AW_SSH_PASSWORD:?AW_SSH_PASSWORD is required}"
|
||||||
|
: "${AW_WINRM_PASSWORD:?AW_WINRM_PASSWORD is required}"
|
||||||
|
|
||||||
|
command -v sshpass >/dev/null 2>&1 || { echo "missing sshpass" >&2; exit 127; }
|
||||||
|
command -v ansible-playbook >/dev/null 2>&1 || { echo "missing ansible-playbook" >&2; exit 127; }
|
||||||
|
|
||||||
|
SERVER_HOST="${AW_SERVER_HOST:-10.10.10.13}"
|
||||||
|
SERVER_USER="${AW_SERVER_USER:-igor}"
|
||||||
|
TIMESTAMP="$(date +%Y%m%d-%H%M%S)"
|
||||||
|
REMOTE_BACKUP_DIR="/var/lib/activitywatch/backups/prod-restore-${TIMESTAMP}"
|
||||||
|
LEGACY_DB="/root/.local/share/activitywatch/aw-server-rust/sqlite.db"
|
||||||
|
TARGET_DB="/var/lib/activitywatch/.local/share/activitywatch/aw-server-rust/sqlite.db"
|
||||||
|
REMOTE_MERGE_SCRIPT="/tmp/merge_aw_server_dbs.py"
|
||||||
|
|
||||||
|
ssh_remote() {
|
||||||
|
sshpass -p "$AW_SSH_PASSWORD" ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "${SERVER_USER}@${SERVER_HOST}" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
scp_remote() {
|
||||||
|
sshpass -p "$AW_SSH_PASSWORD" scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
scp_remote "${ROOT_DIR}/scripts/merge_aw_server_dbs.py" "${SERVER_USER}@${SERVER_HOST}:${REMOTE_MERGE_SCRIPT}"
|
||||||
|
|
||||||
|
ssh_remote "sudo mkdir -p '${REMOTE_BACKUP_DIR}' && sudo chown root:root '${REMOTE_BACKUP_DIR}'"
|
||||||
|
ssh_remote "sudo test -f '${LEGACY_DB}'"
|
||||||
|
ssh_remote "sudo test -f '${TARGET_DB}'"
|
||||||
|
ssh_remote "sudo cp -a '${LEGACY_DB}' '${REMOTE_BACKUP_DIR}/legacy-root-sqlite.db' && sudo cp -a '${TARGET_DB}' '${REMOTE_BACKUP_DIR}/target-before-merge-sqlite.db'"
|
||||||
|
ssh_remote "sudo systemctl stop activitywatch-server.service || true"
|
||||||
|
ssh_remote "sudo python3 '${REMOTE_MERGE_SCRIPT}' --base '${LEGACY_DB}' --overlay '${TARGET_DB}' --output '${REMOTE_BACKUP_DIR}/sqlite.merged.db'"
|
||||||
|
ssh_remote "sudo install -o activitywatch -g activitywatch -m 0644 '${REMOTE_BACKUP_DIR}/sqlite.merged.db' '${TARGET_DB}'"
|
||||||
|
|
||||||
|
ansible-playbook -i ansible/inventory.ini ansible/deploy_aw_server.yml
|
||||||
|
ansible-playbook -i ansible/inventory.ini ansible/deploy_aw_windows.yml
|
||||||
|
ansible-playbook -i ansible/inventory.ini ansible/post_validate_aw_windows.yml
|
||||||
|
|
||||||
|
python3 - <<'PY'
|
||||||
|
import json, urllib.request
|
||||||
|
base = 'http://10.10.10.13:5600'
|
||||||
|
window_payload = {
|
||||||
|
'timeperiods': ['2026-04-29T00:00:00+03:00/2026-04-29T23:59:59+03:00'],
|
||||||
|
'query': [
|
||||||
|
'window_events = query_bucket(find_bucket("aw-watcher-window_SHARKON2025"));',
|
||||||
|
'RETURN = window_events;'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
req = urllib.request.Request(base + '/api/0/query/', data=json.dumps(window_payload).encode(), method='POST', headers={'Content-Type': 'application/json', 'Origin': 'http://10.10.10.13:5600'})
|
||||||
|
with urllib.request.urlopen(req) as response:
|
||||||
|
data = json.loads(response.read().decode())
|
||||||
|
window_count = len(data[0]) if isinstance(data, list) and data else 0
|
||||||
|
if window_count <= 0:
|
||||||
|
raise SystemExit('no historical window data restored for 2026-04-29')
|
||||||
|
with urllib.request.urlopen(base + '/api/0/settings/') as response:
|
||||||
|
settings = json.loads(response.read().decode())
|
||||||
|
if settings.get('always_active_pattern') != 'aw-watcher-window':
|
||||||
|
raise SystemExit('always_active_pattern is not configured')
|
||||||
|
print(json.dumps({'restored_window_events_2026_04_29': window_count, 'always_active_pattern': settings.get('always_active_pattern')}, ensure_ascii=False))
|
||||||
|
PY
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
|
timestamp() { date +"%Y%m%d-%H%M%S"; }
|
||||||
|
|
||||||
|
LOG_DIR="${ROOT_DIR}/.rollout-logs/$(timestamp)"
|
||||||
|
mkdir -p "$LOG_DIR"
|
||||||
|
|
||||||
|
log() { printf "%s %s\n" "$(date +"%F %T")" "$*" | tee -a "${LOG_DIR}/rollout.log" >&2; }
|
||||||
|
|
||||||
|
require_cmd() {
|
||||||
|
command -v "$1" >/dev/null 2>&1 || { log "ERROR: missing command: $1"; exit 127; }
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt_secret() {
|
||||||
|
local var_name="$1"
|
||||||
|
local prompt="$2"
|
||||||
|
if [[ -n "${!var_name:-}" ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
read -r -s -p "${prompt}: " "$var_name"
|
||||||
|
echo
|
||||||
|
export "$var_name"
|
||||||
|
}
|
||||||
|
|
||||||
|
require_cmd git
|
||||||
|
require_cmd ansible-playbook
|
||||||
|
require_cmd ansible
|
||||||
|
|
||||||
|
log "Repo: ${ROOT_DIR}"
|
||||||
|
log "Branch: $(git branch --show-current)"
|
||||||
|
|
||||||
|
log "Running local quality gate..."
|
||||||
|
./scripts/quality-gate.sh | tee -a "${LOG_DIR}/quality-gate.log"
|
||||||
|
|
||||||
|
if [[ -f "${ROOT_DIR}/secrets/runtime.env" ]]; then
|
||||||
|
log "Loading secrets/runtime.env"
|
||||||
|
set -a
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
source "${ROOT_DIR}/secrets/runtime.env"
|
||||||
|
set +a
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f ansible/inventory.ini ]]; then
|
||||||
|
log "ERROR: missing ansible/inventory.ini"
|
||||||
|
log "Hint: copy ansible/inventory.example.ini -> ansible/inventory.ini and adjust hosts."
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -t 0 ]]; then
|
||||||
|
prompt_secret AW_SSH_PASSWORD "Enter SSH password for aw_server (root@10.10.10.13)"
|
||||||
|
prompt_secret AW_WINRM_PASSWORD "Enter WinRM password for aw_windows (192.168.100.21)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "${AW_SSH_PASSWORD:-}" || -z "${AW_WINRM_PASSWORD:-}" ]]; then
|
||||||
|
log "ERROR: missing AW_SSH_PASSWORD or AW_WINRM_PASSWORD."
|
||||||
|
log "Provide them via interactive prompt (TTY) or create secrets/runtime.env."
|
||||||
|
exit 3
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Preflight connectivity..."
|
||||||
|
ansible -i ansible/inventory.ini aw_server -m ping | tee -a "${LOG_DIR}/ping_aw_server.log"
|
||||||
|
ansible -i ansible/inventory.ini aw_windows -m win_ping | tee -a "${LOG_DIR}/ping_aw_windows.log"
|
||||||
|
|
||||||
|
log "Dry-run aw_server..."
|
||||||
|
ansible-playbook -i ansible/inventory.ini ansible/deploy_aw_server.yml --check --diff | tee -a "${LOG_DIR}/check_aw_server.log"
|
||||||
|
|
||||||
|
log "Deploy aw_server..."
|
||||||
|
ansible-playbook -i ansible/inventory.ini ansible/deploy_aw_server.yml | tee -a "${LOG_DIR}/deploy_aw_server.log"
|
||||||
|
|
||||||
|
log "Dry-run aw_windows..."
|
||||||
|
ansible-playbook -i ansible/inventory.ini ansible/deploy_aw_windows.yml --check --diff | tee -a "${LOG_DIR}/check_aw_windows.log"
|
||||||
|
|
||||||
|
log "Deploy aw_windows..."
|
||||||
|
ansible-playbook -i ansible/inventory.ini ansible/deploy_aw_windows.yml | tee -a "${LOG_DIR}/deploy_aw_windows.log"
|
||||||
|
|
||||||
|
log "DONE. Logs: ${LOG_DIR}"
|
||||||
@@ -49,7 +49,9 @@ function Get-ActivityWatchArchive {
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||||
$archivePath = Join-Path $WorkingRoot ("activitywatch-{0}.zip" -f $Version.TrimStart('v'))
|
$stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
|
||||||
|
$suffix = ([guid]::NewGuid().Guid.Substring(0, 8))
|
||||||
|
$archivePath = Join-Path $WorkingRoot ("activitywatch-{0}-{1}-{2}.zip" -f $Version.TrimStart('v'), $stamp, $suffix)
|
||||||
Invoke-WebRequest -Uri $PackageUrl -OutFile $archivePath
|
Invoke-WebRequest -Uri $PackageUrl -OutFile $archivePath
|
||||||
return $archivePath
|
return $archivePath
|
||||||
}
|
}
|
||||||
@@ -85,6 +87,16 @@ function Install-ActivityWatchPackage {
|
|||||||
New-ActivityWatchDirectory -Path $WorkingRoot
|
New-ActivityWatchDirectory -Path $WorkingRoot
|
||||||
New-ActivityWatchDirectory -Path $BackupRoot
|
New-ActivityWatchDirectory -Path $BackupRoot
|
||||||
|
|
||||||
|
# Ensure nothing is holding locks inside InstallRoot during upgrade.
|
||||||
|
foreach ($procName in @('aw-watcher-afk', 'aw-watcher-window', 'aw-server', 'aw-qt')) {
|
||||||
|
try {
|
||||||
|
Get-Process -Name $procName -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Start-Sleep -Seconds 2
|
||||||
|
|
||||||
$extractRoot = Join-Path $WorkingRoot ('extract-' + [guid]::NewGuid().Guid)
|
$extractRoot = Join-Path $WorkingRoot ('extract-' + [guid]::NewGuid().Guid)
|
||||||
if (Test-Path -LiteralPath $extractRoot) {
|
if (Test-Path -LiteralPath $extractRoot) {
|
||||||
Remove-Item -LiteralPath $extractRoot -Recurse -Force
|
Remove-Item -LiteralPath $extractRoot -Recurse -Force
|
||||||
@@ -243,6 +255,8 @@ function Copy-ActivityWatchCollectorAssets {
|
|||||||
[Parameter(Mandatory = $true)]
|
[Parameter(Mandatory = $true)]
|
||||||
[string]$EndpointCollectorScriptSource,
|
[string]$EndpointCollectorScriptSource,
|
||||||
[Parameter(Mandatory = $true)]
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$FileCollectorScriptSource,
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
[string]$SessionCollectorScriptSource,
|
[string]$SessionCollectorScriptSource,
|
||||||
[Parameter(Mandatory = $true)]
|
[Parameter(Mandatory = $true)]
|
||||||
[string]$ExampleRulesSource,
|
[string]$ExampleRulesSource,
|
||||||
@@ -258,6 +272,7 @@ function Copy-ActivityWatchCollectorAssets {
|
|||||||
|
|
||||||
$collectorTarget = Join-Path $StateRoot 'browser-domains-native-collector.ps1'
|
$collectorTarget = Join-Path $StateRoot 'browser-domains-native-collector.ps1'
|
||||||
$endpointCollectorTarget = Join-Path $StateRoot 'dlp-endpoint-signals-collector.ps1'
|
$endpointCollectorTarget = Join-Path $StateRoot 'dlp-endpoint-signals-collector.ps1'
|
||||||
|
$fileCollectorTarget = Join-Path $StateRoot 'file-operations-collector.ps1'
|
||||||
$sessionCollectorTarget = Join-Path $StateRoot 'worktime-session-collector.ps1'
|
$sessionCollectorTarget = Join-Path $StateRoot 'worktime-session-collector.ps1'
|
||||||
$exampleRulesTarget = Join-Path $StateRoot 'web-category-rules.example.json'
|
$exampleRulesTarget = Join-Path $StateRoot 'web-category-rules.example.json'
|
||||||
$rulesTarget = Join-Path $StateRoot 'web-category-rules.json'
|
$rulesTarget = Join-Path $StateRoot 'web-category-rules.json'
|
||||||
@@ -266,6 +281,7 @@ function Copy-ActivityWatchCollectorAssets {
|
|||||||
|
|
||||||
Copy-Item -LiteralPath $CollectorScriptSource -Destination $collectorTarget -Force
|
Copy-Item -LiteralPath $CollectorScriptSource -Destination $collectorTarget -Force
|
||||||
Copy-Item -LiteralPath $EndpointCollectorScriptSource -Destination $endpointCollectorTarget -Force
|
Copy-Item -LiteralPath $EndpointCollectorScriptSource -Destination $endpointCollectorTarget -Force
|
||||||
|
Copy-Item -LiteralPath $FileCollectorScriptSource -Destination $fileCollectorTarget -Force
|
||||||
Copy-Item -LiteralPath $SessionCollectorScriptSource -Destination $sessionCollectorTarget -Force
|
Copy-Item -LiteralPath $SessionCollectorScriptSource -Destination $sessionCollectorTarget -Force
|
||||||
Copy-Item -LiteralPath $ExampleRulesSource -Destination $exampleRulesTarget -Force
|
Copy-Item -LiteralPath $ExampleRulesSource -Destination $exampleRulesTarget -Force
|
||||||
Copy-Item -LiteralPath $ExamplePolicySource -Destination $examplePolicyTarget -Force
|
Copy-Item -LiteralPath $ExamplePolicySource -Destination $examplePolicyTarget -Force
|
||||||
@@ -286,6 +302,7 @@ function Copy-ActivityWatchCollectorAssets {
|
|||||||
return [pscustomobject]@{
|
return [pscustomobject]@{
|
||||||
CollectorScript = $collectorTarget
|
CollectorScript = $collectorTarget
|
||||||
EndpointCollectorScript = $endpointCollectorTarget
|
EndpointCollectorScript = $endpointCollectorTarget
|
||||||
|
FileCollectorScript = $fileCollectorTarget
|
||||||
SessionCollectorScript = $sessionCollectorTarget
|
SessionCollectorScript = $sessionCollectorTarget
|
||||||
ExampleRules = $exampleRulesTarget
|
ExampleRules = $exampleRulesTarget
|
||||||
ActiveRules = $rulesTarget
|
ActiveRules = $rulesTarget
|
||||||
@@ -313,6 +330,8 @@ function New-ActivityWatchDeploymentConfig {
|
|||||||
[Parameter(Mandatory = $true)]
|
[Parameter(Mandatory = $true)]
|
||||||
[string]$EndpointCollectorScript,
|
[string]$EndpointCollectorScript,
|
||||||
[Parameter(Mandatory = $true)]
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$FileCollectorScript,
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
[string]$SessionCollectorScript,
|
[string]$SessionCollectorScript,
|
||||||
[Parameter(Mandatory = $true)]
|
[Parameter(Mandatory = $true)]
|
||||||
[string]$RulesPath,
|
[string]$RulesPath,
|
||||||
@@ -326,6 +345,7 @@ function New-ActivityWatchDeploymentConfig {
|
|||||||
[int]$RecoveryIntervalSeconds,
|
[int]$RecoveryIntervalSeconds,
|
||||||
[bool]$AfkEnabled = $true,
|
[bool]$AfkEnabled = $true,
|
||||||
[bool]$WindowEnabled = $true,
|
[bool]$WindowEnabled = $true,
|
||||||
|
[bool]$FileOpsEnabled = $true,
|
||||||
[bool]$LocalAgentLogsEnabled = $true,
|
[bool]$LocalAgentLogsEnabled = $true,
|
||||||
[bool]$IncidentCaptureEnabled = $true,
|
[bool]$IncidentCaptureEnabled = $true,
|
||||||
[bool]$IncidentScreenshotEnabled = $true,
|
[bool]$IncidentScreenshotEnabled = $true,
|
||||||
@@ -356,6 +376,7 @@ function New-ActivityWatchDeploymentConfig {
|
|||||||
logsRoot = $LogsRoot
|
logsRoot = $LogsRoot
|
||||||
collectorScript = $CollectorScript
|
collectorScript = $CollectorScript
|
||||||
endpointCollectorScript = $EndpointCollectorScript
|
endpointCollectorScript = $EndpointCollectorScript
|
||||||
|
fileCollectorScript = $FileCollectorScript
|
||||||
sessionCollectorScript = $SessionCollectorScript
|
sessionCollectorScript = $SessionCollectorScript
|
||||||
rulesPath = $RulesPath
|
rulesPath = $RulesPath
|
||||||
policyPath = $PolicyPath
|
policyPath = $PolicyPath
|
||||||
@@ -369,6 +390,7 @@ function New-ActivityWatchDeploymentConfig {
|
|||||||
collectors = [pscustomobject]@{
|
collectors = [pscustomobject]@{
|
||||||
afkEnabled = $AfkEnabled
|
afkEnabled = $AfkEnabled
|
||||||
windowEnabled = $WindowEnabled
|
windowEnabled = $WindowEnabled
|
||||||
|
fileOpsEnabled = $FileOpsEnabled
|
||||||
}
|
}
|
||||||
logging = [pscustomobject]@{
|
logging = [pscustomobject]@{
|
||||||
localAgentLogsEnabled = $LocalAgentLogsEnabled
|
localAgentLogsEnabled = $LocalAgentLogsEnabled
|
||||||
@@ -614,6 +636,10 @@ function Start-CollectorScriptIfNeeded {
|
|||||||
[int]`$SessionId
|
[int]`$SessionId
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if ([string]::IsNullOrWhiteSpace(`$ScriptPath)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (-not (Test-Path -LiteralPath `$ScriptPath)) {
|
if (-not (Test-Path -LiteralPath `$ScriptPath)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -634,18 +660,21 @@ function Start-CollectorScriptIfNeeded {
|
|||||||
`$config = Get-DeploymentConfig -Path `$ConfigPath
|
`$config = Get-DeploymentConfig -Path `$ConfigPath
|
||||||
`$sessionId = (Get-Process -Id `$PID).SessionId
|
`$sessionId = (Get-Process -Id `$PID).SessionId
|
||||||
`$installRoot = [string]`$config.paths.installRoot
|
`$installRoot = [string]`$config.paths.installRoot
|
||||||
|
`$stateRoot = [string]`$config.paths.stateRoot
|
||||||
`$script:ApiBase = '{0}://{1}:{2}/api/0' -f [string]`$config.server.scheme, [string]`$config.server.host, [string]`$config.server.port
|
`$script:ApiBase = '{0}://{1}:{2}/api/0' -f [string]`$config.server.scheme, [string]`$config.server.host, [string]`$config.server.port
|
||||||
`$script:Hostname = `$env:COMPUTERNAME
|
`$script:Hostname = `$env:COMPUTERNAME
|
||||||
`$script:KnownBuckets = @{}
|
`$script:KnownBuckets = @{}
|
||||||
`$collectorScript = [string]`$config.paths.collectorScript
|
`$collectorScript = [string]`$config.paths.collectorScript
|
||||||
`$endpointCollectorScript = if (`$config.paths.PSObject.Properties.Name -contains 'endpointCollectorScript') { [string]`$config.paths.endpointCollectorScript } else { '' }
|
`$endpointCollectorScript = if (`$config.paths.PSObject.Properties.Name -contains 'endpointCollectorScript') { [string]`$config.paths.endpointCollectorScript } else { Join-Path `$stateRoot 'dlp-endpoint-signals-collector.ps1' }
|
||||||
`$sessionCollectorScript = if (`$config.paths.PSObject.Properties.Name -contains 'sessionCollectorScript') { [string]`$config.paths.sessionCollectorScript } else { '' }
|
`$fileCollectorScript = if (`$config.paths.PSObject.Properties.Name -contains 'fileCollectorScript') { [string]`$config.paths.fileCollectorScript } else { Join-Path `$stateRoot 'file-operations-collector.ps1' }
|
||||||
|
`$sessionCollectorScript = if (`$config.paths.PSObject.Properties.Name -contains 'sessionCollectorScript') { [string]`$config.paths.sessionCollectorScript } else { Join-Path `$stateRoot 'worktime-session-collector.ps1' }
|
||||||
`$afkExe = Join-Path `$installRoot 'aw-watcher-afk\aw-watcher-afk.exe'
|
`$afkExe = Join-Path `$installRoot 'aw-watcher-afk\aw-watcher-afk.exe'
|
||||||
`$windowExe = Join-Path `$installRoot 'aw-watcher-window\aw-watcher-window.exe'
|
`$windowExe = Join-Path `$installRoot 'aw-watcher-window\aw-watcher-window.exe'
|
||||||
`$serverArgs = @('--host', [string]`$config.server.host, '--port', [string]`$config.server.port)
|
`$serverArgs = @('--host', [string]`$config.server.host, '--port', [string]`$config.server.port)
|
||||||
`$powershellExe = Join-Path `$env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'
|
`$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 }
|
`$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 }
|
`$windowEnabled = if (`$config.PSObject.Properties.Name -contains 'collectors' -and `$config.collectors.PSObject.Properties.Name -contains 'windowEnabled') { [bool]`$config.collectors.windowEnabled } else { `$true }
|
||||||
|
`$fileOpsEnabled = if (`$config.PSObject.Properties.Name -contains 'collectors' -and `$config.collectors.PSObject.Properties.Name -contains 'fileOpsEnabled') { [bool]`$config.collectors.fileOpsEnabled } else { `$true }
|
||||||
|
|
||||||
if (`$afkEnabled -and -not (Test-Path -LiteralPath `$afkExe)) {
|
if (`$afkEnabled -and -not (Test-Path -LiteralPath `$afkExe)) {
|
||||||
throw "Не найден aw-watcher-afk.exe: `$afkExe"
|
throw "Не найден aw-watcher-afk.exe: `$afkExe"
|
||||||
@@ -670,6 +699,9 @@ catch {
|
|||||||
}
|
}
|
||||||
Start-CollectorScriptIfNeeded -ScriptPath `$collectorScript -ConfigPath `$ConfigPath -PowerShellExe `$powershellExe -SessionId `$sessionId
|
Start-CollectorScriptIfNeeded -ScriptPath `$collectorScript -ConfigPath `$ConfigPath -PowerShellExe `$powershellExe -SessionId `$sessionId
|
||||||
Start-CollectorScriptIfNeeded -ScriptPath `$endpointCollectorScript -ConfigPath `$ConfigPath -PowerShellExe `$powershellExe -SessionId `$sessionId
|
Start-CollectorScriptIfNeeded -ScriptPath `$endpointCollectorScript -ConfigPath `$ConfigPath -PowerShellExe `$powershellExe -SessionId `$sessionId
|
||||||
|
if (`$fileOpsEnabled) {
|
||||||
|
Start-CollectorScriptIfNeeded -ScriptPath `$fileCollectorScript -ConfigPath `$ConfigPath -PowerShellExe `$powershellExe -SessionId `$sessionId
|
||||||
|
}
|
||||||
Start-CollectorScriptIfNeeded -ScriptPath `$sessionCollectorScript -ConfigPath `$ConfigPath -PowerShellExe `$powershellExe -SessionId `$sessionId
|
Start-CollectorScriptIfNeeded -ScriptPath `$sessionCollectorScript -ConfigPath `$ConfigPath -PowerShellExe `$powershellExe -SessionId `$sessionId
|
||||||
"@
|
"@
|
||||||
|
|
||||||
@@ -891,6 +923,37 @@ function Get-ActivityWatchScheduledTaskByCommand {
|
|||||||
return $null
|
return $null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Remove-StaleActivityWatchUserTasks {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[pscustomobject[]]$TaskDefinitions,
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$LaunchScriptPath
|
||||||
|
)
|
||||||
|
|
||||||
|
$launcherPath = Get-ActivityWatchHiddenLauncherPath -ScriptPath $LaunchScriptPath
|
||||||
|
$desiredTaskNames = @($TaskDefinitions | ForEach-Object { [string]$_.LaunchTaskName })
|
||||||
|
|
||||||
|
foreach ($candidate in @(Get-ScheduledTask | Where-Object { $_.TaskName -like 'ActivityWatch Launch*' })) {
|
||||||
|
$taskName = [string]$candidate.TaskName
|
||||||
|
if ($desiredTaskNames -contains $taskName) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
$usesCurrentLauncher = $false
|
||||||
|
foreach ($action in @($candidate.Actions)) {
|
||||||
|
if ([string]$action.Arguments -like "*$launcherPath*") {
|
||||||
|
$usesCurrentLauncher = $true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($usesCurrentLauncher) {
|
||||||
|
Remove-ActivityWatchScheduledTask -TaskName $taskName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function Register-ActivityWatchUserTasks {
|
function Register-ActivityWatchUserTasks {
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory = $true)]
|
[Parameter(Mandatory = $true)]
|
||||||
@@ -904,6 +967,7 @@ function Register-ActivityWatchUserTasks {
|
|||||||
$wscriptExe = Join-Path $env:SystemRoot 'System32\wscript.exe'
|
$wscriptExe = Join-Path $env:SystemRoot 'System32\wscript.exe'
|
||||||
$launcherPath = Get-ActivityWatchHiddenLauncherPath -ScriptPath $LaunchScriptPath
|
$launcherPath = Get-ActivityWatchHiddenLauncherPath -ScriptPath $LaunchScriptPath
|
||||||
Write-ActivityWatchHiddenPowerShellWrapper -Path $launcherPath -ScriptPath $LaunchScriptPath -ConfigPath $ConfigPath
|
Write-ActivityWatchHiddenPowerShellWrapper -Path $launcherPath -ScriptPath $LaunchScriptPath -ConfigPath $ConfigPath
|
||||||
|
Remove-StaleActivityWatchUserTasks -TaskDefinitions $TaskDefinitions -LaunchScriptPath $LaunchScriptPath
|
||||||
|
|
||||||
foreach ($definition in $TaskDefinitions) {
|
foreach ($definition in $TaskDefinitions) {
|
||||||
$action = New-ScheduledTaskAction -Execute $wscriptExe -Argument "//B //NoLogo `"$launcherPath`""
|
$action = New-ScheduledTaskAction -Execute $wscriptExe -Argument "//B //NoLogo `"$launcherPath`""
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ param(
|
|||||||
[int]$RecoveryIntervalSeconds = 180,
|
[int]$RecoveryIntervalSeconds = 180,
|
||||||
[bool]$AfkEnabled = $true,
|
[bool]$AfkEnabled = $true,
|
||||||
[bool]$WindowEnabled = $true,
|
[bool]$WindowEnabled = $true,
|
||||||
|
[bool]$FileOpsEnabled = $true,
|
||||||
[bool]$LocalAgentLogsEnabled = $false,
|
[bool]$LocalAgentLogsEnabled = $false,
|
||||||
[bool]$IncidentCaptureEnabled = $true,
|
[bool]$IncidentCaptureEnabled = $true,
|
||||||
[bool]$IncidentScreenshotEnabled = $true,
|
[bool]$IncidentScreenshotEnabled = $true,
|
||||||
@@ -44,6 +45,7 @@ $launchScriptPath = Join-Path $StateRoot 'launch-watchers.ps1'
|
|||||||
$recoveryScriptPath = Join-Path $StateRoot 'recovery-loop.ps1'
|
$recoveryScriptPath = Join-Path $StateRoot 'recovery-loop.ps1'
|
||||||
$collectorSource = Join-Path $PSScriptRoot 'browser-domains-native-collector.ps1'
|
$collectorSource = Join-Path $PSScriptRoot 'browser-domains-native-collector.ps1'
|
||||||
$endpointCollectorSource = Join-Path $PSScriptRoot 'dlp-endpoint-signals-collector.ps1'
|
$endpointCollectorSource = Join-Path $PSScriptRoot 'dlp-endpoint-signals-collector.ps1'
|
||||||
|
$fileCollectorSource = Join-Path $PSScriptRoot 'file-operations-collector.ps1'
|
||||||
$sessionCollectorSource = Join-Path $PSScriptRoot 'worktime-session-collector.ps1'
|
$sessionCollectorSource = Join-Path $PSScriptRoot 'worktime-session-collector.ps1'
|
||||||
$exampleRulesSource = Join-Path $PSScriptRoot 'web-category-rules.example.json'
|
$exampleRulesSource = Join-Path $PSScriptRoot 'web-category-rules.example.json'
|
||||||
$examplePolicySource = Join-Path $PSScriptRoot 'dlp-policy.example.json'
|
$examplePolicySource = Join-Path $PSScriptRoot 'dlp-policy.example.json'
|
||||||
@@ -58,6 +60,7 @@ Get-ActivityWatchExecutableMap -InstallRoot $InstallRoot | Out-Null
|
|||||||
$assetResult = Copy-ActivityWatchCollectorAssets `
|
$assetResult = Copy-ActivityWatchCollectorAssets `
|
||||||
-CollectorScriptSource $collectorSource `
|
-CollectorScriptSource $collectorSource `
|
||||||
-EndpointCollectorScriptSource $endpointCollectorSource `
|
-EndpointCollectorScriptSource $endpointCollectorSource `
|
||||||
|
-FileCollectorScriptSource $fileCollectorSource `
|
||||||
-SessionCollectorScriptSource $sessionCollectorSource `
|
-SessionCollectorScriptSource $sessionCollectorSource `
|
||||||
-ExampleRulesSource $exampleRulesSource `
|
-ExampleRulesSource $exampleRulesSource `
|
||||||
-ExamplePolicySource $examplePolicySource `
|
-ExamplePolicySource $examplePolicySource `
|
||||||
@@ -78,6 +81,7 @@ $config = New-ActivityWatchDeploymentConfig `
|
|||||||
-LogsRoot $logsRoot `
|
-LogsRoot $logsRoot `
|
||||||
-CollectorScript $assetResult.CollectorScript `
|
-CollectorScript $assetResult.CollectorScript `
|
||||||
-EndpointCollectorScript $assetResult.EndpointCollectorScript `
|
-EndpointCollectorScript $assetResult.EndpointCollectorScript `
|
||||||
|
-FileCollectorScript $assetResult.FileCollectorScript `
|
||||||
-SessionCollectorScript $assetResult.SessionCollectorScript `
|
-SessionCollectorScript $assetResult.SessionCollectorScript `
|
||||||
-RulesPath $assetResult.ActiveRules `
|
-RulesPath $assetResult.ActiveRules `
|
||||||
-PolicyPath $assetResult.ActivePolicy `
|
-PolicyPath $assetResult.ActivePolicy `
|
||||||
@@ -86,6 +90,7 @@ $config = New-ActivityWatchDeploymentConfig `
|
|||||||
-RecoveryIntervalSeconds $RecoveryIntervalSeconds `
|
-RecoveryIntervalSeconds $RecoveryIntervalSeconds `
|
||||||
-AfkEnabled $AfkEnabled `
|
-AfkEnabled $AfkEnabled `
|
||||||
-WindowEnabled $WindowEnabled `
|
-WindowEnabled $WindowEnabled `
|
||||||
|
-FileOpsEnabled $FileOpsEnabled `
|
||||||
-LocalAgentLogsEnabled $LocalAgentLogsEnabled `
|
-LocalAgentLogsEnabled $LocalAgentLogsEnabled `
|
||||||
-IncidentCaptureEnabled $IncidentCaptureEnabled `
|
-IncidentCaptureEnabled $IncidentCaptureEnabled `
|
||||||
-IncidentScreenshotEnabled $IncidentScreenshotEnabled `
|
-IncidentScreenshotEnabled $IncidentScreenshotEnabled `
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ param(
|
|||||||
[int]$RecoveryIntervalSeconds = 180,
|
[int]$RecoveryIntervalSeconds = 180,
|
||||||
[bool]$AfkEnabled = $true,
|
[bool]$AfkEnabled = $true,
|
||||||
[bool]$WindowEnabled = $true,
|
[bool]$WindowEnabled = $true,
|
||||||
|
[bool]$FileOpsEnabled = $true,
|
||||||
[bool]$LocalAgentLogsEnabled = $false,
|
[bool]$LocalAgentLogsEnabled = $false,
|
||||||
[bool]$IncidentCaptureEnabled = $true,
|
[bool]$IncidentCaptureEnabled = $true,
|
||||||
[bool]$IncidentScreenshotEnabled = $true,
|
[bool]$IncidentScreenshotEnabled = $true,
|
||||||
@@ -64,6 +65,7 @@ if (-not (Test-Path -LiteralPath $deployScript)) {
|
|||||||
-RecoveryIntervalSeconds $RecoveryIntervalSeconds `
|
-RecoveryIntervalSeconds $RecoveryIntervalSeconds `
|
||||||
-AfkEnabled $AfkEnabled `
|
-AfkEnabled $AfkEnabled `
|
||||||
-WindowEnabled $WindowEnabled `
|
-WindowEnabled $WindowEnabled `
|
||||||
|
-FileOpsEnabled $FileOpsEnabled `
|
||||||
-LocalAgentLogsEnabled $LocalAgentLogsEnabled `
|
-LocalAgentLogsEnabled $LocalAgentLogsEnabled `
|
||||||
-IncidentCaptureEnabled $IncidentCaptureEnabled `
|
-IncidentCaptureEnabled $IncidentCaptureEnabled `
|
||||||
-IncidentScreenshotEnabled $IncidentScreenshotEnabled `
|
-IncidentScreenshotEnabled $IncidentScreenshotEnabled `
|
||||||
@@ -86,6 +88,7 @@ if (-not $SkipHardening) {
|
|||||||
-RecoveryIntervalSeconds $RecoveryIntervalSeconds `
|
-RecoveryIntervalSeconds $RecoveryIntervalSeconds `
|
||||||
-AfkEnabled $AfkEnabled `
|
-AfkEnabled $AfkEnabled `
|
||||||
-WindowEnabled $WindowEnabled `
|
-WindowEnabled $WindowEnabled `
|
||||||
|
-FileOpsEnabled $FileOpsEnabled `
|
||||||
-LocalAgentLogsEnabled $LocalAgentLogsEnabled `
|
-LocalAgentLogsEnabled $LocalAgentLogsEnabled `
|
||||||
-IncidentCaptureEnabled $IncidentCaptureEnabled `
|
-IncidentCaptureEnabled $IncidentCaptureEnabled `
|
||||||
-IncidentScreenshotEnabled $IncidentScreenshotEnabled `
|
-IncidentScreenshotEnabled $IncidentScreenshotEnabled `
|
||||||
@@ -112,6 +115,7 @@ $report = [ordered]@{
|
|||||||
collectors = [ordered]@{
|
collectors = [ordered]@{
|
||||||
afkEnabled = $AfkEnabled
|
afkEnabled = $AfkEnabled
|
||||||
windowEnabled = $WindowEnabled
|
windowEnabled = $WindowEnabled
|
||||||
|
fileOpsEnabled = $FileOpsEnabled
|
||||||
}
|
}
|
||||||
hardeningApplied = (-not $SkipHardening)
|
hardeningApplied = (-not $SkipHardening)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json',
|
||||||
|
[string]$ServerHost,
|
||||||
|
[int]$ServerPort,
|
||||||
|
[ValidateSet('http', 'https')]
|
||||||
|
[string]$ServerScheme,
|
||||||
|
[string]$PolicyPath,
|
||||||
|
[string]$LogPath,
|
||||||
|
[int]$PollSeconds = 10,
|
||||||
|
[string[]]$WatchPaths = @('Desktop', 'Documents', 'Downloads')
|
||||||
|
)
|
||||||
|
|
||||||
|
Set-StrictMode -Version Latest
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
# Force TLS 1.2 and load networking types
|
||||||
|
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12
|
||||||
|
Add-Type -AssemblyName System.Net.Http
|
||||||
|
|
||||||
|
# Bucket registry
|
||||||
|
$script:KnownBuckets = @{}
|
||||||
|
$script:Hostname = $env:COMPUTERNAME
|
||||||
|
$script:SessionId = [System.Diagnostics.Process]::GetCurrentProcess().SessionId
|
||||||
|
|
||||||
|
# Настройка логирования
|
||||||
|
$script:LogPath = $LogPath
|
||||||
|
$script:LocalAgentLogsEnabled = [bool]$LogPath
|
||||||
|
|
||||||
|
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-FileCollectorLog {
|
||||||
|
param([string]$Message)
|
||||||
|
if (-not $script:LocalAgentLogsEnabled) { return }
|
||||||
|
try {
|
||||||
|
Add-Content -LiteralPath $script:LogPath -Value ('{0} [FileCollector] {1}' -f (Get-Date -Format s), $Message)
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-AwJsonPost {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)][string]$Uri,
|
||||||
|
[Parameter(Mandatory = $true)][string]$Json
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
$httpClient = New-Object System.Net.Http.HttpClient
|
||||||
|
$content = New-Object System.Net.Http.StringContent($Json, [System.Text.Encoding]::UTF8, "application/json")
|
||||||
|
$response = $httpClient.PostAsync($Uri, $content).Result
|
||||||
|
$httpClient.Dispose()
|
||||||
|
} catch {
|
||||||
|
Write-FileCollectorLog "POST Error: $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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-FileOperationEvent {
|
||||||
|
param(
|
||||||
|
[string]$Operation,
|
||||||
|
[string]$FilePath,
|
||||||
|
[string]$OldFilePath = $null,
|
||||||
|
[long]$Size = 0
|
||||||
|
)
|
||||||
|
|
||||||
|
$bucketId = 'aw-file-operations_' + $script:Hostname
|
||||||
|
Ensure-Bucket -BucketId $bucketId -ClientName 'aw-file-operations' -BucketType 'aw.file.operation'
|
||||||
|
|
||||||
|
$data = @{
|
||||||
|
operation = $Operation
|
||||||
|
path = $FilePath
|
||||||
|
extension = [System.IO.Path]::GetExtension($FilePath)
|
||||||
|
username = $env:USERNAME
|
||||||
|
hostname = $script:Hostname
|
||||||
|
}
|
||||||
|
if ($OldFilePath) { $data.oldPath = $OldFilePath }
|
||||||
|
if ($Size -gt 0) { $data.size = $Size }
|
||||||
|
|
||||||
|
# Детекция архивации (упрощенная)
|
||||||
|
if ($Operation -eq 'Created' -and $data.extension -match '\.(zip|7z|rar|tar|gz)$') {
|
||||||
|
$data.archiveHint = $true
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = @{
|
||||||
|
timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
|
||||||
|
duration = 0
|
||||||
|
data = $data
|
||||||
|
} | ConvertTo-Json -Depth 5 -Compress
|
||||||
|
|
||||||
|
Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=15" -Json $payload
|
||||||
|
}
|
||||||
|
|
||||||
|
$config = Get-DeploymentConfig -Path $ConfigPath
|
||||||
|
if (-not $config) { throw "Configuration file not found: $ConfigPath" }
|
||||||
|
|
||||||
|
$scheme = if ($ServerScheme) { $ServerScheme } elseif ($config.server.scheme) { $config.server.scheme } else { 'http' }
|
||||||
|
$hostName = if ($ServerHost) { $ServerHost } elseif ($config.server.host) { $config.server.host } else { 'localhost' }
|
||||||
|
$port = if ($ServerPort) { $ServerPort } elseif ($config.server.port) { $config.server.port } else { 5600 }
|
||||||
|
$script:ApiBase = "{0}://{1}:{2}/api/0" -f $scheme, $hostName, $port
|
||||||
|
|
||||||
|
$bucketId = 'aw-file-operations_' + $script:Hostname
|
||||||
|
Ensure-Bucket -BucketId $bucketId -ClientName 'aw-file-operations' -BucketType 'aw.file.operation'
|
||||||
|
|
||||||
|
# Resolve paths for monitoring
|
||||||
|
$resolvedPaths = @()
|
||||||
|
foreach ($p in $WatchPaths) {
|
||||||
|
$fullPath = $p
|
||||||
|
if (-not [System.IO.Path]::IsPathRooted($p)) {
|
||||||
|
try {
|
||||||
|
if ($p -eq 'Desktop') { $fullPath = [Environment]::GetFolderPath('Desktop') }
|
||||||
|
elseif ($p -eq 'Documents') { $fullPath = [Environment]::GetFolderPath('MyDocuments') }
|
||||||
|
elseif ($p -eq 'Downloads') { $fullPath = Join-Path $env:USERPROFILE 'Downloads' }
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
if ($fullPath -and (Test-Path -LiteralPath $fullPath)) {
|
||||||
|
$resolvedPaths += $fullPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($resolvedPaths.Count -eq 0) {
|
||||||
|
Write-FileCollectorLog "No valid watch paths found. Exiting."
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-FileCollectorLog "Starting watch on paths: $($resolvedPaths -join ', ')"
|
||||||
|
|
||||||
|
$watchers = @()
|
||||||
|
foreach ($path in $resolvedPaths) {
|
||||||
|
$watcher = New-Object System.IO.FileSystemWatcher
|
||||||
|
$watcher.Path = $path
|
||||||
|
$watcher.IncludeSubdirectories = $true
|
||||||
|
$watcher.EnableRaisingEvents = $true
|
||||||
|
|
||||||
|
$onChanged = Register-ObjectEvent $watcher "Created" -Action {
|
||||||
|
$path = $Event.SourceEventArgs.FullPath
|
||||||
|
$size = 0
|
||||||
|
try { if (Test-Path -LiteralPath $path) { $size = (Get-Item -LiteralPath $path).Length } } catch {}
|
||||||
|
Send-FileOperationEvent -Operation 'Created' -FilePath $path -Size $size
|
||||||
|
}
|
||||||
|
$onDeleted = Register-ObjectEvent $watcher "Deleted" -Action {
|
||||||
|
Send-FileOperationEvent -Operation 'Deleted' -FilePath $Event.SourceEventArgs.FullPath
|
||||||
|
}
|
||||||
|
$onRenamed = Register-ObjectEvent $watcher "Renamed" -Action {
|
||||||
|
Send-FileOperationEvent -Operation 'Renamed' -FilePath $Event.SourceEventArgs.FullPath -OldFilePath $Event.SourceEventArgs.OldFullPath
|
||||||
|
}
|
||||||
|
|
||||||
|
$watchers += $watcher
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-FileCollectorLog "Collector started. Waiting for events..."
|
||||||
|
|
||||||
|
try {
|
||||||
|
while ($true) {
|
||||||
|
Start-Sleep -Seconds $PollSeconds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
Write-FileCollectorLog "Stopping collector..."
|
||||||
|
foreach ($w in $watchers) {
|
||||||
|
$w.EnableRaisingEvents = $false
|
||||||
|
$w.Dispose()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ param(
|
|||||||
[int]$RecoveryIntervalSeconds,
|
[int]$RecoveryIntervalSeconds,
|
||||||
[bool]$AfkEnabled,
|
[bool]$AfkEnabled,
|
||||||
[bool]$WindowEnabled,
|
[bool]$WindowEnabled,
|
||||||
|
[bool]$FileOpsEnabled,
|
||||||
[bool]$LocalAgentLogsEnabled,
|
[bool]$LocalAgentLogsEnabled,
|
||||||
[bool]$IncidentCaptureEnabled,
|
[bool]$IncidentCaptureEnabled,
|
||||||
[bool]$IncidentScreenshotEnabled,
|
[bool]$IncidentScreenshotEnabled,
|
||||||
@@ -53,6 +54,8 @@ $effectiveLaunchScript = Join-Path $effectiveStateRoot 'launch-watchers.ps1'
|
|||||||
$effectiveRecoveryScript = Join-Path $effectiveStateRoot 'recovery-loop.ps1'
|
$effectiveRecoveryScript = Join-Path $effectiveStateRoot 'recovery-loop.ps1'
|
||||||
$effectiveCollector = Join-Path $effectiveStateRoot 'browser-domains-native-collector.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' }
|
$effectiveEndpointCollector = if ($existingConfig -and $existingConfig.paths.PSObject.Properties.Name -contains 'endpointCollectorScript') { [string]$existingConfig.paths.endpointCollectorScript } else { Join-Path $effectiveStateRoot 'dlp-endpoint-signals-collector.ps1' }
|
||||||
|
$effectiveFileCollector = if ($existingConfig -and $existingConfig.paths.PSObject.Properties.Name -contains 'fileCollectorScript') { [string]$existingConfig.paths.fileCollectorScript } else { Join-Path $effectiveStateRoot 'file-operations-collector.ps1' }
|
||||||
|
$effectiveSessionCollector = if ($existingConfig -and $existingConfig.paths.PSObject.Properties.Name -contains 'sessionCollectorScript') { [string]$existingConfig.paths.sessionCollectorScript } else { Join-Path $effectiveStateRoot 'worktime-session-collector.ps1' }
|
||||||
$effectiveRules = Join-Path $effectiveStateRoot 'web-category-rules.json'
|
$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' }
|
$effectivePolicy = if ($existingConfig -and $existingConfig.paths.PSObject.Properties.Name -contains 'policyPath') { [string]$existingConfig.paths.policyPath } else { Join-Path $effectiveStateRoot 'dlp-policy.json' }
|
||||||
|
|
||||||
@@ -64,6 +67,7 @@ $effectivePulseSeconds = if ($PSBoundParameters.ContainsKey('PulseSeconds')) { $
|
|||||||
$effectiveRecoveryInterval = if ($PSBoundParameters.ContainsKey('RecoveryIntervalSeconds')) { $RecoveryIntervalSeconds } elseif ($existingConfig) { [int]$existingConfig.recovery.intervalSeconds } else { 180 }
|
$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 }
|
$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 }
|
$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 }
|
||||||
|
$effectiveFileOpsEnabled = if ($PSBoundParameters.ContainsKey('FileOpsEnabled')) { [bool]$FileOpsEnabled } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'collectors' -and $existingConfig.collectors.PSObject.Properties.Name -contains 'fileOpsEnabled') { [bool]$existingConfig.collectors.fileOpsEnabled } 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 }
|
$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 }
|
$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 }
|
$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 }
|
||||||
@@ -96,6 +100,7 @@ Get-ActivityWatchExecutableMap -InstallRoot $effectiveInstallRoot | Out-Null
|
|||||||
$assetResult = Copy-ActivityWatchCollectorAssets `
|
$assetResult = Copy-ActivityWatchCollectorAssets `
|
||||||
-CollectorScriptSource (Join-Path $PSScriptRoot 'browser-domains-native-collector.ps1') `
|
-CollectorScriptSource (Join-Path $PSScriptRoot 'browser-domains-native-collector.ps1') `
|
||||||
-EndpointCollectorScriptSource (Join-Path $PSScriptRoot 'dlp-endpoint-signals-collector.ps1') `
|
-EndpointCollectorScriptSource (Join-Path $PSScriptRoot 'dlp-endpoint-signals-collector.ps1') `
|
||||||
|
-FileCollectorScriptSource (Join-Path $PSScriptRoot 'file-operations-collector.ps1') `
|
||||||
-SessionCollectorScriptSource (Join-Path $PSScriptRoot 'worktime-session-collector.ps1') `
|
-SessionCollectorScriptSource (Join-Path $PSScriptRoot 'worktime-session-collector.ps1') `
|
||||||
-ExampleRulesSource (Join-Path $PSScriptRoot 'web-category-rules.example.json') `
|
-ExampleRulesSource (Join-Path $PSScriptRoot 'web-category-rules.example.json') `
|
||||||
-ExamplePolicySource (Join-Path $PSScriptRoot 'dlp-policy.example.json') `
|
-ExamplePolicySource (Join-Path $PSScriptRoot 'dlp-policy.example.json') `
|
||||||
@@ -116,6 +121,7 @@ $config = New-ActivityWatchDeploymentConfig `
|
|||||||
-LogsRoot $effectiveLogsRoot `
|
-LogsRoot $effectiveLogsRoot `
|
||||||
-CollectorScript $effectiveCollector `
|
-CollectorScript $effectiveCollector `
|
||||||
-EndpointCollectorScript $effectiveEndpointCollector `
|
-EndpointCollectorScript $effectiveEndpointCollector `
|
||||||
|
-FileCollectorScript $effectiveFileCollector `
|
||||||
-SessionCollectorScript $effectiveSessionCollector `
|
-SessionCollectorScript $effectiveSessionCollector `
|
||||||
-RulesPath $effectiveRules `
|
-RulesPath $effectiveRules `
|
||||||
-PolicyPath $effectivePolicy `
|
-PolicyPath $effectivePolicy `
|
||||||
@@ -124,6 +130,7 @@ $config = New-ActivityWatchDeploymentConfig `
|
|||||||
-RecoveryIntervalSeconds $effectiveRecoveryInterval `
|
-RecoveryIntervalSeconds $effectiveRecoveryInterval `
|
||||||
-AfkEnabled $effectiveAfkEnabled `
|
-AfkEnabled $effectiveAfkEnabled `
|
||||||
-WindowEnabled $effectiveWindowEnabled `
|
-WindowEnabled $effectiveWindowEnabled `
|
||||||
|
-FileOpsEnabled $effectiveFileOpsEnabled `
|
||||||
-LocalAgentLogsEnabled $effectiveLocalAgentLogsEnabled `
|
-LocalAgentLogsEnabled $effectiveLocalAgentLogsEnabled `
|
||||||
-IncidentCaptureEnabled $effectiveIncidentCaptureEnabled `
|
-IncidentCaptureEnabled $effectiveIncidentCaptureEnabled `
|
||||||
-IncidentScreenshotEnabled $effectiveIncidentScreenshotEnabled `
|
-IncidentScreenshotEnabled $effectiveIncidentScreenshotEnabled `
|
||||||
|
|||||||
@@ -154,7 +154,36 @@ if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Миграция ActivityWatch W
|
|||||||
@{ Source = $NewStateRoot; Name = 'new-state' }
|
@{ Source = $NewStateRoot; Name = 'new-state' }
|
||||||
)) {
|
)) {
|
||||||
if (Test-Path -LiteralPath $item.Source) {
|
if (Test-Path -LiteralPath $item.Source) {
|
||||||
Copy-Item -LiteralPath $item.Source -Destination (Join-Path $backupRoot $item.Name) -Recurse -Force
|
$backupDest = Join-Path $backupRoot $item.Name
|
||||||
|
New-ActivityWatchDirectory -Path $backupDest
|
||||||
|
|
||||||
|
$excludeDirs = @()
|
||||||
|
if ($item.Source -eq $NewStateRoot) {
|
||||||
|
# Avoid infinite recursion: backupRoot is inside NewStateRoot by default.
|
||||||
|
$excludeDirs += $backupRoot
|
||||||
|
}
|
||||||
|
|
||||||
|
$robocopyArgs = @(
|
||||||
|
$item.Source,
|
||||||
|
$backupDest,
|
||||||
|
'/E',
|
||||||
|
'/R:1',
|
||||||
|
'/W:1',
|
||||||
|
'/NFL',
|
||||||
|
'/NDL',
|
||||||
|
'/NJH',
|
||||||
|
'/NJS',
|
||||||
|
'/NP'
|
||||||
|
)
|
||||||
|
if ($excludeDirs.Count -gt 0) {
|
||||||
|
$robocopyArgs += '/XD'
|
||||||
|
$robocopyArgs += $excludeDirs
|
||||||
|
}
|
||||||
|
|
||||||
|
& robocopy @robocopyArgs | Out-Null
|
||||||
|
if ($LASTEXITCODE -ge 8) {
|
||||||
|
throw "Backup robocopy failed (exit=$LASTEXITCODE) for source '$($item.Source)' to '$backupDest'"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ $installRoot = [string]$config.paths.installRoot
|
|||||||
$stateRoot = [string]$config.paths.stateRoot
|
$stateRoot = [string]$config.paths.stateRoot
|
||||||
$collectorScript = [string]$config.paths.collectorScript
|
$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' }
|
$endpointCollectorScript = if ($config.paths.PSObject.Properties.Name -contains 'endpointCollectorScript') { [string]$config.paths.endpointCollectorScript } else { Join-Path $stateRoot 'dlp-endpoint-signals-collector.ps1' }
|
||||||
|
$fileCollectorScript = if ($config.paths.PSObject.Properties.Name -contains 'fileCollectorScript') { [string]$config.paths.fileCollectorScript } else { Join-Path $stateRoot 'file-operations-collector.ps1' }
|
||||||
$sessionCollectorScript = if ($config.paths.PSObject.Properties.Name -contains 'sessionCollectorScript') { [string]$config.paths.sessionCollectorScript } else { Join-Path $stateRoot 'worktime-session-collector.ps1' }
|
$sessionCollectorScript = if ($config.paths.PSObject.Properties.Name -contains 'sessionCollectorScript') { [string]$config.paths.sessionCollectorScript } else { Join-Path $stateRoot 'worktime-session-collector.ps1' }
|
||||||
$rulesPath = [string]$config.paths.rulesPath
|
$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' }
|
$policyPath = if ($config.paths.PSObject.Properties.Name -contains 'policyPath') { [string]$config.paths.policyPath } else { Join-Path $stateRoot 'dlp-policy.json' }
|
||||||
@@ -22,6 +23,7 @@ $recoveryScript = [string]$config.paths.recoveryScript
|
|||||||
|
|
||||||
$afkExpected = if ($config.PSObject.Properties.Name -contains 'collectors' -and $config.collectors.PSObject.Properties.Name -contains 'afkEnabled') { [bool]$config.collectors.afkEnabled } else { $true }
|
$afkExpected = if ($config.PSObject.Properties.Name -contains 'collectors' -and $config.collectors.PSObject.Properties.Name -contains 'afkEnabled') { [bool]$config.collectors.afkEnabled } else { $true }
|
||||||
$windowExpected = if ($config.PSObject.Properties.Name -contains 'collectors' -and $config.collectors.PSObject.Properties.Name -contains 'windowEnabled') { [bool]$config.collectors.windowEnabled } else { $true }
|
$windowExpected = if ($config.PSObject.Properties.Name -contains 'collectors' -and $config.collectors.PSObject.Properties.Name -contains 'windowEnabled') { [bool]$config.collectors.windowEnabled } else { $true }
|
||||||
|
$fileOpsExpected = if ($config.PSObject.Properties.Name -contains 'collectors' -and $config.collectors.PSObject.Properties.Name -contains 'fileOpsEnabled') { [bool]$config.collectors.fileOpsEnabled } else { $true }
|
||||||
$requiredFiles = @(
|
$requiredFiles = @(
|
||||||
$collectorScript,
|
$collectorScript,
|
||||||
$endpointCollectorScript,
|
$endpointCollectorScript,
|
||||||
@@ -32,6 +34,9 @@ $requiredFiles = @(
|
|||||||
$recoveryScript,
|
$recoveryScript,
|
||||||
$ConfigPath
|
$ConfigPath
|
||||||
)
|
)
|
||||||
|
if ($fileOpsExpected) {
|
||||||
|
$requiredFiles += $fileCollectorScript
|
||||||
|
}
|
||||||
if ($afkExpected) {
|
if ($afkExpected) {
|
||||||
$requiredFiles += (Join-Path $installRoot 'aw-watcher-afk\aw-watcher-afk.exe')
|
$requiredFiles += (Join-Path $installRoot 'aw-watcher-afk\aw-watcher-afk.exe')
|
||||||
}
|
}
|
||||||
@@ -50,12 +55,14 @@ $runningProcesses = @()
|
|||||||
if ($processNames.Count -gt 0) {
|
if ($processNames.Count -gt 0) {
|
||||||
$runningProcesses = Get-Process -Name $processNames -ErrorAction SilentlyContinue | Select-Object Name, Id, SessionId
|
$runningProcesses = Get-Process -Name $processNames -ErrorAction SilentlyContinue | Select-Object Name, Id, SessionId
|
||||||
}
|
}
|
||||||
$sessionCollectorProcesses = Get-CimInstance Win32_Process -ErrorAction SilentlyContinue |
|
$sessionCollectorProcesses = @(
|
||||||
|
Get-CimInstance Win32_Process -ErrorAction SilentlyContinue |
|
||||||
Where-Object {
|
Where-Object {
|
||||||
($_.Name -ieq 'powershell.exe' -or $_.Name -ieq 'pwsh.exe') -and
|
($_.Name -ieq 'powershell.exe' -or $_.Name -ieq 'pwsh.exe') -and
|
||||||
$_.CommandLine -match [Regex]::Escape($sessionCollectorScript)
|
$_.CommandLine -match [Regex]::Escape($sessionCollectorScript)
|
||||||
} |
|
} |
|
||||||
Select-Object Name, ProcessId, SessionId, CommandLine
|
Select-Object Name, ProcessId, SessionId, CommandLine
|
||||||
|
)
|
||||||
|
|
||||||
$taskNames = @()
|
$taskNames = @()
|
||||||
if ($config.userTasks) {
|
if ($config.userTasks) {
|
||||||
@@ -64,7 +71,8 @@ if ($config.userTasks) {
|
|||||||
$taskNames += [string]$config.recovery.taskName
|
$taskNames += [string]$config.recovery.taskName
|
||||||
$taskNames = $taskNames | Sort-Object -Unique
|
$taskNames = $taskNames | Sort-Object -Unique
|
||||||
|
|
||||||
$tasks = foreach ($taskName in $taskNames) {
|
$tasks = @(
|
||||||
|
foreach ($taskName in $taskNames) {
|
||||||
$task = Get-ScheduledTask -ErrorAction SilentlyContinue | Where-Object { $_.TaskName -eq $taskName } | Select-Object -First 1
|
$task = Get-ScheduledTask -ErrorAction SilentlyContinue | Where-Object { $_.TaskName -eq $taskName } | Select-Object -First 1
|
||||||
if ($task) {
|
if ($task) {
|
||||||
[pscustomobject]@{
|
[pscustomobject]@{
|
||||||
@@ -81,8 +89,10 @@ $tasks = foreach ($taskName in $taskNames) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
)
|
||||||
|
|
||||||
$serverUrl = '{0}://{1}:{2}' -f [string]$config.server.scheme, [string]$config.server.host, [int]$config.server.port
|
$serverUrl = '{0}://{1}:{2}' -f [string]$config.server.scheme, [string]$config.server.host, [int]$config.server.port
|
||||||
|
$uniqueRunningProcessNames = @($runningProcesses | Select-Object -ExpandProperty Name -Unique)
|
||||||
$result = [ordered]@{
|
$result = [ordered]@{
|
||||||
generatedAtUtc = (Get-Date).ToUniversalTime().ToString('o')
|
generatedAtUtc = (Get-Date).ToUniversalTime().ToString('o')
|
||||||
configPath = $ConfigPath
|
configPath = $ConfigPath
|
||||||
@@ -105,7 +115,7 @@ $result = [ordered]@{
|
|||||||
ok = [bool](
|
ok = [bool](
|
||||||
(
|
(
|
||||||
($processNames.Count -eq 0) -or
|
($processNames.Count -eq 0) -or
|
||||||
(($runningProcesses | Select-Object -ExpandProperty Name -Unique).Count -ge $processNames.Count)
|
($uniqueRunningProcessNames.Count -ge $processNames.Count)
|
||||||
) -and
|
) -and
|
||||||
($sessionCollectorProcesses.Count -ge 1)
|
($sessionCollectorProcesses.Count -ge 1)
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user