Compare commits

...
Author SHA1 Message Date
IgorRachkovandGitHub 9a8f01049e Merge pull request #10 from igor04091968/devin/1777752962-file-collector-bucket
Ensure file operations bucket is created on collector startup
2026-05-07 07:35:34 +03:00
igor04091968 6578f6341f fix: add UTF-8 BOM for PS 5.1 2026-05-04 00:22:15 +03:00
igor04091968 cc33ffa3dc fix: add UTF-8 BOM for PowerShell 5.1 compatibility 2026-05-04 00:14:32 +03:00
igor04091968 ff548a5840 Merge PR #14: feat(dlp) enforcement + email outbound collector 2026-05-03 23:41:00 +03:00
Devin AIandFashion Lisa f916764d53 feat(dlp): add email outbound collector — Outlook COM + SMTP monitor
Two collection modes:
- outlook: polls Sent Items via COM, extracts metadata (subject hash,
  recipients hash, attachment names, body length)
- smtp: monitors SMTP connections (25/587/465/2525) via Get-NetTCPConnection

DLP policy rules: endpoint.email[] with regex matching on subject,
recipients, sender, attachments, externalOnly flag.

Enforcement: action=block moves mail to Drafts (Outlook mode).
Privacy: subject/recipients stored as SHA256, body never read.
Co-Authored-By: Fashion Lisa <igor04091968@gmail.com>
2026-05-03 20:30:18 +00:00
Devin AIandFashion Lisa 2bab84f9f9 feat(dlp): add enforcement — USB write-block, print cancel, clipboard clear
Phase 2.5: when DLP policy rule has action="block", the collector
now actively prevents the action instead of just logging:

- USB: Set-Disk -IsReadOnly via Get-Partition/Get-Disk pipeline
- Print: Remove-CimInstance Win32_PrintJob for matching jobs
- Clipboard: Set-Clipboard -Value $null to clear sensitive content

Each enforcement adds enforced=true/false to incident telemetry.
Windows balloon notification shown to user on every block action.
Backward-compatible: existing action="alert" rules unchanged.

Co-Authored-By: Fashion Lisa <igor04091968@gmail.com>
2026-05-03 20:21:39 +00:00
igor04091968 3e565b7a2e fix: create /root/bootstrap directory before copying files
Add ansible.builtin.file task to ensure /root/bootstrap exists
before copying RU patch files to it (prevents first-deploy failure)
2026-05-03 23:08:48 +03:00
igor04091968 3fa15f826d fix: env file before hotfixes + improved error handling
- Move env file creation before apply_webui_ru_patch.sh execution
- Replace ignore_errors with failed_when: false + register + debug output
- Provides visible feedback on hotfix script execution result
2026-05-03 22:56:31 +03:00
igor04091968 08ba731345 fix: apply WebUI hotfixes via apply_webui_ru_patch.sh + filter undefined hostname
- Add CATEGORY_HELPER filter for 'undefined' in addition to 'unknown'
- Add copy of apply_webui_ru_patch.sh to /opt/activitywatch/aw-server/
- Add task to run apply_webui_ru_patch.sh for Trends/Timespiral/Category helper hotfixes
- Fix in both deploy_aw_server.yml (ansible and install-kit)
2026-05-03 22:52:39 +03:00
igor04091968 df497839f6 Merge remote-tracking branch 'origin/main' into devin/1777752962-file-collector-bucket 2026-05-03 22:28:37 +03:00
IgorRachkovGitHubDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
36e4255ad9 fix: handle undefined bucket filters in AQL query rewriter (#12)
The network patch that intercepts /api/0/query/ requests only handled
'unknown' hostnames in bucket IDs. When the WebUI activity store has
uninitialized bucket IDs (e.g. browser watcher not installed on a host),
find_bucket("undefined") or query_bucket("undefined") calls reach the
server and fail with BucketQueryError.

Extend rewriteUnknownCategoryBuilderQueryBody to:
- Replace query_bucket(find_bucket("undefined")) and flood() wrappers
  with empty arrays ([]) so the query continues without missing data.
- Rewrite aw-watcher-{window,afk}_undefined to the preferred host,
  matching the existing 'unknown' hostname logic.

Applied to both aw-server/ and install-kit copies of aw-ru-patch.js.

Fixes: BucketЗапросError on Trends page for host SHARKON2025

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-05-03 22:27:50 +03:00
igor04091968 3971c459ef Feat: implement automated DLP incident aggregation on server (timer + service) 2026-05-03 01:50:17 +03:00
Devin AI b7a7ac42e4 Improve print DLP telemetry reliability 2026-05-02 22:45:48 +00: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
37 changed files with 3921 additions and 513 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/`.
+478 -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,328 @@
- Перезагрузить 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: Создать каталог /root/bootstrap для apply_webui_ru_patch.sh
ansible.builtin.file:
path: /root/bootstrap
state: directory
mode: "0755"
- 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 файлы для apply_webui_ru_patch.sh (хотфиксы compiled JS чанков)
ansible.builtin.copy:
src: "{{ item.src }}"
dest: "{{ item.dest }}"
mode: "{{ item.mode }}"
loop:
- { src: "{{ aw_repo_root }}/aw-server/aw-ru-patch.js", dest: "/root/bootstrap/aw-ru-patch.js", mode: "0644" }
- { src: "{{ aw_repo_root }}/aw-server/aw-sw-cleanup.js", dest: "/root/bootstrap/aw-sw-cleanup.js", mode: "0644" }
- { src: "{{ aw_repo_root }}/aw-server/aw-host-groups.json", dest: "/root/bootstrap/aw-host-groups.json", mode: "0644" }
- 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: Скопировать apply_webui_ru_patch.sh скрипт
ansible.builtin.copy:
src: "{{ aw_repo_root }}/aw-server/apply_webui_ru_patch.sh"
dest: /opt/activitywatch/aw-server/apply_webui_ru_patch.sh
mode: "0755"
- 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: Записать /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: Записать /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: Применить хотфиксы compiled JS чанков (Trends, Timespiral, Category helper)
ansible.builtin.command:
cmd: "/opt/activitywatch/aw-server/apply_webui_ru_patch.sh"
register: apply_ru_patch_result
failed_when: false
- name: Включить и запустить сервис
ansible.builtin.systemd:
name: activitywatch-server.service
enabled: true
state: restarted
daemon_reload: true
- name: Вывести результат применения хотфиксов
ansible.builtin.debug:
msg: "apply_webui_ru_patch.sh: {{ apply_ru_patch_result.stdout }}"
- name: Дождаться ответа API
- name: Проверить наличие index.html после копирования
ansible.builtin.stat:
path: "{{ aw_server_webui_dir }}/index.html"
register: aw_webui_ru_index
- name: Проверить, что index.html доступен для RU patch
ansible.builtin.assert:
that:
- aw_webui_ru_index.stat.exists
fail_msg: "Не найден index.html WebUI для применения RU patch."
- 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: Добавить 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 перед закрытием 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: Скопировать 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 +579,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 +612,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]
+1 -1
View File
@@ -24,7 +24,7 @@ TRENDS_REPLACEMENT='this.activityStore.ensure_loaded(r)'
TIMESPIRAL_NEEDLE='start:new Date("2022-08-08")'
TIMESPIRAL_REPLACEMENT='start:new Date(Date.now()-12*36e5)'
CATEGORY_HELPER_NEEDLE='hostname:t.hostnameChoices[0]'
CATEGORY_HELPER_REPLACEMENT='hostname:t.hostnameChoices.filter((function(t){return"unknown"!==t}))[0]||t.hostnameChoices[0]'
CATEGORY_HELPER_REPLACEMENT='hostname:t.hostnameChoices.filter((function(t){return"unknown"!==t&&"undefined"!==t}))[0]||t.hostnameChoices[0]'
[[ -f "$PATCH_JS_SRC" ]] || { echo "missing $PATCH_JS_SRC" >&2; exit 1; }
[[ -f "$SW_CLEANUP_SRC" ]] || { echo "missing $SW_CLEANUP_SRC" >&2; exit 1; }
+21 -7
View File
@@ -1541,14 +1541,28 @@
function rewriteUnknownCategoryBuilderQueryBody(body) {
if (typeof body !== "string") return body;
if (body.indexOf("aw-watcher-window_unknown") === -1 && body.indexOf("aw-watcher-afk_unknown") === -1) {
return body;
if (body.indexOf("undefined") !== -1) {
body = body
.replace(/flood\(query_bucket\(find_bucket\(\\"undefined\\"\)\)\)/g, '[]')
.replace(/query_bucket\(find_bucket\(\\"undefined\\"\)\)/g, '[]')
.replace(/flood\(query_bucket\(\\"undefined\\"\)\)/g, '[]')
.replace(/query_bucket\(\\"undefined\\"\)/g, '[]');
const ph = getPreferredWindowHostFromBuckets();
if (ph) {
body = body
.replace(/aw-watcher-window_undefined/g, "aw-watcher-window_" + ph)
.replace(/aw-watcher-afk_undefined/g, "aw-watcher-afk_" + ph);
}
}
const preferredHost = getPreferredWindowHostFromBuckets();
if (!preferredHost) return body;
return body
.replace(/aw-watcher-window_unknown/g, "aw-watcher-window_" + preferredHost)
.replace(/aw-watcher-afk_unknown/g, "aw-watcher-afk_" + preferredHost);
if (body.indexOf("aw-watcher-window_unknown") !== -1 || body.indexOf("aw-watcher-afk_unknown") !== -1) {
const preferredHost = getPreferredWindowHostFromBuckets();
if (preferredHost) {
body = body
.replace(/aw-watcher-window_unknown/g, "aw-watcher-window_" + preferredHost)
.replace(/aw-watcher-afk_unknown/g, "aw-watcher-afk_" + preferredHost);
}
}
return body;
}
function installCategoryBuilderNetworkPatch() {
+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;
```
+126
View File
@@ -0,0 +1,126 @@
# DLP Enforcement (action: "block")
## Обзор
Phase 2.5 расширяет DLP endpoint collector функциями **активного предотвращения** (enforcement).
При `action: "block"` в правиле DLP-политики коллектор не только регистрирует инцидент, но и выполняет блокирующее действие:
| Канал | Действие при `block` |
|-----------|-----------------------------------------------------------|
| clipboard | Очистка буфера обмена (`Set-Clipboard -Value $null`) |
| usb | Перевод USB-диска в read-only (`Set-Disk -IsReadOnly`) |
| print | Отмена задания печати (`Remove-CimInstance Win32_PrintJob`)|
Во всех случаях пользователь получает Windows-уведомление (balloon notification) с описанием причины блокировки.
## Конфигурация политики
Формат `dlp-policy.json` не изменился — поле `action` в правиле теперь поддерживает значение `"block"` наряду с `"alert"` (по умолчанию).
### Пример: блокировка USB записи
```json
{
"defaults": {
"enabled": true,
"action": "alert",
"severity": "medium",
"cooldownSeconds": 300
},
"endpoint": {
"usb": [
{
"id": "block-all-usb-write",
"action": "block",
"severity": "high",
"message": "Запись на USB-носитель заблокирована политикой DLP"
}
],
"clipboard": [
{
"id": "block-pdn-clipboard",
"action": "block",
"severity": "high",
"regexPatterns": [
"\\b\\d{3}-\\d{3}-\\d{3}\\s?\\d{2}\\b",
"\\b\\d{4}\\s?\\d{6}\\b"
],
"minLength": 8,
"message": "Буфер обмена очищен: обнаружены персональные данные (СНИЛС/паспорт)"
}
],
"print": [
{
"id": "block-confidential-print",
"action": "block",
"severity": "high",
"documentRegex": "(?i)(конфиденциально|секретно|confidential|restricted)",
"message": "Печать заблокирована: документ содержит метку конфиденциальности"
}
]
}
}
```
### Пример: только мониторинг (без блокировки)
```json
{
"endpoint": {
"usb": [
{
"id": "monitor-usb",
"action": "alert",
"severity": "medium",
"message": "Обнаружено подключение USB-носителя"
}
]
}
}
```
## Телеметрия
Каждый инцидент с enforcement записывается в bucket `aw-dlp-incidents_<host>` с дополнительным полем:
```json
{
"ruleId": "block-all-usb-write",
"action": "block",
"severity": "high",
"signalType": "usb_insert",
"enforced": true,
"driveLetter": "E:",
"volumeName": "FLASH_DRIVE"
}
```
- `enforced: true` — блокировка выполнена успешно
- `enforced: false` — блокировка не удалась (недостаточно прав, устройство недоступно и т.д.)
## Требования
- **Clipboard block**: Не требует повышенных прав.
- **USB write-block**: Требует запуск от имени администратора (для `Set-Disk -IsReadOnly`). При запуске без прав блокировка не сработает, но инцидент будет зарегистрирован с `enforced: false`.
- **Print block**: Требует права на отмену заданий печати (обычно — SYSTEM или администратор принт-сервера).
## Уведомления
При каждой блокировке пользователю показывается Windows balloon notification:
| Канал | Заголовок |
|-----------|--------------------------------------|
| clipboard | `DLP: буфер обмена очищен` |
| usb | `DLP: USB заблокирован для записи` |
| print | `DLP: печать заблокирована` |
Текст уведомления берётся из поля `message` правила политики.
## Rollback
Для отключения enforcement без изменения кода — смените `action` с `"block"` на `"alert"` в `dlp-policy.json`. Все правила продолжат мониторинг без блокировки.
Для USB, переведённого в read-only, восстановление:
```powershell
Get-Disk | Where-Object { $_.BusType -eq 'USB' -and $_.IsReadOnly } | Set-Disk -IsReadOnly $false
```
+20 -2
View File
@@ -29,8 +29,26 @@
- 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 2.5 — Enforcement (внедрено)
- USB write-block (`Set-Disk -IsReadOnly`) при `action: "block"` — внедрено.
- Print job cancel (`Remove-CimInstance Win32_PrintJob`) при `action: "block"` — внедрено.
- Clipboard clear (`Set-Clipboard -Value $null`) при `action: "block"` — внедрено.
- Windows balloon notification пользователю при блокировке — внедрено.
- Телеметрия enforcement (`enforced: true/false` в incident heartbeat) — внедрено.
- Документация: `docs/dlp-enforcement.md`.
### Phase 2.5 — Email Outbound Collector (внедрено)
- Мониторинг исходящей почты через Outlook COM (Sent Items polling) — внедрено.
- SMTP network connection detection (порты 25/587/465/2525) — внедрено.
- DLP-правила `endpoint.email[]` (regex по теме, получателям, вложениям, externalOnly) — внедрено.
- Enforcement: перемещение в Drafts при `action: "block"` (Outlook mode) — внедрено.
- Приватность: тема/получатели как SHA256, тело не читается — внедрено.
- Документация: `docs/email-outbound-collector.md`.
### Phase 3
+164
View File
@@ -0,0 +1,164 @@
# Email Outbound Collector
## Обзор
Мониторинг исходящей почты на Windows-эндпоинтах. Два режима работы:
| Режим | Источник | Данные |
|----------|--------------------------------|-------------------------------------------------------|
| outlook | Outlook COM (Sent Items) | Subject, From, To/CC, вложения, размер тела |
| smtp | `Get-NetTCPConnection` | SMTP-соединения (порты 25/587/465/2525), процесс |
По умолчанию `Mode = 'both'` — оба режима активны одновременно.
## Запуск
```powershell
# С deployment-config.json (штатный вариант)
.\email-outbound-collector.ps1
# С явными параметрами
.\email-outbound-collector.ps1 -ServerHost 10.10.10.13 -ServerPort 5600 -Mode outlook
# Только SMTP мониторинг (без Outlook)
.\email-outbound-collector.ps1 -ServerHost 10.10.10.13 -Mode smtp
```
### Параметры
| Параметр | По умолчанию | Описание |
|----------------|-----------------------------------------|---------------------------------|
| `-ConfigPath` | `C:\ProgramData\ActivityWatch\deployment-config.json` | Путь к конфигу |
| `-ServerHost` | из конфига | Адрес AW-сервера |
| `-ServerPort` | из конфига / 5600 | Порт AW-сервера |
| `-PolicyPath` | из конфига / `dlp-policy.json` | Путь к DLP-политике |
| `-Mode` | `both` | `outlook`, `smtp`, или `both` |
| `-PollSeconds` | из конфига / 10 | Интервал опроса |
## AW Buckets
- `aw-email-monitor_<host>` — все email-события (signal heartbeats)
- `aw-dlp-incidents_<host>` — инциденты при срабатывании DLP-правил
## DLP-политика: секция `endpoint.email`
Добавляется в существующий `dlp-policy.json`:
```json
{
"endpoint": {
"email": [
{
"id": "block-external-attachments",
"action": "block",
"severity": "high",
"minAttachments": 1,
"externalOnly": true,
"internalDomain": "@company.ru",
"message": "Запрещена отправка вложений на внешние адреса"
},
{
"id": "alert-confidential-subject",
"action": "alert",
"severity": "medium",
"subjectRegex": "(?i)(конфиденциально|секретно|для служебного пользования)",
"message": "Обнаружена отправка письма с пометкой конфиденциальности"
},
{
"id": "alert-personal-data",
"action": "alert",
"severity": "high",
"recipientRegex": "(?i)(gmail\\.com|mail\\.ru|yandex\\.ru|yahoo\\.com)",
"minAttachments": 1,
"message": "Отправка вложений на личную почту"
}
]
}
}
```
### Параметры правил
| Поле | Тип | Описание |
|-------------------|--------|-----------------------------------------------------------|
| `id` | string | Уникальный ID правила (обязательно) |
| `action` | string | `alert` (по умолчанию) или `block` |
| `severity` | string | `low`, `medium`, `high`, `critical` |
| `subjectRegex` | string | Regex по теме письма |
| `recipientRegex` | string | Regex по списку получателей |
| `senderRegex` | string | Regex по адресу отправителя |
| `attachmentRegex` | string | Regex по именам вложений |
| `minAttachments` | int | Минимальное количество вложений для срабатывания |
| `minBodyLength` | int | Минимальная длина тела письма |
| `externalOnly` | bool | Срабатывать только на внешних получателей |
| `internalDomain` | string | Домен организации (используется с `externalOnly`) |
| `cooldownSeconds` | int | Cooldown между повторными инцидентами |
| `message` | string | Текст уведомления пользователю и в инцидент |
## Enforcement (action: "block")
**Outlook mode**: письмо перемещается из Sent Items в Drafts. Пользователь получает balloon notification.
**SMTP mode**: только уведомление (перехват SMTP-соединения на сетевом уровне не реализуем из PowerShell). Инцидент записывается с `enforced: false`.
## Телеметрия
### Heartbeat `email_sent` (Outlook mode)
```json
{
"signalType": "email_sent",
"subject": "<sha256 hash>",
"sender": "user@company.ru",
"recipientCount": 3,
"recipients": "<sha256 hash>",
"attachmentCount": 2,
"attachmentNames": "report.xlsx; data.csv",
"bodyLength": 1520,
"collectionMode": "outlook"
}
```
### Heartbeat `smtp_connection` (SMTP mode)
```json
{
"signalType": "smtp_connection",
"remoteAddress": "74.125.205.108",
"remotePort": 587,
"processId": 12340,
"processName": "OUTLOOK",
"collectionMode": "smtp"
}
```
### Incident
```json
{
"ruleId": "block-external-attachments",
"action": "block",
"severity": "high",
"signalType": "email_outbound",
"subject": "<sha256>",
"attachmentCount": 2,
"enforced": true
}
```
## Приватность
- Тема и получатели записываются как SHA256-хеш (не открытый текст).
- Тело письма не читается и не хранится — записывается только длина.
- Имена вложений записываются открытым текстом (для DLP-анализа).
## Интеграция в ensemble
Добавьте в `launch-watchers.ps1` или Task Scheduler:
```powershell
Start-Process powershell.exe -ArgumentList '-ExecutionPolicy Bypass -File "C:\ProgramData\ActivityWatch\email-outbound-collector.ps1"' -WindowStyle Hidden
```
## Требования
- **Outlook mode**: Microsoft Outlook установлен и настроен для текущего пользователя.
- **SMTP mode**: Не требует дополнительного ПО. Работает на уровне TCP-соединений.
- **Enforcement (block)**: Outlook mode — требует доступ к COM объекту Outlook.
@@ -196,6 +196,56 @@
- { 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: Создать каталог /root/bootstrap для apply_webui_ru_patch.sh
ansible.builtin.file:
path: /root/bootstrap
state: directory
mode: "0755"
- name: Скопировать RU patch файлы для apply_webui_ru_patch.sh (хотфиксы compiled JS чанков)
ansible.builtin.copy:
src: "{{ item.src }}"
dest: "{{ item.dest }}"
mode: "{{ item.mode }}"
loop:
- { src: "{{ aw_repo_root }}/aw-server/aw-ru-patch.js", dest: "/root/bootstrap/aw-ru-patch.js", mode: "0644" }
- { src: "{{ aw_repo_root }}/aw-server/aw-sw-cleanup.js", dest: "/root/bootstrap/aw-sw-cleanup.js", mode: "0644" }
- { src: "{{ aw_repo_root }}/aw-server/aw-host-groups.json", dest: "/root/bootstrap/aw-host-groups.json", mode: "0644" }
- name: Скопировать apply_webui_ru_patch.sh скрипт
ansible.builtin.copy:
src: "{{ aw_repo_root }}/aw-server/apply_webui_ru_patch.sh"
dest: /opt/activitywatch/aw-server/apply_webui_ru_patch.sh
mode: "0755"
- 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: Применить хотфиксы compiled JS чанков (Trends, Timespiral, Category helper)
ansible.builtin.command:
cmd: "/opt/activitywatch/aw-server/apply_webui_ru_patch.sh"
register: apply_ru_patch_result
failed_when: false
- name: Вывести результат применения хотфиксов
ansible.builtin.debug:
msg: "apply_webui_ru_patch.sh: {{ apply_ru_patch_result.stdout }}"
- name: Проверить наличие index.html после копирования
ansible.builtin.stat:
path: "{{ aw_server_webui_dir }}/index.html"
@@ -225,21 +275,6 @@
regexp: '</body>'
replace: '<script defer="defer" src="/js/ru-patch-v5.js?v={{ aw_ru_patch_cache_bust }}"></script></body>'
- 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: Включить и запустить сервис
ansible.builtin.systemd:
name: activitywatch-server.service
@@ -24,7 +24,7 @@ TRENDS_REPLACEMENT='this.activityStore.ensure_loaded(r)'
TIMESPIRAL_NEEDLE='start:new Date("2022-08-08")'
TIMESPIRAL_REPLACEMENT='start:new Date(Date.now()-12*36e5)'
CATEGORY_HELPER_NEEDLE='hostname:t.hostnameChoices[0]'
CATEGORY_HELPER_REPLACEMENT='hostname:t.hostnameChoices.filter((function(t){return"unknown"!==t}))[0]||t.hostnameChoices[0]'
CATEGORY_HELPER_REPLACEMENT='hostname:t.hostnameChoices.filter((function(t){return"unknown"!==t&&"undefined"!==t}))[0]||t.hostnameChoices[0]'
[[ -f "$PATCH_JS_SRC" ]] || { echo "missing $PATCH_JS_SRC" >&2; exit 1; }
[[ -f "$SW_CLEANUP_SRC" ]] || { echo "missing $SW_CLEANUP_SRC" >&2; exit 1; }
@@ -1500,14 +1500,28 @@
function rewriteUnknownCategoryBuilderQueryBody(body) {
if (typeof body !== "string") return body;
if (body.indexOf("aw-watcher-window_unknown") === -1 && body.indexOf("aw-watcher-afk_unknown") === -1) {
return body;
if (body.indexOf("undefined") !== -1) {
body = body
.replace(/flood\(query_bucket\(find_bucket\(\\"undefined\\"\)\)\)/g, '[]')
.replace(/query_bucket\(find_bucket\(\\"undefined\\"\)\)/g, '[]')
.replace(/flood\(query_bucket\(\\"undefined\\"\)\)/g, '[]')
.replace(/query_bucket\(\\"undefined\\"\)/g, '[]');
const ph = getPreferredWindowHostFromBuckets();
if (ph) {
body = body
.replace(/aw-watcher-window_undefined/g, "aw-watcher-window_" + ph)
.replace(/aw-watcher-afk_undefined/g, "aw-watcher-afk_" + ph);
}
}
const preferredHost = getPreferredWindowHostFromBuckets();
if (!preferredHost) return body;
return body
.replace(/aw-watcher-window_unknown/g, "aw-watcher-window_" + preferredHost)
.replace(/aw-watcher-afk_unknown/g, "aw-watcher-afk_" + preferredHost);
if (body.indexOf("aw-watcher-window_unknown") !== -1 || body.indexOf("aw-watcher-afk_unknown") !== -1) {
const preferredHost = getPreferredWindowHostFromBuckets();
if (preferredHost) {
body = body
.replace(/aw-watcher-window_unknown/g, "aw-watcher-window_" + preferredHost)
.replace(/aw-watcher-afk_unknown/g, "aw-watcher-afk_" + preferredHost);
}
}
return body;
}
function installCategoryBuilderNetworkPatch() {
@@ -1,6 +1,6 @@
[CmdletBinding()]
param(
[string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json',
[string]$ConfigPath = 'C:\ProgramData\ActivityWatch\deployment-config.json',
[string]$ServerHost,
[int]$ServerPort,
[ValidateSet('http', 'https')]
@@ -81,7 +81,7 @@ function Send-EndpointSignalHeartbeat {
username = $env:USERNAME
sessionId = $script:SessionId
hostname = $script:Hostname
source = 'endpoint-signals-awatch-rus'
source = 'endpoint-signals-phase2'
} + $Data
} | ConvertTo-Json -Depth 6 -Compress
@@ -122,7 +122,7 @@ function Send-DlpIncidentHeartbeat {
username = $env:USERNAME
sessionId = $script:SessionId
hostname = $script:Hostname
source = 'endpoint-signals-awatch-rus'
source = 'endpoint-signals-phase2'
} + $Data + $captureData
} | ConvertTo-Json -Depth 7 -Compress
@@ -210,11 +210,104 @@ function Capture-IncidentScreenshot {
}
}
catch {
Write-EndpointLog ("не удалось сделать снимок инцидента: {0}" -f $_.Exception.Message)
Write-EndpointLog ("screenshot capture failed: {0}" -f $_.Exception.Message)
return @{}
}
}
# ---------------------------------------------------------------------------
# Enforcement functions (action = "block")
# ---------------------------------------------------------------------------
function Show-EnforcementNotification {
param(
[Parameter(Mandatory = $true)][string]$Title,
[Parameter(Mandatory = $true)][string]$Body
)
try {
Add-Type -AssemblyName System.Windows.Forms -ErrorAction SilentlyContinue
$icon = New-Object System.Windows.Forms.NotifyIcon
$icon.Icon = [System.Drawing.SystemIcons]::Warning
$icon.BalloonTipTitle = $Title
$icon.BalloonTipText = $Body
$icon.BalloonTipIcon = [System.Windows.Forms.ToolTipIcon]::Warning
$icon.Visible = $true
$icon.ShowBalloonTip(5000)
Start-Sleep -Milliseconds 200
$icon.Dispose()
}
catch {
Write-EndpointLog ("notification failed: {0}" -f $_.Exception.Message)
}
}
function Invoke-ClipboardEnforcement {
[OutputType([bool])]
param()
try {
Set-Clipboard -Value $null -ErrorAction Stop
Write-EndpointLog "enforcement: clipboard cleared"
return $true
}
catch {
Write-EndpointLog ("enforcement: clipboard clear failed: {0}" -f $_.Exception.Message)
return $false
}
}
function Invoke-UsbWriteBlockEnforcement {
[OutputType([bool])]
param(
[Parameter(Mandatory = $true)][string]$DriveLetter
)
try {
$partition = Get-Partition -DriveLetter ($DriveLetter.TrimEnd(':')) -ErrorAction Stop
$disk = Get-Disk -Number $partition.DiskNumber -ErrorAction Stop
if ($disk.BusType -ne 'USB') {
Write-EndpointLog ("enforcement: skip non-USB disk {0} bus={1}" -f $disk.Number, $disk.BusType)
return $false
}
if (-not $disk.IsReadOnly) {
Set-Disk -Number $disk.Number -IsReadOnly $true -ErrorAction Stop
Write-EndpointLog ("enforcement: USB disk {0} ({1}) set read-only" -f $disk.Number, $DriveLetter)
}
return $true
}
catch {
Write-EndpointLog ("enforcement: USB write-block failed drive={0}: {1}" -f $DriveLetter, $_.Exception.Message)
return $false
}
}
function Invoke-PrintJobEnforcement {
[OutputType([bool])]
param(
[Parameter(Mandatory = $true)][string]$PrinterName,
[string]$DocumentName,
[string]$Owner
)
$cancelled = $false
try {
$jobs = Get-CimInstance Win32_PrintJob -ErrorAction SilentlyContinue
foreach ($job in @($jobs)) {
$jobPrinter = [string]$job.Name
$jobOwner = [string]$job.Owner
$jobDoc = [string]$job.Document
$matchPrinter = ($jobPrinter -like "*$PrinterName*")
$matchOwner = (-not $Owner) -or ($jobOwner -like "*$Owner*") -or ($jobOwner -like "*$env:USERNAME*")
if ($matchPrinter -and $matchOwner) {
Remove-CimInstance -InputObject $job -ErrorAction Stop
Write-EndpointLog ("enforcement: print job cancelled id={0} printer={1} doc={2}" -f $job.JobId, $jobPrinter, $jobDoc)
$cancelled = $true
}
}
}
catch {
Write-EndpointLog ("enforcement: print cancel failed printer={0}: {1}" -f $PrinterName, $_.Exception.Message)
}
return $cancelled
}
function Get-StringHash {
param([AllowNull()][string]$Value)
if ($null -eq $Value) { return $null }
@@ -246,7 +339,7 @@ function Load-DlpPolicy {
}
if (-not $Path -or -not (Test-Path -LiteralPath $Path)) {
Write-EndpointLog ("DLP-политика не найдена, используются значения по умолчанию: {0}" -f $Path)
Write-EndpointLog ("policy not found, using defaults: {0}" -f $Path)
return
}
@@ -266,7 +359,7 @@ function Load-DlpPolicy {
}
}
catch {
Write-EndpointLog ("не удалось разобрать DLP-политику: {0}" -f $_.Exception.Message)
Write-EndpointLog ("policy parse failed: {0}" -f $_.Exception.Message)
}
}
@@ -319,13 +412,20 @@ function Evaluate-ClipboardRules {
$action = if ($rule.action) { [string]$rule.action } else { [string]$script:Policy.defaults.action }
$severity = if ($rule.severity) { [string]$rule.severity } else { [string]$script:Policy.defaults.severity }
$message = if ($rule.message) { [string]$rule.message } else { "Сработало правило буфера обмена: $ruleId" }
$message = if ($rule.message) { [string]$rule.message } else { "Clipboard rule matched: $ruleId" }
$enforced = $false
if ($action -eq 'block') {
$enforced = Invoke-ClipboardEnforcement
Show-EnforcementNotification -Title 'DLP: буфер обмена очищен' -Body $message
}
Send-DlpIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -SignalType 'clipboard' -Data @{
clipboardHash = $ClipboardHash
clipboardLength = $ClipboardText.Length
enforced = $enforced
}
Write-EndpointLog ("инцидент буфера обмена правило={0} действие={1} важность={2}" -f $ruleId, $action, $severity)
Write-EndpointLog ("incident clipboard rule={0} action={1} severity={2} enforced={3}" -f $ruleId, $action, $severity, $enforced)
}
}
@@ -347,13 +447,20 @@ function Evaluate-UsbRules {
$action = if ($rule.action) { [string]$rule.action } else { [string]$script:Policy.defaults.action }
$severity = if ($rule.severity) { [string]$rule.severity } else { [string]$script:Policy.defaults.severity }
$message = if ($rule.message) { [string]$rule.message } else { "Сработало правило USB-носителя: $ruleId" }
$message = if ($rule.message) { [string]$rule.message } else { "USB rule matched: $ruleId" }
$enforced = $false
if ($action -eq 'block') {
$enforced = Invoke-UsbWriteBlockEnforcement -DriveLetter $DriveLetter
Show-EnforcementNotification -Title 'DLP: USB заблокирован для записи' -Body $message
}
Send-DlpIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -SignalType 'usb_insert' -Data @{
driveLetter = $DriveLetter
volumeName = $VolumeName
enforced = $enforced
}
Write-EndpointLog ("инцидент USB правило={0} действие={1} важность={2} диск={3}" -f $ruleId, $action, $severity, $DriveLetter)
Write-EndpointLog ("incident usb rule={0} action={1} severity={2} drive={3} enforced={4}" -f $ruleId, $action, $severity, $DriveLetter, $enforced)
}
}
@@ -385,14 +492,21 @@ function Evaluate-PrintRules {
$action = if ($rule.action) { [string]$rule.action } else { [string]$script:Policy.defaults.action }
$severity = if ($rule.severity) { [string]$rule.severity } else { [string]$script:Policy.defaults.severity }
$message = if ($rule.message) { [string]$rule.message } else { "Сработало правило печати: $ruleId" }
$message = if ($rule.message) { [string]$rule.message } else { "Print rule matched: $ruleId" }
$enforced = $false
if ($action -eq 'block') {
$enforced = Invoke-PrintJobEnforcement -PrinterName $PrinterName -DocumentName $DocumentName -Owner $Owner
Show-EnforcementNotification -Title 'DLP: печать заблокирована' -Body $message
}
Send-DlpIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -SignalType 'print_job' -Data @{
printerName = $PrinterName
documentName = $DocumentName
owner = $Owner
enforced = $enforced
}
Write-EndpointLog ("инцидент печати правило={0} действие={1} важность={2} принтер={3}" -f $ruleId, $action, $severity, $PrinterName)
Write-EndpointLog ("incident print rule={0} action={1} severity={2} printer={3} enforced={4}" -f $ruleId, $action, $severity, $PrinterName, $enforced)
}
}
@@ -402,52 +516,6 @@ function Test-LooksLikeMojibakeQuestionMarks {
return $Value -match '\?{2,}'
}
function Test-DocumentNameNeedsFallback {
param([AllowNull()][string]$Value)
if ([string]::IsNullOrWhiteSpace($Value)) { return $true }
$trimmed = $Value.Trim()
if (Test-LooksLikeMojibakeQuestionMarks -Value $trimmed) { return $true }
if ($trimmed -match '^[0-9]+$') { return $true }
if ($trimmed -match '^(?i)(print document|document|local downlevel document)$') { return $true }
return $false
}
function Get-EventXmlValue {
param(
[Parameter(Mandatory = $true)][xml]$EventXml,
[Parameter(Mandatory = $true)][string]$Name
)
$node = $EventXml.Event.UserData.DocumentPrinted.$Name
if ($null -ne $node) {
return [string]$node
}
return ''
}
function Get-PrintJobPrinterName {
param(
[AllowNull()][string]$JobName,
[AllowNull()][string]$FallbackPrinterName
)
if ([string]::IsNullOrWhiteSpace($JobName)) {
if (-not [string]::IsNullOrWhiteSpace($FallbackPrinterName)) {
return $FallbackPrinterName.Trim()
}
return ''
}
$parts = $JobName -split ',', 2
if ($parts.Count -gt 0 -and -not [string]::IsNullOrWhiteSpace($parts[0])) {
return $parts[0].Trim()
}
return $JobName.Trim()
}
function Normalize-OwnerForMatch {
param([AllowNull()][string]$Value)
if ([string]::IsNullOrWhiteSpace($Value)) { return '' }
@@ -515,50 +583,13 @@ function Get-PrintServiceEventSummary {
$propertyValues += [string]$prop.Value
}
$xml = $null
try {
$xml = [xml]$Event.ToXml()
}
catch {
}
$jobId = ''
$documentName = ''
$owner = ''
$portName = ''
$printerName = ''
$sizeBytes = ''
$pageCount = ''
if ($xml) {
$jobId = Get-EventXmlValue -EventXml $xml -Name 'Param1'
$documentName = Get-EventXmlValue -EventXml $xml -Name 'Param2'
$owner = Get-EventXmlValue -EventXml $xml -Name 'Param3'
$portName = Get-EventXmlValue -EventXml $xml -Name 'Param4'
$printerName = Get-EventXmlValue -EventXml $xml -Name 'Param5'
$sizeBytes = Get-EventXmlValue -EventXml $xml -Name 'Param7'
$pageCount = Get-EventXmlValue -EventXml $xml -Name 'Param8'
}
if ([string]::IsNullOrWhiteSpace($jobId) -and $props.Count -ge 1) { $jobId = [string]$props[0].Value }
if ([string]::IsNullOrWhiteSpace($documentName) -and $props.Count -ge 2) { $documentName = [string]$props[1].Value }
if ([string]::IsNullOrWhiteSpace($owner) -and $props.Count -ge 3) { $owner = [string]$props[2].Value }
if ([string]::IsNullOrWhiteSpace($portName) -and $props.Count -ge 4) { $portName = [string]$props[3].Value }
if ([string]::IsNullOrWhiteSpace($printerName) -and $props.Count -ge 5) { $printerName = [string]$props[4].Value }
if ([string]::IsNullOrWhiteSpace($sizeBytes) -and $props.Count -ge 7) { $sizeBytes = [string]$props[6].Value }
if ([string]::IsNullOrWhiteSpace($pageCount) -and $props.Count -ge 8) { $pageCount = [string]$props[7].Value }
[pscustomobject]@{
RecordId = [string]$Event.RecordId
TimeCreated = if ($Event.TimeCreated) { $Event.TimeCreated.ToString('o') } else { '' }
PropertyCount = $props.Count
JobId = $jobId
DocumentName = $documentName
Owner = $owner
PortName = $portName
PrinterName = $printerName
SizeBytes = $sizeBytes
PageCount = $pageCount
DocumentName = if ($props.Count -ge 1) { [string]$props[0].Value } else { '' }
Owner = if ($props.Count -ge 2) { [string]$props[1].Value } else { '' }
PrinterName = if ($props.Count -ge 4) { [string]$props[3].Value } else { '' }
PropertyValues = $propertyValues
}
}
@@ -571,7 +602,7 @@ function Get-PrintServiceDocumentFallback {
)
$preferred = [string]$EventSummary.DocumentName
if (-not (Test-DocumentNameNeedsFallback -Value $preferred)) {
if (-not (Test-LooksLikeMojibakeQuestionMarks -Value $preferred) -and $preferred -notmatch '^[0-9]+$') {
return $preferred
}
@@ -582,10 +613,9 @@ function Get-PrintServiceDocumentFallback {
$candidate = [string]$value
if ([string]::IsNullOrWhiteSpace($candidate)) { continue }
if ($candidate -eq $preferred) { continue }
if ($EventSummary.JobId -and $candidate -eq [string]$EventSummary.JobId) { continue }
if ($Owner -and $candidate -like "*$Owner*") { continue }
if ($PrinterName -and $candidate -like "*$PrinterName*") { continue }
if (Test-DocumentNameNeedsFallback -Value $candidate) { continue }
if (Test-LooksLikeMojibakeQuestionMarks -Value $candidate) { continue }
if ($candidate -match '[\\/:]' -and $candidate -match '\.[A-Za-z0-9]{1,8}$') {
$pathCandidates.Add($candidate)
@@ -630,7 +660,7 @@ function Write-PrintServiceEventTrace {
}
Write-EndpointLog (
'printservice-307 этап={0} recordId={1} время={2} владелец={3} принтер={4} документ={5} итоговыйДокумент={6} свойства=[{7}] причина={8}' -f
'printservice-307 phase={0} recordId={1} time={2} owner={3} printer={4} document={5} resolved={6} properties=[{7}] reason={8}' -f
$Phase,
$EventSummary.RecordId,
$EventSummary.TimeCreated,
@@ -645,7 +675,6 @@ function Write-PrintServiceEventTrace {
function Get-BetterDocumentNameFromPrintServiceEvents {
param(
[string]$JobId,
[string]$Owner,
[string]$PrinterName
)
@@ -663,41 +692,32 @@ function Get-BetterDocumentNameFromPrintServiceEvents {
$summary = Get-PrintServiceEventSummary -Event $event
$resolvedDocument = Get-PrintServiceDocumentFallback -EventSummary $summary -Owner $Owner -PrinterName $PrinterName
$jobMatches = if ($JobId) { [string]$summary.JobId -eq [string]$JobId } else { $true }
$ownerMatches = if ($Owner) { Test-OwnerLooseMatch -Expected $Owner -Actual $summary.Owner } else { $true }
$printerMatches = if ($PrinterName) { Test-PrinterLooseMatch -Expected $PrinterName -Actual $summary.PrinterName } else { $true }
if ($pass -eq 'strict') {
if ($JobId -and -not $jobMatches) {
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'несовпадение-jobid-strict' -ResolvedDocument $resolvedDocument
continue
}
if ($Owner -and -not $ownerMatches) {
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'несовпадение-владельца-strict' -ResolvedDocument $resolvedDocument
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'owner-mismatch-strict' -ResolvedDocument $resolvedDocument
continue
}
if ($PrinterName -and -not $printerMatches) {
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'несовпадение-принтера-strict' -ResolvedDocument $resolvedDocument
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'printer-mismatch-strict' -ResolvedDocument $resolvedDocument
continue
}
}
else {
if ($JobId -and (-not $jobMatches) -and $Owner -and $PrinterName -and -not $ownerMatches -and -not $printerMatches) {
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'несовпадение-владельца-и-принтера-relaxed' -ResolvedDocument $resolvedDocument
continue
}
if ((-not $JobId) -and $Owner -and $PrinterName -and -not $ownerMatches -and -not $printerMatches) {
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'несовпадение-владельца-и-принтера-relaxed' -ResolvedDocument $resolvedDocument
if ($Owner -and $PrinterName -and -not $ownerMatches -and -not $printerMatches) {
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'owner-and-printer-mismatch-relaxed' -ResolvedDocument $resolvedDocument
continue
}
}
if ([string]::IsNullOrWhiteSpace($resolvedDocument)) {
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason ('нет-кандидата-документа-' + $pass) -ResolvedDocument ''
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason ('no-document-candidate-' + $pass) -ResolvedDocument ''
continue
}
$matchReasonBase = if (Test-DocumentNameNeedsFallback -Value $summary.DocumentName) { 'использован-резервный-вариант' } else { 'напрямую' }
$matchReasonBase = if (Test-LooksLikeMojibakeQuestionMarks -Value $summary.DocumentName) { 'fallback-used' } else { 'direct' }
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'selected' -MatchReason ($matchReasonBase + '-' + $pass) -ResolvedDocument $resolvedDocument
return $resolvedDocument
}
@@ -710,15 +730,15 @@ function Get-BetterDocumentNameFromPrintServiceEvents {
}
$deploymentConfig = Get-DeploymentConfig -Path $ConfigPath
$resolvedServerHost = if ($ServerHost) { $ServerHost } elseif ($deploymentConfig) { [string]$deploymentConfig.server.host } else { throw 'Укажите ServerHost или подготовьте deployment-config.json.' }
$resolvedServerHost = if ($ServerHost) { $ServerHost } elseif ($deploymentConfig) { [string]$deploymentConfig.server.host } else { throw 'ServerHost is required.' }
$resolvedServerPort = if ($PSBoundParameters.ContainsKey('ServerPort')) { $ServerPort } elseif ($deploymentConfig) { [int]$deploymentConfig.server.port } else { 5600 }
$resolvedServerScheme = if ($ServerScheme) { $ServerScheme } elseif ($deploymentConfig) { [string]$deploymentConfig.server.scheme } else { 'http' }
$resolvedPolicyPath = if ($PolicyPath) { $PolicyPath } elseif ($deploymentConfig -and $deploymentConfig.paths.PSObject.Properties.Name -contains 'policyPath') { [string]$deploymentConfig.paths.policyPath } else { 'C:\ProgramData\AWatch-rus\dlp-policy.json' }
$resolvedPolicyPath = if ($PolicyPath) { $PolicyPath } elseif ($deploymentConfig -and $deploymentConfig.paths.PSObject.Properties.Name -contains 'policyPath') { [string]$deploymentConfig.paths.policyPath } else { 'C:\ProgramData\ActivityWatch\dlp-policy.json' }
$resolvedPollSeconds = if ($PSBoundParameters.ContainsKey('PollSeconds')) { $PollSeconds } elseif ($deploymentConfig) { [int]$deploymentConfig.collector.pollSeconds } else { 5 }
$resolvedLogsRoot = if ($deploymentConfig) { [string]$deploymentConfig.paths.logsRoot } else { 'C:\ProgramData\AWatch-rus\logs' }
$resolvedLogsRoot = if ($deploymentConfig) { [string]$deploymentConfig.paths.logsRoot } else { 'C:\ProgramData\ActivityWatch\logs' }
$resolvedLogPath = if ($LogPath) { $LogPath } else { Join-Path $resolvedLogsRoot ("endpoint-signals-{0}.log" -f $env:USERNAME) }
$resolvedLocalAgentLogsEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'logging' -and $deploymentConfig.logging.PSObject.Properties.Name -contains 'localAgentLogsEnabled') { [bool]$deploymentConfig.logging.localAgentLogsEnabled } else { $true }
$resolvedIncidentArtifactsRoot = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'artifactsRoot') { [string]$deploymentConfig.incidentCapture.artifactsRoot } else { Join-Path $env:LOCALAPPDATA 'AWatch-rus\\incident-artifacts' }
$resolvedIncidentArtifactsRoot = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'artifactsRoot') { [string]$deploymentConfig.incidentCapture.artifactsRoot } else { Join-Path $env:LOCALAPPDATA 'ActivityWatch-Phase2\\incident-artifacts' }
$resolvedIncidentScreenshotEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'screenshotEnabled') { [bool]$deploymentConfig.incidentCapture.screenshotEnabled } else { $true }
if ($resolvedLocalAgentLogsEnabled -and -not (Test-Path -LiteralPath $resolvedLogsRoot)) {
@@ -742,7 +762,7 @@ $script:IncidentScreenshotEnabled = $resolvedIncidentScreenshotEnabled
$script:ScreenshotTypesLoaded = $false
Load-DlpPolicy -Path $resolvedPolicyPath
Write-EndpointLog ("endpoint-коллектор запущен для {0}" -f $script:ApiBase)
Write-EndpointLog ("endpoint collector started against {0}" -f $script:ApiBase)
while ($true) {
try {
@@ -803,13 +823,13 @@ while ($true) {
if ($script:SeenPrintJob.ContainsKey($jobId)) { continue }
$script:SeenPrintJob[$jobId] = (Get-Date).ToUniversalTime()
$printerName = Get-PrintJobPrinterName -JobName ([string]$job.Name) -FallbackPrinterName ([string]$job.DriverName)
$printerName = [string]$job.Name
$documentName = [string]$job.Document
$owner = [string]$job.Owner
$documentNameOriginal = $documentName
if (Test-DocumentNameNeedsFallback -Value $documentName) {
$eventDocumentName = Get-BetterDocumentNameFromPrintServiceEvents -JobId $jobId -Owner $owner -PrinterName $printerName
if (Test-LooksLikeMojibakeQuestionMarks -Value $documentName) {
$eventDocumentName = Get-BetterDocumentNameFromPrintServiceEvents -Owner $owner -PrinterName $printerName
if ($eventDocumentName) {
$documentName = $eventDocumentName
}
@@ -820,7 +840,6 @@ while ($true) {
documentName = $documentName
documentNameOriginal = $documentNameOriginal
owner = $owner
printJobId = $jobId
}
Evaluate-PrintRules -PrinterName $printerName -DocumentName $documentName -Owner $owner
}
@@ -884,7 +903,7 @@ while ($true) {
}
}
catch {
Write-EndpointLog ("ошибка коллектора: {0}" -f $_.Exception.Message)
Write-EndpointLog ("collector error: {0}" -f $_.Exception.Message)
}
Start-Sleep -Seconds $resolvedPollSeconds
@@ -0,0 +1,582 @@
<#
.SYNOPSIS
DLP email outbound collector for AWatch-rus (Phase 2.5).
Monitors outgoing email via Outlook COM Sent Items polling
and/or SMTP network connection detection.
.DESCRIPTION
Two collection modes (configurable, can run simultaneously):
- outlook : Polls Outlook Sent Items via COM for new messages.
- smtp : Monitors SMTP connections (ports 25/587/465) via
Get-NetTCPConnection for any process sending mail.
Sends heartbeats to AW bucket `aw-email-monitor_<host>`.
Evaluates DLP policy rules from `endpoint.email[]` section.
Supports enforcement: action="block" moves the email to Drafts
(Outlook mode) or logs with enforced=false (SMTP mode).
#>
[CmdletBinding()]
param(
[string]$ConfigPath = 'C:\ProgramData\ActivityWatch\deployment-config.json',
[string]$ServerHost,
[int]$ServerPort,
[ValidateSet('http', 'https')]
[string]$ServerScheme,
[string]$PolicyPath,
[string]$LogPath,
[int]$PollSeconds,
[ValidateSet('outlook', 'smtp', 'both')]
[string]$Mode = 'both'
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
# ---------------------------------------------------------------------------
# Shared infrastructure (mirrors other collectors)
# ---------------------------------------------------------------------------
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-CollectorLog {
param([string]$Message)
if (-not $script:LocalAgentLogsEnabled) { return }
try {
Add-Content -LiteralPath $script:LogPath -Value ('{0} {1}' -f (Get-Date -Format s), $Message)
}
catch { }
}
function Invoke-AwJsonPost {
param(
[Parameter(Mandatory = $true)][string]$Uri,
[Parameter(Mandatory = $true)][string]$Json
)
$bytes = [Text.Encoding]::UTF8.GetBytes($Json)
Invoke-RestMethod -Method Post -Uri $Uri -ContentType 'application/json; charset=utf-8' -Body $bytes | Out-Null
}
function Ensure-Bucket {
param(
[string]$BucketId,
[string]$ClientName,
[string]$BucketType
)
if ($script:KnownBuckets.ContainsKey($BucketId)) { return }
$body = @{
client = $ClientName
type = $BucketType
hostname = $script:Hostname
} | ConvertTo-Json -Compress
Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$BucketId" -Json $body
$script:KnownBuckets[$BucketId] = $true
}
function Get-StringHash {
param([AllowNull()][string]$Value)
if ($null -eq $Value) { return $null }
$bytes = [Text.Encoding]::UTF8.GetBytes($Value)
$sha = [Security.Cryptography.SHA256]::Create()
try {
($sha.ComputeHash($bytes) | ForEach-Object { $_.ToString('x2') }) -join ''
}
finally { $sha.Dispose() }
}
function Send-EmailHeartbeat {
param(
[string]$SignalType,
[hashtable]$Data
)
$bucketId = 'aw-email-monitor_' + $script:Hostname
Ensure-Bucket -BucketId $bucketId -ClientName 'aw-email-monitor' -BucketType 'aw.dlp.email'
$payload = @{
timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
duration = 0
data = @{
signalType = $SignalType
username = $env:USERNAME
sessionId = $script:SessionId
hostname = $script:Hostname
source = 'email-outbound-collector'
} + $Data
} | ConvertTo-Json -Depth 6 -Compress
Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$script:PulseSeconds" -Json $payload
}
function Send-EmailIncidentHeartbeat {
param(
[string]$RuleId,
[string]$Action,
[string]$Severity,
[string]$Message,
[hashtable]$Data
)
$bucketId = 'aw-dlp-incidents_' + $script:Hostname
Ensure-Bucket -BucketId $bucketId -ClientName 'aw-dlp-incidents' -BucketType 'aw.dlp.incident'
$payload = @{
timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
duration = 0
data = @{
ruleId = $RuleId
action = $Action
severity = $Severity
message = $Message
signalType = 'email_outbound'
username = $env:USERNAME
sessionId = $script:SessionId
hostname = $script:Hostname
source = 'email-outbound-collector'
} + $Data
} | ConvertTo-Json -Depth 7 -Compress
Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$script:PulseSeconds" -Json $payload
}
function Show-EnforcementNotification {
param(
[Parameter(Mandatory = $true)][string]$Title,
[Parameter(Mandatory = $true)][string]$Body
)
try {
Add-Type -AssemblyName System.Windows.Forms -ErrorAction SilentlyContinue
$icon = New-Object System.Windows.Forms.NotifyIcon
$icon.Icon = [System.Drawing.SystemIcons]::Warning
$icon.BalloonTipTitle = $Title
$icon.BalloonTipText = $Body
$icon.BalloonTipIcon = [System.Windows.Forms.ToolTipIcon]::Warning
$icon.Visible = $true
$icon.ShowBalloonTip(5000)
Start-Sleep -Milliseconds 200
$icon.Dispose()
}
catch { }
}
# ---------------------------------------------------------------------------
# DLP Policy
# ---------------------------------------------------------------------------
function Load-EmailPolicy {
param([string]$Path)
$script:Policy = [ordered]@{
defaults = [ordered]@{
enabled = $true
cooldownSeconds = 300
action = 'alert'
severity = 'medium'
}
endpoint = [ordered]@{
email = @()
}
}
if (-not $Path -or -not (Test-Path -LiteralPath $Path)) {
Write-CollectorLog ("policy not found, using defaults: {0}" -f $Path)
return
}
try {
$raw = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json
if ($raw.defaults) {
if ($raw.defaults.PSObject.Properties.Name -contains 'enabled') { $script:Policy.defaults.enabled = [bool]$raw.defaults.enabled }
if ($raw.defaults.cooldownSeconds) { $script:Policy.defaults.cooldownSeconds = [int]$raw.defaults.cooldownSeconds }
if ($raw.defaults.action) { $script:Policy.defaults.action = [string]$raw.defaults.action }
if ($raw.defaults.severity) { $script:Policy.defaults.severity = [string]$raw.defaults.severity }
}
if ($raw.endpoint -and $raw.endpoint.email) {
$script:Policy.endpoint.email = @($raw.endpoint.email)
}
}
catch {
Write-CollectorLog ("policy parse failed: {0}" -f $_.Exception.Message)
}
}
function Should-EmitByCooldown {
param(
[string]$Fingerprint,
[int]$CooldownSeconds
)
$now = (Get-Date).ToUniversalTime()
if ($script:Cooldown.ContainsKey($Fingerprint)) {
$last = [datetime]$script:Cooldown[$Fingerprint]
if ((New-TimeSpan -Start $last -End $now).TotalSeconds -lt $CooldownSeconds) {
return $false
}
}
$script:Cooldown[$Fingerprint] = $now
return $true
}
# ---------------------------------------------------------------------------
# Email DLP rule evaluation
# ---------------------------------------------------------------------------
function Evaluate-EmailRules {
param(
[string]$Subject,
[string]$RecipientsJoined,
[string]$SenderAddress,
[int]$AttachmentCount,
[string]$AttachmentNames,
[int]$BodyLength,
[string]$MessageId,
$OutlookMailItem
)
foreach ($rule in @($script:Policy.endpoint.email)) {
if (-not $rule) { continue }
if ($rule.PSObject.Properties.Name -contains 'enabled' -and -not [bool]$rule.enabled) { continue }
$ruleId = [string]$rule.id
if (-not $ruleId) { continue }
$matched = $true
if ($rule.subjectRegex) {
$matched = $matched -and ($Subject -match [string]$rule.subjectRegex)
}
if ($rule.recipientRegex) {
$matched = $matched -and ($RecipientsJoined -match [string]$rule.recipientRegex)
}
if ($rule.senderRegex) {
$matched = $matched -and ($SenderAddress -match [string]$rule.senderRegex)
}
if ($rule.attachmentRegex -and $AttachmentNames) {
$matched = $matched -and ($AttachmentNames -match [string]$rule.attachmentRegex)
}
if ($rule.minAttachments) {
$matched = $matched -and ($AttachmentCount -ge [int]$rule.minAttachments)
}
if ($rule.minBodyLength) {
$matched = $matched -and ($BodyLength -ge [int]$rule.minBodyLength)
}
if ($rule.externalOnly -and [bool]$rule.externalOnly) {
$internalDomain = if ($rule.internalDomain) { [string]$rule.internalDomain } else { '' }
if ($internalDomain -and $RecipientsJoined -notmatch [regex]::Escape($internalDomain)) {
# all recipients are external — continue matching
}
elseif ($internalDomain) {
$matched = $false
}
}
if (-not $matched) { continue }
$cooldown = if ($rule.cooldownSeconds) { [int]$rule.cooldownSeconds } else { [int]$script:Policy.defaults.cooldownSeconds }
$fingerprint = "email|$ruleId|$MessageId|$env:USERNAME"
if (-not (Should-EmitByCooldown -Fingerprint $fingerprint -CooldownSeconds ([Math]::Max($cooldown, 30)))) { continue }
$action = if ($rule.action) { [string]$rule.action } else { [string]$script:Policy.defaults.action }
$severity = if ($rule.severity) { [string]$rule.severity } else { [string]$script:Policy.defaults.severity }
$message = if ($rule.message) { [string]$rule.message } else { "Email rule matched: $ruleId" }
$enforced = $false
if ($action -eq 'block' -and $null -ne $OutlookMailItem) {
$enforced = Invoke-EmailEnforcement -MailItem $OutlookMailItem -RuleId $ruleId
Show-EnforcementNotification -Title 'DLP: письмо перемещено в черновики' -Body $message
}
elseif ($action -eq 'block') {
Show-EnforcementNotification -Title 'DLP: обнаружена отправка письма' -Body $message
}
Send-EmailIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -Data @{
subject = (Get-StringHash -Value $Subject)
recipients = (Get-StringHash -Value $RecipientsJoined)
sender = $SenderAddress
attachmentCount = $AttachmentCount
attachmentNames = $AttachmentNames
bodyLength = $BodyLength
enforced = $enforced
}
Write-CollectorLog ("incident email rule={0} action={1} severity={2} enforced={3} subject_hash={4}" -f $ruleId, $action, $severity, $enforced, (Get-StringHash -Value $Subject))
}
}
function Invoke-EmailEnforcement {
[OutputType([bool])]
param(
[Parameter(Mandatory = $true)]$MailItem,
[string]$RuleId
)
try {
$draftsFolder = $script:OutlookNamespace.GetDefaultFolder(16) # olFolderDrafts
$MailItem.Move($draftsFolder) | Out-Null
Write-CollectorLog ("enforcement: email moved to Drafts rule={0} subject_hash={1}" -f $RuleId, (Get-StringHash -Value $MailItem.Subject))
return $true
}
catch {
Write-CollectorLog ("enforcement: email move to Drafts failed rule={0}: {1}" -f $RuleId, $_.Exception.Message)
return $false
}
}
# ---------------------------------------------------------------------------
# Outlook Sent Items polling
# ---------------------------------------------------------------------------
function Initialize-OutlookCom {
try {
$script:OutlookApp = New-Object -ComObject Outlook.Application
$script:OutlookNamespace = $script:OutlookApp.GetNamespace('MAPI')
$script:SentFolder = $script:OutlookNamespace.GetDefaultFolder(5) # olFolderSentMail
Write-CollectorLog "Outlook COM initialized, Sent Items folder opened"
return $true
}
catch {
Write-CollectorLog ("Outlook COM init failed: {0}" -f $_.Exception.Message)
return $false
}
}
function Get-OutlookSentItems {
param([datetime]$Since)
$results = @()
try {
$items = $script:SentFolder.Items
$items.Sort('[SentOn]', $true)
$filter = "[SentOn] >= '{0}'" -f $Since.ToString('MM/dd/yyyy HH:mm')
$restricted = $items.Restrict($filter)
foreach ($item in $restricted) {
try {
if ($item.Class -ne 43) { continue } # olMail = 43
$recipients = @()
for ($i = 1; $i -le $item.Recipients.Count; $i++) {
$recip = $item.Recipients.Item($i)
$recipients += [string]$recip.Address
}
$attachmentNames = @()
for ($i = 1; $i -le $item.Attachments.Count; $i++) {
$attachmentNames += [string]$item.Attachments.Item($i).FileName
}
$results += [pscustomobject]@{
EntryID = [string]$item.EntryID
Subject = [string]$item.Subject
SenderAddress = [string]$item.SenderEmailAddress
SenderName = [string]$item.SenderName
Recipients = $recipients
RecipientsJoined = ($recipients -join '; ')
AttachmentCount = [int]$item.Attachments.Count
AttachmentNames = ($attachmentNames -join '; ')
BodyLength = if ($item.Body) { $item.Body.Length } else { 0 }
SentOn = $item.SentOn
MailItem = $item
}
}
catch { }
}
}
catch {
Write-CollectorLog ("Outlook Sent Items scan failed: {0}" -f $_.Exception.Message)
}
return $results
}
function Poll-OutlookSentItems {
$items = Get-OutlookSentItems -Since $script:OutlookLastPoll
foreach ($item in $items) {
$entryId = $item.EntryID
if ($script:SeenEntryIds.ContainsKey($entryId)) { continue }
$script:SeenEntryIds[$entryId] = (Get-Date).ToUniversalTime()
$subjectHash = Get-StringHash -Value $item.Subject
Send-EmailHeartbeat -SignalType 'email_sent' -Data @{
subject = $subjectHash
sender = [string]$item.SenderAddress
senderName = [string]$item.SenderName
recipientCount = $item.Recipients.Count
recipients = (Get-StringHash -Value $item.RecipientsJoined)
attachmentCount = [int]$item.AttachmentCount
attachmentNames = [string]$item.AttachmentNames
bodyLength = [int]$item.BodyLength
sentOn = if ($item.SentOn) { $item.SentOn.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') } else { '' }
collectionMode = 'outlook'
}
Write-CollectorLog ("email_sent outlook subject_hash={0} to={1} attachments={2}" -f $subjectHash, $item.Recipients.Count, $item.AttachmentCount)
Evaluate-EmailRules `
-Subject $item.Subject `
-RecipientsJoined $item.RecipientsJoined `
-SenderAddress $item.SenderAddress `
-AttachmentCount $item.AttachmentCount `
-AttachmentNames $item.AttachmentNames `
-BodyLength $item.BodyLength `
-MessageId $entryId `
-OutlookMailItem $item.MailItem
}
$script:OutlookLastPoll = (Get-Date).AddSeconds(-10)
# Cleanup old entry IDs (keep last 24h)
$cleanupBefore = (Get-Date).ToUniversalTime().AddHours(-24)
foreach ($k in @($script:SeenEntryIds.Keys)) {
if ([datetime]$script:SeenEntryIds[$k] -lt $cleanupBefore) {
$script:SeenEntryIds.Remove($k)
}
}
}
# ---------------------------------------------------------------------------
# SMTP network connection monitoring
# ---------------------------------------------------------------------------
function Poll-SmtpConnections {
try {
$smtpPorts = @(25, 587, 465, 2525)
$connections = Get-NetTCPConnection -State Established -ErrorAction SilentlyContinue |
Where-Object { $smtpPorts -contains $_.RemotePort }
foreach ($conn in @($connections)) {
$processId = [int]$conn.OwningProcess
$remoteAddr = [string]$conn.RemoteAddress
$remotePort = [int]$conn.RemotePort
$fingerprint = "{0}:{1}:{2}" -f $processId, $remoteAddr, $remotePort
if ($script:SeenSmtpConnections.ContainsKey($fingerprint)) { continue }
$script:SeenSmtpConnections[$fingerprint] = (Get-Date).ToUniversalTime()
$processName = ''
try {
$proc = Get-Process -Id $processId -ErrorAction SilentlyContinue
$processName = [string]$proc.ProcessName
}
catch { }
Send-EmailHeartbeat -SignalType 'smtp_connection' -Data @{
remoteAddress = $remoteAddr
remotePort = $remotePort
processId = $processId
processName = $processName
localPort = [int]$conn.LocalPort
collectionMode = 'smtp'
}
Write-CollectorLog ("smtp_connection process={0}({1}) remote={2}:{3}" -f $processName, $processId, $remoteAddr, $remotePort)
Evaluate-EmailRules `
-Subject '' `
-RecipientsJoined $remoteAddr `
-SenderAddress $env:USERNAME `
-AttachmentCount 0 `
-AttachmentNames '' `
-BodyLength 0 `
-MessageId $fingerprint `
-OutlookMailItem $null
}
# Cleanup old SMTP connections (keep last 8h)
$cleanupBefore = (Get-Date).ToUniversalTime().AddHours(-8)
foreach ($k in @($script:SeenSmtpConnections.Keys)) {
if ([datetime]$script:SeenSmtpConnections[$k] -lt $cleanupBefore) {
$script:SeenSmtpConnections.Remove($k)
}
}
}
catch {
Write-CollectorLog ("SMTP poll error: {0}" -f $_.Exception.Message)
}
}
# ---------------------------------------------------------------------------
# Initialization
# ---------------------------------------------------------------------------
$deploymentConfig = Get-DeploymentConfig -Path $ConfigPath
$resolvedServerHost = if ($ServerHost) { $ServerHost } elseif ($deploymentConfig) { [string]$deploymentConfig.server.host } else { throw 'ServerHost is required.' }
$resolvedServerPort = if ($PSBoundParameters.ContainsKey('ServerPort')) { $ServerPort } elseif ($deploymentConfig) { [int]$deploymentConfig.server.port } else { 5600 }
$resolvedServerScheme = if ($ServerScheme) { $ServerScheme } elseif ($deploymentConfig) { [string]$deploymentConfig.server.scheme } else { 'http' }
$resolvedPolicyPath = if ($PolicyPath) { $PolicyPath } elseif ($deploymentConfig -and $deploymentConfig.paths.PSObject.Properties.Name -contains 'policyPath') { [string]$deploymentConfig.paths.policyPath } else { 'C:\ProgramData\ActivityWatch\dlp-policy.json' }
$resolvedPollSeconds = if ($PSBoundParameters.ContainsKey('PollSeconds')) { $PollSeconds } elseif ($deploymentConfig) { [int]$deploymentConfig.collector.pollSeconds } else { 10 }
$resolvedLogsRoot = if ($deploymentConfig) { [string]$deploymentConfig.paths.logsRoot } else { 'C:\ProgramData\ActivityWatch\logs' }
$resolvedLogPath = if ($LogPath) { $LogPath } else { Join-Path $resolvedLogsRoot ("email-outbound-{0}.log" -f $env:USERNAME) }
$resolvedLocalAgentLogsEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'logging' -and $deploymentConfig.logging.PSObject.Properties.Name -contains 'localAgentLogsEnabled') { [bool]$deploymentConfig.logging.localAgentLogsEnabled } else { $true }
if ($resolvedLocalAgentLogsEnabled -and -not (Test-Path -LiteralPath $resolvedLogsRoot)) {
New-Item -Path $resolvedLogsRoot -ItemType Directory -Force | Out-Null
}
$script:ApiBase = '{0}://{1}:{2}/api/0' -f $resolvedServerScheme, $resolvedServerHost, $resolvedServerPort
$script:Hostname = $env:COMPUTERNAME
$script:SessionId = (Get-Process -Id $PID).SessionId
$script:KnownBuckets = @{}
$script:Cooldown = @{}
$script:SeenEntryIds = @{}
$script:SeenSmtpConnections = @{}
$script:PulseSeconds = [Math]::Max($resolvedPollSeconds * 3, 30)
$script:LocalAgentLogsEnabled = $resolvedLocalAgentLogsEnabled
$script:LogPath = $resolvedLogPath
$script:OutlookApp = $null
$script:OutlookNamespace = $null
$script:SentFolder = $null
$script:OutlookLastPoll = (Get-Date).AddMinutes(-5)
Load-EmailPolicy -Path $resolvedPolicyPath
Write-CollectorLog ("email collector started mode={0} against {1}" -f $Mode, $script:ApiBase)
$useOutlook = ($Mode -eq 'outlook' -or $Mode -eq 'both')
$useSmtp = ($Mode -eq 'smtp' -or $Mode -eq 'both')
$outlookReady = $false
if ($useOutlook) {
$outlookReady = Initialize-OutlookCom
if (-not $outlookReady -and $Mode -eq 'outlook') {
Write-CollectorLog "Outlook COM not available, collector will retry"
}
}
# ---------------------------------------------------------------------------
# Main loop
# ---------------------------------------------------------------------------
while ($true) {
try {
if (-not $script:Policy.defaults.enabled) {
Start-Sleep -Seconds $resolvedPollSeconds
continue
}
if ($useOutlook) {
if (-not $outlookReady) {
$outlookReady = Initialize-OutlookCom
}
if ($outlookReady) {
try {
Poll-OutlookSentItems
}
catch {
Write-CollectorLog ("outlook poll error: {0}" -f $_.Exception.Message)
$outlookReady = $false
$script:OutlookApp = $null
$script:OutlookNamespace = $null
$script:SentFolder = $null
}
}
}
if ($useSmtp) {
try {
Poll-SmtpConnections
}
catch {
Write-CollectorLog ("smtp poll error: {0}" -f $_.Exception.Message)
}
}
}
catch {
Write-CollectorLog ("collector error: {0}" -f $_.Exception.Message)
}
Start-Sleep -Seconds $resolvedPollSeconds
}
+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}"
+99 -6
View File
@@ -20,6 +20,17 @@ function New-ActivityWatchDirectory {
}
}
function Enable-ActivityWatchPrintTelemetry {
$policyPath = 'HKLM:\Software\Policies\Microsoft\Windows NT\Printers'
if (-not (Test-Path -LiteralPath $policyPath)) {
New-Item -Path $policyPath -Force | Out-Null
}
New-ItemProperty -Path $policyPath -Name 'ShowJobTitleInEventLogs' -Value 1 -PropertyType DWord -Force | Out-Null
& wevtutil.exe sl 'Microsoft-Windows-PrintService/Operational' /e:true | Out-Null
}
function Get-ActivityWatchPackageUrl {
param(
[string]$Version = 'v0.13.2'
@@ -49,7 +60,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 +98,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 +266,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 +283,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 +292,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 +313,7 @@ function Copy-ActivityWatchCollectorAssets {
return [pscustomobject]@{
CollectorScript = $collectorTarget
EndpointCollectorScript = $endpointCollectorTarget
FileCollectorScript = $fileCollectorTarget
SessionCollectorScript = $sessionCollectorTarget
ExampleRules = $exampleRulesTarget
ActiveRules = $rulesTarget
@@ -313,6 +341,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 +356,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 +387,7 @@ function New-ActivityWatchDeploymentConfig {
logsRoot = $LogsRoot
collectorScript = $CollectorScript
endpointCollectorScript = $EndpointCollectorScript
fileCollectorScript = $FileCollectorScript
sessionCollectorScript = $SessionCollectorScript
rulesPath = $RulesPath
policyPath = $PolicyPath
@@ -369,6 +401,7 @@ function New-ActivityWatchDeploymentConfig {
collectors = [pscustomobject]@{
afkEnabled = $AfkEnabled
windowEnabled = $WindowEnabled
fileOpsEnabled = $FileOpsEnabled
}
logging = [pscustomobject]@{
localAgentLogsEnabled = $LocalAgentLogsEnabled
@@ -443,6 +476,9 @@ param(
Set-StrictMode -Version Latest
`$ErrorActionPreference = 'Stop'
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12
Add-Type -AssemblyName System.Net.Http
function Get-DeploymentConfig {
param([string]`$Path)
return Get-Content -LiteralPath `$Path -Raw | ConvertFrom-Json
@@ -480,8 +516,21 @@ function Invoke-AwJsonPost {
[Parameter(Mandatory = `$true)][string]`$Json
)
`$bytes = [Text.Encoding]::UTF8.GetBytes(`$Json)
Invoke-RestMethod -Method Post -Uri `$Uri -ContentType 'application/json; charset=utf-8' -Body `$bytes | Out-Null
`$httpClient = New-Object System.Net.Http.HttpClient
try {
`$content = New-Object System.Net.Http.StringContent(`$Json, [System.Text.Encoding]::UTF8, 'application/json')
`$response = `$httpClient.PostAsync(`$Uri, `$content).Result
if (-not `$response.IsSuccessStatusCode) {
return `$false
}
return `$true
}
catch {
return `$false
}
finally {
`$httpClient.Dispose()
}
}
function Ensure-Bucket {
@@ -510,7 +559,9 @@ function Ensure-Bucket {
} | ConvertTo-Json -Compress
try {
Invoke-AwJsonPost -Uri "`$(`$script:ApiBase)/buckets/`$BucketId" -Json `$body
if (-not (Invoke-AwJsonPost -Uri "`$(`$script:ApiBase)/buckets/`$BucketId" -Json `$body)) {
return
}
}
catch {
try {
@@ -614,6 +665,10 @@ function Start-CollectorScriptIfNeeded {
[int]`$SessionId
)
if ([string]::IsNullOrWhiteSpace(`$ScriptPath)) {
return
}
if (-not (Test-Path -LiteralPath `$ScriptPath)) {
return
}
@@ -634,18 +689,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 +728,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 +952,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 +996,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`""
+6
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,12 +45,14 @@ $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'
New-ActivityWatchDirectory -Path $StateRoot
New-ActivityWatchDirectory -Path $logsRoot
Enable-ActivityWatchPrintTelemetry
$archivePath = Get-ActivityWatchArchive -PackageZipPath $PackageZipPath -PackageUrl $PackageUrl -Version $Version -WorkingRoot $workingRoot
Install-ActivityWatchPackage -ArchivePath $archivePath -InstallRoot $InstallRoot -WorkingRoot $workingRoot -BackupRoot $backupRoot | Out-Null
@@ -58,6 +61,7 @@ Get-ActivityWatchExecutableMap -InstallRoot $InstallRoot | Out-Null
$assetResult = Copy-ActivityWatchCollectorAssets `
-CollectorScriptSource $collectorSource `
-EndpointCollectorScriptSource $endpointCollectorSource `
-FileCollectorScriptSource $fileCollectorSource `
-SessionCollectorScriptSource $sessionCollectorSource `
-ExampleRulesSource $exampleRulesSource `
-ExamplePolicySource $examplePolicySource `
@@ -78,6 +82,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 +91,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)
}
+149 -129
View File
@@ -1,6 +1,7 @@
[CmdletBinding()]
\xEF\xBB\xBF-ne \xEF\xBB\xBF
[CmdletBinding()]
param(
[string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json',
[string]$ConfigPath = 'C:\ProgramData\ActivityWatch\deployment-config.json',
[string]$ServerHost,
[int]$ServerPort,
[ValidateSet('http', 'https')]
@@ -81,7 +82,7 @@ function Send-EndpointSignalHeartbeat {
username = $env:USERNAME
sessionId = $script:SessionId
hostname = $script:Hostname
source = 'endpoint-signals-awatch-rus'
source = 'endpoint-signals-phase2'
} + $Data
} | ConvertTo-Json -Depth 6 -Compress
@@ -122,7 +123,7 @@ function Send-DlpIncidentHeartbeat {
username = $env:USERNAME
sessionId = $script:SessionId
hostname = $script:Hostname
source = 'endpoint-signals-awatch-rus'
source = 'endpoint-signals-phase2'
} + $Data + $captureData
} | ConvertTo-Json -Depth 7 -Compress
@@ -210,11 +211,104 @@ function Capture-IncidentScreenshot {
}
}
catch {
Write-EndpointLog ("не удалось сделать снимок инцидента: {0}" -f $_.Exception.Message)
Write-EndpointLog ("screenshot capture failed: {0}" -f $_.Exception.Message)
return @{}
}
}
# ---------------------------------------------------------------------------
# Enforcement functions (action = "block")
# ---------------------------------------------------------------------------
function Show-EnforcementNotification {
param(
[Parameter(Mandatory = $true)][string]$Title,
[Parameter(Mandatory = $true)][string]$Body
)
try {
Add-Type -AssemblyName System.Windows.Forms -ErrorAction SilentlyContinue
$icon = New-Object System.Windows.Forms.NotifyIcon
$icon.Icon = [System.Drawing.SystemIcons]::Warning
$icon.BalloonTipTitle = $Title
$icon.BalloonTipText = $Body
$icon.BalloonTipIcon = [System.Windows.Forms.ToolTipIcon]::Warning
$icon.Visible = $true
$icon.ShowBalloonTip(5000)
Start-Sleep -Milliseconds 200
$icon.Dispose()
}
catch {
Write-EndpointLog ("notification failed: {0}" -f $_.Exception.Message)
}
}
function Invoke-ClipboardEnforcement {
[OutputType([bool])]
param()
try {
Set-Clipboard -Value $null -ErrorAction Stop
Write-EndpointLog "enforcement: clipboard cleared"
return $true
}
catch {
Write-EndpointLog ("enforcement: clipboard clear failed: {0}" -f $_.Exception.Message)
return $false
}
}
function Invoke-UsbWriteBlockEnforcement {
[OutputType([bool])]
param(
[Parameter(Mandatory = $true)][string]$DriveLetter
)
try {
$partition = Get-Partition -DriveLetter ($DriveLetter.TrimEnd(':')) -ErrorAction Stop
$disk = Get-Disk -Number $partition.DiskNumber -ErrorAction Stop
if ($disk.BusType -ne 'USB') {
Write-EndpointLog ("enforcement: skip non-USB disk {0} bus={1}" -f $disk.Number, $disk.BusType)
return $false
}
if (-not $disk.IsReadOnly) {
Set-Disk -Number $disk.Number -IsReadOnly $true -ErrorAction Stop
Write-EndpointLog ("enforcement: USB disk {0} ({1}) set read-only" -f $disk.Number, $DriveLetter)
}
return $true
}
catch {
Write-EndpointLog ("enforcement: USB write-block failed drive={0}: {1}" -f $DriveLetter, $_.Exception.Message)
return $false
}
}
function Invoke-PrintJobEnforcement {
[OutputType([bool])]
param(
[Parameter(Mandatory = $true)][string]$PrinterName,
[string]$DocumentName,
[string]$Owner
)
$cancelled = $false
try {
$jobs = Get-CimInstance Win32_PrintJob -ErrorAction SilentlyContinue
foreach ($job in @($jobs)) {
$jobPrinter = [string]$job.Name
$jobOwner = [string]$job.Owner
$jobDoc = [string]$job.Document
$matchPrinter = ($jobPrinter -like "*$PrinterName*")
$matchOwner = (-not $Owner) -or ($jobOwner -like "*$Owner*") -or ($jobOwner -like "*$env:USERNAME*")
if ($matchPrinter -and $matchOwner) {
Remove-CimInstance -InputObject $job -ErrorAction Stop
Write-EndpointLog ("enforcement: print job cancelled id={0} printer={1} doc={2}" -f $job.JobId, $jobPrinter, $jobDoc)
$cancelled = $true
}
}
}
catch {
Write-EndpointLog ("enforcement: print cancel failed printer={0}: {1}" -f $PrinterName, $_.Exception.Message)
}
return $cancelled
}
function Get-StringHash {
param([AllowNull()][string]$Value)
if ($null -eq $Value) { return $null }
@@ -246,7 +340,7 @@ function Load-DlpPolicy {
}
if (-not $Path -or -not (Test-Path -LiteralPath $Path)) {
Write-EndpointLog ("DLP-политика не найдена, используются значения по умолчанию: {0}" -f $Path)
Write-EndpointLog ("policy not found, using defaults: {0}" -f $Path)
return
}
@@ -266,7 +360,7 @@ function Load-DlpPolicy {
}
}
catch {
Write-EndpointLog ("не удалось разобрать DLP-политику: {0}" -f $_.Exception.Message)
Write-EndpointLog ("policy parse failed: {0}" -f $_.Exception.Message)
}
}
@@ -319,13 +413,20 @@ function Evaluate-ClipboardRules {
$action = if ($rule.action) { [string]$rule.action } else { [string]$script:Policy.defaults.action }
$severity = if ($rule.severity) { [string]$rule.severity } else { [string]$script:Policy.defaults.severity }
$message = if ($rule.message) { [string]$rule.message } else { "Сработало правило буфера обмена: $ruleId" }
$message = if ($rule.message) { [string]$rule.message } else { "Clipboard rule matched: $ruleId" }
$enforced = $false
if ($action -eq 'block') {
$enforced = Invoke-ClipboardEnforcement
Show-EnforcementNotification -Title 'DLP: буфер обмена очищен' -Body $message
}
Send-DlpIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -SignalType 'clipboard' -Data @{
clipboardHash = $ClipboardHash
clipboardLength = $ClipboardText.Length
enforced = $enforced
}
Write-EndpointLog ("инцидент буфера обмена правило={0} действие={1} важность={2}" -f $ruleId, $action, $severity)
Write-EndpointLog ("incident clipboard rule={0} action={1} severity={2} enforced={3}" -f $ruleId, $action, $severity, $enforced)
}
}
@@ -347,13 +448,20 @@ function Evaluate-UsbRules {
$action = if ($rule.action) { [string]$rule.action } else { [string]$script:Policy.defaults.action }
$severity = if ($rule.severity) { [string]$rule.severity } else { [string]$script:Policy.defaults.severity }
$message = if ($rule.message) { [string]$rule.message } else { "Сработало правило USB-носителя: $ruleId" }
$message = if ($rule.message) { [string]$rule.message } else { "USB rule matched: $ruleId" }
$enforced = $false
if ($action -eq 'block') {
$enforced = Invoke-UsbWriteBlockEnforcement -DriveLetter $DriveLetter
Show-EnforcementNotification -Title 'DLP: USB заблокирован для записи' -Body $message
}
Send-DlpIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -SignalType 'usb_insert' -Data @{
driveLetter = $DriveLetter
volumeName = $VolumeName
enforced = $enforced
}
Write-EndpointLog ("инцидент USB правило={0} действие={1} важность={2} диск={3}" -f $ruleId, $action, $severity, $DriveLetter)
Write-EndpointLog ("incident usb rule={0} action={1} severity={2} drive={3} enforced={4}" -f $ruleId, $action, $severity, $DriveLetter, $enforced)
}
}
@@ -385,14 +493,21 @@ function Evaluate-PrintRules {
$action = if ($rule.action) { [string]$rule.action } else { [string]$script:Policy.defaults.action }
$severity = if ($rule.severity) { [string]$rule.severity } else { [string]$script:Policy.defaults.severity }
$message = if ($rule.message) { [string]$rule.message } else { "Сработало правило печати: $ruleId" }
$message = if ($rule.message) { [string]$rule.message } else { "Print rule matched: $ruleId" }
$enforced = $false
if ($action -eq 'block') {
$enforced = Invoke-PrintJobEnforcement -PrinterName $PrinterName -DocumentName $DocumentName -Owner $Owner
Show-EnforcementNotification -Title 'DLP: печать заблокирована' -Body $message
}
Send-DlpIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -SignalType 'print_job' -Data @{
printerName = $PrinterName
documentName = $DocumentName
owner = $Owner
enforced = $enforced
}
Write-EndpointLog ("инцидент печати правило={0} действие={1} важность={2} принтер={3}" -f $ruleId, $action, $severity, $PrinterName)
Write-EndpointLog ("incident print rule={0} action={1} severity={2} printer={3} enforced={4}" -f $ruleId, $action, $severity, $PrinterName, $enforced)
}
}
@@ -402,52 +517,6 @@ function Test-LooksLikeMojibakeQuestionMarks {
return $Value -match '\?{2,}'
}
function Test-DocumentNameNeedsFallback {
param([AllowNull()][string]$Value)
if ([string]::IsNullOrWhiteSpace($Value)) { return $true }
$trimmed = $Value.Trim()
if (Test-LooksLikeMojibakeQuestionMarks -Value $trimmed) { return $true }
if ($trimmed -match '^[0-9]+$') { return $true }
if ($trimmed -match '^(?i)(print document|document|local downlevel document)$') { return $true }
return $false
}
function Get-EventXmlValue {
param(
[Parameter(Mandatory = $true)][xml]$EventXml,
[Parameter(Mandatory = $true)][string]$Name
)
$node = $EventXml.Event.UserData.DocumentPrinted.$Name
if ($null -ne $node) {
return [string]$node
}
return ''
}
function Get-PrintJobPrinterName {
param(
[AllowNull()][string]$JobName,
[AllowNull()][string]$FallbackPrinterName
)
if ([string]::IsNullOrWhiteSpace($JobName)) {
if (-not [string]::IsNullOrWhiteSpace($FallbackPrinterName)) {
return $FallbackPrinterName.Trim()
}
return ''
}
$parts = $JobName -split ',', 2
if ($parts.Count -gt 0 -and -not [string]::IsNullOrWhiteSpace($parts[0])) {
return $parts[0].Trim()
}
return $JobName.Trim()
}
function Normalize-OwnerForMatch {
param([AllowNull()][string]$Value)
if ([string]::IsNullOrWhiteSpace($Value)) { return '' }
@@ -515,50 +584,13 @@ function Get-PrintServiceEventSummary {
$propertyValues += [string]$prop.Value
}
$xml = $null
try {
$xml = [xml]$Event.ToXml()
}
catch {
}
$jobId = ''
$documentName = ''
$owner = ''
$portName = ''
$printerName = ''
$sizeBytes = ''
$pageCount = ''
if ($xml) {
$jobId = Get-EventXmlValue -EventXml $xml -Name 'Param1'
$documentName = Get-EventXmlValue -EventXml $xml -Name 'Param2'
$owner = Get-EventXmlValue -EventXml $xml -Name 'Param3'
$portName = Get-EventXmlValue -EventXml $xml -Name 'Param4'
$printerName = Get-EventXmlValue -EventXml $xml -Name 'Param5'
$sizeBytes = Get-EventXmlValue -EventXml $xml -Name 'Param7'
$pageCount = Get-EventXmlValue -EventXml $xml -Name 'Param8'
}
if ([string]::IsNullOrWhiteSpace($jobId) -and $props.Count -ge 1) { $jobId = [string]$props[0].Value }
if ([string]::IsNullOrWhiteSpace($documentName) -and $props.Count -ge 2) { $documentName = [string]$props[1].Value }
if ([string]::IsNullOrWhiteSpace($owner) -and $props.Count -ge 3) { $owner = [string]$props[2].Value }
if ([string]::IsNullOrWhiteSpace($portName) -and $props.Count -ge 4) { $portName = [string]$props[3].Value }
if ([string]::IsNullOrWhiteSpace($printerName) -and $props.Count -ge 5) { $printerName = [string]$props[4].Value }
if ([string]::IsNullOrWhiteSpace($sizeBytes) -and $props.Count -ge 7) { $sizeBytes = [string]$props[6].Value }
if ([string]::IsNullOrWhiteSpace($pageCount) -and $props.Count -ge 8) { $pageCount = [string]$props[7].Value }
[pscustomobject]@{
RecordId = [string]$Event.RecordId
TimeCreated = if ($Event.TimeCreated) { $Event.TimeCreated.ToString('o') } else { '' }
PropertyCount = $props.Count
JobId = $jobId
DocumentName = $documentName
Owner = $owner
PortName = $portName
PrinterName = $printerName
SizeBytes = $sizeBytes
PageCount = $pageCount
DocumentName = if ($props.Count -ge 1) { [string]$props[0].Value } else { '' }
Owner = if ($props.Count -ge 2) { [string]$props[1].Value } else { '' }
PrinterName = if ($props.Count -ge 4) { [string]$props[3].Value } else { '' }
PropertyValues = $propertyValues
}
}
@@ -571,7 +603,7 @@ function Get-PrintServiceDocumentFallback {
)
$preferred = [string]$EventSummary.DocumentName
if (-not (Test-DocumentNameNeedsFallback -Value $preferred)) {
if (-not (Test-LooksLikeMojibakeQuestionMarks -Value $preferred) -and $preferred -notmatch '^[0-9]+$') {
return $preferred
}
@@ -582,10 +614,9 @@ function Get-PrintServiceDocumentFallback {
$candidate = [string]$value
if ([string]::IsNullOrWhiteSpace($candidate)) { continue }
if ($candidate -eq $preferred) { continue }
if ($EventSummary.JobId -and $candidate -eq [string]$EventSummary.JobId) { continue }
if ($Owner -and $candidate -like "*$Owner*") { continue }
if ($PrinterName -and $candidate -like "*$PrinterName*") { continue }
if (Test-DocumentNameNeedsFallback -Value $candidate) { continue }
if (Test-LooksLikeMojibakeQuestionMarks -Value $candidate) { continue }
if ($candidate -match '[\\/:]' -and $candidate -match '\.[A-Za-z0-9]{1,8}$') {
$pathCandidates.Add($candidate)
@@ -630,7 +661,7 @@ function Write-PrintServiceEventTrace {
}
Write-EndpointLog (
'printservice-307 этап={0} recordId={1} время={2} владелец={3} принтер={4} документ={5} итоговыйДокумент={6} свойства=[{7}] причина={8}' -f
'printservice-307 phase={0} recordId={1} time={2} owner={3} printer={4} document={5} resolved={6} properties=[{7}] reason={8}' -f
$Phase,
$EventSummary.RecordId,
$EventSummary.TimeCreated,
@@ -645,7 +676,6 @@ function Write-PrintServiceEventTrace {
function Get-BetterDocumentNameFromPrintServiceEvents {
param(
[string]$JobId,
[string]$Owner,
[string]$PrinterName
)
@@ -663,41 +693,32 @@ function Get-BetterDocumentNameFromPrintServiceEvents {
$summary = Get-PrintServiceEventSummary -Event $event
$resolvedDocument = Get-PrintServiceDocumentFallback -EventSummary $summary -Owner $Owner -PrinterName $PrinterName
$jobMatches = if ($JobId) { [string]$summary.JobId -eq [string]$JobId } else { $true }
$ownerMatches = if ($Owner) { Test-OwnerLooseMatch -Expected $Owner -Actual $summary.Owner } else { $true }
$printerMatches = if ($PrinterName) { Test-PrinterLooseMatch -Expected $PrinterName -Actual $summary.PrinterName } else { $true }
if ($pass -eq 'strict') {
if ($JobId -and -not $jobMatches) {
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'несовпадение-jobid-strict' -ResolvedDocument $resolvedDocument
continue
}
if ($Owner -and -not $ownerMatches) {
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'несовпадение-владельца-strict' -ResolvedDocument $resolvedDocument
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'owner-mismatch-strict' -ResolvedDocument $resolvedDocument
continue
}
if ($PrinterName -and -not $printerMatches) {
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'несовпадение-принтера-strict' -ResolvedDocument $resolvedDocument
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'printer-mismatch-strict' -ResolvedDocument $resolvedDocument
continue
}
}
else {
if ($JobId -and (-not $jobMatches) -and $Owner -and $PrinterName -and -not $ownerMatches -and -not $printerMatches) {
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'несовпадение-владельца-и-принтера-relaxed' -ResolvedDocument $resolvedDocument
continue
}
if ((-not $JobId) -and $Owner -and $PrinterName -and -not $ownerMatches -and -not $printerMatches) {
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'несовпадение-владельца-и-принтера-relaxed' -ResolvedDocument $resolvedDocument
if ($Owner -and $PrinterName -and -not $ownerMatches -and -not $printerMatches) {
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'owner-and-printer-mismatch-relaxed' -ResolvedDocument $resolvedDocument
continue
}
}
if ([string]::IsNullOrWhiteSpace($resolvedDocument)) {
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason ('нет-кандидата-документа-' + $pass) -ResolvedDocument ''
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason ('no-document-candidate-' + $pass) -ResolvedDocument ''
continue
}
$matchReasonBase = if (Test-DocumentNameNeedsFallback -Value $summary.DocumentName) { 'использован-резервный-вариант' } else { 'напрямую' }
$matchReasonBase = if (Test-LooksLikeMojibakeQuestionMarks -Value $summary.DocumentName) { 'fallback-used' } else { 'direct' }
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'selected' -MatchReason ($matchReasonBase + '-' + $pass) -ResolvedDocument $resolvedDocument
return $resolvedDocument
}
@@ -710,15 +731,15 @@ function Get-BetterDocumentNameFromPrintServiceEvents {
}
$deploymentConfig = Get-DeploymentConfig -Path $ConfigPath
$resolvedServerHost = if ($ServerHost) { $ServerHost } elseif ($deploymentConfig) { [string]$deploymentConfig.server.host } else { throw 'Укажите ServerHost или подготовьте deployment-config.json.' }
$resolvedServerHost = if ($ServerHost) { $ServerHost } elseif ($deploymentConfig) { [string]$deploymentConfig.server.host } else { throw 'ServerHost is required.' }
$resolvedServerPort = if ($PSBoundParameters.ContainsKey('ServerPort')) { $ServerPort } elseif ($deploymentConfig) { [int]$deploymentConfig.server.port } else { 5600 }
$resolvedServerScheme = if ($ServerScheme) { $ServerScheme } elseif ($deploymentConfig) { [string]$deploymentConfig.server.scheme } else { 'http' }
$resolvedPolicyPath = if ($PolicyPath) { $PolicyPath } elseif ($deploymentConfig -and $deploymentConfig.paths.PSObject.Properties.Name -contains 'policyPath') { [string]$deploymentConfig.paths.policyPath } else { 'C:\ProgramData\AWatch-rus\dlp-policy.json' }
$resolvedPolicyPath = if ($PolicyPath) { $PolicyPath } elseif ($deploymentConfig -and $deploymentConfig.paths.PSObject.Properties.Name -contains 'policyPath') { [string]$deploymentConfig.paths.policyPath } else { 'C:\ProgramData\ActivityWatch\dlp-policy.json' }
$resolvedPollSeconds = if ($PSBoundParameters.ContainsKey('PollSeconds')) { $PollSeconds } elseif ($deploymentConfig) { [int]$deploymentConfig.collector.pollSeconds } else { 5 }
$resolvedLogsRoot = if ($deploymentConfig) { [string]$deploymentConfig.paths.logsRoot } else { 'C:\ProgramData\AWatch-rus\logs' }
$resolvedLogsRoot = if ($deploymentConfig) { [string]$deploymentConfig.paths.logsRoot } else { 'C:\ProgramData\ActivityWatch\logs' }
$resolvedLogPath = if ($LogPath) { $LogPath } else { Join-Path $resolvedLogsRoot ("endpoint-signals-{0}.log" -f $env:USERNAME) }
$resolvedLocalAgentLogsEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'logging' -and $deploymentConfig.logging.PSObject.Properties.Name -contains 'localAgentLogsEnabled') { [bool]$deploymentConfig.logging.localAgentLogsEnabled } else { $true }
$resolvedIncidentArtifactsRoot = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'artifactsRoot') { [string]$deploymentConfig.incidentCapture.artifactsRoot } else { Join-Path $env:LOCALAPPDATA 'AWatch-rus\\incident-artifacts' }
$resolvedIncidentArtifactsRoot = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'artifactsRoot') { [string]$deploymentConfig.incidentCapture.artifactsRoot } else { Join-Path $env:LOCALAPPDATA 'ActivityWatch-Phase2\\incident-artifacts' }
$resolvedIncidentScreenshotEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'screenshotEnabled') { [bool]$deploymentConfig.incidentCapture.screenshotEnabled } else { $true }
if ($resolvedLocalAgentLogsEnabled -and -not (Test-Path -LiteralPath $resolvedLogsRoot)) {
@@ -742,7 +763,7 @@ $script:IncidentScreenshotEnabled = $resolvedIncidentScreenshotEnabled
$script:ScreenshotTypesLoaded = $false
Load-DlpPolicy -Path $resolvedPolicyPath
Write-EndpointLog ("endpoint-коллектор запущен для {0}" -f $script:ApiBase)
Write-EndpointLog ("endpoint collector started against {0}" -f $script:ApiBase)
while ($true) {
try {
@@ -803,13 +824,13 @@ while ($true) {
if ($script:SeenPrintJob.ContainsKey($jobId)) { continue }
$script:SeenPrintJob[$jobId] = (Get-Date).ToUniversalTime()
$printerName = Get-PrintJobPrinterName -JobName ([string]$job.Name) -FallbackPrinterName ([string]$job.DriverName)
$printerName = [string]$job.Name
$documentName = [string]$job.Document
$owner = [string]$job.Owner
$documentNameOriginal = $documentName
if (Test-DocumentNameNeedsFallback -Value $documentName) {
$eventDocumentName = Get-BetterDocumentNameFromPrintServiceEvents -JobId $jobId -Owner $owner -PrinterName $printerName
if (Test-LooksLikeMojibakeQuestionMarks -Value $documentName) {
$eventDocumentName = Get-BetterDocumentNameFromPrintServiceEvents -Owner $owner -PrinterName $printerName
if ($eventDocumentName) {
$documentName = $eventDocumentName
}
@@ -820,7 +841,6 @@ while ($true) {
documentName = $documentName
documentNameOriginal = $documentNameOriginal
owner = $owner
printJobId = $jobId
}
Evaluate-PrintRules -PrinterName $printerName -DocumentName $documentName -Owner $owner
}
@@ -884,7 +904,7 @@ while ($true) {
}
}
catch {
Write-EndpointLog ("ошибка коллектора: {0}" -f $_.Exception.Message)
Write-EndpointLog ("collector error: {0}" -f $_.Exception.Message)
}
Start-Sleep -Seconds $resolvedPollSeconds
+582
View File
@@ -0,0 +1,582 @@
<#
.SYNOPSIS
DLP email outbound collector for AWatch-rus (Phase 2.5).
Monitors outgoing email via Outlook COM Sent Items polling
and/or SMTP network connection detection.
.DESCRIPTION
Two collection modes (configurable, can run simultaneously):
- outlook : Polls Outlook Sent Items via COM for new messages.
- smtp : Monitors SMTP connections (ports 25/587/465) via
Get-NetTCPConnection for any process sending mail.
Sends heartbeats to AW bucket `aw-email-monitor_<host>`.
Evaluates DLP policy rules from `endpoint.email[]` section.
Supports enforcement: action="block" moves the email to Drafts
(Outlook mode) or logs with enforced=false (SMTP mode).
#>
[CmdletBinding()]
param(
[string]$ConfigPath = 'C:\ProgramData\ActivityWatch\deployment-config.json',
[string]$ServerHost,
[int]$ServerPort,
[ValidateSet('http', 'https')]
[string]$ServerScheme,
[string]$PolicyPath,
[string]$LogPath,
[int]$PollSeconds,
[ValidateSet('outlook', 'smtp', 'both')]
[string]$Mode = 'both'
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
# ---------------------------------------------------------------------------
# Shared infrastructure (mirrors other collectors)
# ---------------------------------------------------------------------------
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-CollectorLog {
param([string]$Message)
if (-not $script:LocalAgentLogsEnabled) { return }
try {
Add-Content -LiteralPath $script:LogPath -Value ('{0} {1}' -f (Get-Date -Format s), $Message)
}
catch { }
}
function Invoke-AwJsonPost {
param(
[Parameter(Mandatory = $true)][string]$Uri,
[Parameter(Mandatory = $true)][string]$Json
)
$bytes = [Text.Encoding]::UTF8.GetBytes($Json)
Invoke-RestMethod -Method Post -Uri $Uri -ContentType 'application/json; charset=utf-8' -Body $bytes | Out-Null
}
function Ensure-Bucket {
param(
[string]$BucketId,
[string]$ClientName,
[string]$BucketType
)
if ($script:KnownBuckets.ContainsKey($BucketId)) { return }
$body = @{
client = $ClientName
type = $BucketType
hostname = $script:Hostname
} | ConvertTo-Json -Compress
Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$BucketId" -Json $body
$script:KnownBuckets[$BucketId] = $true
}
function Get-StringHash {
param([AllowNull()][string]$Value)
if ($null -eq $Value) { return $null }
$bytes = [Text.Encoding]::UTF8.GetBytes($Value)
$sha = [Security.Cryptography.SHA256]::Create()
try {
($sha.ComputeHash($bytes) | ForEach-Object { $_.ToString('x2') }) -join ''
}
finally { $sha.Dispose() }
}
function Send-EmailHeartbeat {
param(
[string]$SignalType,
[hashtable]$Data
)
$bucketId = 'aw-email-monitor_' + $script:Hostname
Ensure-Bucket -BucketId $bucketId -ClientName 'aw-email-monitor' -BucketType 'aw.dlp.email'
$payload = @{
timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
duration = 0
data = @{
signalType = $SignalType
username = $env:USERNAME
sessionId = $script:SessionId
hostname = $script:Hostname
source = 'email-outbound-collector'
} + $Data
} | ConvertTo-Json -Depth 6 -Compress
Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$script:PulseSeconds" -Json $payload
}
function Send-EmailIncidentHeartbeat {
param(
[string]$RuleId,
[string]$Action,
[string]$Severity,
[string]$Message,
[hashtable]$Data
)
$bucketId = 'aw-dlp-incidents_' + $script:Hostname
Ensure-Bucket -BucketId $bucketId -ClientName 'aw-dlp-incidents' -BucketType 'aw.dlp.incident'
$payload = @{
timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
duration = 0
data = @{
ruleId = $RuleId
action = $Action
severity = $Severity
message = $Message
signalType = 'email_outbound'
username = $env:USERNAME
sessionId = $script:SessionId
hostname = $script:Hostname
source = 'email-outbound-collector'
} + $Data
} | ConvertTo-Json -Depth 7 -Compress
Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$script:PulseSeconds" -Json $payload
}
function Show-EnforcementNotification {
param(
[Parameter(Mandatory = $true)][string]$Title,
[Parameter(Mandatory = $true)][string]$Body
)
try {
Add-Type -AssemblyName System.Windows.Forms -ErrorAction SilentlyContinue
$icon = New-Object System.Windows.Forms.NotifyIcon
$icon.Icon = [System.Drawing.SystemIcons]::Warning
$icon.BalloonTipTitle = $Title
$icon.BalloonTipText = $Body
$icon.BalloonTipIcon = [System.Windows.Forms.ToolTipIcon]::Warning
$icon.Visible = $true
$icon.ShowBalloonTip(5000)
Start-Sleep -Milliseconds 200
$icon.Dispose()
}
catch { }
}
# ---------------------------------------------------------------------------
# DLP Policy
# ---------------------------------------------------------------------------
function Load-EmailPolicy {
param([string]$Path)
$script:Policy = [ordered]@{
defaults = [ordered]@{
enabled = $true
cooldownSeconds = 300
action = 'alert'
severity = 'medium'
}
endpoint = [ordered]@{
email = @()
}
}
if (-not $Path -or -not (Test-Path -LiteralPath $Path)) {
Write-CollectorLog ("policy not found, using defaults: {0}" -f $Path)
return
}
try {
$raw = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json
if ($raw.defaults) {
if ($raw.defaults.PSObject.Properties.Name -contains 'enabled') { $script:Policy.defaults.enabled = [bool]$raw.defaults.enabled }
if ($raw.defaults.cooldownSeconds) { $script:Policy.defaults.cooldownSeconds = [int]$raw.defaults.cooldownSeconds }
if ($raw.defaults.action) { $script:Policy.defaults.action = [string]$raw.defaults.action }
if ($raw.defaults.severity) { $script:Policy.defaults.severity = [string]$raw.defaults.severity }
}
if ($raw.endpoint -and $raw.endpoint.email) {
$script:Policy.endpoint.email = @($raw.endpoint.email)
}
}
catch {
Write-CollectorLog ("policy parse failed: {0}" -f $_.Exception.Message)
}
}
function Should-EmitByCooldown {
param(
[string]$Fingerprint,
[int]$CooldownSeconds
)
$now = (Get-Date).ToUniversalTime()
if ($script:Cooldown.ContainsKey($Fingerprint)) {
$last = [datetime]$script:Cooldown[$Fingerprint]
if ((New-TimeSpan -Start $last -End $now).TotalSeconds -lt $CooldownSeconds) {
return $false
}
}
$script:Cooldown[$Fingerprint] = $now
return $true
}
# ---------------------------------------------------------------------------
# Email DLP rule evaluation
# ---------------------------------------------------------------------------
function Evaluate-EmailRules {
param(
[string]$Subject,
[string]$RecipientsJoined,
[string]$SenderAddress,
[int]$AttachmentCount,
[string]$AttachmentNames,
[int]$BodyLength,
[string]$MessageId,
$OutlookMailItem
)
foreach ($rule in @($script:Policy.endpoint.email)) {
if (-not $rule) { continue }
if ($rule.PSObject.Properties.Name -contains 'enabled' -and -not [bool]$rule.enabled) { continue }
$ruleId = [string]$rule.id
if (-not $ruleId) { continue }
$matched = $true
if ($rule.subjectRegex) {
$matched = $matched -and ($Subject -match [string]$rule.subjectRegex)
}
if ($rule.recipientRegex) {
$matched = $matched -and ($RecipientsJoined -match [string]$rule.recipientRegex)
}
if ($rule.senderRegex) {
$matched = $matched -and ($SenderAddress -match [string]$rule.senderRegex)
}
if ($rule.attachmentRegex -and $AttachmentNames) {
$matched = $matched -and ($AttachmentNames -match [string]$rule.attachmentRegex)
}
if ($rule.minAttachments) {
$matched = $matched -and ($AttachmentCount -ge [int]$rule.minAttachments)
}
if ($rule.minBodyLength) {
$matched = $matched -and ($BodyLength -ge [int]$rule.minBodyLength)
}
if ($rule.externalOnly -and [bool]$rule.externalOnly) {
$internalDomain = if ($rule.internalDomain) { [string]$rule.internalDomain } else { '' }
if ($internalDomain -and $RecipientsJoined -notmatch [regex]::Escape($internalDomain)) {
# all recipients are external — continue matching
}
elseif ($internalDomain) {
$matched = $false
}
}
if (-not $matched) { continue }
$cooldown = if ($rule.cooldownSeconds) { [int]$rule.cooldownSeconds } else { [int]$script:Policy.defaults.cooldownSeconds }
$fingerprint = "email|$ruleId|$MessageId|$env:USERNAME"
if (-not (Should-EmitByCooldown -Fingerprint $fingerprint -CooldownSeconds ([Math]::Max($cooldown, 30)))) { continue }
$action = if ($rule.action) { [string]$rule.action } else { [string]$script:Policy.defaults.action }
$severity = if ($rule.severity) { [string]$rule.severity } else { [string]$script:Policy.defaults.severity }
$message = if ($rule.message) { [string]$rule.message } else { "Email rule matched: $ruleId" }
$enforced = $false
if ($action -eq 'block' -and $null -ne $OutlookMailItem) {
$enforced = Invoke-EmailEnforcement -MailItem $OutlookMailItem -RuleId $ruleId
Show-EnforcementNotification -Title 'DLP: письмо перемещено в черновики' -Body $message
}
elseif ($action -eq 'block') {
Show-EnforcementNotification -Title 'DLP: обнаружена отправка письма' -Body $message
}
Send-EmailIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -Data @{
subject = (Get-StringHash -Value $Subject)
recipients = (Get-StringHash -Value $RecipientsJoined)
sender = $SenderAddress
attachmentCount = $AttachmentCount
attachmentNames = $AttachmentNames
bodyLength = $BodyLength
enforced = $enforced
}
Write-CollectorLog ("incident email rule={0} action={1} severity={2} enforced={3} subject_hash={4}" -f $ruleId, $action, $severity, $enforced, (Get-StringHash -Value $Subject))
}
}
function Invoke-EmailEnforcement {
[OutputType([bool])]
param(
[Parameter(Mandatory = $true)]$MailItem,
[string]$RuleId
)
try {
$draftsFolder = $script:OutlookNamespace.GetDefaultFolder(16) # olFolderDrafts
$MailItem.Move($draftsFolder) | Out-Null
Write-CollectorLog ("enforcement: email moved to Drafts rule={0} subject_hash={1}" -f $RuleId, (Get-StringHash -Value $MailItem.Subject))
return $true
}
catch {
Write-CollectorLog ("enforcement: email move to Drafts failed rule={0}: {1}" -f $RuleId, $_.Exception.Message)
return $false
}
}
# ---------------------------------------------------------------------------
# Outlook Sent Items polling
# ---------------------------------------------------------------------------
function Initialize-OutlookCom {
try {
$script:OutlookApp = New-Object -ComObject Outlook.Application
$script:OutlookNamespace = $script:OutlookApp.GetNamespace('MAPI')
$script:SentFolder = $script:OutlookNamespace.GetDefaultFolder(5) # olFolderSentMail
Write-CollectorLog "Outlook COM initialized, Sent Items folder opened"
return $true
}
catch {
Write-CollectorLog ("Outlook COM init failed: {0}" -f $_.Exception.Message)
return $false
}
}
function Get-OutlookSentItems {
param([datetime]$Since)
$results = @()
try {
$items = $script:SentFolder.Items
$items.Sort('[SentOn]', $true)
$filter = "[SentOn] >= '{0}'" -f $Since.ToString('MM/dd/yyyy HH:mm')
$restricted = $items.Restrict($filter)
foreach ($item in $restricted) {
try {
if ($item.Class -ne 43) { continue } # olMail = 43
$recipients = @()
for ($i = 1; $i -le $item.Recipients.Count; $i++) {
$recip = $item.Recipients.Item($i)
$recipients += [string]$recip.Address
}
$attachmentNames = @()
for ($i = 1; $i -le $item.Attachments.Count; $i++) {
$attachmentNames += [string]$item.Attachments.Item($i).FileName
}
$results += [pscustomobject]@{
EntryID = [string]$item.EntryID
Subject = [string]$item.Subject
SenderAddress = [string]$item.SenderEmailAddress
SenderName = [string]$item.SenderName
Recipients = $recipients
RecipientsJoined = ($recipients -join '; ')
AttachmentCount = [int]$item.Attachments.Count
AttachmentNames = ($attachmentNames -join '; ')
BodyLength = if ($item.Body) { $item.Body.Length } else { 0 }
SentOn = $item.SentOn
MailItem = $item
}
}
catch { }
}
}
catch {
Write-CollectorLog ("Outlook Sent Items scan failed: {0}" -f $_.Exception.Message)
}
return $results
}
function Poll-OutlookSentItems {
$items = Get-OutlookSentItems -Since $script:OutlookLastPoll
foreach ($item in $items) {
$entryId = $item.EntryID
if ($script:SeenEntryIds.ContainsKey($entryId)) { continue }
$script:SeenEntryIds[$entryId] = (Get-Date).ToUniversalTime()
$subjectHash = Get-StringHash -Value $item.Subject
Send-EmailHeartbeat -SignalType 'email_sent' -Data @{
subject = $subjectHash
sender = [string]$item.SenderAddress
senderName = [string]$item.SenderName
recipientCount = $item.Recipients.Count
recipients = (Get-StringHash -Value $item.RecipientsJoined)
attachmentCount = [int]$item.AttachmentCount
attachmentNames = [string]$item.AttachmentNames
bodyLength = [int]$item.BodyLength
sentOn = if ($item.SentOn) { $item.SentOn.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') } else { '' }
collectionMode = 'outlook'
}
Write-CollectorLog ("email_sent outlook subject_hash={0} to={1} attachments={2}" -f $subjectHash, $item.Recipients.Count, $item.AttachmentCount)
Evaluate-EmailRules `
-Subject $item.Subject `
-RecipientsJoined $item.RecipientsJoined `
-SenderAddress $item.SenderAddress `
-AttachmentCount $item.AttachmentCount `
-AttachmentNames $item.AttachmentNames `
-BodyLength $item.BodyLength `
-MessageId $entryId `
-OutlookMailItem $item.MailItem
}
$script:OutlookLastPoll = (Get-Date).AddSeconds(-10)
# Cleanup old entry IDs (keep last 24h)
$cleanupBefore = (Get-Date).ToUniversalTime().AddHours(-24)
foreach ($k in @($script:SeenEntryIds.Keys)) {
if ([datetime]$script:SeenEntryIds[$k] -lt $cleanupBefore) {
$script:SeenEntryIds.Remove($k)
}
}
}
# ---------------------------------------------------------------------------
# SMTP network connection monitoring
# ---------------------------------------------------------------------------
function Poll-SmtpConnections {
try {
$smtpPorts = @(25, 587, 465, 2525)
$connections = Get-NetTCPConnection -State Established -ErrorAction SilentlyContinue |
Where-Object { $smtpPorts -contains $_.RemotePort }
foreach ($conn in @($connections)) {
$processId = [int]$conn.OwningProcess
$remoteAddr = [string]$conn.RemoteAddress
$remotePort = [int]$conn.RemotePort
$fingerprint = "{0}:{1}:{2}" -f $processId, $remoteAddr, $remotePort
if ($script:SeenSmtpConnections.ContainsKey($fingerprint)) { continue }
$script:SeenSmtpConnections[$fingerprint] = (Get-Date).ToUniversalTime()
$processName = ''
try {
$proc = Get-Process -Id $processId -ErrorAction SilentlyContinue
$processName = [string]$proc.ProcessName
}
catch { }
Send-EmailHeartbeat -SignalType 'smtp_connection' -Data @{
remoteAddress = $remoteAddr
remotePort = $remotePort
processId = $processId
processName = $processName
localPort = [int]$conn.LocalPort
collectionMode = 'smtp'
}
Write-CollectorLog ("smtp_connection process={0}({1}) remote={2}:{3}" -f $processName, $processId, $remoteAddr, $remotePort)
Evaluate-EmailRules `
-Subject '' `
-RecipientsJoined $remoteAddr `
-SenderAddress $env:USERNAME `
-AttachmentCount 0 `
-AttachmentNames '' `
-BodyLength 0 `
-MessageId $fingerprint `
-OutlookMailItem $null
}
# Cleanup old SMTP connections (keep last 8h)
$cleanupBefore = (Get-Date).ToUniversalTime().AddHours(-8)
foreach ($k in @($script:SeenSmtpConnections.Keys)) {
if ([datetime]$script:SeenSmtpConnections[$k] -lt $cleanupBefore) {
$script:SeenSmtpConnections.Remove($k)
}
}
}
catch {
Write-CollectorLog ("SMTP poll error: {0}" -f $_.Exception.Message)
}
}
# ---------------------------------------------------------------------------
# Initialization
# ---------------------------------------------------------------------------
$deploymentConfig = Get-DeploymentConfig -Path $ConfigPath
$resolvedServerHost = if ($ServerHost) { $ServerHost } elseif ($deploymentConfig) { [string]$deploymentConfig.server.host } else { throw 'ServerHost is required.' }
$resolvedServerPort = if ($PSBoundParameters.ContainsKey('ServerPort')) { $ServerPort } elseif ($deploymentConfig) { [int]$deploymentConfig.server.port } else { 5600 }
$resolvedServerScheme = if ($ServerScheme) { $ServerScheme } elseif ($deploymentConfig) { [string]$deploymentConfig.server.scheme } else { 'http' }
$resolvedPolicyPath = if ($PolicyPath) { $PolicyPath } elseif ($deploymentConfig -and $deploymentConfig.paths.PSObject.Properties.Name -contains 'policyPath') { [string]$deploymentConfig.paths.policyPath } else { 'C:\ProgramData\ActivityWatch\dlp-policy.json' }
$resolvedPollSeconds = if ($PSBoundParameters.ContainsKey('PollSeconds')) { $PollSeconds } elseif ($deploymentConfig) { [int]$deploymentConfig.collector.pollSeconds } else { 10 }
$resolvedLogsRoot = if ($deploymentConfig) { [string]$deploymentConfig.paths.logsRoot } else { 'C:\ProgramData\ActivityWatch\logs' }
$resolvedLogPath = if ($LogPath) { $LogPath } else { Join-Path $resolvedLogsRoot ("email-outbound-{0}.log" -f $env:USERNAME) }
$resolvedLocalAgentLogsEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'logging' -and $deploymentConfig.logging.PSObject.Properties.Name -contains 'localAgentLogsEnabled') { [bool]$deploymentConfig.logging.localAgentLogsEnabled } else { $true }
if ($resolvedLocalAgentLogsEnabled -and -not (Test-Path -LiteralPath $resolvedLogsRoot)) {
New-Item -Path $resolvedLogsRoot -ItemType Directory -Force | Out-Null
}
$script:ApiBase = '{0}://{1}:{2}/api/0' -f $resolvedServerScheme, $resolvedServerHost, $resolvedServerPort
$script:Hostname = $env:COMPUTERNAME
$script:SessionId = (Get-Process -Id $PID).SessionId
$script:KnownBuckets = @{}
$script:Cooldown = @{}
$script:SeenEntryIds = @{}
$script:SeenSmtpConnections = @{}
$script:PulseSeconds = [Math]::Max($resolvedPollSeconds * 3, 30)
$script:LocalAgentLogsEnabled = $resolvedLocalAgentLogsEnabled
$script:LogPath = $resolvedLogPath
$script:OutlookApp = $null
$script:OutlookNamespace = $null
$script:SentFolder = $null
$script:OutlookLastPoll = (Get-Date).AddMinutes(-5)
Load-EmailPolicy -Path $resolvedPolicyPath
Write-CollectorLog ("email collector started mode={0} against {1}" -f $Mode, $script:ApiBase)
$useOutlook = ($Mode -eq 'outlook' -or $Mode -eq 'both')
$useSmtp = ($Mode -eq 'smtp' -or $Mode -eq 'both')
$outlookReady = $false
if ($useOutlook) {
$outlookReady = Initialize-OutlookCom
if (-not $outlookReady -and $Mode -eq 'outlook') {
Write-CollectorLog "Outlook COM not available, collector will retry"
}
}
# ---------------------------------------------------------------------------
# Main loop
# ---------------------------------------------------------------------------
while ($true) {
try {
if (-not $script:Policy.defaults.enabled) {
Start-Sleep -Seconds $resolvedPollSeconds
continue
}
if ($useOutlook) {
if (-not $outlookReady) {
$outlookReady = Initialize-OutlookCom
}
if ($outlookReady) {
try {
Poll-OutlookSentItems
}
catch {
Write-CollectorLog ("outlook poll error: {0}" -f $_.Exception.Message)
$outlookReady = $false
$script:OutlookApp = $null
$script:OutlookNamespace = $null
$script:SentFolder = $null
}
}
}
if ($useSmtp) {
try {
Poll-SmtpConnections
}
catch {
Write-CollectorLog ("smtp poll error: {0}" -f $_.Exception.Message)
}
}
}
catch {
Write-CollectorLog ("collector error: {0}" -f $_.Exception.Message)
}
Start-Sleep -Seconds $resolvedPollSeconds
}
+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()
}
}
+8
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 }
@@ -83,6 +87,7 @@ else {
New-ActivityWatchDirectory -Path $effectiveStateRoot
New-ActivityWatchDirectory -Path $effectiveLogsRoot
Enable-ActivityWatchPrintTelemetry
if ($RepairPackage) {
$workingRoot = Join-Path $env:TEMP 'activitywatch-windows-deploy'
@@ -96,6 +101,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 +122,7 @@ $config = New-ActivityWatchDeploymentConfig `
-LogsRoot $effectiveLogsRoot `
-CollectorScript $effectiveCollector `
-EndpointCollectorScript $effectiveEndpointCollector `
-FileCollectorScript $effectiveFileCollector `
-SessionCollectorScript $effectiveSessionCollector `
-RulesPath $effectiveRules `
-PolicyPath $effectivePolicy `
@@ -124,6 +131,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'"
}
}
}
+52 -23
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,21 @@ $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 }
$printServiceOperationalEnabled = $false
try {
$printServiceLog = Get-WinEvent -ListLog 'Microsoft-Windows-PrintService/Operational' -ErrorAction Stop
$printServiceOperationalEnabled = [bool]$printServiceLog.IsEnabled
}
catch {
}
$printJobTitlePolicyEnabled = $false
try {
$printPolicy = Get-ItemProperty -LiteralPath 'HKLM:\Software\Policies\Microsoft\Windows NT\Printers' -Name 'ShowJobTitleInEventLogs' -ErrorAction Stop
$printJobTitlePolicyEnabled = ([int]$printPolicy.ShowJobTitleInEventLogs -eq 1)
}
catch {
}
$requiredFiles = @(
$collectorScript,
$endpointCollectorScript,
@@ -32,6 +48,9 @@ $requiredFiles = @(
$recoveryScript,
$ConfigPath
)
if ($fileOpsExpected) {
$requiredFiles += $fileCollectorScript
}
if ($afkExpected) {
$requiredFiles += (Join-Path $installRoot 'aw-watcher-afk\aw-watcher-afk.exe')
}
@@ -50,12 +69,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 +85,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,13 +129,18 @@ $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)
)
}
printTelemetry = [ordered]@{
operationalLogEnabled = $printServiceOperationalEnabled
jobTitlePolicyEnabled = $printJobTitlePolicyEnabled
ok = [bool]($printServiceOperationalEnabled -and $printJobTitlePolicyEnabled)
}
}
$result.overallOk = [bool]($result.files.ok -and $result.tasks.ok -and $result.processes.ok)
$result.overallOk = [bool]($result.files.ok -and $result.tasks.ok -and $result.processes.ok -and $result.printTelemetry.ok)
$result