Compare commits

..
Author SHA1 Message Date
igor04091968 d75b3eb05e Feat: implement automated DLP incident aggregation on server (timer + service) 2026-05-03 01:24:54 +03:00
Devin AI 4359f6d5eb Fix Windows file telemetry playbook wiring 2026-05-02 21:44:18 +00:00
Devin AI 7ea4ebd463 Merge PR #11 DLP incident aggregation prototype 2026-05-02 21:33:42 +00:00
Devin AI 5a4064dc0f Add DLP incident aggregation prototype 2026-05-02 20:55:44 +00:00
igor04091968 c97ffe2cbd Fix: ensure file collector robustness (HttpClient, TLS 1.2, English logs) 2026-05-02 23:42:14 +03:00
Devin AI f45ef0038d Use string JSON body for file collector posts 2026-05-02 20:34:07 +00:00
Devin AI 8088b19dc7 Create file operation bucket before path checks 2026-05-02 20:28:03 +00:00
Devin AI 046aa3ed1d Create file operations bucket on startup 2026-05-02 20:17:43 +00:00
igor04091968 b6f019982d gemeni-3-light 2026-05-02 23:09:54 +03:00
igor04091968andDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> fae2e2ca14 Feat & Fix: implement File Telemetry, restore DB history, and stabilize production
- Added File Operations Collector (Plan A) for Windows endpoints
- Restored historical server DB via merging and moved to durable /var/lib/activitywatch path
- Forced XDG_DATA_HOME and XDG_CONFIG_HOME for aw-server-rust in environment and systemd
- Updated Ansible playbooks to handle new file collector and durable server paths
- Added DB merge and backup-restore automation scripts
- Fixed CORS and RU WebUI persistence in production deployment

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-05-02 20:59:08 +03:00
igor04091968 f436950bda Update after codex restore 2026-05-02 19:36:25 +03:00
igor04091968 d7fedde69d After deploy from hand restore 2026-05-02 17:54:36 +03:00
igor04091968 7f58a49c0a Разворачивание деплоя 2026-05-02 17:28:49 +03:00
26 changed files with 1973 additions and 221 deletions
+2
View File
@@ -1,11 +1,13 @@
# Local secrets
secrets/deploy.secrets.env
secrets/runtime.env
# Runtime / reports
*.log
*.tmp
*.bak
windows/*.report.json
.rollout-logs/
# IDE
.idea/
+2
View File
@@ -13,12 +13,14 @@
- `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/dlp-gap-analysis.md` — разрыв до enterprise DLP и roadmap.
- `docs/dlp-aggregator.md` — прототип централизованной агрегации DLP/file-operation событий.
- `proxmox/` — шаблонные скрипты подготовки и наполнения CT на стороне Proxmox.
- `aw-server/` — установочные скрипты, env-шаблон, systemd unit и RU patch для Web UI.
- `ansible/` — Ansible-ensemble для автоматизированного сервера (Debian/CT).
- `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_*`).
- `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_console_ssh_logger.sh` — user-space установка console/ssh logger.
- `scripts/install_aw_linux_web_category_logger.sh` — user-space классификация browser admin UI по title/class.
+19
View File
@@ -31,6 +31,15 @@ cd ansible
ansible-playbook -i inventory.ini deploy_aw_server.yml
```
## Секреты (пароли) безопасно
Рекомендуемый способ не хранить пароли в репозитории — перед запуском экспортировать их в переменные окружения:
- Linux `aw_server` (SSH пароль root): `AW_SSH_PASSWORD`
- Windows `aw_windows` (WinRM пароль): `AW_WINRM_PASSWORD`
В `group_vars/aw_server.yml` и `group_vars/windows.yml` они читаются через `lookup('env', ...)`.
## Полный установочный playbook (всё за один запуск)
Если нужно прогнать полный цикл одной командой:
@@ -149,3 +158,13 @@ Playbook:
- Для полного сценария CT создаётся автоматически через `pct create`.
- На Windows/RDP host развёрнуты AFK/window watchers, browser domain collector, DLP endpoint collector и worktime session collector.
- Проверочный JSON-отчёт Windows playbook должен иметь `overallOk=true`.
## Prod rollout одной командой
Для ручного запуска с dry-run и логированием используйте:
```bash
bash scripts/prod_rollout.sh
```
Скрипт попросит `AW_SSH_PASSWORD` и `AW_WINRM_PASSWORD` интерактивно (ввод скрыт) и сложит логи в `.rollout-logs/`.
+446 -154
View File
@@ -54,6 +54,11 @@
- "{{ aw_server_webui_dir }}"
- "{{ aw_server_webui_dir }}/js"
- "{{ 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 }}"
- /etc/activitywatch
- "{{ aw_bootstrap_dir }}"
@@ -74,100 +79,147 @@
- "{{ aw_server_webui_dir }}"
- "{{ aw_server_webui_dir }}/js"
- "{{ 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 }}"
- name: Скачать архив релиза ActivityWatch
ansible.builtin.get_url:
url: "{{ aw_server_download_url }}"
dest: "{{ aw_archive_path }}"
mode: "0644"
- 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
ansible.builtin.unarchive:
src: "{{ aw_archive_path }}"
dest: "{{ aw_release_dir }}"
remote_src: true
extra_opts: ["-o"]
- name: Установить релиз ActivityWatch (download/unarchive/install)
when: not ansible_check_mode
block:
- name: Скачать архив релиза ActivityWatch
ansible.builtin.get_url:
url: "{{ aw_server_download_url }}"
dest: "{{ aw_archive_path }}"
mode: "0644"
- name: Найти распакованный каталог ActivityWatch
ansible.builtin.find:
paths: "{{ aw_release_dir }}"
file_type: directory
patterns: "activitywatch*"
register: aw_release_find
- name: Распаковать релиз ActivityWatch
ansible.builtin.unarchive:
src: "{{ aw_archive_path }}"
dest: "{{ aw_release_dir }}"
remote_src: true
extra_opts: ["-o"]
- name: Найти бинарный файл AW server
ansible.builtin.find:
paths: "{{ aw_release_dir }}"
file_type: file
patterns:
- aw-server-rust
- aw-server
register: aw_server_binary_find
- name: Найти распакованный каталог ActivityWatch
ansible.builtin.find:
paths: "{{ aw_release_dir }}"
recurse: true
file_type: directory
patterns: "activitywatch*"
register: aw_release_find
- name: Найти каталог WebUI
ansible.builtin.find:
paths: "{{ aw_release_dir }}"
file_type: directory
patterns:
- aw-webui
- webui
register: aw_webui_dir_find
- name: Найти бинарный файл AW server
ansible.builtin.find:
paths: "{{ aw_release_dir }}"
recurse: true
file_type: file
patterns:
- aw-server-rust
- aw-server
register: aw_server_binary_find
- name: Сохранить пути распакованного релиза
ansible.builtin.set_fact:
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_webui_source_path: "{{ (aw_webui_dir_find.files | default([]) | sort(attribute='path') | map(attribute='path') | list | first) | default('') }}"
- name: Найти index.html WebUI
ansible.builtin.find:
paths: "{{ aw_release_dir }}"
recurse: true
file_type: file
patterns:
- index.html
register: aw_webui_index_find
- name: Проверить, что компоненты релиза найдены
ansible.builtin.assert:
that:
- aw_release_extracted is defined
- aw_release_extracted | length > 0
- aw_server_binary_path is defined
- aw_server_binary_path | length > 0
- aw_webui_source_path is defined
- aw_webui_source_path | length > 0
fail_msg: "Не удалось найти бинарный файл или WebUI в распакованном релизе ActivityWatch."
- name: Сохранить пути распакованного релиза (binary + webui index)
ansible.builtin.set_fact:
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)
| 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: Создать каталог установленного релиза
ansible.builtin.file:
path: "{{ aw_release_install_dir }}"
state: directory
owner: "{{ aw_server_user }}"
group: "{{ aw_server_group }}"
mode: "0755"
- name: Сохранить каталог WebUI (dirname index.html)
ansible.builtin.set_fact:
aw_webui_source_path: "{{ aw_webui_index_path | dirname }}"
- name: Установить бинарный файл AW server
ansible.builtin.copy:
remote_src: true
src: "{{ aw_server_binary_path }}"
dest: "{{ aw_release_install_dir }}/aw-server-rust"
owner: "{{ aw_server_user }}"
group: "{{ aw_server_group }}"
mode: "0755"
- name: Проверить, что компоненты релиза найдены
ansible.builtin.assert:
that:
- aw_release_extracted is defined
- aw_release_extracted | length > 0
- aw_server_binary_path is defined
- aw_server_binary_path | length > 0
- aw_webui_source_path is defined
- aw_webui_source_path | length > 0
fail_msg: "Не удалось найти бинарный файл или WebUI в распакованном релизе ActivityWatch."
- name: Создать ссылку на активный бинарный файл AW server
ansible.builtin.file:
src: "{{ aw_release_install_dir }}/aw-server-rust"
dest: /opt/activitywatch/bin/aw-server-rust
owner: "{{ aw_server_user }}"
group: "{{ aw_server_group }}"
state: link
force: true
- name: Создать каталог установленного релиза
ansible.builtin.file:
path: "{{ aw_release_install_dir }}"
state: directory
owner: "{{ aw_server_user }}"
group: "{{ aw_server_group }}"
mode: "0755"
- name: Синхронизировать WebUI в RU каталог
ansible.builtin.command:
cmd: "rsync -a {{ aw_webui_source_path }}/ {{ aw_server_webui_dir }}/"
- name: Установить бинарный файл AW server
ansible.builtin.copy:
remote_src: true
src: "{{ aw_server_binary_path }}"
dest: "{{ aw_release_install_dir }}/aw-server-rust"
owner: "{{ aw_server_user }}"
group: "{{ aw_server_group }}"
mode: "0755"
- name: Настроить владельца файлов /opt/activitywatch
ansible.builtin.file:
path: /opt/activitywatch
state: directory
owner: "{{ aw_server_user }}"
group: "{{ aw_server_group }}"
recurse: true
- name: Создать ссылку на активный бинарный файл AW server
ansible.builtin.file:
src: "{{ aw_release_install_dir }}/aw-server-rust"
dest: /opt/activitywatch/bin/aw-server-rust
owner: "{{ aw_server_user }}"
group: "{{ aw_server_group }}"
state: link
force: true
- name: Синхронизировать WebUI в RU каталог
ansible.builtin.command:
cmd: "rsync -a {{ aw_webui_source_path }}/ {{ aw_server_webui_dir }}/"
- name: Настроить владельца файлов /opt/activitywatch
ansible.builtin.file:
path: /opt/activitywatch
state: directory
owner: "{{ aw_server_user }}"
group: "{{ aw_server_group }}"
recurse: true
- name: Установить systemd service из шаблона репозитория
ansible.builtin.copy:
@@ -184,78 +236,296 @@
- Перезагрузить systemd
- Перезапустить activitywatch
- name: Скопировать RU patch файлы WebUI из репозитория
ansible.builtin.copy:
src: "{{ item.src }}"
dest: "{{ item.dest }}"
mode: "{{ item.mode }}"
owner: "{{ aw_server_user }}"
group: "{{ aw_server_group }}"
loop:
- { src: "{{ aw_repo_root }}/aw-server/aw-ru-patch.js", dest: "{{ aw_server_webui_dir }}/js/ru-patch-v5.js", mode: "0644" }
- { src: "{{ aw_repo_root }}/aw-server/aw-sw-cleanup.js", dest: "{{ aw_server_webui_dir }}/js/sw-cleanup.js", mode: "0644" }
- { src: "{{ aw_repo_root }}/aw-server/aw-host-groups.json", dest: "{{ aw_server_webui_dir }}/js/aw-host-groups.json", mode: "0644" }
- 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: Проверить наличие index.html после копирования
ansible.builtin.stat:
path: "{{ aw_server_webui_dir }}/index.html"
register: aw_webui_ru_index
- name: Применить WebUI RU patch и запустить сервис
when: not ansible_check_mode
block:
- name: Скопировать RU patch файлы WebUI из репозитория
ansible.builtin.copy:
src: "{{ item.src }}"
dest: "{{ item.dest }}"
mode: "{{ item.mode }}"
owner: "{{ aw_server_user }}"
group: "{{ aw_server_group }}"
loop:
- { src: "{{ aw_repo_root }}/aw-server/aw-ru-patch.js", dest: "{{ aw_server_webui_dir }}/js/ru-patch-v5.js", mode: "0644" }
- { src: "{{ aw_repo_root }}/aw-server/aw-sw-cleanup.js", dest: "{{ aw_server_webui_dir }}/js/sw-cleanup.js", mode: "0644" }
- { src: "{{ aw_repo_root }}/aw-server/aw-host-groups.json", dest: "{{ aw_server_webui_dir }}/js/aw-host-groups.json", mode: "0644" }
- name: Проверить, что index.html доступен для RU patch
ansible.builtin.assert:
that:
- aw_webui_ru_index.stat.exists
fail_msg: "Не найден index.html WebUI для применения RU patch."
- name: Проверить наличие index.html после копирования
ansible.builtin.stat:
path: "{{ aw_server_webui_dir }}/index.html"
register: aw_webui_ru_index
- name: Удалить старые теги RU patch из index.html
ansible.builtin.replace:
path: "{{ aw_server_webui_dir }}/index.html"
regexp: '<script[^>]+(?:ru-patch-v5\.js|sw-cleanup\.js|aw-ru-patch\.js|aw-sw-cleanup\.js)[^>]*></script>'
replace: ''
- name: Проверить, что index.html доступен для RU patch
ansible.builtin.assert:
that:
- aw_webui_ru_index.stat.exists
fail_msg: "Не найден index.html WebUI для применения RU patch."
- name: Добавить cleanup script RU patch в index.html
ansible.builtin.replace:
path: "{{ aw_server_webui_dir }}/index.html"
regexp: '</head>'
replace: '<script src="/js/sw-cleanup.js?v={{ aw_sw_cleanup_cache_bust }}"></script></head>'
- name: Удалить старые теги RU patch из index.html
ansible.builtin.replace:
path: "{{ aw_server_webui_dir }}/index.html"
regexp: '<script[^>]+(?:ru-patch-v5\.js|sw-cleanup\.js|aw-ru-patch\.js|aw-sw-cleanup\.js)[^>]*></script>'
replace: ''
- name: Добавить загрузчик RU patch перед закрытием body
ansible.builtin.replace:
path: "{{ aw_server_webui_dir }}/index.html"
regexp: '</body>'
replace: '<script defer="defer" src="/js/ru-patch-v5.js?v={{ aw_ru_patch_cache_bust }}"></script></body>'
- name: Добавить cleanup script RU patch в index.html
ansible.builtin.replace:
path: "{{ aw_server_webui_dir }}/index.html"
regexp: '</head>'
replace: '<script src="/js/sw-cleanup.js?v={{ aw_sw_cleanup_cache_bust }}"></script></head>'
- name: Записать /etc/activitywatch/aw-server.env
ansible.builtin.copy:
dest: /etc/activitywatch/aw-server.env
mode: "0640"
owner: root
group: root
content: |
AW_SERVER_BIND_HOST={{ aw_server_bind_host }}
AW_SERVER_PORT={{ aw_server_port }}
AW_SERVER_DATA_DIR={{ aw_server_data_dir }}
AW_SERVER_LOG_DIR={{ aw_server_log_dir }}
AW_SERVER_WEBUI_DIR={{ aw_server_webui_dir }}
AW_SERVER_USER={{ aw_server_user }}
AW_SERVER_GROUP={{ aw_server_group }}
- name: Добавить загрузчик RU patch перед закрытием body
ansible.builtin.replace:
path: "{{ aw_server_webui_dir }}/index.html"
regexp: '</body>'
replace: '<script defer="defer" src="/js/ru-patch-v5.js?v={{ aw_ru_patch_cache_bust }}"></script></body>'
- name: Включить и запустить сервис
ansible.builtin.systemd:
name: activitywatch-server.service
enabled: true
state: restarted
daemon_reload: true
- name: Записать /etc/activitywatch/aw-server.env
ansible.builtin.copy:
dest: /etc/activitywatch/aw-server.env
mode: "0640"
owner: root
group: root
content: |
AW_SERVER_BIND_HOST={{ aw_server_bind_host }}
AW_SERVER_PORT={{ aw_server_port }}
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_WEBUI_DIR={{ aw_server_webui_dir }}
AW_SERVER_USER={{ aw_server_user }}
AW_SERVER_GROUP={{ aw_server_group }}
XDG_DATA_HOME={{ aw_server_data_dir }}/.local/share
XDG_CONFIG_HOME={{ aw_server_data_dir }}/.config
- name: Дождаться ответа API
- 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: Включить и запустить сервис
ansible.builtin.systemd:
name: activitywatch-server.service
enabled: true
state: restarted
daemon_reload: true
- name: Дождаться ответа API
ansible.builtin.uri:
url: "http://127.0.0.1:{{ aw_server_port }}/api/0/info"
method: GET
status_code: 200
register: aw_api
retries: 10
delay: 3
until: aw_api.status == 200
- name: Считать текущие server-side settings
ansible.builtin.uri:
url: "http://127.0.0.1:{{ aw_server_port }}/api/0/info"
url: "http://127.0.0.1:{{ aw_server_port }}/api/0/settings/"
method: GET
status_code: 200
register: aw_api
retries: 10
delay: 3
until: aw_api.status == 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)
ansible.builtin.uri:
@@ -277,16 +547,12 @@
- name: Вычислить worktime durationDefault из aw_worktime_from/to
ansible.builtin.set_fact:
aw_worktime_from_h: "{{ (aw_worktime_from | default('08:00')).split(':')[0] | int }}"
aw_worktime_from_m: "{{ (aw_worktime_from | default('08:00')).split(':')[1] | int }}"
aw_worktime_to_h: "{{ (aw_worktime_to | default('17:00')).split(':')[0] | int }}"
aw_worktime_to_m: "{{ (aw_worktime_to | default('17:00')).split(':')[1] | int }}"
aw_worktime_duration_default_derived: >-
{{
(
(
((aw_worktime_to_h | int) * 60 + (aw_worktime_to_m | int)) -
((aw_worktime_from_h | int) * 60 + (aw_worktime_from_m | int))
(((aw_worktime_to | default('17:00')).split(':')[0] | int) * 60 + ((aw_worktime_to | default('17:00')).split(':')[1] | int)) -
(((aw_worktime_from | default('08:00')).split(':')[0] | int) * 60 + ((aw_worktime_from | default('08:00')).split(':')[1] | int))
) * 60
)
}}
@@ -314,20 +580,46 @@
ansible.builtin.uri:
url: "http://127.0.0.1:{{ aw_server_port }}/api/0/settings/startOfDay"
method: POST
body: "{{ aw_worktime_start_of_day }}"
body_format: json
status_code: 200
body: "\"{{ aw_worktime_start_of_day }}\""
headers:
Content-Type: application/json
status_code: [200, 201]
when: aw_apply_worktime_settings | default(false) | bool
- name: Применить базовый период worktime (durationDefault seconds)
ansible.builtin.uri:
url: "http://127.0.0.1:{{ aw_server_port }}/api/0/settings/durationDefault"
method: POST
body: "{{ aw_worktime_duration_default_effective }}"
body_format: json
status_code: 200
body: "{{ aw_worktime_duration_default_effective | string }}"
headers:
Content-Type: application/json
status_code: [200, 201]
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:
- name: Перезагрузить systemd
ansible.builtin.systemd:
+55 -34
View File
@@ -25,6 +25,7 @@
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
@@ -77,6 +78,7 @@
- ActivityWatch.Windows.Common.psm1
- browser-domains-native-collector.ps1
- dlp-endpoint-signals-collector.ps1
- file-operations-collector.ps1
- worktime-session-collector.ps1
- migrate-awatch-rus-paths.ps1
- deploy-domain-users.ps1
@@ -86,6 +88,18 @@
- web-category-rules.example.json
- dlp-policy.example.json
- name: Нормализовать кодировку PowerShell файлов (UTF-8 BOM для Windows PowerShell)
ansible.windows.win_powershell:
script: |
$ErrorActionPreference = 'Stop'
$toolkitDir = "{{ aw_windows_deploy_root }}\windows"
$encIn = New-Object System.Text.UTF8Encoding($false)
$encOut = New-Object System.Text.UTF8Encoding($true)
Get-ChildItem -LiteralPath $toolkitDir -File -Include *.ps1,*.psm1,*.psd1 | ForEach-Object {
$text = [System.IO.File]::ReadAllText($_.FullName, $encIn)
[System.IO.File]::WriteAllText($_.FullName, $text, $encOut)
}
- name: Загрузить список пользователей для доменного развёртывания
ansible.windows.win_copy:
dest: "{{ aw_windows_deploy_root }}\\windows\\users.txt"
@@ -130,6 +144,7 @@
StateRoot = "{{ aw_windows_state_root }}"
AfkEnabled = {{ '$true' if (aw_windows_afk_enabled | bool) else '$false' }}
WindowEnabled = {{ '$true' if (aw_windows_window_enabled | bool) else '$false' }}
FileOpsEnabled = {{ '$true' if (aw_windows_file_ops_enabled | bool) else '$false' }}
LocalAgentLogsEnabled = {{ '$true' if (aw_windows_local_agent_logs_enabled | bool) else '$false' }}
IncidentCaptureEnabled = {{ '$true' if (aw_windows_incident_capture_enabled | bool) else '$false' }}
IncidentScreenshotEnabled = {{ '$true' if (aw_windows_incident_screenshot_enabled | bool) else '$false' }}
@@ -149,6 +164,19 @@
{% endif %}
& "{{ aw_windows_deploy_root }}\windows\deploy-ensemble.ps1" @params
- name: Удалить лишние ActivityWatch Launch tasks вне текущего deployment-config
ansible.windows.win_powershell:
script: |
$ErrorActionPreference = 'Stop'
$config = Get-Content -Raw -LiteralPath "{{ aw_windows_state_root }}\deployment-config.json" | ConvertFrom-Json
$desired = @($config.userTasks | ForEach-Object { [string]$_.LaunchTaskName })
foreach ($task in @(Get-ScheduledTask | Where-Object { $_.TaskName -like 'ActivityWatch Launch *' })) {
if ($desired -notcontains [string]$task.TaskName) {
Unregister-ScheduledTask -TaskName $task.TaskName -Confirm:$false -ErrorAction SilentlyContinue
& cmd.exe /c "schtasks /Delete /TN `"$($task.TaskName)`" /F >nul 2>&1" | Out-Null
}
}
- name: Принудительно запустить ActivityWatch recovery и launch tasks
when: aw_windows_force_task_restart | bool
ansible.windows.win_powershell:
@@ -171,62 +199,55 @@
when:
- aw_windows_api_smoke_check_enabled | bool
- aw_windows_afk_enabled | bool
- aw_windows_hostname_result.stdout is defined
ansible.builtin.set_fact:
aw_windows_api_smoke_check_bucket_effective: >-
{{
aw_windows_api_smoke_check_bucket
if (aw_windows_api_smoke_check_bucket | default('') | string | length) > 0
else 'aw-watcher-afk_' ~ (aw_windows_hostname_result.stdout | trim)
}}
aw_windows_api_smoke_check_bucket_effective: "aw-watcher-afk_{{ aw_windows_hostname_result.stdout | trim }}"
- name: Дождаться свежих AFK событий на AW server
- name: Выполнить AW API smoke-check (проверка наличия свежих событий в AFK бакете)
when:
- aw_windows_api_smoke_check_enabled | bool
- aw_windows_afk_enabled | bool
delegate_to: localhost
ansible.builtin.uri:
url: "{{ aw_windows_server_scheme }}://{{ aw_windows_server_host }}:{{ aw_windows_server_port }}/api/0/buckets/{{ aw_windows_api_smoke_check_bucket_effective }}/events?limit={{ aw_windows_api_smoke_check_limit }}"
method: GET
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
status_code: 200
register: aw_windows_api_smoke_result
until: aw_windows_api_smoke_result.json | length > 0
retries: 5
delay: 5
ignore_errors: true
- name: Выполнить валидацию и сохранить отчёт на целевом Windows host
- name: Валидировать развёртывание на эндпоинте
ansible.windows.win_powershell:
script: |
$ErrorActionPreference = 'Stop'
$report = & "{{ aw_windows_deploy_root }}\windows\validate-deployment.ps1" `
$result = & "{{ aw_windows_deploy_root }}\windows\validate-deployment.ps1" `
-ConfigPath "{{ aw_windows_state_root }}\deployment-config.json"
$report | ConvertTo-Json -Depth 12 | Out-File -FilePath "{{ aw_windows_validation_remote_path }}" -Encoding utf8
if ({{ '$true' if (aw_windows_fail_on_validation_error | bool) else '$false' }} -and -not [bool]$report.overallOk) {
throw "Проверка развёртывания ActivityWatch завершилась ошибкой. Отчёт: {{ aw_windows_validation_remote_path }}"
}
$result | ConvertTo-Json -Depth 8 | Out-File -FilePath "{{ aw_windows_validation_remote_path }}" -Encoding utf8
return $result
- name: Создать локальный каталог для validation reports
- name: Создать локальную директорию для отчётов валидации
ansible.builtin.file:
path: "{{ aw_windows_validation_local_dir }}"
state: directory
mode: "0755"
delegate_to: localhost
- name: Забрать validation report
- name: Стянуть отчёт валидации с эндпоинта
ansible.builtin.fetch:
src: "{{ aw_windows_validation_remote_path }}"
dest: "{{ aw_windows_validation_local_dir }}/{{ inventory_hostname }}-aw_validate_ansible.json"
flat: true
- name: Показать путь к отчёту
ansible.builtin.debug:
msg:
- "Windows/RDP развёртывание завершено на {{ inventory_hostname }}."
- "Отчёт проверки: {{ aw_windows_validation_local_dir }}/{{ inventory_hostname }}-aw_validate_ansible.json"
- name: Проверить статус валидации
ansible.builtin.shell: |
python3 - <<'PY'
import json, sys
with open('{{ aw_windows_validation_local_dir }}/{{ inventory_hostname }}-aw_validate_ansible.json', 'r') 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
+14 -3
View File
@@ -4,15 +4,24 @@ 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 }}"
# Опционально: применить базовые категории и views для рабочего времени через AW settings API.
# Внимание: это перезаписывает существующие server-side settings/classes/views.
aw_apply_worktime_settings: false
# Применить базовые категории и views для рабочего времени через AW settings API.
# При прод-обновлениях это нужно оставлять включённым, иначе UI остаётся без views/classes.
aw_apply_worktime_settings: true
# Дополнительные origin для aw-server-rust CORS.
# Обязательно включите тот origin, с которого реально открывается Web UI.
aw_server_cors_origins:
- "http://127.0.0.1:5600"
- "http://localhost:5600"
- "http://10.10.10.13:5600"
- "http://aw-server:5600"
# Опциональные значения периода рабочего времени в Web UI.
# startOfDay задаёт границу дня и стартовое время окна отчёта.
@@ -22,3 +31,5 @@ aw_apply_worktime_settings: false
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/"
+27
View File
@@ -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/"
+9
View File
@@ -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) }}"
+53
View File
@@ -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
+1
View File
@@ -23,6 +23,7 @@ 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
+14
View File
@@ -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
+90
View File
@@ -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"
+2 -2
View File
@@ -9,7 +9,7 @@ EnvironmentFile=/etc/activitywatch/aw-server.env
User=__AW_SERVER_USER__
Group=__AW_SERVER_GROUP__
WorkingDirectory=__AW_SERVER_DATA_DIR__
ExecStart=/bin/sh -lc 'exec /opt/activitywatch/bin/aw-server-rust --host "$AW_SERVER_BIND_HOST" --port "$AW_SERVER_PORT"'
ExecStart=/bin/sh -lc 'exec /opt/activitywatch/bin/aw-server-rust --host "$AW_SERVER_BIND_HOST" --port "$AW_SERVER_PORT" --dbpath "$AW_SERVER_DB_PATH" --webpath "$AW_SERVER_WEBUI_DIR"'
Restart=on-failure
RestartSec=5s
StateDirectory=activitywatch
@@ -17,7 +17,7 @@ LogsDirectory=activitywatch
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ProtectHome=true
ProtectHome=read-only
LimitNOFILE=65535
[Install]
+103
View File
@@ -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;
```
+2 -2
View File
@@ -29,8 +29,8 @@
- USB/print/clipboard collectors (endpoint signals) — внедрено.
- Incident pipeline расширен на endpoint события — внедрено.
- File-operation telemetry (create/copy/archive/upload hints) — в backlog.
- Central incident aggregation/export — в backlog.
- File-operation telemetry (create/delete/rename/archive hints) — прототип внедрён (`windows/file-operations-collector.ps1`).
- Central incident aggregation/export — прототип внедрён (`scripts/aggregate_dlp_events.py`, `docs/dlp-aggregator.md`).
### Phase 3
+517
View File
@@ -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)
+139
View File
@@ -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())
+71
View File
@@ -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
+80
View File
@@ -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}"
+67 -3
View File
@@ -49,7 +49,9 @@ function Get-ActivityWatchArchive {
}
[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
return $archivePath
}
@@ -85,6 +87,16 @@ function Install-ActivityWatchPackage {
New-ActivityWatchDirectory -Path $WorkingRoot
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)
if (Test-Path -LiteralPath $extractRoot) {
Remove-Item -LiteralPath $extractRoot -Recurse -Force
@@ -243,6 +255,8 @@ function Copy-ActivityWatchCollectorAssets {
[Parameter(Mandatory = $true)]
[string]$EndpointCollectorScriptSource,
[Parameter(Mandatory = $true)]
[string]$FileCollectorScriptSource,
[Parameter(Mandatory = $true)]
[string]$SessionCollectorScriptSource,
[Parameter(Mandatory = $true)]
[string]$ExampleRulesSource,
@@ -258,6 +272,7 @@ function Copy-ActivityWatchCollectorAssets {
$collectorTarget = Join-Path $StateRoot 'browser-domains-native-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'
$exampleRulesTarget = Join-Path $StateRoot 'web-category-rules.example.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 $EndpointCollectorScriptSource -Destination $endpointCollectorTarget -Force
Copy-Item -LiteralPath $FileCollectorScriptSource -Destination $fileCollectorTarget -Force
Copy-Item -LiteralPath $SessionCollectorScriptSource -Destination $sessionCollectorTarget -Force
Copy-Item -LiteralPath $ExampleRulesSource -Destination $exampleRulesTarget -Force
Copy-Item -LiteralPath $ExamplePolicySource -Destination $examplePolicyTarget -Force
@@ -286,6 +302,7 @@ function Copy-ActivityWatchCollectorAssets {
return [pscustomobject]@{
CollectorScript = $collectorTarget
EndpointCollectorScript = $endpointCollectorTarget
FileCollectorScript = $fileCollectorTarget
SessionCollectorScript = $sessionCollectorTarget
ExampleRules = $exampleRulesTarget
ActiveRules = $rulesTarget
@@ -313,6 +330,8 @@ function New-ActivityWatchDeploymentConfig {
[Parameter(Mandatory = $true)]
[string]$EndpointCollectorScript,
[Parameter(Mandatory = $true)]
[string]$FileCollectorScript,
[Parameter(Mandatory = $true)]
[string]$SessionCollectorScript,
[Parameter(Mandatory = $true)]
[string]$RulesPath,
@@ -326,6 +345,7 @@ function New-ActivityWatchDeploymentConfig {
[int]$RecoveryIntervalSeconds,
[bool]$AfkEnabled = $true,
[bool]$WindowEnabled = $true,
[bool]$FileOpsEnabled = $true,
[bool]$LocalAgentLogsEnabled = $true,
[bool]$IncidentCaptureEnabled = $true,
[bool]$IncidentScreenshotEnabled = $true,
@@ -356,6 +376,7 @@ function New-ActivityWatchDeploymentConfig {
logsRoot = $LogsRoot
collectorScript = $CollectorScript
endpointCollectorScript = $EndpointCollectorScript
fileCollectorScript = $FileCollectorScript
sessionCollectorScript = $SessionCollectorScript
rulesPath = $RulesPath
policyPath = $PolicyPath
@@ -369,6 +390,7 @@ function New-ActivityWatchDeploymentConfig {
collectors = [pscustomobject]@{
afkEnabled = $AfkEnabled
windowEnabled = $WindowEnabled
fileOpsEnabled = $FileOpsEnabled
}
logging = [pscustomobject]@{
localAgentLogsEnabled = $LocalAgentLogsEnabled
@@ -614,6 +636,10 @@ function Start-CollectorScriptIfNeeded {
[int]`$SessionId
)
if ([string]::IsNullOrWhiteSpace(`$ScriptPath)) {
return
}
if (-not (Test-Path -LiteralPath `$ScriptPath)) {
return
}
@@ -634,18 +660,21 @@ function Start-CollectorScriptIfNeeded {
`$config = Get-DeploymentConfig -Path `$ConfigPath
`$sessionId = (Get-Process -Id `$PID).SessionId
`$installRoot = [string]`$config.paths.installRoot
`$stateRoot = [string]`$config.paths.stateRoot
`$script:ApiBase = '{0}://{1}:{2}/api/0' -f [string]`$config.server.scheme, [string]`$config.server.host, [string]`$config.server.port
`$script:Hostname = `$env:COMPUTERNAME
`$script:KnownBuckets = @{}
`$collectorScript = [string]`$config.paths.collectorScript
`$endpointCollectorScript = if (`$config.paths.PSObject.Properties.Name -contains 'endpointCollectorScript') { [string]`$config.paths.endpointCollectorScript } else { '' }
`$sessionCollectorScript = if (`$config.paths.PSObject.Properties.Name -contains 'sessionCollectorScript') { [string]`$config.paths.sessionCollectorScript } else { '' }
`$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' }
`$afkExe = Join-Path `$installRoot 'aw-watcher-afk\aw-watcher-afk.exe'
`$windowExe = Join-Path `$installRoot 'aw-watcher-window\aw-watcher-window.exe'
`$serverArgs = @('--host', [string]`$config.server.host, '--port', [string]`$config.server.port)
`$powershellExe = Join-Path `$env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'
`$afkEnabled = if (`$config.PSObject.Properties.Name -contains 'collectors' -and `$config.collectors.PSObject.Properties.Name -contains 'afkEnabled') { [bool]`$config.collectors.afkEnabled } else { `$true }
`$windowEnabled = if (`$config.PSObject.Properties.Name -contains 'collectors' -and `$config.collectors.PSObject.Properties.Name -contains 'windowEnabled') { [bool]`$config.collectors.windowEnabled } else { `$true }
`$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)) {
throw "Не найден aw-watcher-afk.exe: `$afkExe"
@@ -670,6 +699,9 @@ catch {
}
Start-CollectorScriptIfNeeded -ScriptPath `$collectorScript -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
"@
@@ -891,6 +923,37 @@ function Get-ActivityWatchScheduledTaskByCommand {
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 {
param(
[Parameter(Mandatory = $true)]
@@ -904,6 +967,7 @@ function Register-ActivityWatchUserTasks {
$wscriptExe = Join-Path $env:SystemRoot 'System32\wscript.exe'
$launcherPath = Get-ActivityWatchHiddenLauncherPath -ScriptPath $LaunchScriptPath
Write-ActivityWatchHiddenPowerShellWrapper -Path $launcherPath -ScriptPath $LaunchScriptPath -ConfigPath $ConfigPath
Remove-StaleActivityWatchUserTasks -TaskDefinitions $TaskDefinitions -LaunchScriptPath $LaunchScriptPath
foreach ($definition in $TaskDefinitions) {
$action = New-ScheduledTaskAction -Execute $wscriptExe -Argument "//B //NoLogo `"$launcherPath`""
+5
View File
@@ -18,6 +18,7 @@ param(
[int]$RecoveryIntervalSeconds = 180,
[bool]$AfkEnabled = $true,
[bool]$WindowEnabled = $true,
[bool]$FileOpsEnabled = $true,
[bool]$LocalAgentLogsEnabled = $false,
[bool]$IncidentCaptureEnabled = $true,
[bool]$IncidentScreenshotEnabled = $true,
@@ -44,6 +45,7 @@ $launchScriptPath = Join-Path $StateRoot 'launch-watchers.ps1'
$recoveryScriptPath = Join-Path $StateRoot 'recovery-loop.ps1'
$collectorSource = Join-Path $PSScriptRoot 'browser-domains-native-collector.ps1'
$endpointCollectorSource = Join-Path $PSScriptRoot 'dlp-endpoint-signals-collector.ps1'
$fileCollectorSource = Join-Path $PSScriptRoot 'file-operations-collector.ps1'
$sessionCollectorSource = Join-Path $PSScriptRoot 'worktime-session-collector.ps1'
$exampleRulesSource = Join-Path $PSScriptRoot 'web-category-rules.example.json'
$examplePolicySource = Join-Path $PSScriptRoot 'dlp-policy.example.json'
@@ -58,6 +60,7 @@ Get-ActivityWatchExecutableMap -InstallRoot $InstallRoot | Out-Null
$assetResult = Copy-ActivityWatchCollectorAssets `
-CollectorScriptSource $collectorSource `
-EndpointCollectorScriptSource $endpointCollectorSource `
-FileCollectorScriptSource $fileCollectorSource `
-SessionCollectorScriptSource $sessionCollectorSource `
-ExampleRulesSource $exampleRulesSource `
-ExamplePolicySource $examplePolicySource `
@@ -78,6 +81,7 @@ $config = New-ActivityWatchDeploymentConfig `
-LogsRoot $logsRoot `
-CollectorScript $assetResult.CollectorScript `
-EndpointCollectorScript $assetResult.EndpointCollectorScript `
-FileCollectorScript $assetResult.FileCollectorScript `
-SessionCollectorScript $assetResult.SessionCollectorScript `
-RulesPath $assetResult.ActiveRules `
-PolicyPath $assetResult.ActivePolicy `
@@ -86,6 +90,7 @@ $config = New-ActivityWatchDeploymentConfig `
-RecoveryIntervalSeconds $RecoveryIntervalSeconds `
-AfkEnabled $AfkEnabled `
-WindowEnabled $WindowEnabled `
-FileOpsEnabled $FileOpsEnabled `
-LocalAgentLogsEnabled $LocalAgentLogsEnabled `
-IncidentCaptureEnabled $IncidentCaptureEnabled `
-IncidentScreenshotEnabled $IncidentScreenshotEnabled `
+4
View File
@@ -18,6 +18,7 @@ param(
[int]$RecoveryIntervalSeconds = 180,
[bool]$AfkEnabled = $true,
[bool]$WindowEnabled = $true,
[bool]$FileOpsEnabled = $true,
[bool]$LocalAgentLogsEnabled = $false,
[bool]$IncidentCaptureEnabled = $true,
[bool]$IncidentScreenshotEnabled = $true,
@@ -64,6 +65,7 @@ if (-not (Test-Path -LiteralPath $deployScript)) {
-RecoveryIntervalSeconds $RecoveryIntervalSeconds `
-AfkEnabled $AfkEnabled `
-WindowEnabled $WindowEnabled `
-FileOpsEnabled $FileOpsEnabled `
-LocalAgentLogsEnabled $LocalAgentLogsEnabled `
-IncidentCaptureEnabled $IncidentCaptureEnabled `
-IncidentScreenshotEnabled $IncidentScreenshotEnabled `
@@ -86,6 +88,7 @@ if (-not $SkipHardening) {
-RecoveryIntervalSeconds $RecoveryIntervalSeconds `
-AfkEnabled $AfkEnabled `
-WindowEnabled $WindowEnabled `
-FileOpsEnabled $FileOpsEnabled `
-LocalAgentLogsEnabled $LocalAgentLogsEnabled `
-IncidentCaptureEnabled $IncidentCaptureEnabled `
-IncidentScreenshotEnabled $IncidentScreenshotEnabled `
@@ -112,6 +115,7 @@ $report = [ordered]@{
collectors = [ordered]@{
afkEnabled = $AfkEnabled
windowEnabled = $WindowEnabled
fileOpsEnabled = $FileOpsEnabled
}
hardeningApplied = (-not $SkipHardening)
}
+182
View File
@@ -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()
}
}
+7
View File
@@ -15,6 +15,7 @@ param(
[int]$RecoveryIntervalSeconds,
[bool]$AfkEnabled,
[bool]$WindowEnabled,
[bool]$FileOpsEnabled,
[bool]$LocalAgentLogsEnabled,
[bool]$IncidentCaptureEnabled,
[bool]$IncidentScreenshotEnabled,
@@ -53,6 +54,8 @@ $effectiveLaunchScript = Join-Path $effectiveStateRoot 'launch-watchers.ps1'
$effectiveRecoveryScript = Join-Path $effectiveStateRoot 'recovery-loop.ps1'
$effectiveCollector = Join-Path $effectiveStateRoot 'browser-domains-native-collector.ps1'
$effectiveEndpointCollector = if ($existingConfig -and $existingConfig.paths.PSObject.Properties.Name -contains 'endpointCollectorScript') { [string]$existingConfig.paths.endpointCollectorScript } else { Join-Path $effectiveStateRoot 'dlp-endpoint-signals-collector.ps1' }
$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'
$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 }
$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 }
$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 }
$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 }
@@ -96,6 +100,7 @@ Get-ActivityWatchExecutableMap -InstallRoot $effectiveInstallRoot | Out-Null
$assetResult = Copy-ActivityWatchCollectorAssets `
-CollectorScriptSource (Join-Path $PSScriptRoot 'browser-domains-native-collector.ps1') `
-EndpointCollectorScriptSource (Join-Path $PSScriptRoot 'dlp-endpoint-signals-collector.ps1') `
-FileCollectorScriptSource (Join-Path $PSScriptRoot 'file-operations-collector.ps1') `
-SessionCollectorScriptSource (Join-Path $PSScriptRoot 'worktime-session-collector.ps1') `
-ExampleRulesSource (Join-Path $PSScriptRoot 'web-category-rules.example.json') `
-ExamplePolicySource (Join-Path $PSScriptRoot 'dlp-policy.example.json') `
@@ -116,6 +121,7 @@ $config = New-ActivityWatchDeploymentConfig `
-LogsRoot $effectiveLogsRoot `
-CollectorScript $effectiveCollector `
-EndpointCollectorScript $effectiveEndpointCollector `
-FileCollectorScript $effectiveFileCollector `
-SessionCollectorScript $effectiveSessionCollector `
-RulesPath $effectiveRules `
-PolicyPath $effectivePolicy `
@@ -124,6 +130,7 @@ $config = New-ActivityWatchDeploymentConfig `
-RecoveryIntervalSeconds $effectiveRecoveryInterval `
-AfkEnabled $effectiveAfkEnabled `
-WindowEnabled $effectiveWindowEnabled `
-FileOpsEnabled $effectiveFileOpsEnabled `
-LocalAgentLogsEnabled $effectiveLocalAgentLogsEnabled `
-IncidentCaptureEnabled $effectiveIncidentCaptureEnabled `
-IncidentScreenshotEnabled $effectiveIncidentScreenshotEnabled `
+30 -1
View File
@@ -154,7 +154,36 @@ if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Миграция ActivityWatch W
@{ Source = $NewStateRoot; Name = 'new-state' }
)) {
if (Test-Path -LiteralPath $item.Source) {
Copy-Item -LiteralPath $item.Source -Destination (Join-Path $backupRoot $item.Name) -Recurse -Force
$backupDest = Join-Path $backupRoot $item.Name
New-ActivityWatchDirectory -Path $backupDest
$excludeDirs = @()
if ($item.Source -eq $NewStateRoot) {
# Avoid infinite recursion: backupRoot is inside NewStateRoot by default.
$excludeDirs += $backupRoot
}
$robocopyArgs = @(
$item.Source,
$backupDest,
'/E',
'/R:1',
'/W:1',
'/NFL',
'/NDL',
'/NJH',
'/NJS',
'/NP'
)
if ($excludeDirs.Count -gt 0) {
$robocopyArgs += '/XD'
$robocopyArgs += $excludeDirs
}
& robocopy @robocopyArgs | Out-Null
if ($LASTEXITCODE -ge 8) {
throw "Backup robocopy failed (exit=$LASTEXITCODE) for source '$($item.Source)' to '$backupDest'"
}
}
}
+32 -22
View File
@@ -14,6 +14,7 @@ $installRoot = [string]$config.paths.installRoot
$stateRoot = [string]$config.paths.stateRoot
$collectorScript = [string]$config.paths.collectorScript
$endpointCollectorScript = if ($config.paths.PSObject.Properties.Name -contains 'endpointCollectorScript') { [string]$config.paths.endpointCollectorScript } else { Join-Path $stateRoot 'dlp-endpoint-signals-collector.ps1' }
$fileCollectorScript = if ($config.paths.PSObject.Properties.Name -contains 'fileCollectorScript') { [string]$config.paths.fileCollectorScript } else { Join-Path $stateRoot 'file-operations-collector.ps1' }
$sessionCollectorScript = if ($config.paths.PSObject.Properties.Name -contains 'sessionCollectorScript') { [string]$config.paths.sessionCollectorScript } else { Join-Path $stateRoot 'worktime-session-collector.ps1' }
$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' }
@@ -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 }
$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 = @(
$collectorScript,
$endpointCollectorScript,
@@ -32,6 +34,9 @@ $requiredFiles = @(
$recoveryScript,
$ConfigPath
)
if ($fileOpsExpected) {
$requiredFiles += $fileCollectorScript
}
if ($afkExpected) {
$requiredFiles += (Join-Path $installRoot 'aw-watcher-afk\aw-watcher-afk.exe')
}
@@ -50,12 +55,14 @@ $runningProcesses = @()
if ($processNames.Count -gt 0) {
$runningProcesses = Get-Process -Name $processNames -ErrorAction SilentlyContinue | Select-Object Name, Id, SessionId
}
$sessionCollectorProcesses = Get-CimInstance Win32_Process -ErrorAction SilentlyContinue |
Where-Object {
($_.Name -ieq 'powershell.exe' -or $_.Name -ieq 'pwsh.exe') -and
$_.CommandLine -match [Regex]::Escape($sessionCollectorScript)
} |
Select-Object Name, ProcessId, SessionId, CommandLine
$sessionCollectorProcesses = @(
Get-CimInstance Win32_Process -ErrorAction SilentlyContinue |
Where-Object {
($_.Name -ieq 'powershell.exe' -or $_.Name -ieq 'pwsh.exe') -and
$_.CommandLine -match [Regex]::Escape($sessionCollectorScript)
} |
Select-Object Name, ProcessId, SessionId, CommandLine
)
$taskNames = @()
if ($config.userTasks) {
@@ -64,25 +71,28 @@ if ($config.userTasks) {
$taskNames += [string]$config.recovery.taskName
$taskNames = $taskNames | Sort-Object -Unique
$tasks = foreach ($taskName in $taskNames) {
$task = Get-ScheduledTask -ErrorAction SilentlyContinue | Where-Object { $_.TaskName -eq $taskName } | Select-Object -First 1
if ($task) {
[pscustomobject]@{
taskName = $task.TaskName
state = [string]$task.State
present = $true
$tasks = @(
foreach ($taskName in $taskNames) {
$task = Get-ScheduledTask -ErrorAction SilentlyContinue | Where-Object { $_.TaskName -eq $taskName } | Select-Object -First 1
if ($task) {
[pscustomobject]@{
taskName = $task.TaskName
state = [string]$task.State
present = $true
}
}
else {
[pscustomobject]@{
taskName = $taskName
state = 'Отсутствует'
present = $false
}
}
}
else {
[pscustomobject]@{
taskName = $taskName
state = 'Отсутствует'
present = $false
}
}
}
)
$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]@{
generatedAtUtc = (Get-Date).ToUniversalTime().ToString('o')
configPath = $ConfigPath
@@ -105,7 +115,7 @@ $result = [ordered]@{
ok = [bool](
(
($processNames.Count -eq 0) -or
(($runningProcesses | Select-Object -ExpandProperty Name -Unique).Count -ge $processNames.Count)
($uniqueRunningProcessNames.Count -ge $processNames.Count)
) -and
($sessionCollectorProcesses.Count -ge 1)
)