Compare commits

..
Author SHA1 Message Date
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
Devin AI 2e49310023 Добавить безопасную миграцию prod в AWatch-rus 2026-05-02 11:58:28 +00:00
Devin AI 2ec90b05ae Унифицировать Windows пути AWatch-rus 2026-05-02 09:44:27 +00:00
Devin AI 21f0184115 Согласовать Windows пути с InnoSetup 2026-05-02 09:20:28 +00:00
Devin AI 342ab77f44 Исправить recovery config и разбор RDP sessions 2026-05-02 09:08:14 +00:00
Devin AI 1e92b8b679 Русифицировать и доработать Ansible развёртывание 2026-05-02 09:07:08 +00:00
Devin AI 8c9dfffc7d Ограничить PSScriptAnalyzer ошибками 2026-05-02 08:35:23 +00:00
Devin AI 087883b663 Уточнить настройки PSScriptAnalyzer 2026-05-02 08:34:21 +00:00
Devin AI 4bbf0b8b73 Исправить CI-проверки shellcheck и PSScriptAnalyzer 2026-05-02 08:32:53 +00:00
Devin AI da99d1ac98 Русифицировать DLP PowerShell и исправить имя документа печати 2026-05-02 08:30:37 +00:00
igor04091968 f7cf5556a0 Ofline install InnoSetup 2026-05-02 10:01:39 +03:00
igor04091968 65da55be7a Patch files Innosetup 2026-05-02 04:12:14 +03:00
igor04091968 8278d51840 feat(worktime): add linux remote worker and pve headless tracking 2026-04-30 15:48:09 +03:00
96 changed files with 5598 additions and 1748 deletions
-34
View File
@@ -1,34 +0,0 @@
name: Build Windows installer
on:
workflow_dispatch:
push:
paths:
- 'windows/**'
- '.github/workflows/build-installer.yml'
jobs:
build:
runs-on: windows-latest
env:
SIGNTOOL_CMD: ${{ secrets.SIGNTOOL_CMD }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Inno
shell: pwsh
run: |
choco install innosetup --no-progress -y
- name: Build installer
shell: pwsh
run: |
./windows/installer/build-installer.ps1
- name: Upload Setup.exe artifact
uses: actions/upload-artifact@v4
with:
name: awatch-rus-setup
path: windows/installer/output/*.exe
if-no-files-found: error
+4 -2
View File
@@ -18,7 +18,7 @@ jobs:
- name: Run shellcheck
run: |
find . -type f -name "*.sh" -print0 | xargs -0 -r shellcheck
find . -type f -name "*.sh" -print0 | xargs -0 -r shellcheck -e SC1007,SC1090,SC2016
powershell-analyzer:
runs-on: ubuntu-latest
@@ -40,7 +40,9 @@ jobs:
"windows/*.psm1",
"windows/*.psd1"
)
$issues = Invoke-ScriptAnalyzer -Path $targets -Recurse -Severity Error,Warning
$issues = $targets | ForEach-Object {
Invoke-ScriptAnalyzer -Path $_ -Recurse -Severity Error
}
if ($issues) {
$issues | Format-Table -AutoSize
throw "PSScriptAnalyzer detected issues."
+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/
+13 -3
View File
@@ -10,16 +10,21 @@
- `docs/operations.md` — регламент сопровождения, бэкапов, обновлений и rollback.
- `docs/windows/ensemble.md` — orchestration-пакет для Windows-деплоя и проверки.
- `docs/linux-client.md` — user-space rollout Linux-клиента ActivityWatch на удалённый `AW server`.
- `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, phase-2 DLP telemetry (`aw-dlp-incidents_*`, `aw-dlp-endpoint-signals_*`).
- `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.
- `scripts/install_aw_linux_remote_worker.sh` — полный Linux remote-worker installer.
## Базовый сценарий
@@ -38,9 +43,9 @@
- `ansible/provision_proxmox_ct_and_deploy_aw.yml`
- `ansible/provision_proxmox_ct_matrix_and_deploy_aw.yml` (массово по матрице CT)
Для централизованного phase-2 деплоя Windows-клиентов через WinRM:
Для централизованного деплоя Windows/RDP-клиентов через WinRM:
- `ansible/deploy_aw_windows_phase2.yml`
- `ansible/deploy_aw_windows.yml`
Для внешнего pfSense poller'а:
@@ -51,6 +56,11 @@
- `docs/linux-client.md`
- `scripts/install_aw_linux_client.sh`
Для полного Linux remote-worker сценария:
- `docs/linux-remote-worker.md`
- `scripts/install_aw_linux_remote_worker.sh`
Для режима “только консоль/ssh” без GUI watcher'ов:
- `docs/console-ssh-logger.md`
+17
View File
@@ -0,0 +1,17 @@
#!/bin/sh
set -eu
REPO_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
cd "$REPO_DIR"
echo "==> Fetch origin"
git fetch origin
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
echo "==> Pull origin/$CURRENT_BRANCH"
git pull --ff-only origin "$CURRENT_BRANCH"
echo "==> Done"
git status --short --branch
Executable
+34
View File
@@ -0,0 +1,34 @@
#!/bin/sh
set -eu
REPO_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
cd "$REPO_DIR"
BRANCH=$(git rev-parse --abbrev-ref HEAD)
if [ "${1:-}" = "" ]; then
echo "Usage: $0 \"commit message\"" >&2
exit 1
fi
COMMIT_MESSAGE=$1
echo "==> Git status"
git status --short --branch
echo "==> Stage changes"
git add -A
if git diff --cached --quiet; then
echo "No staged changes to commit."
exit 0
fi
echo "==> Commit"
git commit -m "$COMMIT_MESSAGE"
echo "==> Push origin/$BRANCH"
git push origin "$BRANCH"
echo "==> Done"
+67 -39
View File
@@ -1,46 +1,51 @@
# Ansible ensemble for AWatch-rus
Эта директория содержит Ansible-ensemble для двух сценариев:
Эта директория содержит Ansible-ensemble для полного развёртывания AWatch-rus:
- деплой на уже существующий Debian host/CT;
- полный цикл с нуля в Proxmox: создание CT + bootstrap + установка ActivityWatch + RU patch.
- централизованный деплой Windows phase-2 collectors по WinRM.
- deployment внешнего pfSense poller'а на Debian/Ubuntu utility VM.
- полный цикл с нуля в Proxmox: создание CT + bootstrap + установка ActivityWatch + RU patch;
- централизованное развёртывание Windows/RDP collector'ов по WinRM;
- развёртывание внешнего pfSense poller'а на Debian/Ubuntu utility VM.
## Файлы
- `/home/igor/tmp/AWatch-rus/ansible/deploy_aw_server.yml` — основной playbook.
- `/home/igor/tmp/AWatch-rus/ansible/provision_proxmox_ct_and_deploy_aw.yml`full-stack playbook для Proxmox.
- `/home/igor/tmp/AWatch-rus/ansible/provision_proxmox_ct_matrix_and_deploy_aw.yml` — массовый full-stack playbook (несколько CT).
- `/home/igor/tmp/AWatch-rus/ansible/deploy_aw_windows_phase2.yml` — WinRM playbook для развёртывания phase-2 Windows collector'ов.
- `/home/igor/tmp/AWatch-rus/ansible/deploy_aw_pfsense_poller.yml`deployment pfSense poller'а.
- `/home/igor/tmp/AWatch-rus/ansible/install_full_stack.yml` — полный установочный playbook (оркестратор всех этапов).
- `/home/igor/tmp/AWatch-rus/ansible/inventory.example.ini` — шаблон inventory.
- `/home/igor/tmp/AWatch-rus/ansible/group_vars/all.example.yml` — шаблон переменных.
- `/home/igor/tmp/AWatch-rus/ansible/group_vars/proxmox.example.yml` — шаблон переменных CT в Proxmox.
- `/home/igor/tmp/AWatch-rus/ansible/group_vars/proxmox-matrix.example.yml` — шаблон матрицы CT.
- `/home/igor/tmp/AWatch-rus/ansible/group_vars/windows.example.yml` — шаблон переменных Windows phase-2.
- `/home/igor/tmp/AWatch-rus/ansible/group_vars/pfsense-poller.example.yml` — шаблон переменных pfSense poller'а.
- `ansible/deploy_aw_server.yml` — основной playbook для уже существующего Debian/CT host.
- `ansible/provision_proxmox_ct_and_deploy_aw.yml`полный playbook для Proxmox.
- `ansible/provision_proxmox_ct_matrix_and_deploy_aw.yml` — массовый полный playbook (несколько CT).
- `ansible/deploy_aw_windows.yml` — WinRM playbook для развёртывания Windows/RDP collector'ов.
- `ansible/deploy_aw_pfsense_poller.yml`развёртывание pfSense poller'а.
- `ansible/install_full_stack.yml` — полный установочный playbook (оркестратор всех этапов).
- `ansible/inventory.example.ini` — шаблон inventory.
- `ansible/group_vars/*.example.yml` — шаблоны переменных.
## Быстрый запуск
1. Скопируйте шаблоны:
- `cp /home/igor/tmp/AWatch-rus/ansible/inventory.example.ini /home/igor/tmp/AWatch-rus/ansible/inventory.ini`
- `cp /home/igor/tmp/AWatch-rus/ansible/group_vars/all.example.yml /home/igor/tmp/AWatch-rus/ansible/group_vars/all.yml`
- `cp ansible/inventory.example.ini ansible/inventory.ini`
- `cp ansible/group_vars/all.example.yml ansible/group_vars/all.yml`
2. Заполните значения в `inventory.ini` и `group_vars/all.yml`.
3. Запустите:
```bash
cd /home/igor/tmp/AWatch-rus/ansible
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 (всё за один запуск)
Если нужно прогнать полный цикл одной командой:
```bash
cd /home/igor/tmp/AWatch-rus/ansible
cd ansible
ansible-playbook -i inventory.ini install_full_stack.yml
```
@@ -48,7 +53,7 @@ ansible-playbook -i inventory.ini install_full_stack.yml
- `provision_proxmox_ct_and_deploy_aw.yml` (если есть хосты в группе `[proxmox]`);
- `deploy_aw_server.yml` (группа `[aw_server]`);
- `deploy_aw_windows_phase2.yml` (группа `[aw_windows]`);
- `deploy_aw_windows.yml` (группа `[aw_windows]`);
- `deploy_aw_pfsense_poller.yml` (группа `[aw_pfsense_pollers]`).
Пустые группы в `inventory.ini` безопасны: соответствующий play будет пропущен.
@@ -56,50 +61,51 @@ ansible-playbook -i inventory.ini install_full_stack.yml
## Полный запуск с нуля в Proxmox
1. Подготовьте inventory и vars:
- `cp /home/igor/tmp/AWatch-rus/ansible/inventory.example.ini /home/igor/tmp/AWatch-rus/ansible/inventory.ini`
- `cp /home/igor/tmp/AWatch-rus/ansible/group_vars/all.example.yml /home/igor/tmp/AWatch-rus/ansible/group_vars/all.yml`
- `cp /home/igor/tmp/AWatch-rus/ansible/group_vars/proxmox.example.yml /home/igor/tmp/AWatch-rus/ansible/group_vars/proxmox.yml`
- `cp ansible/inventory.example.ini ansible/inventory.ini`
- `cp ansible/group_vars/all.example.yml ansible/group_vars/all.yml`
- `cp ansible/group_vars/proxmox.example.yml ansible/group_vars/proxmox.yml`
2. Заполните `group_vars/proxmox.yml` и `group_vars/all.yml`.
3. Запустите playbook:
```bash
cd /home/igor/tmp/AWatch-rus/ansible
cd ansible
ansible-playbook -i inventory.ini provision_proxmox_ct_and_deploy_aw.yml
```
## Массовый запуск (матрица CT)
1. Подготовьте матрицу:
- `cp /home/igor/tmp/AWatch-rus/ansible/group_vars/proxmox-matrix.example.yml /home/igor/tmp/AWatch-rus/ansible/group_vars/proxmox-matrix.yml`
- `cp ansible/group_vars/proxmox-matrix.example.yml ansible/group_vars/proxmox-matrix.yml`
2. Заполните `proxmox-matrix.yml`.
3. Запустите:
```bash
cd /home/igor/tmp/AWatch-rus/ansible
cd ansible
ansible-playbook -i inventory.ini provision_proxmox_ct_matrix_and_deploy_aw.yml
```
## Windows phase-2 rollout (WinRM)
## Windows/RDP rollout (WinRM)
1. Подготовьте inventory и vars:
- `cp /home/igor/tmp/AWatch-rus/ansible/inventory.example.ini /home/igor/tmp/AWatch-rus/ansible/inventory.ini`
- `cp /home/igor/tmp/AWatch-rus/ansible/group_vars/windows.example.yml /home/igor/tmp/AWatch-rus/ansible/group_vars/windows.yml`
- `cp ansible/inventory.example.ini ansible/inventory.ini`
- `cp ansible/group_vars/windows.example.yml ansible/group_vars/windows.yml`
2. Заполните `inventory.ini` (секция `[aw_windows]`) и `group_vars/windows.yml`.
- Для русской локализации Windows часто нужен `ansible_user=Администратор` (а не `Administrator`).
- Если WinRM закрыт, playbook не сможет стартовать и нужно сначала открыть `5985/5986` и `wsman`.
3. Запустите:
```bash
cd /home/igor/tmp/AWatch-rus/ansible
ansible-playbook -i inventory.ini deploy_aw_windows_phase2.yml
cd ansible
ansible-playbook -i inventory.ini deploy_aw_windows.yml
```
Playbook:
- выгружает `windows/*` toolkit на целевой хост в `C:\Deploy\AWatch-rus\windows`;
- выполняет `deploy-ensemble.ps1` (deploy + hardening/recovery) с phase-2 policy/rules;
- выгружает полный `windows/*` toolkit на целевой хост в InnoSetup-compatible каталог `C:\Program Files\AWatch-rus\windows`, включая DLP и `worktime-session-collector.ps1`;
- если найден legacy config `C:\ProgramData\ActivityWatch-Phase2\deployment-config.json`, выполняет безопасную миграцию через `migrate-awatch-rus-paths.ps1`: backup, остановка задач, перенос данных, переписывание путей, пересоздание scheduled tasks и validation;
- выполняет `deploy-ensemble.ps1` (deploy + hardening/recovery) с policy/rules из AWatch-rus toolkit;
- после deploy принудительно запускает `ActivityWatch Recovery` и все `ActivityWatch Launch *` задачи;
- выполняет API smoke-check bucket `aw-watcher-afk_SHARKON2025` и ожидает свежие `not-afk` события;
- выполняет API smoke-check bucket `aw-watcher-afk_<COMPUTERNAME>` и ожидает свежие `not-afk` события;
- запускает `validate-deployment.ps1`;
- забирает JSON-отчёт в локальную директорию (`/tmp/aw-rus-validation` по умолчанию).
@@ -110,17 +116,27 @@ Playbook:
- `aw_windows_incident_capture_enabled: false` — отключить блок incidentCapture;
- `aw_windows_incident_screenshot_enabled: false` — не делать скриншот при DLP-инциденте;
- `aw_windows_incident_artifacts_root: 'C:\...\incident-artifacts'` — переопределить путь артефактов;
- `aw_windows_deploy_root: 'C:\Program Files\AWatch-rus'` — каталог toolkit, совпадает с InnoSetup `{app}`;
- `aw_windows_install_root: 'C:\Program Files\AWatch-rus\bin'` — каталог бинарников, совпадает с InnoSetup `AwDefaultInstallRoot`;
- `aw_windows_state_root: 'C:\ProgramData\AWatch-rus'` — каталог состояния/отчётов, совпадает с InnoSetup `AwDefaultStateRoot`;
- `aw_windows_validation_remote_path: '{{ aw_windows_state_root }}\aw_validate_ansible.json'` — отчёт Ansible-валидации хранится рядом с `ensemble-report-*.json`;
- `aw_windows_migration_enabled: true` — включить guard миграции текущего production из `ActivityWatch-Phase2` в единый `AWatch-rus`;
- `aw_windows_legacy_install_root` / `aw_windows_legacy_state_root` — старые production paths, откуда выполняется перенос;
- `aw_windows_migration_report_remote_path` — JSON-отчёт о миграции на Windows-хосте;
- `aw_windows_package_version`, `aw_windows_package_url`, `aw_windows_package_zip_path` — версия и источник Windows-пакета ActivityWatch;
- `aw_windows_api_smoke_check_bucket: ""` — автоматически использовать `aw-watcher-afk_<COMPUTERNAME>`;
- `aw_windows_fail_on_validation_error: true` — завершать playbook ошибкой, если `validate-deployment.ps1` возвращает `overallOk=false`;
- `aw_windows_skip_hardening: true` — пропустить `hardening-recovery.ps1` внутри ensemble-скрипта.
## pfSense poller rollout
## Развёртывание pfSense poller
1. Подготовьте vars:
- `cp /home/igor/tmp/AWatch-rus/ansible/group_vars/pfsense-poller.example.yml /home/igor/tmp/AWatch-rus/ansible/group_vars/pfsense-poller.yml`
- `cp ansible/group_vars/pfsense-poller.example.yml ansible/group_vars/pfsense-poller.yml`
2. Добавьте inventory group `[aw_pfsense_pollers]`.
3. Запустите:
```bash
cd /home/igor/tmp/AWatch-rus/ansible
cd ansible
ansible-playbook -i inventory.ini deploy_aw_pfsense_poller.yml
```
@@ -139,4 +155,16 @@ Playbook:
- Для Web UI используется checksum-based cache-bust для `ru-patch-v5.js` и `sw-cleanup.js`, чтобы браузер не держал старую DLP/русскую статику после деплоя.
- На `#/home` Web UI делит хосты на `Windows RDP` и `Virtual servers + Proxmox`.
- Выполнена валидация API `http://127.0.0.1:5600/api/0/info`.
- Для full-stack сценария CT создаётся автоматически через `pct create`.
- Для полного сценария 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/`.
+11 -11
View File
@@ -1,5 +1,5 @@
---
- name: Deploy pfSense ActivityWatch poller
- name: Развернуть pfSense ActivityWatch poller
hosts: aw_pfsense_pollers
become: true
gather_facts: true
@@ -10,14 +10,14 @@
aw_pfsense_service_name: "aw-pfsense-poller.service"
tasks:
- name: Install required packages
- name: Установить обязательные пакеты
ansible.builtin.apt:
name:
- python3
state: present
update_cache: true
- name: Ensure directories exist
- name: Создать каталоги
ansible.builtin.file:
path: "{{ item }}"
state: directory
@@ -26,29 +26,29 @@
- "{{ aw_pfsense_install_root }}"
- "{{ aw_pfsense_config_dir }}"
- name: Install pfSense poller script
- name: Установить скрипт pfSense poller
ansible.builtin.copy:
src: "{{ aw_repo_root }}/pfsense/pfsense-aw-poller.py"
dest: "{{ aw_pfsense_install_root }}/pfsense-aw-poller.py"
mode: "0755"
- name: Install systemd service
- name: Установить systemd service
ansible.builtin.copy:
src: "{{ aw_repo_root }}/pfsense/pfsense-aw-poller.service"
dest: "/etc/systemd/system/{{ aw_pfsense_service_name }}"
mode: "0644"
notify:
- Reload systemd
- Перезагрузить systemd
- name: Write pfSense poller config
- name: Записать конфигурацию pfSense poller
ansible.builtin.copy:
dest: "{{ aw_pfsense_config_dir }}/poller.json"
mode: "0600"
content: "{{ aw_pfsense_poller_config | to_nice_json }}"
notify:
- Restart pfSense poller
- Перезапустить pfSense poller
- name: Enable and start pfSense poller
- name: Включить и запустить pfSense poller
ansible.builtin.systemd:
name: "{{ aw_pfsense_service_name }}"
enabled: true
@@ -56,11 +56,11 @@
daemon_reload: true
handlers:
- name: Reload systemd
- name: Перезагрузить systemd
ansible.builtin.systemd:
daemon_reload: true
- name: Restart pfSense poller
- name: Перезапустить pfSense poller
ansible.builtin.systemd:
name: "{{ aw_pfsense_service_name }}"
state: restarted
+453 -111
View File
@@ -1,5 +1,5 @@
---
- name: Deploy AWatch-rus server
- name: Развернуть сервер AWatch-rus
hosts: aw_server
become: true
gather_facts: true
@@ -9,22 +9,29 @@
aw_release_dir: "{{ aw_release_root }}/{{ aw_server_version }}"
aw_archive_path: "/tmp/activitywatch-{{ aw_server_version }}.zip"
aw_bootstrap_dir: "/tmp/aw-rus-bootstrap"
aw_release_install_dir: "{{ aw_release_root }}/aw-server-rust-{{ aw_server_version }}"
aw_ru_patch_cache_bust: "{{ lookup('file', aw_repo_root + '/aw-server/aw-ru-patch.js') | hash('sha1') | truncate(12, true, '') }}"
aw_sw_cleanup_cache_bust: "{{ lookup('file', aw_repo_root + '/aw-server/aw-sw-cleanup.js') | hash('sha1') | truncate(12, true, '') }}"
aw_host_groups_cache_bust: "{{ lookup('file', aw_repo_root + '/aw-server/aw-host-groups.json') | hash('sha1') | truncate(12, true, '') }}"
aw_worktime_classes: "{{ lookup('file', aw_repo_root + '/aw-server/settings/classes-worktime.json') | from_json }}"
aw_default_views: "{{ lookup('file', aw_repo_root + '/aw-server/settings/views-default.json') | from_json }}"
tasks:
- name: Install base packages
- name: Установить базовые пакеты
ansible.builtin.apt:
name:
- curl
- rsync
- unzip
state: present
update_cache: true
- name: Ensure service account exists
- name: Создать системную группу сервиса
ansible.builtin.group:
name: "{{ aw_server_group }}"
system: true
state: present
- name: Создать системную учётную запись сервиса
ansible.builtin.user:
name: "{{ aw_server_user }}"
group: "{{ aw_server_group }}"
@@ -33,7 +40,30 @@
system: true
create_home: false
- name: Ensure required directories
- name: Создать обязательные каталоги
ansible.builtin.file:
path: "{{ item }}"
state: directory
mode: "0755"
loop:
- "{{ aw_release_root }}"
- "{{ aw_release_dir }}"
- "{{ aw_release_install_dir }}"
- /opt/activitywatch
- /opt/activitywatch/bin
- "{{ 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 }}"
- name: Настроить каталоги ActivityWatch с владельцем сервиса
ansible.builtin.file:
path: "{{ item }}"
state: directory
@@ -41,148 +71,434 @@
group: "{{ aw_server_group }}"
mode: "0755"
loop:
- /opt/activitywatch
- /opt/activitywatch/bin
- "{{ aw_release_root }}"
- "{{ aw_release_dir }}"
- "{{ aw_release_install_dir }}"
- "{{ 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 }}"
- name: Download ActivityWatch release archive
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: Unpack ActivityWatch release
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: Discover extracted AW directory
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: Set release extracted path
ansible.builtin.set_fact:
aw_release_extracted: "{{ (aw_release_find.files | sort(attribute='path') | map(attribute='path') | list | first) }}"
- name: Найти распакованный каталог ActivityWatch
ansible.builtin.find:
paths: "{{ aw_release_dir }}"
recurse: true
file_type: directory
patterns: "activitywatch*"
register: aw_release_find
- name: Verify extracted directory exists
ansible.builtin.assert:
that:
- aw_release_extracted is defined
- aw_release_extracted | length > 0
fail_msg: "Cannot locate extracted ActivityWatch release directory."
- name: Найти бинарный файл 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: Sync release content to /opt/activitywatch
ansible.builtin.command:
cmd: "rsync -a --delete {{ aw_release_extracted }}/ /opt/activitywatch/"
- 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: Copy bootstrap files from repository
- 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: Сохранить каталог WebUI (dirname index.html)
ansible.builtin.set_fact:
aw_webui_source_path: "{{ aw_webui_index_path | dirname }}"
- 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: Создать каталог установленного релиза
ansible.builtin.file:
path: "{{ aw_release_install_dir }}"
state: directory
owner: "{{ aw_server_user }}"
group: "{{ aw_server_group }}"
mode: "0755"
- 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: Создать ссылку на активный бинарный файл 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:
src: "{{ item.src }}"
dest: "{{ item.dest }}"
mode: "{{ item.mode }}"
loop:
- { src: "{{ aw_repo_root }}/aw-server/activitywatch-server.service", dest: "/etc/systemd/system/activitywatch-server.service", mode: "0644" }
- { src: "{{ aw_repo_root }}/aw-server/aw-ru-patch.js", dest: "{{ aw_server_webui_dir }}/js/ru-patch-v5.js", mode: "0644" }
- { src: "{{ aw_repo_root }}/aw-server/aw-sw-cleanup.js", dest: "{{ aw_server_webui_dir }}/js/sw-cleanup.js", mode: "0644" }
- { src: "{{ aw_repo_root }}/aw-server/aw-host-groups.json", dest: "{{ aw_server_webui_dir }}/js/aw-host-groups.json", mode: "0644" }
dest: /etc/systemd/system/activitywatch-server.service
mode: "0644"
content: >-
{{
lookup('file', aw_repo_root + '/aw-server/activitywatch-server.service')
| replace('__AW_SERVER_USER__', aw_server_user)
| replace('__AW_SERVER_GROUP__', aw_server_group)
| replace('__AW_SERVER_DATA_DIR__', aw_server_data_dir)
}}
notify:
- Reload systemd
- Restart activitywatch
- Перезагрузить systemd
- Перезапустить activitywatch
- name: Copy WebUI index template from installed distribution
ansible.builtin.copy:
remote_src: true
src: "/opt/activitywatch/aw-webui/index.html"
dest: "{{ aw_server_webui_dir }}/index.html"
mode: "0644"
- name: (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: Insert RU patch scripts into index.html
ansible.builtin.replace:
path: "{{ aw_server_webui_dir }}/index.html"
regexp: '</head>'
replace: '<script src="/js/sw-cleanup.js?v={{ aw_sw_cleanup_cache_bust }}"></script></head>'
- name: Применить 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: Insert RU patch loader before body end
ansible.builtin.replace:
path: "{{ aw_server_webui_dir }}/index.html"
regexp: '</body>'
replace: '<script defer="defer" src="/js/ru-patch-v5.js?v={{ aw_ru_patch_cache_bust }}"></script></body>'
- name: Проверить наличие index.html после копирования
ansible.builtin.stat:
path: "{{ aw_server_webui_dir }}/index.html"
register: aw_webui_ru_index
- name: Write /etc/activitywatch/aw-server.env
ansible.builtin.copy:
dest: /etc/activitywatch/aw-server.env
mode: "0640"
content: |
AW_SERVER_HOST={{ aw_server_bind_host }}
AW_SERVER_PORT={{ aw_server_port }}
AW_DATA_DIR={{ aw_server_data_dir }}
AW_LOG_DIR={{ aw_server_log_dir }}
AW_WEBUI_DIR={{ aw_server_webui_dir }}
AW_SERVER_USER={{ aw_server_user }}
AW_SERVER_GROUP={{ aw_server_group }}
- name: Проверить, что index.html доступен для RU patch
ansible.builtin.assert:
that:
- aw_webui_ru_index.stat.exists
fail_msg: "Не найден index.html WebUI для применения RU patch."
- name: Enable and start service
ansible.builtin.systemd:
name: activitywatch-server.service
enabled: true
state: restarted
daemon_reload: true
- 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: Wait for API
- 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: Записать /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: Скопировать 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: Apply baseline worktime settings (classes)
- 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: Применить базовые worktime settings (classes)
ansible.builtin.uri:
url: "http://127.0.0.1:{{ aw_server_port }}/api/0/settings/classes"
method: POST
body: "{{ aw_worktime_classes }}"
body_format: json
status_code: 201
status_code: [200, 201]
when: aw_apply_worktime_settings | default(false) | bool
- name: Apply baseline views (include DLP and worktime)
- name: Применить базовые views для DLP и worktime
ansible.builtin.uri:
url: "http://127.0.0.1:{{ aw_server_port }}/api/0/settings/views"
method: POST
body: "{{ aw_default_views }}"
body_format: json
status_code: 201
status_code: [200, 201]
when: aw_apply_worktime_settings | default(false) | bool
- name: Derive worktime durationDefault from aw_worktime_from/to
- 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
)
}}
when: aw_apply_worktime_settings | default(false) | bool
- name: Normalize derived durationDefault for overnight shifts
- name: Нормализовать durationDefault для ночных смен
ansible.builtin.set_fact:
aw_worktime_duration_default_effective: >-
{{
@@ -192,38 +508,64 @@
}}
when: aw_apply_worktime_settings | default(false) | bool
- name: Validate derived durationDefault is sane
- name: Проверить корректность durationDefault
ansible.builtin.assert:
that:
- aw_worktime_duration_default_effective | int > 0
- aw_worktime_duration_default_effective | int <= 86400
fail_msg: "Invalid worktime window: {{ aw_worktime_from }}..{{ aw_worktime_to }}"
fail_msg: "Некорректный интервал рабочего времени: {{ aw_worktime_from }}..{{ aw_worktime_to }}"
when: aw_apply_worktime_settings | default(false) | bool
- name: Apply baseline worktime period (startOfDay)
- name: Применить базовый период worktime (startOfDay)
ansible.builtin.uri:
url: "http://127.0.0.1:{{ aw_server_port }}/api/0/settings/startOfDay"
method: POST
body: "{{ aw_worktime_start_of_day }}"
body_format: json
status_code: 200
body: "\"{{ aw_worktime_start_of_day }}\""
headers:
Content-Type: application/json
status_code: [200, 201]
when: aw_apply_worktime_settings | default(false) | bool
- name: Apply baseline worktime period (durationDefault seconds)
- 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: Reload systemd
- name: Перезагрузить systemd
ansible.builtin.systemd:
daemon_reload: true
- name: Restart activitywatch
- name: Перезапустить activitywatch
ansible.builtin.systemd:
name: activitywatch-server.service
state: restarted
+253
View File
@@ -0,0 +1,253 @@
---
- name: Развернуть Windows/RDP collector'ы AWatch-rus
hosts: aw_windows
gather_facts: false
vars:
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_users_effective: "{{ (aw_windows_users + aw_windows_extra_users) | unique }}"
aw_windows_install_root: "C:\\Program Files\\AWatch-rus\\bin"
aw_windows_state_root: "C:\\ProgramData\\AWatch-rus"
aw_windows_afk_enabled: true
aw_windows_window_enabled: true
aw_windows_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_launch_task_pattern: "ActivityWatch Launch *"
aw_windows_recovery_task_name: "ActivityWatch Recovery"
aw_windows_force_task_restart: true
aw_windows_api_smoke_check_enabled: true
aw_windows_api_smoke_check_bucket: ""
aw_windows_api_smoke_check_limit: 10
aw_windows_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"
tasks:
- name: Проверить обязательные переменные
ansible.builtin.assert:
that:
- aw_windows_server_host is defined
- aw_windows_server_port is defined
- aw_windows_server_scheme is defined
- aw_windows_domain is defined
- aw_windows_users_effective | length > 0
- aw_windows_install_root is defined
- aw_windows_state_root is defined
fail_msg: "Не заданы обязательные переменные Windows-развёртывания."
- name: Создать каталоги развёртывания
ansible.windows.win_file:
path: "{{ item }}"
state: directory
loop:
- "{{ aw_windows_deploy_root }}"
- "{{ aw_windows_deploy_root }}\\windows"
- name: Загрузить Windows toolkit развёртывания
ansible.windows.win_copy:
src: "{{ aw_windows_repo_root }}/windows/{{ item }}"
dest: "{{ aw_windows_deploy_root }}\\windows\\{{ item }}"
loop:
- ActivityWatch.Windows.Common.psd1
- ActivityWatch.Windows.Common.psm1
- browser-domains-native-collector.ps1
- dlp-endpoint-signals-collector.ps1
- file-operations-collector.ps1
- worktime-session-collector.ps1
- migrate-awatch-rus-paths.ps1
- deploy-domain-users.ps1
- deploy-ensemble.ps1
- hardening-recovery.ps1
- validate-deployment.ps1
- web-category-rules.example.json
- dlp-policy.example.json
- name: Нормализовать кодировку PowerShell файлов (UTF-8 BOM для Windows PowerShell)
ansible.windows.win_powershell:
script: |
$ErrorActionPreference = 'Stop'
$toolkitDir = "{{ aw_windows_deploy_root }}\windows"
$encIn = New-Object System.Text.UTF8Encoding($false)
$encOut = New-Object System.Text.UTF8Encoding($true)
Get-ChildItem -LiteralPath $toolkitDir -File -Include *.ps1,*.psm1,*.psd1 | ForEach-Object {
$text = [System.IO.File]::ReadAllText($_.FullName, $encIn)
[System.IO.File]::WriteAllText($_.FullName, $text, $encOut)
}
- name: Загрузить список пользователей для доменного развёртывания
ansible.windows.win_copy:
dest: "{{ aw_windows_deploy_root }}\\windows\\users.txt"
content: |
{% for user in aw_windows_users_effective -%}
{{ user }}
{% endfor -%}
- name: Проверить нужен ли migration с legacy ActivityWatch путей
when: aw_windows_migration_enabled | bool
ansible.windows.win_stat:
path: "{{ aw_windows_legacy_state_root }}\\deployment-config.json"
register: aw_windows_legacy_config
- name: Выполнить безопасную migration legacy prod в AWatch-rus
when:
- aw_windows_migration_enabled | bool
- aw_windows_legacy_config.stat.exists | default(false)
ansible.windows.win_powershell:
script: |
$ErrorActionPreference = 'Stop'
$result = & "{{ aw_windows_deploy_root }}\windows\migrate-awatch-rus-paths.ps1" `
-OldInstallRoot "{{ aw_windows_legacy_install_root }}" `
-OldStateRoot "{{ aw_windows_legacy_state_root }}" `
-NewInstallRoot "{{ aw_windows_install_root }}" `
-NewStateRoot "{{ aw_windows_state_root }}" `
-ToolkitRoot "{{ aw_windows_deploy_root }}\windows"
$result | ConvertTo-Json -Depth 8 | Out-File -FilePath "{{ aw_windows_migration_report_remote_path }}" -Encoding utf8
- name: Запустить Windows/RDP ensemble развёртывание
ansible.windows.win_powershell:
script: |
$ErrorActionPreference = 'Stop'
$params = @{
ServerScheme = "{{ aw_windows_server_scheme }}"
ServerHost = "{{ aw_windows_server_host }}"
ServerPort = {{ aw_windows_server_port }}
Version = "{{ aw_windows_package_version }}"
Domain = "{{ aw_windows_domain }}"
UserListPath = "{{ aw_windows_deploy_root }}\windows\users.txt"
InstallRoot = "{{ aw_windows_install_root }}"
StateRoot = "{{ aw_windows_state_root }}"
AfkEnabled = {{ '$true' if (aw_windows_afk_enabled | bool) else '$false' }}
WindowEnabled = {{ '$true' if (aw_windows_window_enabled | bool) else '$false' }}
FileOpsEnabled = {{ '$true' if (aw_windows_file_ops_enabled | bool) else '$false' }}
LocalAgentLogsEnabled = {{ '$true' if (aw_windows_local_agent_logs_enabled | bool) else '$false' }}
IncidentCaptureEnabled = {{ '$true' if (aw_windows_incident_capture_enabled | bool) else '$false' }}
IncidentScreenshotEnabled = {{ '$true' if (aw_windows_incident_screenshot_enabled | bool) else '$false' }}
IncidentArtifactsRoot = "{{ aw_windows_incident_artifacts_root }}"
LogonMarkerEnabled = {{ '$true' if (aw_windows_logon_marker_enabled | bool) else '$false' }}
CustomRulesPath = "{{ aw_windows_rules_path }}"
CustomPolicyPath = "{{ aw_windows_policy_path }}"
}
{% if (aw_windows_package_url | default('') | string | length) > 0 %}
$params.PackageUrl = "{{ aw_windows_package_url }}"
{% endif %}
{% if (aw_windows_package_zip_path | default('') | string | length) > 0 %}
$params.PackageZipPath = "{{ aw_windows_package_zip_path }}"
{% endif %}
{% if aw_windows_skip_hardening | bool %}
$params.SkipHardening = $true
{% endif %}
& "{{ aw_windows_deploy_root }}\windows\deploy-ensemble.ps1" @params
- name: Удалить лишние ActivityWatch Launch tasks вне текущего deployment-config
ansible.windows.win_powershell:
script: |
$ErrorActionPreference = 'Stop'
$config = Get-Content -Raw -LiteralPath "{{ aw_windows_state_root }}\deployment-config.json" | ConvertFrom-Json
$desired = @($config.userTasks | ForEach-Object { [string]$_.LaunchTaskName })
foreach ($task in @(Get-ScheduledTask | Where-Object { $_.TaskName -like 'ActivityWatch Launch *' })) {
if ($desired -notcontains [string]$task.TaskName) {
Unregister-ScheduledTask -TaskName $task.TaskName -Confirm:$false -ErrorAction SilentlyContinue
& cmd.exe /c "schtasks /Delete /TN `"$($task.TaskName)`" /F >nul 2>&1" | Out-Null
}
}
- name: Принудительно запустить ActivityWatch recovery и launch tasks
when: aw_windows_force_task_restart | bool
ansible.windows.win_powershell:
script: |
$ErrorActionPreference = 'Stop'
Start-ScheduledTask -TaskName "{{ aw_windows_recovery_task_name }}"
Get-ScheduledTask |
Where-Object TaskName -like "{{ aw_windows_launch_task_pattern }}" |
ForEach-Object { Start-ScheduledTask -TaskName $_.TaskName }
- name: Получить Windows hostname для AW smoke-check bucket
when:
- aw_windows_api_smoke_check_enabled | bool
- aw_windows_afk_enabled | bool
ansible.windows.win_command: powershell.exe -NoProfile -Command "$env:COMPUTERNAME"
register: aw_windows_hostname_result
changed_when: false
- name: Вычислить AW AFK smoke-check bucket
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-watcher-afk_{{ aw_windows_hostname_result.stdout | trim }}"
- name: Выполнить AW API smoke-check (проверка наличия свежих событий в AFK бакете)
when:
- aw_windows_api_smoke_check_enabled | bool
- aw_windows_afk_enabled | bool
ansible.builtin.uri:
url: "{{ aw_windows_server_scheme }}://{{ aw_windows_server_host }}:{{ aw_windows_server_port }}/api/0/buckets/{{ aw_windows_api_smoke_check_bucket_effective }}/events?limit={{ aw_windows_api_smoke_check_limit }}"
method: GET
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: Валидировать развёртывание на эндпоинте
ansible.windows.win_powershell:
script: |
$ErrorActionPreference = 'Stop'
$result = & "{{ aw_windows_deploy_root }}\windows\validate-deployment.ps1" `
-ConfigPath "{{ aw_windows_state_root }}\deployment-config.json"
$result | ConvertTo-Json -Depth 8 | Out-File -FilePath "{{ aw_windows_validation_remote_path }}" -Encoding utf8
return $result
- name: Создать локальную директорию для отчётов валидации
ansible.builtin.file:
path: "{{ aw_windows_validation_local_dir }}"
state: directory
mode: "0755"
delegate_to: localhost
- name: Стянуть отчёт валидации с эндпоинта
ansible.windows.win_fetch:
src: "{{ aw_windows_validation_remote_path }}"
dest: "{{ aw_windows_validation_local_dir }}/{{ inventory_hostname }}-aw_validate_ansible.json"
flat: true
- 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
-169
View File
@@ -1,169 +0,0 @@
---
- name: Deploy AWatch-rus Windows phase2 collectors
hosts: aw_windows
gather_facts: false
vars:
aw_windows_repo_root: "/home/igor/tmp/AWatch-rus"
aw_windows_deploy_root: "C:\\Deploy\\AWatch-rus"
aw_windows_server_host: "10.10.10.13"
aw_windows_server_port: 5600
aw_windows_domain: "SHARKON2025"
aw_windows_users:
- user1
- user2
- user3
- user4
- user5
aw_windows_extra_users: []
aw_windows_users_effective: "{{ (aw_windows_users + aw_windows_extra_users) | unique }}"
aw_windows_install_root: "C:\\Program Files\\ActivityWatch-Phase2"
aw_windows_state_root: "C:\\ProgramData\\ActivityWatch"
aw_windows_afk_enabled: true
aw_windows_window_enabled: true
aw_windows_local_agent_logs_enabled: false
aw_windows_incident_capture_enabled: true
aw_windows_incident_screenshot_enabled: true
aw_windows_incident_artifacts_root: "{{ aw_windows_state_root }}\\incident-artifacts"
aw_windows_logon_marker_enabled: true
aw_windows_skip_hardening: false
aw_windows_rules_path: "{{ aw_windows_deploy_root }}\\windows\\web-category-rules.example.json"
aw_windows_policy_path: "{{ aw_windows_deploy_root }}\\windows\\dlp-policy.example.json"
aw_windows_validation_remote_path: "C:\\Windows\\Temp\\aw_validate_phase2_ansible.json"
aw_windows_validation_local_dir: "/tmp/aw-rus-validation"
aw_windows_launch_task_pattern: "ActivityWatch Launch *"
aw_windows_recovery_task_name: "ActivityWatch Recovery"
aw_windows_force_task_restart: true
aw_windows_api_smoke_check_enabled: true
aw_windows_api_smoke_check_bucket: "aw-watcher-afk_SHARKON2025"
aw_windows_api_smoke_check_limit: 10
tasks:
- name: Validate required variables
ansible.builtin.assert:
that:
- aw_windows_server_host is defined
- aw_windows_server_port is defined
- aw_windows_domain is defined
- aw_windows_users_effective | length > 0
- aw_windows_install_root is defined
- aw_windows_state_root is defined
fail_msg: "Missing required Windows deployment variables."
- name: Ensure deploy directories exist
ansible.windows.win_file:
path: "{{ item }}"
state: directory
loop:
- "{{ aw_windows_deploy_root }}"
- "{{ aw_windows_deploy_root }}\\windows"
- name: Upload Windows deployment toolkit
ansible.windows.win_copy:
src: "{{ aw_windows_repo_root }}/windows/{{ item }}"
dest: "{{ aw_windows_deploy_root }}\\windows\\{{ item }}"
loop:
- ActivityWatch.Windows.Common.psd1
- ActivityWatch.Windows.Common.psm1
- browser-domains-native-collector.ps1
- dlp-endpoint-signals-collector.ps1
- deploy-domain-users.ps1
- deploy-ensemble.ps1
- hardening-recovery.ps1
- validate-deployment.ps1
- web-category-rules.example.json
- dlp-policy.example.json
- name: Upload user list for domain deploy
ansible.windows.win_copy:
dest: "{{ aw_windows_deploy_root }}\\windows\\users.txt"
content: |
{% for user in aw_windows_users -%}
{{ user }}
{% endfor -%}
{% for user in aw_windows_extra_users -%}
{{ user }}
{% endfor -%}
- name: Run phase2 ensemble deployment
ansible.windows.win_powershell:
script: |
$ErrorActionPreference = 'Stop'
$params = @{
ServerHost = "{{ aw_windows_server_host }}"
ServerPort = {{ aw_windows_server_port }}
Domain = "{{ aw_windows_domain }}"
UserListPath = "{{ aw_windows_deploy_root }}\windows\users.txt"
InstallRoot = "{{ aw_windows_install_root }}"
StateRoot = "{{ aw_windows_state_root }}"
AfkEnabled = {{ '$true' if (aw_windows_afk_enabled | bool) else '$false' }}
WindowEnabled = {{ '$true' if (aw_windows_window_enabled | bool) else '$false' }}
LocalAgentLogsEnabled = {{ '$true' if (aw_windows_local_agent_logs_enabled | bool) else '$false' }}
IncidentCaptureEnabled = {{ '$true' if (aw_windows_incident_capture_enabled | bool) else '$false' }}
IncidentScreenshotEnabled = {{ '$true' if (aw_windows_incident_screenshot_enabled | bool) else '$false' }}
IncidentArtifactsRoot = "{{ aw_windows_incident_artifacts_root }}"
LogonMarkerEnabled = {{ '$true' if (aw_windows_logon_marker_enabled | bool) else '$false' }}
CustomRulesPath = "{{ aw_windows_rules_path }}"
CustomPolicyPath = "{{ aw_windows_policy_path }}"
}
{% if aw_windows_skip_hardening | bool %}
$params.SkipHardening = $true
{% endif %}
& "{{ aw_windows_deploy_root }}\windows\deploy-ensemble.ps1" @params
- name: Force start ActivityWatch recovery and launch tasks
when: aw_windows_force_task_restart | bool
ansible.windows.win_powershell:
script: |
$ErrorActionPreference = 'Stop'
Start-ScheduledTask -TaskName "{{ aw_windows_recovery_task_name }}"
Get-ScheduledTask |
Where-Object TaskName -like "{{ aw_windows_launch_task_pattern }}" |
ForEach-Object { Start-ScheduledTask -TaskName $_.TaskName }
- name: Wait for fresh AFK events to appear on AW server
when: aw_windows_api_smoke_check_enabled | bool
delegate_to: localhost
ansible.builtin.uri:
url: "http://{{ aw_windows_server_host }}:{{ aw_windows_server_port }}/api/0/buckets/{{ aw_windows_api_smoke_check_bucket }}/events?limit={{ aw_windows_api_smoke_check_limit }}"
method: GET
return_content: true
register: aw_windows_api_smoke
until: >
aw_windows_api_smoke.status == 200 and
(aw_windows_api_smoke.json | length) > 0 and
(
aw_windows_api_smoke.json
| selectattr('data.status', 'equalto', 'not-afk')
| list
| length
) > 0
retries: 10
delay: 6
- name: Run validation and store report on target
ansible.windows.win_powershell:
script: |
$ErrorActionPreference = 'Stop'
$report = & "{{ aw_windows_deploy_root }}\windows\validate-deployment.ps1" `
-ConfigPath "{{ aw_windows_state_root }}\deployment-config.json"
$report | ConvertTo-Json -Depth 12 | Out-File -FilePath "{{ aw_windows_validation_remote_path }}" -Encoding utf8
- name: Ensure local validation directory exists
ansible.builtin.file:
path: "{{ aw_windows_validation_local_dir }}"
state: directory
mode: "0755"
delegate_to: localhost
- name: Fetch validation report
ansible.builtin.fetch:
src: "{{ aw_windows_validation_remote_path }}"
dest: "{{ aw_windows_validation_local_dir }}/"
flat: false
- name: Show report location
ansible.builtin.debug:
msg:
- "Windows phase2 deploy completed on {{ inventory_hostname }}."
- "Validation report: {{ aw_windows_validation_local_dir }}/{{ inventory_hostname }}/C$/Windows/Temp/aw_validate_phase2_ansible.json"
+19 -8
View File
@@ -4,21 +4,32 @@ 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: "/home/igor/tmp/AWatch-rus"
aw_repo_root: "{{ playbook_dir | dirname }}"
# Optional: apply a baseline worktime-focused categorization and views via AW settings API.
# WARNING: this overwrites existing server-side settings/classes/views.
aw_apply_worktime_settings: false
# Применить базовые категории и views для рабочего времени через AW settings API.
# При прод-обновлениях это нужно оставлять включённым, иначе UI остаётся без views/classes.
aw_apply_worktime_settings: true
# Optional defaults for the worktime period in Web UI.
# startOfDay controls day-boundary and default report window start.
# durationDefault controls default time range (seconds) shown in UI.
# Дополнительные origin для aw-server-rust CORS.
# Обязательно включите тот origin, с которого реально открывается Web UI.
aw_server_cors_origins:
- "http://127.0.0.1:5600"
- "http://localhost:5600"
- "http://10.10.10.13:5600"
- "http://aw-server:5600"
# Опциональные значения периода рабочего времени в Web UI.
# startOfDay задаёт границу дня и стартовое время окна отчёта.
# durationDefault задаёт диапазон по умолчанию в секундах.
#
# Recommended: set worktime window explicitly and let the playbook derive duration.
# Рекомендуется явно задать рабочий интервал и дать playbook вычислить duration.
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) }}"
+52
View File
@@ -0,0 +1,52 @@
# 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_local_agent_logs_enabled: false
aw_windows_incident_capture_enabled: true
aw_windows_incident_screenshot_enabled: true
aw_windows_incident_artifacts_root: "{{ aw_windows_state_root }}\\incident-artifacts"
aw_windows_logon_marker_enabled: true
aw_windows_skip_hardening: false
aw_windows_rules_path: "{{ aw_windows_deploy_root }}\\windows\\web-category-rules.example.json"
aw_windows_policy_path: "{{ aw_windows_deploy_root }}\\windows\\dlp-policy.example.json"
aw_windows_validation_remote_path: "{{ aw_windows_state_root }}\\aw_validate_ansible.json"
aw_windows_validation_local_dir: "/tmp/aw-rus-validation"
aw_windows_fail_on_validation_error: true
aw_windows_migration_enabled: true
aw_windows_legacy_install_root: "C:\\Program Files\\ActivityWatch-Phase2"
aw_windows_legacy_state_root: "C:\\ProgramData\\ActivityWatch-Phase2"
aw_windows_migration_report_remote_path: "{{ aw_windows_state_root }}\\aw_migration_ansible.json"
aw_windows_api_smoke_check_enabled: true
aw_windows_api_smoke_check_bucket: ""
aw_windows_api_smoke_check_limit: 10
+23 -6
View File
@@ -1,7 +1,11 @@
aw_windows_repo_root: "/home/igor/tmp/AWatch-rus"
aw_windows_deploy_root: "C:\\Deploy\\AWatch-rus"
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
@@ -14,9 +18,9 @@ aw_windows_extra_users: []
# aw_windows_extra_users:
# - Администратор
# Рекомендуемый изолированный профиль для фазового раската.
aw_windows_install_root: "C:\\Program Files\\ActivityWatch-Phase2"
aw_windows_state_root: "C:\\ProgramData\\ActivityWatch"
# Единые Windows/RDP пути: те же, что использует InnoSetup.
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_local_agent_logs_enabled: false
@@ -29,5 +33,18 @@ aw_windows_skip_hardening: false
aw_windows_rules_path: "{{ aw_windows_deploy_root }}\\windows\\web-category-rules.example.json"
aw_windows_policy_path: "{{ aw_windows_deploy_root }}\\windows\\dlp-policy.example.json"
aw_windows_validation_remote_path: "C:\\Windows\\Temp\\aw_validate_phase2_ansible.json"
aw_windows_validation_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
# Безопасная миграция текущего прода со старых путей в единый профиль AWatch-rus.
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"
# По умолчанию AFK bucket вычисляется как aw-watcher-afk_<COMPUTERNAME>.
# Задайте явное значение только если watcher пишет в нестандартный bucket.
aw_windows_api_smoke_check_enabled: true
aw_windows_api_smoke_check_bucket: ""
aw_windows_api_smoke_check_limit: 10
+10 -10
View File
@@ -1,16 +1,16 @@
---
# Full-stack installer for AWatch-rus.
# Runs end-to-end rollout in one command:
# 1) Proxmox CT provision + AW bootstrap (if [proxmox] exists in inventory)
# 2) AW server deploy on [aw_server] hosts
# 3) Windows phase2 rollout on [aw_windows] hosts
# 4) pfSense poller deploy on [aw_pfsense_pollers] hosts
# Полный установщик AWatch-rus.
# Выполняет развёртывание одной командой:
# 1) создание Proxmox CT + bootstrap AW (если в inventory есть [proxmox])
# 2) развёртывание AW server на хостах [aw_server]
# 3) развёртывание Windows/RDP collector'ов на [aw_windows]
# 4) развёртывание pfSense poller'а на [aw_pfsense_pollers]
#
# Notes:
# - Keep only relevant inventory groups filled for your environment.
# - Plays with unmatched host groups are skipped automatically by Ansible.
# Примечания:
# - Заполняйте только нужные группы inventory для своего окружения.
# - Play без совпадающих host groups Ansible пропускает автоматически.
- import_playbook: provision_proxmox_ct_and_deploy_aw.yml
- import_playbook: deploy_aw_server.yml
- import_playbook: deploy_aw_windows_phase2.yml
- import_playbook: deploy_aw_windows.yml
- import_playbook: deploy_aw_pfsense_poller.yml
+4 -1
View File
@@ -5,5 +5,8 @@ pve-main ansible_host=192.168.10.2 ansible_user=root ansible_port=22
aw-ct ansible_host=10.20.30.13 ansible_user=root ansible_port=22
[aw_windows]
# NOTE: in RU-localized installs this account is often "Администратор" instead of "Administrator".
# Примечание: в русифицированных Windows часто нужен "Администратор", а не "Administrator".
win-node1 ansible_host=192.168.100.21 ansible_user=Администратор ansible_password=CHANGE_ME ansible_connection=winrm ansible_winrm_transport=ntlm ansible_port=5985 ansible_winrm_server_cert_validation=ignore
[aw_pfsense_pollers]
# pfsense-poller1 ansible_host=192.168.100.30 ansible_user=root ansible_port=22
+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"
@@ -1,5 +1,5 @@
---
- name: Provision single Proxmox CT and deploy AWatch-rus
- name: Создать один Proxmox CT и развернуть AWatch-rus
hosts: proxmox
gather_facts: false
@@ -17,7 +17,7 @@
- settings/views-default.json
tasks:
- name: Execute single-CT provisioning workflow
- name: Выполнить workflow создания одного CT
ansible.builtin.include_tasks: tasks/provision_ct_and_deploy_aw.yml
vars:
ct_id: "{{ proxmox_ct_id }}"
@@ -1,5 +1,5 @@
---
- name: Provision Proxmox CT matrix and deploy AWatch-rus with RU patch
- name: Создать матрицу Proxmox CT и развернуть AWatch-rus с RU patch
hosts: proxmox
gather_facts: false
@@ -17,14 +17,14 @@
- settings/views-default.json
tasks:
- name: Validate CT matrix is provided
- name: Проверить, что матрица CT задана
ansible.builtin.assert:
that:
- proxmox_ct_matrix is defined
- proxmox_ct_matrix | length > 0
fail_msg: "Define proxmox_ct_matrix in group_vars/proxmox-matrix.yml"
fail_msg: "Задайте proxmox_ct_matrix в group_vars/proxmox-matrix.yml"
- name: Execute provisioning workflow for each CT
- name: Выполнить workflow создания для каждого CT
ansible.builtin.include_tasks: tasks/provision_ct_and_deploy_aw.yml
vars:
ct_id: "{{ item.id }}"
+33 -23
View File
@@ -1,5 +1,5 @@
---
- name: Validate required per-CT variables
- name: Проверить обязательные переменные CT
ansible.builtin.assert:
that:
- ct_id is defined
@@ -27,14 +27,14 @@
- aw_server_log_dir is defined
- aw_server_user is defined
- aw_server_group is defined
fail_msg: "Missing required variables for CT provisioning/deploy."
fail_msg: "Не заданы обязательные переменные для создания CT и развёртывания."
- name: Build CT network string
- name: Сформировать сетевую строку CT
ansible.builtin.set_fact:
ct_net0: >-
name=eth0,bridge={{ ct_bridge }},ip={{ ct_ip }},gw={{ ct_gw }}{% if (ct_vlan | default('') | string | length) > 0 %},tag={{ ct_vlan }}{% endif %}
- name: Check whether CT already exists
- name: Проверить, существует ли CT
ansible.builtin.command:
argv:
- pct
@@ -44,7 +44,7 @@
failed_when: false
changed_when: false
- name: Create CT when absent
- name: Создать CT, если он отсутствует
ansible.builtin.command:
argv:
- pct
@@ -78,8 +78,9 @@
- --ostype
- debian
when: ct_status_check.rc != 0
no_log: true
- name: Check current CT runtime state
- name: Проверить текущее состояние CT
ansible.builtin.command:
argv:
- pct
@@ -88,7 +89,7 @@
register: ct_runtime_status
changed_when: false
- name: Start CT when stopped
- name: Запустить CT, если он остановлен
ansible.builtin.command:
argv:
- pct
@@ -96,20 +97,23 @@
- "{{ ct_id }}"
when: "'stopped' in ct_runtime_status.stdout"
- name: Ensure bootstrap directory on Proxmox host
- name: Создать bootstrap каталог на Proxmox host
ansible.builtin.file:
path: "{{ proxmox_bootstrap_dir }}"
path: "{{ item }}"
state: directory
mode: "0700"
loop:
- "{{ proxmox_bootstrap_dir }}"
- "{{ proxmox_bootstrap_dir }}/settings"
- name: Copy AW bootstrap files to Proxmox host temp
- name: Скопировать AW bootstrap файлы во временный каталог Proxmox host
ansible.builtin.copy:
src: "{{ aw_repo_root }}/aw-server/{{ item }}"
dest: "{{ proxmox_bootstrap_dir }}/{{ item }}"
mode: "0644"
loop: "{{ aw_bootstrap_files }}"
- name: Bootstrap CT OS dependencies
- name: Установить базовые зависимости ОС внутри CT
ansible.builtin.command:
argv:
- pct
@@ -122,12 +126,16 @@
set -euo pipefail
export DEBIAN_FRONTEND=noninteractive
apt-get update
apt-get install -y curl ca-certificates bash unzip xz-utils jq rsync openssh-server
mkdir -p /root/bootstrap /etc/activitywatch
apt-get install -y curl ca-certificates bash unzip xz-utils jq rsync openssh-server python3
mkdir -p /root/bootstrap/settings /etc/activitywatch
systemctl enable ssh || true
systemctl restart ssh || true
register: ct_bootstrap_result
retries: 10
delay: 6
until: ct_bootstrap_result.rc == 0
- name: Push bootstrap files into CT
- name: Передать bootstrap файлы внутрь CT
ansible.builtin.command:
argv:
- pct
@@ -137,7 +145,7 @@
- "/root/bootstrap/{{ item }}"
loop: "{{ aw_bootstrap_files }}"
- name: Write AW server env file on Proxmox host temp
- name: Записать AW server env во временный каталог Proxmox host
ansible.builtin.copy:
dest: "{{ proxmox_bootstrap_dir }}/aw-server.env"
mode: "0600"
@@ -151,8 +159,9 @@
AW_SERVER_LOG_DIR={{ aw_server_log_dir }}
AW_SERVER_USER={{ aw_server_user }}
AW_SERVER_GROUP={{ aw_server_group }}
no_log: true
- name: Push AW server env into CT
- name: Передать AW server env внутрь CT
ansible.builtin.command:
argv:
- pct
@@ -160,8 +169,9 @@
- "{{ ct_id }}"
- "{{ proxmox_bootstrap_dir }}/aw-server.env"
- /etc/activitywatch/aw-server.env
no_log: true
- name: Set mode for env inside CT
- name: Настроить права env файла внутри CT
ansible.builtin.command:
argv:
- pct
@@ -172,7 +182,7 @@
- "0600"
- /etc/activitywatch/aw-server.env
- name: Install server and apply RU patch inside CT
- name: Установить сервер и применить RU patch внутри CT
ansible.builtin.command:
argv:
- pct
@@ -188,7 +198,7 @@
bash /root/bootstrap/apply_webui_ru_patch.sh
systemctl restart activitywatch-server.service
- name: Validate AW API from inside CT
- name: Проверить AW API изнутри CT
ansible.builtin.command:
argv:
- pct
@@ -199,7 +209,7 @@
- -lc
- "curl -fsS http://127.0.0.1:{{ aw_server_port }}/api/0/info >/dev/null"
- name: Validate RU patch hooks in index
- name: Проверить hooks RU patch в index.html
ansible.builtin.command:
argv:
- pct
@@ -210,8 +220,8 @@
- -lc
- "grep -q 'ru-patch-v5.js' {{ aw_server_webui_dir }}/index.html && grep -q 'sw-cleanup.js' {{ aw_server_webui_dir }}/index.html"
- name: Show final endpoint
- name: Показать итоговый endpoint
ansible.builtin.debug:
msg:
- "CT {{ ct_id }} is provisioned and configured."
- "ActivityWatch endpoint: http://{{ ct_ip | regex_replace('/[0-9]+$', '') }}:{{ aw_server_port }}"
- "CT {{ ct_id }} создан и настроен."
- "Endpoint ActivityWatch: http://{{ ct_ip | regex_replace('/[0-9]+$', '') }}:{{ aw_server_port }}"
+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]
+15
View File
@@ -29,6 +29,21 @@
{ "label": "DLP", "type": "bucket", "bucket_prefix": "aw-dlp-endpoint-signals_" }
]
},
{
"id": "linux-remote",
"name": "Linux remote workers",
"description": "Linux-хосты удалённых сотрудников: GUI активность, SSH/console и browser admin UI.",
"patterns": [
"^(LINUX-WS|LINUX-DESKTOP|LX-|DESKTOP-|ADMIN-|WORKSTATION-|DEVBOX-)"
],
"links": [
{ "label": "Активность", "type": "activity" },
{ "label": "SSH сессии", "type": "bucket", "bucket_prefix": "aw-ssh-sessions_" },
{ "label": "Команды shell", "type": "bucket", "bucket_prefix": "aw-console-commands_" },
{ "label": "Web категории", "type": "bucket", "bucket_prefix": "aw-detmir-web-category_" },
{ "label": "Все бакеты", "type": "buckets" }
]
},
{
"id": "virtual-infra",
"name": "Virtual servers + Proxmox",
+50 -150
View File
@@ -1,6 +1,6 @@
(function () {
window.__awRuPatchVersion = "template-v13-pve-audit-day-filter";
document.documentElement.setAttribute("data-aw-ru-patch", "template-v13-pve-audit-day-filter");
window.__awRuPatchVersion = "template-v12-activity-heading-ru";
document.documentElement.setAttribute("data-aw-ru-patch", "template-v12-activity-heading-ru");
const exact = new Map([
["ActivityWatch", "АктивВотч"],
@@ -333,11 +333,6 @@
'.aw-ru-host-item-title { font-weight: 600; margin-bottom: 6px; }',
'.aw-ru-host-links { display: flex; flex-wrap: wrap; gap: 6px; }',
'.aw-ru-host-links a { display: inline-block; padding: 4px 8px; border-radius: 999px; background: rgba(90,140,255,.15); text-decoration: none; }',
'.aw-ru-worktime-center { margin: 16px 0; padding: 16px; border: 1px solid rgba(120,120,120,.35); border-radius: 8px; background: rgba(20,20,20,.03); }',
'.aw-ru-worktime-controls { display: flex; flex-wrap: wrap; gap: 8px; align-items: end; margin-bottom: 10px; }',
'.aw-ru-worktime-controls label { font-size: 12px; display:flex; flex-direction:column; gap:4px; }',
'.aw-ru-worktime-table { width: 100%; border-collapse: collapse; font-size: 13px; }',
'.aw-ru-worktime-table th, .aw-ru-worktime-table td { border: 1px solid rgba(120,120,120,.25); padding: 6px 8px; text-align: left; }',
'.aw-ru-pve-audit { margin: 16px 0; padding: 16px; border: 1px solid rgba(120,120,120,.35); border-radius: 8px; background: rgba(10,20,40,.04); }',
'.aw-ru-pve-audit-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; margin: 12px 0 16px; }',
'.aw-ru-pve-audit-card { border: 1px solid rgba(120,120,120,.22); border-radius: 8px; padding: 12px; background: rgba(255,255,255,.02); }',
@@ -685,6 +680,19 @@
{ label: "DLP", type: "bucket", bucket_prefix: "aw-dlp-endpoint-signals_" }
]
},
{
id: "linux-remote",
name: "Linux remote workers",
description: "Linux-хосты удалённых сотрудников: GUI активность, SSH/console и browser admin UI.",
patterns: ["^(LINUX-WS|LINUX-DESKTOP|LX-|DESKTOP-|ADMIN-|WORKSTATION-|DEVBOX-)"],
links: [
{ label: "Активность", type: "activity" },
{ label: "SSH сессии", type: "bucket", bucket_prefix: "aw-ssh-sessions_" },
{ label: "Команды shell", type: "bucket", bucket_prefix: "aw-console-commands_" },
{ label: "Web категории", type: "bucket", bucket_prefix: "aw-detmir-web-category_" },
{ label: "Все бакеты", type: "buckets" }
]
},
{
id: "virtual-infra",
name: "Virtual servers + Proxmox",
@@ -740,29 +748,20 @@
return !!(match && isPveLikeHost(decodeURIComponent(match[1] || "")));
}
function getSelectedDayFromHash() {
const hash = window.location.hash || "";
const match = hash.match(/^#\/activity\/[^/]+\/day\/([^/?#]+)/i);
return match && match[1] ? decodeURIComponent(match[1]) : "";
}
function filterEventsBySelectedDay(events) {
const day = getSelectedDayFromHash();
if (!day) return Array.isArray(events) ? events : [];
const start = new Date(day + "T00:00:00");
const end = new Date(day + "T23:59:59.999");
return (Array.isArray(events) ? events : []).filter(function (event) {
const timestamp = event && event.timestamp ? new Date(event.timestamp) : null;
return timestamp && !isNaN(timestamp.getTime()) && timestamp >= start && timestamp <= end;
});
}
function extractHostFromBucket(bucketId, bucketMeta) {
if (bucketMeta && bucketMeta.hostname) return String(bucketMeta.hostname);
const prefixes = [
"aw-watcher-window_",
"aw-watcher-afk_",
"aw-console-commands_",
"aw-ssh-sessions_",
"aw-linux-web-context_",
"aw-detmir-web-category_",
"aw-dlp-endpoint-signals_",
"aw-session-events_",
"aw-worktime-sessions_",
"aw-pve-webadmin-events_",
"aw-pve-task-events_",
"aw-dlp-incidents_",
"aw-pfsense-health_",
"aw-pfsense-gateways_",
@@ -792,7 +791,27 @@
return result;
}
function matchHostGroup(host, groups) {
function hostHasBucketPrefix(hostBuckets, prefix) {
return (hostBuckets || []).some(function (bucketId) {
return String(bucketId || "").indexOf(prefix) === 0;
});
}
function matchHostGroup(host, groups, hostBuckets) {
const bucketList = hostBuckets || [];
if (hostHasBucketPrefix(bucketList, "aw-dlp-endpoint-signals_") || hostHasBucketPrefix(bucketList, "aw-session-events_")) {
return "windows-rdp";
}
if (
hostHasBucketPrefix(bucketList, "aw-console-commands_") ||
hostHasBucketPrefix(bucketList, "aw-ssh-sessions_") ||
hostHasBucketPrefix(bucketList, "aw-linux-web-context_") ||
hostHasBucketPrefix(bucketList, "aw-detmir-web-category_")
) {
if (!hostHasBucketPrefix(bucketList, "aw-pve-webadmin-events_") && !hostHasBucketPrefix(bucketList, "aw-pve-task-events_")) {
return "linux-remote";
}
}
for (const group of groups) {
const patterns = Array.isArray(group.patterns) ? group.patterns : [];
for (const pattern of patterns) {
@@ -835,7 +854,7 @@
grouped.set("__ungrouped__", []);
Array.from(hostBuckets.keys()).sort().forEach(function (host) {
const groupId = matchHostGroup(host, groups) || "__ungrouped__";
const groupId = matchHostGroup(host, groups, hostBuckets.get(host) || []) || "__ungrouped__";
grouped.get(groupId).push(host);
});
@@ -891,7 +910,7 @@
center.setAttribute("data-aw-ru-host-groups", "1");
center.innerHTML =
'<h4>Разделы хостов</h4>' +
'<p>Здесь хосты разделены на пользовательские Windows RDP и инфраструктурные виртуальные серверы/Proxmox.</p>' +
'<p>Здесь хосты разделены на Windows RDP, Linux remote workers и инфраструктурные узлы.</p>' +
'<div class="aw-ru-host-groups-grid" data-aw-ru-host-groups-grid><section class="aw-ru-host-group-card"><p>Загрузка...</p></section></div>';
heading.parentElement.insertBefore(center, heading.nextSibling);
}
@@ -1328,10 +1347,10 @@
loadBucketEvents("aw-console-commands_" + host, 50).catch(function () { return []; })
]);
const data = {
web: filterEventsBySelectedDay(webEvents || []),
tasks: filterEventsBySelectedDay(taskEvents || []),
ssh: filterEventsBySelectedDay(sshEvents || []),
cmd: filterEventsBySelectedDay(cmdEvents || [])
web: webEvents || [],
tasks: taskEvents || [],
ssh: sshEvents || [],
cmd: cmdEvents || []
};
center.querySelector("[data-aw-ru-pve-web-count]").textContent = String(data.web.length);
center.querySelector("[data-aw-ru-pve-task-count]").textContent = String(data.tasks.length);
@@ -1606,124 +1625,6 @@
});
}
function ms(v) {
return new Date(v).getTime();
}
function fmtHours(sec) {
return (Math.round((sec / 3600) * 100) / 100).toFixed(2);
}
function rangesIntersect(a0, a1, b0, b1) {
return Math.max(a0, b0) < Math.min(a1, b1);
}
function intersectDurationSeconds(a0, a1, b0, b1) {
return Math.max(0, (Math.min(a1, b1) - Math.max(a0, b0)) / 1000);
}
async function renderPersonalWorktime(host, fromIso, toIso, target) {
const sessionBucket = "aw-worktime-sessions_" + host;
const windowBucket = "aw-watcher-window_" + host;
const afkBucket = "aw-watcher-afk_" + host;
const [sessionEvents, windowEvents, afkEvents] = await Promise.all([
loadBucketEvents(sessionBucket, 5000).catch(function () { return []; }),
loadBucketEvents(windowBucket, 10000).catch(function () { return []; }),
loadBucketEvents(afkBucket, 10000).catch(function () { return []; })
]);
const fromMs = ms(fromIso);
const toMs = ms(toIso);
const userRanges = {};
(sessionEvents || []).forEach(function (e) {
const d = e && e.data ? e.data : {};
const u = String(d.username || "").trim();
if (!u) return;
const t0 = ms(e.timestamp);
const t1 = t0 + Math.max(1, Number(e.duration || 0)) * 1000;
if (!rangesIntersect(t0, t1, fromMs, toMs)) return;
userRanges[u] = userRanges[u] || [];
userRanges[u].push([t0, t1]);
});
const activeRanges = [];
const afkByTs = new Map();
(afkEvents || []).forEach(function (e) { afkByTs.set(String(e.timestamp || ""), e); });
(windowEvents || []).forEach(function (e) {
const t0 = ms(e.timestamp);
const t1 = t0 + Math.max(1, Number(e.duration || 0)) * 1000;
if (!rangesIntersect(t0, t1, fromMs, toMs)) return;
const afk = afkByTs.get(String(e.timestamp || ""));
const status = String(afk && afk.data && afk.data.status || "");
if (status && status.toLowerCase() === "afk") return;
activeRanges.push([t0, t1]);
});
const rows = Object.keys(userRanges).sort().map(function (u) {
let presentSec = 0;
let activeSec = 0;
userRanges[u].forEach(function (r) {
presentSec += intersectDurationSeconds(r[0], r[1], fromMs, toMs);
activeRanges.forEach(function (a) {
activeSec += intersectDurationSeconds(r[0], r[1], a[0], a[1]);
});
});
const idleSec = Math.max(0, presentSec - activeSec);
return { user: u, presentSec: presentSec, activeSec: activeSec, idleSec: idleSec };
});
if (!rows.length) {
target.innerHTML = '<div class="aw-ru-pve-audit-muted">Нет данных в aw-worktime-sessions_' + host + ' за выбранный интервал.</div>';
return;
}
target.innerHTML = '<table class="aw-ru-worktime-table"><thead><tr><th>Пользователь</th><th>Активно, ч</th><th>В сессии, ч</th><th>Простой, ч</th></tr></thead><tbody>' +
rows.map(function (r) {
return '<tr><td>' + r.user + '</td><td>' + fmtHours(r.activeSec) + '</td><td>' + fmtHours(r.presentSec) + '</td><td>' + fmtHours(r.idleSec) + '</td></tr>';
}).join("") +
'</tbody></table>';
}
async function injectPersonalWorktimeCenter(root) {
const isActivity = /^#\/activity\//i.test(window.location.hash || "");
if (!isActivity) return;
if (root.querySelector(".aw-ru-worktime-center")) return;
const host = getCurrentHostFromHash();
if (!host) return;
const container = document.createElement("div");
container.className = "aw-ru-worktime-center";
const now = new Date();
const day = now.toISOString().slice(0, 10);
container.innerHTML =
'<h3 style="margin-top:0">Персональный учёт рабочего времени</h3>' +
'<div class="aw-ru-worktime-controls">' +
'<label>Дата <input type="date" data-aw-wt-date value="' + day + '"></label>' +
'<label>С <input type="time" data-aw-wt-from value="09:00"></label>' +
'<label>По <input type="time" data-aw-wt-to value="18:00"></label>' +
'<button type="button" data-aw-wt-run>Рассчитать</button>' +
'</div>' +
'<div data-aw-wt-result class="aw-ru-pve-audit-muted">Нажмите "Рассчитать".</div>';
root.insertBefore(container, root.firstChild);
const run = async function () {
const date = container.querySelector("[data-aw-wt-date]").value;
const tFrom = container.querySelector("[data-aw-wt-from]").value || "09:00";
const tTo = container.querySelector("[data-aw-wt-to]").value || "18:00";
const result = container.querySelector("[data-aw-wt-result]");
result.textContent = "Расчёт...";
const fromIso = new Date(date + "T" + tFrom + ":00").toISOString();
const toIso = new Date(date + "T" + tTo + ":00").toISOString();
try {
await renderPersonalWorktime(host, fromIso, toIso, result);
} catch (e) {
result.textContent = "Ошибка расчёта: " + String(e.message || e);
}
};
container.querySelector("[data-aw-wt-run]").addEventListener("click", function () { run(); });
}
function applyPatch() {
enforceSafeActivityViewForPveHost();
ensureSettingsHost();
@@ -1739,7 +1640,6 @@
injectDlpNavigation(document.body);
injectDlpReviewCenter(document.body);
injectDlpAlertsCenter(document.body);
injectPersonalWorktimeCenter(document.body).catch(function () {});
injectHostGroupsCenter(document.body).catch(function () {});
redirectBareTrendsRoute();
}
+4 -4
View File
@@ -20,7 +20,7 @@
"name": ["Работа", "Документы"],
"rule": {
"type": "regex",
"regex": "\\b(winword|excel|powerpnt|outlook|acrord32|acrord64)\\.exe\\b|Adobe Reader|Acrobat",
"regex": "\\b(winword|excel|powerpnt|outlook|acrord32|acrord64|libreoffice|writer|calc)\\.exe\\b|LibreOffice|OnlyOffice|Adobe Reader|Acrobat",
"ignore_case": true
},
"data": { "color": "#2E7D32" }
@@ -40,7 +40,7 @@
"name": ["Работа", "Администрирование"],
"rule": {
"type": "regex",
"regex": "\\b(mstsc|putty|kitty|winscp|anydesk|teamviewer|vncviewer|mmc|regedit|services|control|powershell|cmd)\\.exe\\b",
"regex": "\\b(mstsc|putty|kitty|winscp|anydesk|teamviewer|vncviewer|mmc|regedit|services|control|powershell|cmd|gnome-terminal|gnome-terminal-server|xfce4-terminal|konsole|tilix|alacritty|xterm|remmina|virt-manager)\\.exe\\b|\\b(gnome-terminal|gnome-terminal-server|xfce4-terminal|konsole|tilix|alacritty|xterm|remmina|virt-manager)\\b|Proxmox Virtual Environment|\\bpfSense\\b|\\bGrafana\\b|\\bKibana\\b|\\bPortainer\\b",
"ignore_case": true
},
"data": { "color": "#6D4C41" }
@@ -56,7 +56,7 @@
"name": ["Интернет", "Браузер"],
"rule": {
"type": "regex",
"regex": "\\b(chrome|msedge|firefox|opera|brave|vivaldi|browser)\\.exe\\b",
"regex": "\\b(chrome|msedge|firefox|opera|brave|vivaldi|browser|chromium)\\.exe\\b|\\b(chrome|chromium|firefox|opera|brave|vivaldi)\\b",
"ignore_case": true
},
"data": { "color": "#00897B" }
@@ -82,7 +82,7 @@
"name": ["ActivityWatch"],
"rule": {
"type": "regex",
"regex": "ActivityWatch|\\baw-(watcher|qt)\\.exe\\b",
"regex": "ActivityWatch|\\baw-(watcher|qt)\\.exe\\b|\\baw-(watcher|qt)\\b",
"ignore_case": true
},
"data": {}
+32 -19
View File
@@ -21,7 +21,7 @@
- `/home/igor/tmp/AWatch-rus/ansible/deploy_aw_server.yml`
- `/home/igor/tmp/AWatch-rus/ansible/provision_proxmox_ct_and_deploy_aw.yml`
- `/home/igor/tmp/AWatch-rus/ansible/provision_proxmox_ct_matrix_and_deploy_aw.yml`
- `/home/igor/tmp/AWatch-rus/ansible/deploy_aw_windows_phase2.yml`
- `/home/igor/tmp/AWatch-rus/ansible/deploy_aw_windows.yml`
---
@@ -177,7 +177,7 @@ grep -n 'aw-ru-patch\|aw-sw-cleanup' /opt/activitywatch/webui-ru/index.html
например в:
- `C:\Deploy\ActivityWatch-Russian\windows`
- `C:\Program Files\AWatch-rus\windows`
Откройте **elevated PowerShell**:
@@ -187,15 +187,28 @@ Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope Process
### 3.2 Массовое доменное развёртывание (рекомендуется)
Если текущий production ещё работает в старых каталогах
`C:\Program Files\ActivityWatch-Phase2` и `C:\ProgramData\ActivityWatch-Phase2`,
сначала выполните безопасную миграцию:
```powershell
C:\Program Files\AWatch-rus\windows\migrate-awatch-rus-paths.ps1 -WhatIf
C:\Program Files\AWatch-rus\windows\migrate-awatch-rus-paths.ps1
```
Скрипт остановит `ActivityWatch Recovery`/`ActivityWatch Launch *`, создаст backup в
`C:\ProgramData\AWatch-rus\migration-backups\...`, перенесёт файлы в единые пути,
пересоздаст `deployment-config.json`/scheduled tasks и запустит validation.
Пример со списком пользователей:
```powershell
C:\Deploy\ActivityWatch-Russian\windows\deploy-domain-users.ps1 `
C:\Program Files\AWatch-rus\windows\deploy-domain-users.ps1 `
-ServerHost aw.example.local `
-ServerPort 5600 `
-Domain CONTOSO `
-UserListPath C:\Deploy\aw-users.txt `
-CustomRulesPath C:\Deploy\ActivityWatch-Russian\windows\web-category-rules.example.json
-CustomRulesPath C:\Program Files\AWatch-rus\windows\web-category-rules.example.json
```
Поддерживаемые варианты:
@@ -207,7 +220,7 @@ C:\Deploy\ActivityWatch-Russian\windows\deploy-domain-users.ps1 `
### 3.2.1 Ensemble orchestration (рекомендуется для production)
```powershell
C:\Deploy\ActivityWatch-Russian\windows\deploy-ensemble.ps1 `
C:\Program Files\AWatch-rus\windows\deploy-ensemble.ps1 `
-ServerHost aw.example.local `
-ServerPort 5600 `
-Domain CONTOSO `
@@ -217,30 +230,30 @@ C:\Deploy\ActivityWatch-Russian\windows\deploy-ensemble.ps1 `
Отчёт сохраняется в:
- `C:\ProgramData\ActivityWatch\ensemble-report-YYYYMMDD-HHMMSS.json`
- `C:\ProgramData\AWatch-rus\ensemble-report-YYYYMMDD-HHMMSS.json`
### 3.3 Single-user развёртывание
```powershell
C:\Deploy\ActivityWatch-Russian\windows\deploy-single-user.ps1 `
C:\Program Files\AWatch-rus\windows\deploy-single-user.ps1 `
-ServerHost aw.example.local `
-ServerPort 5600 `
-TargetUser 'CONTOSO\user01' `
-CustomRulesPath C:\Deploy\ActivityWatch-Russian\windows\web-category-rules.example.json
-CustomRulesPath C:\Program Files\AWatch-rus\windows\web-category-rules.example.json
```
### 3.4 Recovery / hardening
```powershell
C:\Deploy\ActivityWatch-Russian\windows\hardening-recovery.ps1 `
-ConfigPath C:\ProgramData\ActivityWatch\deployment-config.json
C:\Program Files\AWatch-rus\windows\hardening-recovery.ps1 `
-ConfigPath C:\ProgramData\AWatch-rus\deployment-config.json
```
### 3.5 Валидация deployment-а (PowerShell report)
```powershell
$report = C:\Deploy\ActivityWatch-Russian\windows\validate-deployment.ps1 `
-ConfigPath C:\ProgramData\ActivityWatch\deployment-config.json
$report = C:\Program Files\AWatch-rus\windows\validate-deployment.ps1 `
-ConfigPath C:\ProgramData\AWatch-rus\deployment-config.json
$report | ConvertTo-Json -Depth 12
```
@@ -248,13 +261,13 @@ $report | ConvertTo-Json -Depth 12
## 4) Что должно появиться на Windows после установки
- `C:\Program Files\ActivityWatch`
- `C:\ProgramData\ActivityWatch\deployment-config.json`
- `C:\ProgramData\ActivityWatch\launch-watchers.ps1`
- `C:\ProgramData\ActivityWatch\recovery-loop.ps1`
- `C:\ProgramData\ActivityWatch\browser-domains-native-collector.ps1`
- `C:\ProgramData\ActivityWatch\web-category-rules.json`
- `C:\ProgramData\ActivityWatch\logs\`
- `C:\Program Files\AWatch-rus\bin`
- `C:\ProgramData\AWatch-rus\deployment-config.json`
- `C:\ProgramData\AWatch-rus\launch-watchers.ps1`
- `C:\ProgramData\AWatch-rus\recovery-loop.ps1`
- `C:\ProgramData\AWatch-rus\browser-domains-native-collector.ps1`
- `C:\ProgramData\AWatch-rus\web-category-rules.json`
- `C:\ProgramData\AWatch-rus\logs\`
Задачи планировщика:
+103
View File
@@ -0,0 +1,103 @@
# Central DLP aggregator prototype
`scripts/aggregate_dlp_events.py` collects Phase 2 DLP telemetry from ActivityWatch buckets and stores normalized rows in one database for Grafana/SIEM-style reporting.
## Streams
The prototype reads:
- `aw-file-operations_*` (`aw.file.operation`) — file create/delete/rename telemetry, including `archiveHint`.
- `aw-dlp-incidents_*` (`aw.dlp.incident`) — browser/endpoint DLP incidents and screenshot metadata when available.
## SQLite smoke test
SQLite is the default so the collector can be tested without deploying PostgreSQL:
```bash
python3 scripts/aggregate_dlp_events.py \
--aw-url http://10.10.10.13:5600/api/0 \
--sqlite-path data/dlp-events.sqlite3 \
--lookback-hours 24
```
Useful checks:
```bash
sqlite3 data/dlp-events.sqlite3 \
"select stream_type, hostname, count(*) from dlp_events group by 1,2 order by 3 desc;"
sqlite3 data/dlp-events.sqlite3 \
"select event_ts, hostname, username, file_path from dlp_file_operations where archive_hint = 1 order by event_ts desc limit 20;"
```
## PostgreSQL mode
For centralized reporting, pass a DSN through an environment variable instead of committing secrets:
```bash
export DLP_AGGREGATOR_POSTGRES_DSN='postgresql://aw_dlp:${PASSWORD}@postgres.internal:5432/aw_dlp'
python3 -m pip install 'psycopg[binary]'
python3 scripts/aggregate_dlp_events.py \
--aw-url http://10.10.10.13:5600/api/0
```
Minimum database bootstrap:
```sql
create database aw_dlp;
create user aw_dlp_ingest with password '<strong generated password>';
grant connect on database aw_dlp to aw_dlp_ingest;
grant usage, create on schema public to aw_dlp_ingest;
```
The script creates:
- table `dlp_events`
- view `dlp_file_operations`
- view `dlp_incidents`
## Incremental state
By default, the aggregator stores the last successful end timestamp in:
```text
data/dlp-aggregator-state.json
```
Future runs resume from that timestamp with a small overlap window to avoid missing late events. Duplicate inserts are ignored by `(bucket_id, event_id)`.
## Scheduling example
Cron every minute:
```cron
* * * * * cd /opt/AWatch-rus && /usr/bin/python3 scripts/aggregate_dlp_events.py --aw-url http://10.10.10.13:5600/api/0 >> /var/log/aw-dlp-aggregator.log 2>&1
```
## Example Grafana queries
Archive creation by user:
```sql
select
date_trunc('minute', event_ts) as time,
hostname,
username,
count(*) as archives
from dlp_file_operations
where archive_hint = true
group by 1, 2, 3
order by 1 desc;
```
DLP incidents by severity:
```sql
select
date_trunc('hour', event_ts) as time,
severity,
count(*) as incidents
from dlp_incidents
group by 1, 2
order by 1 desc;
```
+2 -2
View File
@@ -29,8 +29,8 @@
- USB/print/clipboard collectors (endpoint signals) — внедрено.
- Incident pipeline расширен на endpoint события — внедрено.
- File-operation telemetry (create/copy/archive/upload hints) — в backlog.
- Central incident aggregation/export — в backlog.
- File-operation telemetry (create/delete/rename/archive hints) — прототип внедрён (`windows/file-operations-collector.ps1`).
- Central incident aggregation/export — прототип внедрён (`scripts/aggregate_dlp_events.py`, `docs/dlp-aggregator.md`).
### Phase 3
+85
View File
@@ -0,0 +1,85 @@
# Linux remote worker deployment
## Назначение
Этот сценарий закрывает полный набор данных по Linux-удалёнщику:
- GUI active window и `afk` через `ActivityWatch`;
- SSH и shell-команды через console/ssh logger;
- браузерные админки по title-based правилам, включая Proxmox `https://...:8006`.
Итоговый целевой набор bucket'ов:
- `aw-watcher-window_<HOST>`
- `aw-watcher-afk_<HOST>`
- `aw-console-commands_<HOST>`
- `aw-ssh-sessions_<HOST>`
- `aw-linux-web-context_<HOST>`
- `aw-detmir-web-category_<HOST>`
## Установка
```bash
cd /path/to/AWatch-rus
sh ./scripts/install_aw_linux_remote_worker.sh \
--server-host 10.10.10.13 \
--server-port 5600
```
## Что ставится
1. `scripts/install_aw_linux_client.sh`
2. `scripts/install_aw_console_ssh_logger.sh`
3. `scripts/install_aw_linux_web_category_logger.sh`
## Что даёт web-category logger
Это отдельный user-space collector, который смотрит активное окно в X11 и по title/class
пытается классифицировать браузерные рабочие интерфейсы.
Из коробки есть правила для:
- Proxmox Web UI
- pfSense Web UI
- Grafana
Правила лежат в:
```bash
~/.config/aw-linux-web-category/rules.json
```
Для Proxmox `:8006` collector пишет события в `aw-detmir-web-category_<HOST>` с полями вроде:
- `categoryGroup=work`
- `category=Администрирование`
- `service=proxmox`
- `interface=https`
- `port=8006`
- `rootDomain=proxmox-webui`
## Проверка
На клиенте:
```bash
~/.local/bin/aw-console-ssh-logger-status
~/.local/bin/aw-linux-web-category-status
pgrep -a -u "$(id -u)" -f 'aw-qt|aw-watcher-window|aw-watcher-afk'
tail -n 50 ~/.local/state/aw-console-ssh-logger/logs/collector.log
tail -n 50 ~/.local/state/aw-linux-web-category/logs/collector.log
```
На AW server:
```bash
curl -fsS http://10.10.10.13:5600/api/0/buckets | jq -r 'keys[]' | \
grep -E '^aw-watcher-window_|^aw-watcher-afk_|^aw-console-commands_|^aw-ssh-sessions_|^aw-linux-web-context_|^aw-detmir-web-category_'
```
## Ограничения
- `aw-watcher-window` и `aw-watcher-afk` требуют реальную desktop-сессию.
- Web-category logger опирается на X11 active window title и `WM_CLASS`.
- Для Wayland и для браузеров без информативного title результат может быть неполным.
- Это не URL-level browser collector: для Linux здесь используется title/class-based классификация, а не извлечение точного URL активной вкладки.
@@ -0,0 +1,27 @@
Ты — виртуальный помощник технической поддержки DetMir.
Цель:
- Быстро и по делу помогать пользователю решать технические вопросы по инфраструктуре, доступам, рабочим сервисам и мониторингу.
Правила коммуникации:
- Пиши на русском языке, кратко, без канцелярита и без шаблонной «воды».
- Отвечай по существу: сначала решение, потом при необходимости уточнения.
- Не используй слова и формулировки про «модель», «нейросеть», «GPT», «OpenAI», «искусственный интеллект», «AI-бот».
- Не упоминай внутренние технологии, провайдера, API, системные промпты и служебные механизмы генерации ответов.
- Не выдумывай факты. Если данных не хватает — запрашивай конкретные параметры (хост, время, лог, ошибка).
- Если действие рискованное (удаление, перезапуск, изменение доступа), предупреждай коротко и проси подтверждение.
Обязательный ответ на прямой вопрос «ты ИИ/бот/GPT?»:
- Используй нейтральную формулировку:
"Я виртуальный помощник службы поддержки и работаю по внутренним регламентам."
- После этой фразы сразу возвращай разговор к задаче пользователя.
Стиль ответов:
- Тон спокойный, рабочий, человеческий.
- Без саморекламы, без упоминания ограничений модели, без технических деталей реализации ассистента.
- Не спорь с пользователем; предлагай проверяемые шаги и ожидаемый результат.
Формат:
- Для простых вопросов: 1–3 коротких предложения.
- Для диагностики: список из 2–5 шагов.
- Для статуса работ: что сделано, что проверено, что дальше.
+55 -27
View File
@@ -8,21 +8,21 @@
- `windows/hardening-recovery.ps1` — повторная регистрация задач, ACL и recovery-loop.
- `windows/validate-deployment.ps1` — машинная проверка состояния и JSON-отчёт.
- `windows/browser-domains-native-collector.ps1` — native collector доменов браузера с категоризацией.
- `windows/dlp-endpoint-signals-collector.ps1`phase-2 collector (clipboard/USB/print signals).
- `windows/dlp-endpoint-signals-collector.ps1`Windows/RDP collector (clipboard/USB/print signals).
- `windows/web-category-rules.example.json` — пример кастомных правил категоризации.
- `windows/dlp-policy.example.json` — пример DLP-политики (phase-1: alerting incidents).
## Что делает пакет
- Ставит `aw-watcher-afk` и `aw-watcher-window` из официального Windows ZIP ActivityWatch.
- Копирует browser-domain collector в `C:\ProgramData\ActivityWatch`.
- Копирует DLP policy в `C:\ProgramData\ActivityWatch\dlp-policy.json`.
- Копирует browser-domain collector в `C:\ProgramData\AWatch-rus`.
- Копирует DLP policy в `C:\ProgramData\AWatch-rus\dlp-policy.json`.
- Включает `incidentCapture` в `deployment-config.json` для DLP-инцидентов:
- `incidentCapture.screenshotEnabled = true`
- `incidentCapture.artifactsRoot = <StateRoot>\incident-artifacts`
- Создаёт per-user задачи `ActivityWatch Launch [...]` с запуском при логоне.
- Создаёт системную задачу `ActivityWatch Recovery`, которая циклически перезапускает per-user launch tasks.
- Применяет ACL к `C:\Program Files\ActivityWatch`, `C:\ProgramData\ActivityWatch` и каталогу логов.
- Применяет ACL к `C:\Program Files\AWatch-rus\bin`, `C:\ProgramData\AWatch-rus` и каталогу логов.
- Не содержит хардкодов инфраструктуры: сервер, домен, список пользователей и правила передаются параметрами.
- Корректно регистрирует задачи через `-LogonType Interactive` (совместимо с Windows Server, где `InteractiveToken` не поддерживается).
- Поддерживает отключение шумных watcher'ов через `-AfkEnabled:$false` и `-WindowEnabled:$false`.
@@ -143,9 +143,37 @@ CSV-формат: колонка `User`, `Username`, `SamAccountName` или `Lo
Если список уже содержит `DOMAIN\user`, параметр `-Domain` не нужен.
## Рекомендуемый phased rollout (изолированный профиль)
## Безопасная миграция текущего production
Для безопасного параллельного запуска рядом с legacy-инсталляцией используйте отдельные пути:
Если текущий RDP production уже работает в `C:\Program Files\ActivityWatch-Phase2` и
`C:\ProgramData\ActivityWatch-Phase2`, не запускайте обычный update без миграции.
Сначала выполните перенос в единый профиль AWatch-rus:
```powershell
C:\Program Files\AWatch-rus\windows\migrate-awatch-rus-paths.ps1 -WhatIf
C:\Program Files\AWatch-rus\windows\migrate-awatch-rus-paths.ps1
```
Скрипт делает безопасный порядок:
1. Находит старый `deployment-config.json`.
2. Останавливает `ActivityWatch Recovery` и `ActivityWatch Launch *`.
3. Создаёт backup старых и новых каталогов в
`C:\ProgramData\AWatch-rus\migration-backups\YYYYMMDD-HHMMSS`.
4. Копирует бинарники/состояние в единые пути:
- `C:\Program Files\AWatch-rus\bin`
- `C:\ProgramData\AWatch-rus`
5. Переписывает пути в `deployment-config.json`.
6. Пересоздаёт launcher/recovery scripts и scheduled tasks.
7. Запускает `validate-deployment.ps1`; при ошибке оставляет backup path в сообщении.
Ansible playbook `ansible/deploy_aw_windows.yml` выполняет этот migration guard
автоматически, если на хосте найден
`C:\ProgramData\ActivityWatch-Phase2\deployment-config.json`.
## Рекомендуемый rollout
Для запуска после миграции используйте единые пути:
```powershell
.\windows\deploy-domain-users.ps1 `
@@ -153,10 +181,10 @@ CSV-формат: колонка `User`, `Username`, `SamAccountName` или `Lo
-ServerPort 5600 `
-Domain SHARKON2025 `
-Users user2,user3,user4,user5 `
-InstallRoot 'C:\Program Files\ActivityWatch-Phase2-u2u5' `
-StateRoot 'C:\ProgramData\ActivityWatch-Phase2-u2u5' `
-CustomRulesPath C:\Deploy\AWatch-rus\windows\web-category-rules.example.json `
-CustomPolicyPath C:\Deploy\AWatch-rus\windows\dlp-policy.example.json
-InstallRoot 'C:\Program Files\AWatch-rus\bin' `
-StateRoot 'C:\ProgramData\AWatch-rus' `
-CustomRulesPath C:\Program Files\AWatch-rus\windows\web-category-rules.example.json `
-CustomPolicyPath C:\Program Files\AWatch-rus\windows\dlp-policy.example.json
```
Single-user pilot в таком же стиле:
@@ -166,10 +194,10 @@ Single-user pilot в таком же стиле:
-ServerHost 10.10.10.13 `
-ServerPort 5600 `
-TargetUser 'SHARKON2025\user1' `
-InstallRoot 'C:\Program Files\ActivityWatch-Phase2' `
-StateRoot 'C:\ProgramData\ActivityWatch-Phase2-user1' `
-CustomRulesPath C:\Deploy\AWatch-rus\windows\web-category-rules.example.json `
-CustomPolicyPath C:\Deploy\AWatch-rus\windows\dlp-policy.example.json
-InstallRoot 'C:\Program Files\AWatch-rus\bin' `
-StateRoot 'C:\ProgramData\AWatch-rus' `
-CustomRulesPath C:\Program Files\AWatch-rus\windows\web-category-rules.example.json `
-CustomPolicyPath C:\Program Files\AWatch-rus\windows\dlp-policy.example.json
```
## Ensemble deploy (production workflow)
@@ -186,31 +214,31 @@ Single-user pilot в таком же стиле:
Итоговый отчёт:
- `C:\ProgramData\ActivityWatch\ensemble-report-YYYYMMDD-HHMMSS.json`
- `C:\ProgramData\AWatch-rus\ensemble-report-YYYYMMDD-HHMMSS.json`
## Категоризация доменов
- Встроенные категории покрывают базовые рабочие, нейтральные и личные домены.
- Для кастомизации скопируйте `windows/web-category-rules.example.json` и отредактируйте домены.
- Передайте файл через `-CustomRulesPath`; он будет сохранён как `C:\ProgramData\ActivityWatch\web-category-rules.json`.
- Передайте файл через `-CustomRulesPath`; он будет сохранён как `C:\ProgramData\AWatch-rus\web-category-rules.json`.
- Пользовательские правила имеют приоритет над встроенными.
## Структура после установки
- `C:\Program Files\ActivityWatch` — бинарники watcher'ов.
- `C:\ProgramData\ActivityWatch\deployment-config.json` — итоговая конфигурация.
- `C:\ProgramData\ActivityWatch\incident-artifacts\` — скриншоты DLP-инцидентов (если `incidentCapture.screenshotEnabled=true`).
- `C:\ProgramData\ActivityWatch\launch-watchers.ps1` — per-user launcher.
- `C:\ProgramData\ActivityWatch\recovery-loop.ps1` — system recovery loop.
- `C:\ProgramData\ActivityWatch\browser-domains-native-collector.ps1` — runtime collector.
- `C:\ProgramData\ActivityWatch\dlp-endpoint-signals-collector.ps1` — runtime endpoint collector.
- `C:\ProgramData\ActivityWatch\dlp-policy.json` — активная DLP-политика.
- `C:\ProgramData\ActivityWatch\logs\` — логи collector'а.
- `C:\Program Files\AWatch-rus\bin` — бинарники watcher'ов.
- `C:\ProgramData\AWatch-rus\deployment-config.json` — итоговая конфигурация.
- `C:\ProgramData\AWatch-rus\incident-artifacts\` — скриншоты DLP-инцидентов (если `incidentCapture.screenshotEnabled=true`).
- `C:\ProgramData\AWatch-rus\launch-watchers.ps1` — per-user launcher.
- `C:\ProgramData\AWatch-rus\recovery-loop.ps1` — system recovery loop.
- `C:\ProgramData\AWatch-rus\browser-domains-native-collector.ps1` — runtime collector.
- `C:\ProgramData\AWatch-rus\dlp-endpoint-signals-collector.ps1` — runtime endpoint collector.
- `C:\ProgramData\AWatch-rus\dlp-policy.json` — активная DLP-политика.
- `C:\ProgramData\AWatch-rus\logs\` — логи collector'а.
Для phased rollout те же файлы формируются в каталоге `StateRoot`, переданном параметром.
При переопределении `StateRoot` те же файлы формируются в указанном каталоге.
## Повторный прогон
- Скрипты идемпотентны: переустанавливают задачи и обновляют runtime-файлы.
- Предыдущая установка ActivityWatch бэкапится в `C:\ProgramData\ActivityWatch\backups\install-YYYYMMDD-HHMMSS`.
- Предыдущая установка ActivityWatch бэкапится в `C:\ProgramData\AWatch-rus\backups\install-YYYYMMDD-HHMMSS`.
- Для жёсткого восстановления запускайте `windows/hardening-recovery.ps1`.
+6 -6
View File
@@ -17,15 +17,15 @@
```powershell
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope Process
C:\Deploy\AWatch-rus\windows\deploy-ensemble.ps1 `
C:\Program Files\AWatch-rus\windows\deploy-ensemble.ps1 `
-ServerHost 10.10.10.13 `
-ServerPort 5600 `
-Domain SHARKON2025 `
-Users user1,user2,user3,user4,user5 `
-InstallRoot 'C:\Program Files\ActivityWatch-Phase2' `
-StateRoot 'C:\ProgramData\ActivityWatch-Phase2' `
-InstallRoot 'C:\Program Files\AWatch-rus\bin' `
-StateRoot 'C:\ProgramData\AWatch-rus' `
-AfkEnabled:$false `
-CustomPolicyPath C:\Deploy\AWatch-rus\windows\dlp-policy.example.json `
-CustomPolicyPath C:\Program Files\AWatch-rus\windows\dlp-policy.example.json `
-ValidateAfterDeploy
```
@@ -42,8 +42,8 @@ C:\Deploy\AWatch-rus\windows\deploy-ensemble.ps1 `
## Быстрый health-check
```powershell
$report = C:\Deploy\AWatch-rus\windows\validate-deployment.ps1 `
-ConfigPath C:\ProgramData\ActivityWatch-Phase2\deployment-config.json
$report = C:\Program Files\AWatch-rus\windows\validate-deployment.ps1 `
-ConfigPath C:\ProgramData\AWatch-rus\deployment-config.json
$report | ConvertTo-Json -Depth 12
```
@@ -0,0 +1,7 @@
# Moved
Документ перенесён в новую структуру install-kit:
- `windows/installkit/innosetup/innosetup-rdp-package-filelist.md`
Этот файл оставлен как совместимый указатель, чтобы не ломать существующие ссылки в документации/автоматизации.
+3 -3
View File
@@ -23,7 +23,7 @@ Start-ScheduledTask -TaskName 'ActivityWatch Launch [CONTOSO_user01]'
- Скрипт работает через UI Automation и foreground window.
- Некоторые браузеры/страницы могут скрывать адресную строку или блокировать UIA.
- Проверьте лог `C:\ProgramData\ActivityWatch\logs\browser-domains-<user>.log`.
- Проверьте лог `C:\ProgramData\AWatch-rus\logs\browser-domains-<user>.log`.
- Убедитесь, что активное окно — поддерживаемый браузер: Edge, Chrome, Brave, Vivaldi, Opera, Firefox.
### Сервер недоступен
@@ -44,7 +44,7 @@ Invoke-WebRequest http://aw.example.local:5600/api/0/info
### Неправильная категоризация домена
- Проверьте содержимое `C:\ProgramData\ActivityWatch\web-category-rules.json`.
- Проверьте содержимое `C:\ProgramData\AWatch-rus\web-category-rules.json`.
- Пользовательские правила должны быть валидным JSON.
- Один и тот же домен лучше определять только в одной категории.
- После изменения правил достаточно перезапустить collector или задачу пользователя:
@@ -112,7 +112,7 @@ Get-CimInstance Win32_Process |
Проверить конфиг:
```powershell
Get-Content C:\ProgramData\ActivityWatch\deployment-config.json -Raw
Get-Content C:\ProgramData\AWatch-rus\deployment-config.json -Raw
```
## Когда запускать hardening/recovery
+13 -13
View File
@@ -4,7 +4,7 @@
```powershell
$report = .\windows\validate-deployment.ps1 `
-ConfigPath C:\ProgramData\ActivityWatch\deployment-config.json
-ConfigPath C:\ProgramData\AWatch-rus\deployment-config.json
$report | ConvertTo-Json -Depth 12
```
@@ -19,11 +19,11 @@ $report | ConvertTo-Json -Depth 12
### 1. Проверить установленные файлы
```powershell
Test-Path 'C:\Program Files\ActivityWatch\aw-watcher-afk\aw-watcher-afk.exe'
Test-Path 'C:\Program Files\ActivityWatch\aw-watcher-window\aw-watcher-window.exe'
Test-Path 'C:\ProgramData\ActivityWatch\browser-domains-native-collector.ps1'
Test-Path 'C:\ProgramData\ActivityWatch\dlp-policy.json'
Test-Path 'C:\ProgramData\ActivityWatch\deployment-config.json'
Test-Path 'C:\Program Files\AWatch-rus\bin\aw-watcher-afk\aw-watcher-afk.exe'
Test-Path 'C:\Program Files\AWatch-rus\bin\aw-watcher-window\aw-watcher-window.exe'
Test-Path 'C:\ProgramData\AWatch-rus\browser-domains-native-collector.ps1'
Test-Path 'C:\ProgramData\AWatch-rus\dlp-policy.json'
Test-Path 'C:\ProgramData\AWatch-rus\deployment-config.json'
```
Ожидаемый результат — везде `True`.
@@ -50,7 +50,7 @@ Get-ScheduledTask | Where-Object TaskName -eq 'ActivityWatch Recovery'
### 2.1 Проверить incidentCapture в конфиге
```powershell
$cfg = Get-Content 'C:\ProgramData\ActivityWatch\deployment-config.json' -Raw | ConvertFrom-Json
$cfg = Get-Content 'C:\ProgramData\AWatch-rus\deployment-config.json' -Raw | ConvertFrom-Json
$cfg.incidentCapture
```
@@ -119,7 +119,7 @@ Invoke-WebRequest http://aw.example.local:5600/api/0/buckets | Select-Object -Ex
4. Проверьте локальный лог:
```powershell
Get-Content "C:\ProgramData\ActivityWatch\logs\dlp-incidents-$env:USERNAME.log" -Tail 50
Get-Content "C:\ProgramData\AWatch-rus\logs\dlp-incidents-$env:USERNAME.log" -Tail 50
```
Если `screenshotEnabled = True`, проверьте наличие скриншота в инциденте:
@@ -175,7 +175,7 @@ Invoke-RestMethod -Method Post `
- в API появился `ruleId=selftest-dlp-incident`;
- в UI (`#/buckets/aw-dlp-incidents_<hostname>`) событие видно в `Events`.
## Проверка phase-2 endpoint signals
## Проверка endpoint signals
1. Скопируйте любой текст в буфер обмена.
2. Отправьте тестовую печать (любой принтер/виртуальный PDF).
@@ -188,7 +188,7 @@ Invoke-WebRequest http://aw.example.local:5600/api/0/buckets/aw-dlp-endpoint-sig
4. Проверьте локальный лог:
```powershell
Get-Content "C:\ProgramData\ActivityWatch\logs\endpoint-signals-$env:USERNAME.log" -Tail 50
Get-Content "C:\ProgramData\AWatch-rus\logs\endpoint-signals-$env:USERNAME.log" -Tail 50
```
## Проверка восстановления
@@ -206,9 +206,9 @@ Start-ScheduledTask -TaskName 'ActivityWatch Recovery'
## Проверка ACL
```powershell
icacls 'C:\Program Files\ActivityWatch'
icacls 'C:\ProgramData\ActivityWatch'
icacls 'C:\ProgramData\ActivityWatch\logs'
icacls 'C:\Program Files\AWatch-rus\bin'
icacls 'C:\ProgramData\AWatch-rus'
icacls 'C:\ProgramData\AWatch-rus\logs'
```
Ожидаемо:
+25
View File
@@ -36,6 +36,10 @@ RETURN = sort_by_duration(work);
Требует, чтобы на клиенте работал browser collector и писал в:
`aw-detmir-web-category_<HOST>` поля `categoryGroup`, `rootDomain`.
Для Linux-удалёнщиков это может быть не URL-level collector, а title/class-based web-category logger.
Например, работа через Proxmox Web UI `https://...:8006` может попадать сюда как
`rootDomain=proxmox-webui`, `categoryGroup=work`, `category=Администрирование`.
```javascript
web = flood(query_bucket("aw-detmir-web-category_SHARKON2025"));
not_afk = flood(query_bucket("aw-watcher-afk_SHARKON2025"));
@@ -68,3 +72,24 @@ RETURN = sort_by_duration(events);
- Если web-поток пустой, рабочее время в браузере корректно посчитать по доменам не получится. Тогда либо:
- чинить/запускать browser collector;
- либо временно считать браузер в `window` как «Интернет/Браузер» без разделения на work/personal.
## Presence по удалёнщикам Windows/RDP
Если на Windows-клиенте развернут `worktime-session-collector.ps1`, то появляется bucket
`aw-worktime-sessions_<HOST>` с heartbeat по `quser`/RDP session state.
Это не замена `afk/window`, а отдельный канал для ответа на вопрос:
«кто и когда вообще был в активной удалённой сессии».
```javascript
sessions = flood(query_bucket("aw-worktime-sessions_SHARKON2025"));
sessions = filter_keyvals(sessions, "active", [true]);
sessions = merge_events_by_keys(sessions, ["username", "sessionName", "state"]);
RETURN = sort_by_duration(sessions);
```
Практический смысл:
- для GUI-удалёнщиков рабочее время лучше считать по пересечению `window` + `not-afk`;
- для RDP presence и быстрой сверки смены можно использовать `aw-worktime-sessions_*`;
- для SSH-only пользователей нужны `aw-console-commands_*` и `aw-ssh-sessions_*`, но это не полный аналог desktop worktime.
@@ -1,17 +1,18 @@
6e1f304f468d77f12df67face6afaaca5bbcfb1496f43df4e4fa0557cf847829 install-kit-awindows-20260427-211240/README-INSTALL-KIT.txt
a11f41827769be915f73d0de2c5503b05f61ccf56f70a50845771bcb79c5ebb7 install-kit-awindows-20260427-211240/ansible/README.md
02ca96f5ecc6abf89ab3271bd08add5795dbba2281168f158d96166f557e33f0 install-kit-awindows-20260427-211240/ansible/deploy_aw_pfsense_poller.yml
7c1ad9363412e802f4272f2e91a1d9f26be722eaf22e654c0a2512b0cebbbfd0 install-kit-awindows-20260427-211240/ansible/deploy_aw_server.yml
d5c42e6fe49c14a0769517ff28184139e467d40f22d4a632630c42ed1ff34ce5 install-kit-awindows-20260427-211240/ansible/deploy_aw_windows_phase2.yml
bc791462b9c00adc8c68ed81e2a7697c560bdbc46f156b4840c7bca1dee2157d install-kit-awindows-20260427-211240/ansible/group_vars/all.example.yml
0754dcba7c651d67a40e09446868d2fcae623a100d4fb01794dd96272d353b49 install-kit-awindows-20260427-211240/README-INSTALL-KIT.txt
089595753398c8b82980919d230dafac548c3ba36817f5c96a68051582f9faa3 install-kit-awindows-20260427-211240/ansible/README.md
412bb766bbf0791c3593f38daa771d5d0aa58cc1f2d3c9010fcd4588d0fe87df install-kit-awindows-20260427-211240/ansible/deploy_aw_pfsense_poller.yml
90ac38a33918fcd3620f078f51fbf7c6a9d7f8fd1a34d16b38cb3ac45678b0d7 install-kit-awindows-20260427-211240/ansible/deploy_aw_server.yml
a649eeb57472fb259d248983c486f0474bfb7317600feb98f76a80e7af7e4549 install-kit-awindows-20260427-211240/ansible/deploy_aw_windows.yml
531bfec24f86d28a06e5c0d73005489a818e2c7b1cce1d98f524a3f76802b8ee install-kit-awindows-20260427-211240/ansible/group_vars/all.example.yml
95696c243ab331f06e77a40a9800c4b6668de77675ebbdf2ef54ae49e1b18874 install-kit-awindows-20260427-211240/ansible/group_vars/pfsense-poller.example.yml
c5cab36645065815571c99f6d360f910dcccbb54b780c8bfd526a6cdc3684e19 install-kit-awindows-20260427-211240/ansible/group_vars/proxmox-matrix.example.yml
35a33c8a1c75ded5e85c6b79e0b3efde07959ff61ee5f66d83b7e0c2abe87fc5 install-kit-awindows-20260427-211240/ansible/group_vars/proxmox.example.yml
69368b7adb7711fa81304866373e61ed464bcc23a08e4509fa54655b05f95790 install-kit-awindows-20260427-211240/ansible/group_vars/windows.example.yml
00064ce5187569fb3221ddd45c2ad7eb37cd50b2a0a832ddf7843e4ff461849a install-kit-awindows-20260427-211240/ansible/inventory.example.ini
bbef175cb77dd53aa07452dbb2fe8797f38b58f42372005c3404c8dc9d6f8e13 install-kit-awindows-20260427-211240/ansible/provision_proxmox_ct_and_deploy_aw.yml
b8f8b6bc504a51cd87db3f46c35a27295b395ea516533068f96af35f8b720434 install-kit-awindows-20260427-211240/ansible/provision_proxmox_ct_matrix_and_deploy_aw.yml
d35bc97b6de18f0006cbbad4adf8a8a5db8ed912997d8fc47c7f11fa9247e907 install-kit-awindows-20260427-211240/ansible/tasks/provision_ct_and_deploy_aw.yml
7c468f252e328fd3bb7ee776a45feea88efc4b438dfef55b832da4eea867aaf2 install-kit-awindows-20260427-211240/ansible/group_vars/windows.example.yml
195e7dbdb91f4e77db3263bd0812301ddc912a37ba1519688768bb64a2887567 install-kit-awindows-20260427-211240/ansible/install_full_stack.yml
2e4e94d90143923fefd3ec1257d0ec57daa3e96450d85471bc2c418aae37e105 install-kit-awindows-20260427-211240/ansible/inventory.example.ini
d9e43352fd6bdb647db9754ab2c557b6bb27f88f51b2bf23d8c19227e535e7b9 install-kit-awindows-20260427-211240/ansible/provision_proxmox_ct_and_deploy_aw.yml
f3d34547f345ad1c635ee44613a60e7f08479f9ae4d1111810bc7e5968bfb084 install-kit-awindows-20260427-211240/ansible/provision_proxmox_ct_matrix_and_deploy_aw.yml
ef4ed198745777bd1227170241b2db21dc853685f962d6116241c55216b466d6 install-kit-awindows-20260427-211240/ansible/tasks/provision_ct_and_deploy_aw.yml
a50dbadbf619342c2178e255b68f69a36503756daf80eedd9170311c63964f2e install-kit-awindows-20260427-211240/aw-server/activitywatch-server.service
2dbf55d4a8f204ebdc97af926d90435932c0aad7a2431e2b9987e47c5abf71a9 install-kit-awindows-20260427-211240/aw-server/apply_webui_ru_patch.sh
07d4e583f6e9757a11f01558e1f15cfd73c4d82695f1768204f2f50621712168 install-kit-awindows-20260427-211240/aw-server/aw-host-groups.json
@@ -21,17 +22,19 @@ a50dbadbf619342c2178e255b68f69a36503756daf80eedd9170311c63964f2e install-kit-aw
dce731fdfdcfd773c154d12dbd6b9e621a0bff17ced5a05a65f0fdcb1adcb70f install-kit-awindows-20260427-211240/aw-server/install_aw_server.sh
1856e9f44636030b0cb9ece37ba2a0618eb5187fa82c7969976c1bb5f10fc622 install-kit-awindows-20260427-211240/aw-server/settings/classes-worktime.json
ff07b90cb6a7f09b27d522307cf55b0359e136a2e695190b8564e859f14f9204 install-kit-awindows-20260427-211240/aw-server/settings/views-default.json
59307d284caa74eb3dc129765f9db93b6e8dfd5b1d960b98f347a332b23f82dc install-kit-awindows-20260427-211240/server-configs-192.168.100.21/phase2-admin.deployment-config.json
f2cee1872bf274f15dcfb8fb595fb20a11d228a4bae0930dc6cb918a6f800756 install-kit-awindows-20260427-211240/server-configs-192.168.100.21/phase2-u2u5.deployment-config.json
6aefedcdac8c1d3823c9f4065b051a67a221a3e9424c913a673667b2a23ea1e7 install-kit-awindows-20260427-211240/server-configs-192.168.100.21/phase2-user1.deployment-config.json
1654cf688560465fcce629468a0be869b0c81b056179e1b5e9bcc7a2d5ed6ce0 install-kit-awindows-20260427-211240/server-configs-192.168.100.21/awatch-rus-admin.deployment-config.json
ac022b9a074c542ade66d18af8db385f6c14376ac4eadd54ef033dfa7f60fb50 install-kit-awindows-20260427-211240/server-configs-192.168.100.21/awatch-rus-u2u5.deployment-config.json
98cf4c54d494318b74cfbd3c8892830a34928bd76a2296d5333e5e176c1f3f49 install-kit-awindows-20260427-211240/server-configs-192.168.100.21/awatch-rus-user1.deployment-config.json
33aa34b89246d6c079ef9afe2f5cd153bd9d5946b69a175ff6fd678c77f61da5 install-kit-awindows-20260427-211240/windows/ActivityWatch.Windows.Common.psd1
d506614168227fa01fa481289079b432b6d8846d5ee8961cff8c21fd0bf7ea8f install-kit-awindows-20260427-211240/windows/ActivityWatch.Windows.Common.psm1
2a0b94ddad43a6bc684037243e636a54c168d8d4ad25b29778c4d78180da2532 install-kit-awindows-20260427-211240/windows/browser-domains-native-collector.ps1
98bfcf5dca972f1ba1845bbca546133c192824ec40dd2e55f4e6d59c24a834d1 install-kit-awindows-20260427-211240/windows/deploy-domain-users.ps1
a19c2a98e6483eb921457f472d1cf62a76e344f2ef3621f04fafa3d7dc2df353 install-kit-awindows-20260427-211240/windows/deploy-ensemble.ps1
2542425e02acd8a8b02ed701ca2382cf7ae612be28441d1a10a40e48c6a13ad0 install-kit-awindows-20260427-211240/windows/deploy-single-user.ps1
a2f963927c8b263a21aaffc0a926a058dcec65a04a5fa57c271d3dbe59f9347c install-kit-awindows-20260427-211240/windows/dlp-endpoint-signals-collector.ps1
0cb9cd8d8b612429f79899c126f4141ab4fbbb6d919425c0b8fd8d0a74b1bd44 install-kit-awindows-20260427-211240/windows/ActivityWatch.Windows.Common.psm1
7db2d3767ae81c877e8f04ecbc77a2fc26b2d9bbbf77d0e774fba0a7956bd0a6 install-kit-awindows-20260427-211240/windows/browser-domains-native-collector.ps1
b497400a1ba57cddf28dc8e217115dc85eccb67150cbdbb6a81abd804ed20109 install-kit-awindows-20260427-211240/windows/deploy-domain-users.ps1
973db51854fc744539a7b75e13c6749e822a08a79f47447485611f07e8f902a8 install-kit-awindows-20260427-211240/windows/deploy-ensemble.ps1
0d66dcb551889e6b7bc21b29d53b77e46f41d61dd2e4e0d9913dbf0f8bd5eb18 install-kit-awindows-20260427-211240/windows/deploy-single-user.ps1
8857f3e17f3f3ed6f211ce7f0a0c46c586a2548541078401ee3befaa20924b3d install-kit-awindows-20260427-211240/windows/dlp-endpoint-signals-collector.ps1
aef0032edd9b1e0c54f7b575664ed511dfc6cb53364e7496cbc95e137678e11a install-kit-awindows-20260427-211240/windows/dlp-policy.example.json
ade74a55ce00d9295f2efa0fd72f987688154c93f1142ce0eb6e982f07271be7 install-kit-awindows-20260427-211240/windows/hardening-recovery.ps1
88ffe06093ef5f7247bd2b990b0c8f801c706a26dc87725bb1194504fab7e306 install-kit-awindows-20260427-211240/windows/validate-deployment.ps1
f03886caf56c6838e8a163d6b48d1f229e83a5682aeeb447c3a65f13d62dbca4 install-kit-awindows-20260427-211240/windows/hardening-recovery.ps1
71911cd53ad0abd8bf83994f8a79bbbfe2c0eaf4f4c5f6c2d636dd7786191c2f install-kit-awindows-20260427-211240/windows/migrate-awatch-rus-paths.ps1
5dcf249742bd82fa0c803c878bfa0a1344b7b14df85c05f12e8c205663aea158 install-kit-awindows-20260427-211240/windows/validate-deployment.ps1
731098681d89b9af6f3872abd586ac3b1faba2d7f9340211e503f52ad0243b3f install-kit-awindows-20260427-211240/windows/web-category-rules.example.json
41171f0d7ed1e8b00dd0faf1a4b75c9cb063fd09aba8d333851a7a31fb297de1 install-kit-awindows-20260427-211240/windows/worktime-session-collector.ps1
@@ -4,7 +4,7 @@ Includes:
- windows/* (deploy scripts, collectors, common module, configs/examples)
- ansible/* (Windows and AW server playbooks, examples, inventory, tasks)
- aw-server/* (server installer, RU patch loader, host groups, default settings)
- server-configs-192.168.100.21/* (working Windows Phase2 config snapshots)
- server-configs-192.168.100.21/* (working Windows/RDP config snapshots)
Source:
- Local project snapshot at build time.
@@ -1,82 +1,102 @@
# Ansible ensemble for AWatch-rus
Эта директория содержит Ansible-ensemble для двух сценариев:
Эта директория содержит Ansible-ensemble для полного развёртывания AWatch-rus:
- деплой на уже существующий Debian host/CT;
- полный цикл с нуля в Proxmox: создание CT + bootstrap + установка ActivityWatch + RU patch.
- централизованный деплой Windows phase-2 collectors по WinRM.
- deployment внешнего pfSense poller'а на Debian/Ubuntu utility VM.
- полный цикл с нуля в Proxmox: создание CT + bootstrap + установка ActivityWatch + RU patch;
- централизованное развёртывание Windows/RDP collector'ов по WinRM;
- развёртывание внешнего pfSense poller'а на Debian/Ubuntu utility VM.
## Файлы
- `/home/igor/tmp/AWatch-rus/ansible/deploy_aw_server.yml` — основной playbook.
- `/home/igor/tmp/AWatch-rus/ansible/provision_proxmox_ct_and_deploy_aw.yml`full-stack playbook для Proxmox.
- `/home/igor/tmp/AWatch-rus/ansible/provision_proxmox_ct_matrix_and_deploy_aw.yml` — массовый full-stack playbook (несколько CT).
- `/home/igor/tmp/AWatch-rus/ansible/deploy_aw_windows_phase2.yml` — WinRM playbook для развёртывания phase-2 Windows collector'ов.
- `/home/igor/tmp/AWatch-rus/ansible/deploy_aw_pfsense_poller.yml`deployment pfSense poller'а.
- `/home/igor/tmp/AWatch-rus/ansible/inventory.example.ini` — шаблон inventory.
- `/home/igor/tmp/AWatch-rus/ansible/group_vars/all.example.yml` — шаблон переменных.
- `/home/igor/tmp/AWatch-rus/ansible/group_vars/proxmox.example.yml` — шаблон переменных CT в Proxmox.
- `/home/igor/tmp/AWatch-rus/ansible/group_vars/proxmox-matrix.example.yml` — шаблон матрицы CT.
- `/home/igor/tmp/AWatch-rus/ansible/group_vars/windows.example.yml` — шаблон переменных Windows phase-2.
- `/home/igor/tmp/AWatch-rus/ansible/group_vars/pfsense-poller.example.yml` — шаблон переменных pfSense poller'а.
- `ansible/deploy_aw_server.yml` — основной playbook для уже существующего Debian/CT host.
- `ansible/provision_proxmox_ct_and_deploy_aw.yml`полный playbook для Proxmox.
- `ansible/provision_proxmox_ct_matrix_and_deploy_aw.yml` — массовый полный playbook (несколько CT).
- `ansible/deploy_aw_windows.yml` — WinRM playbook для развёртывания Windows/RDP collector'ов.
- `ansible/deploy_aw_pfsense_poller.yml`развёртывание pfSense poller'а.
- `ansible/install_full_stack.yml` — полный установочный playbook (оркестратор всех этапов).
- `ansible/inventory.example.ini` — шаблон inventory.
- `ansible/group_vars/*.example.yml` — шаблоны переменных.
## Быстрый запуск
1. Скопируйте шаблоны:
- `cp /home/igor/tmp/AWatch-rus/ansible/inventory.example.ini /home/igor/tmp/AWatch-rus/ansible/inventory.ini`
- `cp /home/igor/tmp/AWatch-rus/ansible/group_vars/all.example.yml /home/igor/tmp/AWatch-rus/ansible/group_vars/all.yml`
- `cp ansible/inventory.example.ini ansible/inventory.ini`
- `cp ansible/group_vars/all.example.yml ansible/group_vars/all.yml`
2. Заполните значения в `inventory.ini` и `group_vars/all.yml`.
3. Запустите:
```bash
cd /home/igor/tmp/AWatch-rus/ansible
cd ansible
ansible-playbook -i inventory.ini deploy_aw_server.yml
```
## Полный установочный playbook (всё за один запуск)
Если нужно прогнать полный цикл одной командой:
```bash
cd ansible
ansible-playbook -i inventory.ini install_full_stack.yml
```
Что делает:
- `provision_proxmox_ct_and_deploy_aw.yml` (если есть хосты в группе `[proxmox]`);
- `deploy_aw_server.yml` (группа `[aw_server]`);
- `deploy_aw_windows.yml` (группа `[aw_windows]`);
- `deploy_aw_pfsense_poller.yml` (группа `[aw_pfsense_pollers]`).
Пустые группы в `inventory.ini` безопасны: соответствующий play будет пропущен.
## Полный запуск с нуля в Proxmox
1. Подготовьте inventory и vars:
- `cp /home/igor/tmp/AWatch-rus/ansible/inventory.example.ini /home/igor/tmp/AWatch-rus/ansible/inventory.ini`
- `cp /home/igor/tmp/AWatch-rus/ansible/group_vars/all.example.yml /home/igor/tmp/AWatch-rus/ansible/group_vars/all.yml`
- `cp /home/igor/tmp/AWatch-rus/ansible/group_vars/proxmox.example.yml /home/igor/tmp/AWatch-rus/ansible/group_vars/proxmox.yml`
- `cp ansible/inventory.example.ini ansible/inventory.ini`
- `cp ansible/group_vars/all.example.yml ansible/group_vars/all.yml`
- `cp ansible/group_vars/proxmox.example.yml ansible/group_vars/proxmox.yml`
2. Заполните `group_vars/proxmox.yml` и `group_vars/all.yml`.
3. Запустите playbook:
```bash
cd /home/igor/tmp/AWatch-rus/ansible
cd ansible
ansible-playbook -i inventory.ini provision_proxmox_ct_and_deploy_aw.yml
```
## Массовый запуск (матрица CT)
1. Подготовьте матрицу:
- `cp /home/igor/tmp/AWatch-rus/ansible/group_vars/proxmox-matrix.example.yml /home/igor/tmp/AWatch-rus/ansible/group_vars/proxmox-matrix.yml`
- `cp ansible/group_vars/proxmox-matrix.example.yml ansible/group_vars/proxmox-matrix.yml`
2. Заполните `proxmox-matrix.yml`.
3. Запустите:
```bash
cd /home/igor/tmp/AWatch-rus/ansible
cd ansible
ansible-playbook -i inventory.ini provision_proxmox_ct_matrix_and_deploy_aw.yml
```
## Windows phase-2 rollout (WinRM)
## Windows/RDP rollout (WinRM)
1. Подготовьте inventory и vars:
- `cp /home/igor/tmp/AWatch-rus/ansible/inventory.example.ini /home/igor/tmp/AWatch-rus/ansible/inventory.ini`
- `cp /home/igor/tmp/AWatch-rus/ansible/group_vars/windows.example.yml /home/igor/tmp/AWatch-rus/ansible/group_vars/windows.yml`
- `cp ansible/inventory.example.ini ansible/inventory.ini`
- `cp ansible/group_vars/windows.example.yml ansible/group_vars/windows.yml`
2. Заполните `inventory.ini` (секция `[aw_windows]`) и `group_vars/windows.yml`.
- Для русской локализации Windows часто нужен `ansible_user=Администратор` (а не `Administrator`).
- Если WinRM закрыт, playbook не сможет стартовать и нужно сначала открыть `5985/5986` и `wsman`.
3. Запустите:
```bash
cd /home/igor/tmp/AWatch-rus/ansible
ansible-playbook -i inventory.ini deploy_aw_windows_phase2.yml
cd ansible
ansible-playbook -i inventory.ini deploy_aw_windows.yml
```
Playbook:
- выгружает `windows/*` toolkit на целевой хост в `C:\Deploy\AWatch-rus\windows`;
- выполняет `deploy-ensemble.ps1` (deploy + hardening/recovery) с phase-2 policy/rules;
- выгружает полный `windows/*` toolkit на целевой хост в InnoSetup-compatible каталог `C:\Program Files\AWatch-rus\windows`, включая DLP и `worktime-session-collector.ps1`;
- если найден legacy config `C:\ProgramData\ActivityWatch-Phase2\deployment-config.json`, выполняет безопасную миграцию через `migrate-awatch-rus-paths.ps1`: backup, остановка задач, перенос данных, переписывание путей, пересоздание scheduled tasks и validation;
- выполняет `deploy-ensemble.ps1` (deploy + hardening/recovery) с policy/rules из AWatch-rus toolkit;
- после deploy принудительно запускает `ActivityWatch Recovery` и все `ActivityWatch Launch *` задачи;
- выполняет API smoke-check bucket `aw-watcher-afk_<COMPUTERNAME>` и ожидает свежие `not-afk` события;
- запускает `validate-deployment.ps1`;
- забирает JSON-отчёт в локальную директорию (`/tmp/aw-rus-validation` по умолчанию).
@@ -87,17 +107,27 @@ Playbook:
- `aw_windows_incident_capture_enabled: false` — отключить блок incidentCapture;
- `aw_windows_incident_screenshot_enabled: false` — не делать скриншот при DLP-инциденте;
- `aw_windows_incident_artifacts_root: 'C:\...\incident-artifacts'` — переопределить путь артефактов;
- `aw_windows_deploy_root: 'C:\Program Files\AWatch-rus'` — каталог toolkit, совпадает с InnoSetup `{app}`;
- `aw_windows_install_root: 'C:\Program Files\AWatch-rus\bin'` — каталог бинарников, совпадает с InnoSetup `AwDefaultInstallRoot`;
- `aw_windows_state_root: 'C:\ProgramData\AWatch-rus'` — каталог состояния/отчётов, совпадает с InnoSetup `AwDefaultStateRoot`;
- `aw_windows_validation_remote_path: '{{ aw_windows_state_root }}\aw_validate_ansible.json'` — отчёт Ansible-валидации хранится рядом с `ensemble-report-*.json`;
- `aw_windows_migration_enabled: true` — включить guard миграции текущего production из `ActivityWatch-Phase2` в единый `AWatch-rus`;
- `aw_windows_legacy_install_root` / `aw_windows_legacy_state_root` — старые production paths, откуда выполняется перенос;
- `aw_windows_migration_report_remote_path` — JSON-отчёт о миграции на Windows-хосте;
- `aw_windows_package_version`, `aw_windows_package_url`, `aw_windows_package_zip_path` — версия и источник Windows-пакета ActivityWatch;
- `aw_windows_api_smoke_check_bucket: ""` — автоматически использовать `aw-watcher-afk_<COMPUTERNAME>`;
- `aw_windows_fail_on_validation_error: true` — завершать playbook ошибкой, если `validate-deployment.ps1` возвращает `overallOk=false`;
- `aw_windows_skip_hardening: true` — пропустить `hardening-recovery.ps1` внутри ensemble-скрипта.
## pfSense poller rollout
## Развёртывание pfSense poller
1. Подготовьте vars:
- `cp /home/igor/tmp/AWatch-rus/ansible/group_vars/pfsense-poller.example.yml /home/igor/tmp/AWatch-rus/ansible/group_vars/pfsense-poller.yml`
- `cp ansible/group_vars/pfsense-poller.example.yml ansible/group_vars/pfsense-poller.yml`
2. Добавьте inventory group `[aw_pfsense_pollers]`.
3. Запустите:
```bash
cd /home/igor/tmp/AWatch-rus/ansible
cd ansible
ansible-playbook -i inventory.ini deploy_aw_pfsense_poller.yml
```
@@ -116,4 +146,6 @@ Playbook:
- Для Web UI используется checksum-based cache-bust для `ru-patch-v5.js` и `sw-cleanup.js`, чтобы браузер не держал старую DLP/русскую статику после деплоя.
- На `#/home` Web UI делит хосты на `Windows RDP` и `Virtual servers + Proxmox`.
- Выполнена валидация API `http://127.0.0.1:5600/api/0/info`.
- Для full-stack сценария CT создаётся автоматически через `pct create`.
- Для полного сценария CT создаётся автоматически через `pct create`.
- На Windows/RDP host развёрнуты AFK/window watchers, browser domain collector, DLP endpoint collector и worktime session collector.
- Проверочный JSON-отчёт Windows playbook должен иметь `overallOk=true`.
@@ -1,5 +1,5 @@
---
- name: Deploy pfSense ActivityWatch poller
- name: Развернуть pfSense ActivityWatch poller
hosts: aw_pfsense_pollers
become: true
gather_facts: true
@@ -10,14 +10,14 @@
aw_pfsense_service_name: "aw-pfsense-poller.service"
tasks:
- name: Install required packages
- name: Установить обязательные пакеты
ansible.builtin.apt:
name:
- python3
state: present
update_cache: true
- name: Ensure directories exist
- name: Создать каталоги
ansible.builtin.file:
path: "{{ item }}"
state: directory
@@ -26,29 +26,29 @@
- "{{ aw_pfsense_install_root }}"
- "{{ aw_pfsense_config_dir }}"
- name: Install pfSense poller script
- name: Установить скрипт pfSense poller
ansible.builtin.copy:
src: "{{ aw_repo_root }}/pfsense/pfsense-aw-poller.py"
dest: "{{ aw_pfsense_install_root }}/pfsense-aw-poller.py"
mode: "0755"
- name: Install systemd service
- name: Установить systemd service
ansible.builtin.copy:
src: "{{ aw_repo_root }}/pfsense/pfsense-aw-poller.service"
dest: "/etc/systemd/system/{{ aw_pfsense_service_name }}"
mode: "0644"
notify:
- Reload systemd
- Перезагрузить systemd
- name: Write pfSense poller config
- name: Записать конфигурацию pfSense poller
ansible.builtin.copy:
dest: "{{ aw_pfsense_config_dir }}/poller.json"
mode: "0600"
content: "{{ aw_pfsense_poller_config | to_nice_json }}"
notify:
- Restart pfSense poller
- Перезапустить pfSense poller
- name: Enable and start pfSense poller
- name: Включить и запустить pfSense poller
ansible.builtin.systemd:
name: "{{ aw_pfsense_service_name }}"
enabled: true
@@ -56,11 +56,11 @@
daemon_reload: true
handlers:
- name: Reload systemd
- name: Перезагрузить systemd
ansible.builtin.systemd:
daemon_reload: true
- name: Restart pfSense poller
- name: Перезапустить pfSense poller
ansible.builtin.systemd:
name: "{{ aw_pfsense_service_name }}"
state: restarted
@@ -1,5 +1,5 @@
---
- name: Deploy AWatch-rus server
- name: Развернуть сервер AWatch-rus
hosts: aw_server
become: true
gather_facts: true
@@ -9,22 +9,29 @@
aw_release_dir: "{{ aw_release_root }}/{{ aw_server_version }}"
aw_archive_path: "/tmp/activitywatch-{{ aw_server_version }}.zip"
aw_bootstrap_dir: "/tmp/aw-rus-bootstrap"
aw_release_install_dir: "{{ aw_release_root }}/aw-server-rust-{{ aw_server_version }}"
aw_ru_patch_cache_bust: "{{ lookup('file', aw_repo_root + '/aw-server/aw-ru-patch.js') | hash('sha1') | truncate(12, true, '') }}"
aw_sw_cleanup_cache_bust: "{{ lookup('file', aw_repo_root + '/aw-server/aw-sw-cleanup.js') | hash('sha1') | truncate(12, true, '') }}"
aw_host_groups_cache_bust: "{{ lookup('file', aw_repo_root + '/aw-server/aw-host-groups.json') | hash('sha1') | truncate(12, true, '') }}"
aw_worktime_classes: "{{ lookup('file', aw_repo_root + '/aw-server/settings/classes-worktime.json') | from_json }}"
aw_default_views: "{{ lookup('file', aw_repo_root + '/aw-server/settings/views-default.json') | from_json }}"
tasks:
- name: Install base packages
- name: Установить базовые пакеты
ansible.builtin.apt:
name:
- curl
- rsync
- unzip
state: present
update_cache: true
- name: Ensure service account exists
- name: Создать системную группу сервиса
ansible.builtin.group:
name: "{{ aw_server_group }}"
system: true
state: present
- name: Создать системную учётную запись сервиса
ansible.builtin.user:
name: "{{ aw_server_user }}"
group: "{{ aw_server_group }}"
@@ -33,7 +40,25 @@
system: true
create_home: false
- name: Ensure required directories
- name: Создать обязательные каталоги
ansible.builtin.file:
path: "{{ item }}"
state: directory
mode: "0755"
loop:
- "{{ aw_release_root }}"
- "{{ aw_release_dir }}"
- "{{ aw_release_install_dir }}"
- /opt/activitywatch
- /opt/activitywatch/bin
- "{{ aw_server_webui_dir }}"
- "{{ aw_server_webui_dir }}/js"
- "{{ aw_server_data_dir }}"
- "{{ aw_server_log_dir }}"
- /etc/activitywatch
- "{{ aw_bootstrap_dir }}"
- name: Настроить каталоги ActivityWatch с владельцем сервиса
ansible.builtin.file:
path: "{{ item }}"
state: directory
@@ -41,103 +66,188 @@
group: "{{ aw_server_group }}"
mode: "0755"
loop:
- /opt/activitywatch
- /opt/activitywatch/bin
- "{{ aw_release_root }}"
- "{{ aw_release_dir }}"
- "{{ aw_release_install_dir }}"
- "{{ aw_server_webui_dir }}"
- "{{ aw_server_webui_dir }}/js"
- "{{ aw_server_data_dir }}"
- "{{ aw_server_log_dir }}"
- /etc/activitywatch
- "{{ aw_bootstrap_dir }}"
- name: Download ActivityWatch release archive
- name: Скачать архив релиза ActivityWatch
ansible.builtin.get_url:
url: "{{ aw_server_download_url }}"
dest: "{{ aw_archive_path }}"
mode: "0644"
- name: Unpack ActivityWatch release
- name: Распаковать релиз ActivityWatch
ansible.builtin.unarchive:
src: "{{ aw_archive_path }}"
dest: "{{ aw_release_dir }}"
remote_src: true
extra_opts: ["-o"]
- name: Discover extracted AW directory
- name: Найти распакованный каталог ActivityWatch
ansible.builtin.find:
paths: "{{ aw_release_dir }}"
file_type: directory
patterns: "activitywatch*"
register: aw_release_find
- name: Set release extracted path
ansible.builtin.set_fact:
aw_release_extracted: "{{ (aw_release_find.files | sort(attribute='path') | map(attribute='path') | list | first) }}"
- name: Найти бинарный файл AW server
ansible.builtin.find:
paths: "{{ aw_release_dir }}"
file_type: file
patterns:
- aw-server-rust
- aw-server
register: aw_server_binary_find
- name: Verify extracted directory exists
- name: Найти каталог WebUI
ansible.builtin.find:
paths: "{{ aw_release_dir }}"
file_type: directory
patterns:
- aw-webui
- webui
register: aw_webui_dir_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: Проверить, что компоненты релиза найдены
ansible.builtin.assert:
that:
- aw_release_extracted is defined
- aw_release_extracted | length > 0
fail_msg: "Cannot locate extracted ActivityWatch release directory."
- 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: Sync release content to /opt/activitywatch
- name: Создать каталог установленного релиза
ansible.builtin.file:
path: "{{ aw_release_install_dir }}"
state: directory
owner: "{{ aw_server_user }}"
group: "{{ aw_server_group }}"
mode: "0755"
- 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: Создать ссылку на активный бинарный файл 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 --delete {{ aw_release_extracted }}/ /opt/activitywatch/"
cmd: "rsync -a {{ aw_webui_source_path }}/ {{ aw_server_webui_dir }}/"
- name: Copy bootstrap files from repository
- 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:
dest: /etc/systemd/system/activitywatch-server.service
mode: "0644"
content: >-
{{
lookup('file', aw_repo_root + '/aw-server/activitywatch-server.service')
| replace('__AW_SERVER_USER__', aw_server_user)
| replace('__AW_SERVER_GROUP__', aw_server_group)
| replace('__AW_SERVER_DATA_DIR__', aw_server_data_dir)
}}
notify:
- Перезагрузить 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/activitywatch-server.service", dest: "/etc/systemd/system/activitywatch-server.service", mode: "0644" }
- { src: "{{ aw_repo_root }}/aw-server/aw-ru-patch.js", dest: "{{ aw_server_webui_dir }}/js/ru-patch-v5.js", mode: "0644" }
- { src: "{{ aw_repo_root }}/aw-server/aw-sw-cleanup.js", dest: "{{ aw_server_webui_dir }}/js/sw-cleanup.js", mode: "0644" }
- { src: "{{ aw_repo_root }}/aw-server/aw-host-groups.json", dest: "{{ aw_server_webui_dir }}/js/aw-host-groups.json", mode: "0644" }
notify:
- Reload systemd
- Restart activitywatch
- name: Copy WebUI index template from installed distribution
ansible.builtin.copy:
remote_src: true
src: "/opt/activitywatch/aw-webui/index.html"
dest: "{{ aw_server_webui_dir }}/index.html"
mode: "0644"
- name: Проверить наличие index.html после копирования
ansible.builtin.stat:
path: "{{ aw_server_webui_dir }}/index.html"
register: aw_webui_ru_index
- name: Insert RU patch scripts into index.html
- 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: Insert RU patch loader before body end
- 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: Write /etc/activitywatch/aw-server.env
- name: Записать /etc/activitywatch/aw-server.env
ansible.builtin.copy:
dest: /etc/activitywatch/aw-server.env
mode: "0640"
owner: root
group: root
content: |
AW_SERVER_HOST={{ aw_server_bind_host }}
AW_SERVER_BIND_HOST={{ aw_server_bind_host }}
AW_SERVER_PORT={{ aw_server_port }}
AW_DATA_DIR={{ aw_server_data_dir }}
AW_LOG_DIR={{ aw_server_log_dir }}
AW_WEBUI_DIR={{ aw_server_webui_dir }}
AW_SERVER_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: Enable and start service
- name: Включить и запустить сервис
ansible.builtin.systemd:
name: activitywatch-server.service
enabled: true
state: restarted
daemon_reload: true
- name: Wait for API
- name: Дождаться ответа API
ansible.builtin.uri:
url: "http://127.0.0.1:{{ aw_server_port }}/api/0/info"
method: GET
@@ -147,25 +257,25 @@
delay: 3
until: aw_api.status == 200
- name: Apply baseline worktime settings (classes)
- name: Применить базовые worktime settings (classes)
ansible.builtin.uri:
url: "http://127.0.0.1:{{ aw_server_port }}/api/0/settings/classes"
method: POST
body: "{{ aw_worktime_classes }}"
body_format: json
status_code: 201
status_code: [200, 201]
when: aw_apply_worktime_settings | default(false) | bool
- name: Apply baseline views (include DLP and worktime)
- name: Применить базовые views для DLP и worktime
ansible.builtin.uri:
url: "http://127.0.0.1:{{ aw_server_port }}/api/0/settings/views"
method: POST
body: "{{ aw_default_views }}"
body_format: json
status_code: 201
status_code: [200, 201]
when: aw_apply_worktime_settings | default(false) | bool
- name: Derive worktime durationDefault from aw_worktime_from/to
- 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 }}"
@@ -182,7 +292,7 @@
}}
when: aw_apply_worktime_settings | default(false) | bool
- name: Normalize derived durationDefault for overnight shifts
- name: Нормализовать durationDefault для ночных смен
ansible.builtin.set_fact:
aw_worktime_duration_default_effective: >-
{{
@@ -192,15 +302,15 @@
}}
when: aw_apply_worktime_settings | default(false) | bool
- name: Validate derived durationDefault is sane
- name: Проверить корректность durationDefault
ansible.builtin.assert:
that:
- aw_worktime_duration_default_effective | int > 0
- aw_worktime_duration_default_effective | int <= 86400
fail_msg: "Invalid worktime window: {{ aw_worktime_from }}..{{ aw_worktime_to }}"
fail_msg: "Некорректный интервал рабочего времени: {{ aw_worktime_from }}..{{ aw_worktime_to }}"
when: aw_apply_worktime_settings | default(false) | bool
- name: Apply baseline worktime period (startOfDay)
- name: Применить базовый период worktime (startOfDay)
ansible.builtin.uri:
url: "http://127.0.0.1:{{ aw_server_port }}/api/0/settings/startOfDay"
method: POST
@@ -209,7 +319,7 @@
status_code: 200
when: aw_apply_worktime_settings | default(false) | bool
- name: Apply baseline worktime period (durationDefault seconds)
- name: Применить базовый период worktime (durationDefault seconds)
ansible.builtin.uri:
url: "http://127.0.0.1:{{ aw_server_port }}/api/0/settings/durationDefault"
method: POST
@@ -219,11 +329,11 @@
when: aw_apply_worktime_settings | default(false) | bool
handlers:
- name: Reload systemd
- name: Перезагрузить systemd
ansible.builtin.systemd:
daemon_reload: true
- name: Restart activitywatch
- name: Перезапустить activitywatch
ansible.builtin.systemd:
name: activitywatch-server.service
state: restarted
@@ -0,0 +1,232 @@
---
- name: Развернуть Windows/RDP collector'ы AWatch-rus
hosts: aw_windows
gather_facts: false
vars:
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_users_effective: "{{ (aw_windows_users + aw_windows_extra_users) | unique }}"
aw_windows_install_root: "C:\\Program Files\\AWatch-rus\\bin"
aw_windows_state_root: "C:\\ProgramData\\AWatch-rus"
aw_windows_afk_enabled: true
aw_windows_window_enabled: true
aw_windows_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_launch_task_pattern: "ActivityWatch Launch *"
aw_windows_recovery_task_name: "ActivityWatch Recovery"
aw_windows_force_task_restart: true
aw_windows_api_smoke_check_enabled: true
aw_windows_api_smoke_check_bucket: ""
aw_windows_api_smoke_check_limit: 10
aw_windows_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"
tasks:
- name: Проверить обязательные переменные
ansible.builtin.assert:
that:
- aw_windows_server_host is defined
- aw_windows_server_port is defined
- aw_windows_server_scheme is defined
- aw_windows_domain is defined
- aw_windows_users_effective | length > 0
- aw_windows_install_root is defined
- aw_windows_state_root is defined
fail_msg: "Не заданы обязательные переменные Windows-развёртывания."
- name: Создать каталоги развёртывания
ansible.windows.win_file:
path: "{{ item }}"
state: directory
loop:
- "{{ aw_windows_deploy_root }}"
- "{{ aw_windows_deploy_root }}\\windows"
- name: Загрузить Windows toolkit развёртывания
ansible.windows.win_copy:
src: "{{ aw_windows_repo_root }}/windows/{{ item }}"
dest: "{{ aw_windows_deploy_root }}\\windows\\{{ item }}"
loop:
- ActivityWatch.Windows.Common.psd1
- ActivityWatch.Windows.Common.psm1
- browser-domains-native-collector.ps1
- dlp-endpoint-signals-collector.ps1
- worktime-session-collector.ps1
- migrate-awatch-rus-paths.ps1
- deploy-domain-users.ps1
- deploy-ensemble.ps1
- hardening-recovery.ps1
- validate-deployment.ps1
- web-category-rules.example.json
- dlp-policy.example.json
- name: Загрузить список пользователей для доменного развёртывания
ansible.windows.win_copy:
dest: "{{ aw_windows_deploy_root }}\\windows\\users.txt"
content: |
{% for user in aw_windows_users_effective -%}
{{ user }}
{% endfor -%}
- name: Проверить нужен ли migration с legacy ActivityWatch путей
when: aw_windows_migration_enabled | bool
ansible.windows.win_stat:
path: "{{ aw_windows_legacy_state_root }}\\deployment-config.json"
register: aw_windows_legacy_config
- name: Выполнить безопасную migration legacy prod в AWatch-rus
when:
- aw_windows_migration_enabled | bool
- aw_windows_legacy_config.stat.exists | default(false)
ansible.windows.win_powershell:
script: |
$ErrorActionPreference = 'Stop'
$result = & "{{ aw_windows_deploy_root }}\windows\migrate-awatch-rus-paths.ps1" `
-OldInstallRoot "{{ aw_windows_legacy_install_root }}" `
-OldStateRoot "{{ aw_windows_legacy_state_root }}" `
-NewInstallRoot "{{ aw_windows_install_root }}" `
-NewStateRoot "{{ aw_windows_state_root }}" `
-ToolkitRoot "{{ aw_windows_deploy_root }}\windows"
$result | ConvertTo-Json -Depth 8 | Out-File -FilePath "{{ aw_windows_migration_report_remote_path }}" -Encoding utf8
- name: Запустить Windows/RDP ensemble развёртывание
ansible.windows.win_powershell:
script: |
$ErrorActionPreference = 'Stop'
$params = @{
ServerScheme = "{{ aw_windows_server_scheme }}"
ServerHost = "{{ aw_windows_server_host }}"
ServerPort = {{ aw_windows_server_port }}
Version = "{{ aw_windows_package_version }}"
Domain = "{{ aw_windows_domain }}"
UserListPath = "{{ aw_windows_deploy_root }}\windows\users.txt"
InstallRoot = "{{ aw_windows_install_root }}"
StateRoot = "{{ aw_windows_state_root }}"
AfkEnabled = {{ '$true' if (aw_windows_afk_enabled | bool) else '$false' }}
WindowEnabled = {{ '$true' if (aw_windows_window_enabled | bool) else '$false' }}
LocalAgentLogsEnabled = {{ '$true' if (aw_windows_local_agent_logs_enabled | bool) else '$false' }}
IncidentCaptureEnabled = {{ '$true' if (aw_windows_incident_capture_enabled | bool) else '$false' }}
IncidentScreenshotEnabled = {{ '$true' if (aw_windows_incident_screenshot_enabled | bool) else '$false' }}
IncidentArtifactsRoot = "{{ aw_windows_incident_artifacts_root }}"
LogonMarkerEnabled = {{ '$true' if (aw_windows_logon_marker_enabled | bool) else '$false' }}
CustomRulesPath = "{{ aw_windows_rules_path }}"
CustomPolicyPath = "{{ aw_windows_policy_path }}"
}
{% if (aw_windows_package_url | default('') | string | length) > 0 %}
$params.PackageUrl = "{{ aw_windows_package_url }}"
{% endif %}
{% if (aw_windows_package_zip_path | default('') | string | length) > 0 %}
$params.PackageZipPath = "{{ aw_windows_package_zip_path }}"
{% endif %}
{% if aw_windows_skip_hardening | bool %}
$params.SkipHardening = $true
{% endif %}
& "{{ aw_windows_deploy_root }}\windows\deploy-ensemble.ps1" @params
- 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
- aw_windows_afk_enabled | bool
ansible.windows.win_command: powershell.exe -NoProfile -Command "$env:COMPUTERNAME"
register: aw_windows_hostname_result
changed_when: false
- name: Вычислить AW AFK smoke-check bucket
when:
- aw_windows_api_smoke_check_enabled | bool
- aw_windows_afk_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
- 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
- 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 завершилась ошибкой. Отчёт: {{ 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:
- "Windows/RDP развёртывание завершено на {{ inventory_hostname }}."
- "Отчёт проверки: {{ aw_windows_validation_local_dir }}/{{ inventory_hostname }}-aw_validate_ansible.json"
@@ -1,133 +0,0 @@
---
- name: Deploy AWatch-rus Windows phase2 collectors
hosts: aw_windows
gather_facts: false
vars:
aw_windows_repo_root: "/home/igor/tmp/AWatch-rus"
aw_windows_deploy_root: "C:\\Deploy\\AWatch-rus"
aw_windows_server_host: "10.10.10.13"
aw_windows_server_port: 5600
aw_windows_domain: "SHARKON2025"
aw_windows_users:
- user1
- user2
- user3
- user4
- user5
aw_windows_extra_users: []
aw_windows_users_effective: "{{ (aw_windows_users + aw_windows_extra_users) | unique }}"
aw_windows_install_root: "C:\\Program Files\\ActivityWatch-Phase2"
aw_windows_state_root: "C:\\ProgramData\\ActivityWatch"
aw_windows_afk_enabled: true
aw_windows_window_enabled: true
aw_windows_local_agent_logs_enabled: false
aw_windows_incident_capture_enabled: true
aw_windows_incident_screenshot_enabled: true
aw_windows_incident_artifacts_root: "{{ aw_windows_state_root }}\\incident-artifacts"
aw_windows_logon_marker_enabled: true
aw_windows_skip_hardening: false
aw_windows_rules_path: "{{ aw_windows_deploy_root }}\\windows\\web-category-rules.example.json"
aw_windows_policy_path: "{{ aw_windows_deploy_root }}\\windows\\dlp-policy.example.json"
aw_windows_validation_remote_path: "C:\\Windows\\Temp\\aw_validate_phase2_ansible.json"
aw_windows_validation_local_dir: "/tmp/aw-rus-validation"
tasks:
- name: Validate required variables
ansible.builtin.assert:
that:
- aw_windows_server_host is defined
- aw_windows_server_port is defined
- aw_windows_domain is defined
- aw_windows_users_effective | length > 0
- aw_windows_install_root is defined
- aw_windows_state_root is defined
fail_msg: "Missing required Windows deployment variables."
- name: Ensure deploy directories exist
ansible.windows.win_file:
path: "{{ item }}"
state: directory
loop:
- "{{ aw_windows_deploy_root }}"
- "{{ aw_windows_deploy_root }}\\windows"
- name: Upload Windows deployment toolkit
ansible.windows.win_copy:
src: "{{ aw_windows_repo_root }}/windows/{{ item }}"
dest: "{{ aw_windows_deploy_root }}\\windows\\{{ item }}"
loop:
- ActivityWatch.Windows.Common.psd1
- ActivityWatch.Windows.Common.psm1
- browser-domains-native-collector.ps1
- dlp-endpoint-signals-collector.ps1
- deploy-domain-users.ps1
- deploy-ensemble.ps1
- hardening-recovery.ps1
- validate-deployment.ps1
- web-category-rules.example.json
- dlp-policy.example.json
- name: Upload user list for domain deploy
ansible.windows.win_copy:
dest: "{{ aw_windows_deploy_root }}\\windows\\users.txt"
content: |
{% for user in aw_windows_users -%}
{{ user }}
{% endfor -%}
{% for user in aw_windows_extra_users -%}
{{ user }}
{% endfor -%}
- name: Run phase2 ensemble deployment
ansible.windows.win_powershell:
script: |
$ErrorActionPreference = 'Stop'
$params = @{
ServerHost = "{{ aw_windows_server_host }}"
ServerPort = {{ aw_windows_server_port }}
Domain = "{{ aw_windows_domain }}"
UserListPath = "{{ aw_windows_deploy_root }}\windows\users.txt"
InstallRoot = "{{ aw_windows_install_root }}"
StateRoot = "{{ aw_windows_state_root }}"
AfkEnabled = {{ '$true' if (aw_windows_afk_enabled | bool) else '$false' }}
WindowEnabled = {{ '$true' if (aw_windows_window_enabled | bool) else '$false' }}
LocalAgentLogsEnabled = {{ '$true' if (aw_windows_local_agent_logs_enabled | bool) else '$false' }}
IncidentCaptureEnabled = {{ '$true' if (aw_windows_incident_capture_enabled | bool) else '$false' }}
IncidentScreenshotEnabled = {{ '$true' if (aw_windows_incident_screenshot_enabled | bool) else '$false' }}
IncidentArtifactsRoot = "{{ aw_windows_incident_artifacts_root }}"
LogonMarkerEnabled = {{ '$true' if (aw_windows_logon_marker_enabled | bool) else '$false' }}
CustomRulesPath = "{{ aw_windows_rules_path }}"
CustomPolicyPath = "{{ aw_windows_policy_path }}"
}
{% if aw_windows_skip_hardening | bool %}
$params.SkipHardening = $true
{% endif %}
& "{{ aw_windows_deploy_root }}\windows\deploy-ensemble.ps1" @params
- name: Run validation and store report on target
ansible.windows.win_powershell:
script: |
$ErrorActionPreference = 'Stop'
$report = & "{{ aw_windows_deploy_root }}\windows\validate-deployment.ps1" `
-ConfigPath "{{ aw_windows_state_root }}\deployment-config.json"
$report | ConvertTo-Json -Depth 12 | Out-File -FilePath "{{ aw_windows_validation_remote_path }}" -Encoding utf8
- name: Ensure local validation directory exists
ansible.builtin.file:
path: "{{ aw_windows_validation_local_dir }}"
state: directory
mode: "0755"
delegate_to: localhost
- name: Fetch validation report
ansible.builtin.fetch:
src: "{{ aw_windows_validation_remote_path }}"
dest: "{{ aw_windows_validation_local_dir }}/"
flat: false
- name: Show report location
ansible.builtin.debug:
msg:
- "Windows phase2 deploy completed on {{ inventory_hostname }}."
- "Validation report: {{ aw_windows_validation_local_dir }}/{{ inventory_hostname }}/C$/Windows/Temp/aw_validate_phase2_ansible.json"
@@ -8,17 +8,17 @@ aw_server_log_dir: "/var/log/activitywatch"
aw_server_user: "activitywatch"
aw_server_group: "activitywatch"
aw_repo_root: "/home/igor/tmp/AWatch-rus"
aw_repo_root: "{{ playbook_dir | dirname }}"
# Optional: apply a baseline worktime-focused categorization and views via AW settings API.
# WARNING: this overwrites existing server-side settings/classes/views.
# Опционально: применить базовые категории и views для рабочего времени через AW settings API.
# Внимание: это перезаписывает существующие server-side settings/classes/views.
aw_apply_worktime_settings: false
# Optional defaults for the worktime period in Web UI.
# startOfDay controls day-boundary and default report window start.
# durationDefault controls default time range (seconds) shown in UI.
# Опциональные значения периода рабочего времени в Web UI.
# startOfDay задаёт границу дня и стартовое время окна отчёта.
# durationDefault задаёт диапазон по умолчанию в секундах.
#
# Recommended: set worktime window explicitly and let the playbook derive duration.
# Рекомендуется явно задать рабочий интервал и дать playbook вычислить duration.
aw_worktime_from: "08:00"
aw_worktime_to: "17:00"
aw_worktime_start_of_day: "{{ aw_worktime_from }}"
@@ -1,7 +1,11 @@
aw_windows_repo_root: "/home/igor/tmp/AWatch-rus"
aw_windows_deploy_root: "C:\\Deploy\\AWatch-rus"
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
@@ -14,9 +18,9 @@ aw_windows_extra_users: []
# aw_windows_extra_users:
# - Администратор
# Рекомендуемый изолированный профиль для фазового раската.
aw_windows_install_root: "C:\\Program Files\\ActivityWatch-Phase2"
aw_windows_state_root: "C:\\ProgramData\\ActivityWatch"
# Единые Windows/RDP пути: те же, что использует InnoSetup.
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_local_agent_logs_enabled: false
@@ -29,5 +33,18 @@ aw_windows_skip_hardening: false
aw_windows_rules_path: "{{ aw_windows_deploy_root }}\\windows\\web-category-rules.example.json"
aw_windows_policy_path: "{{ aw_windows_deploy_root }}\\windows\\dlp-policy.example.json"
aw_windows_validation_remote_path: "C:\\Windows\\Temp\\aw_validate_phase2_ansible.json"
aw_windows_validation_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
# Безопасная миграция текущего прода со старых путей в единый профиль AWatch-rus.
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"
# По умолчанию AFK bucket вычисляется как aw-watcher-afk_<COMPUTERNAME>.
# Задайте явное значение только если watcher пишет в нестандартный bucket.
aw_windows_api_smoke_check_enabled: true
aw_windows_api_smoke_check_bucket: ""
aw_windows_api_smoke_check_limit: 10
@@ -0,0 +1,16 @@
---
# Полный установщик AWatch-rus.
# Выполняет развёртывание одной командой:
# 1) создание Proxmox CT + bootstrap AW (если в inventory есть [proxmox])
# 2) развёртывание AW server на хостах [aw_server]
# 3) развёртывание Windows/RDP collector'ов на [aw_windows]
# 4) развёртывание pfSense poller'а на [aw_pfsense_pollers]
#
# Примечания:
# - Заполняйте только нужные группы inventory для своего окружения.
# - Play без совпадающих host groups Ansible пропускает автоматически.
- import_playbook: provision_proxmox_ct_and_deploy_aw.yml
- import_playbook: deploy_aw_server.yml
- import_playbook: deploy_aw_windows.yml
- import_playbook: deploy_aw_pfsense_poller.yml
@@ -5,4 +5,8 @@ pve-main ansible_host=192.168.10.2 ansible_user=root ansible_port=22
aw-ct ansible_host=10.20.30.13 ansible_user=root ansible_port=22
[aw_windows]
win-node1 ansible_host=192.168.100.21 ansible_user=Administrator ansible_password=CHANGE_ME ansible_connection=winrm ansible_winrm_transport=ntlm ansible_port=5985 ansible_winrm_server_cert_validation=ignore
# Примечание: в русифицированных Windows часто нужен "Администратор", а не "Administrator".
win-node1 ansible_host=192.168.100.21 ansible_user=Администратор ansible_password=CHANGE_ME ansible_connection=winrm ansible_winrm_transport=ntlm ansible_port=5985 ansible_winrm_server_cert_validation=ignore
[aw_pfsense_pollers]
# pfsense-poller1 ansible_host=192.168.100.30 ansible_user=root ansible_port=22
@@ -1,5 +1,5 @@
---
- name: Provision single Proxmox CT and deploy AWatch-rus
- name: Создать один Proxmox CT и развернуть AWatch-rus
hosts: proxmox
gather_facts: false
@@ -17,7 +17,7 @@
- settings/views-default.json
tasks:
- name: Execute single-CT provisioning workflow
- name: Выполнить workflow создания одного CT
ansible.builtin.include_tasks: tasks/provision_ct_and_deploy_aw.yml
vars:
ct_id: "{{ proxmox_ct_id }}"
@@ -1,5 +1,5 @@
---
- name: Provision Proxmox CT matrix and deploy AWatch-rus with RU patch
- name: Создать матрицу Proxmox CT и развернуть AWatch-rus с RU patch
hosts: proxmox
gather_facts: false
@@ -17,14 +17,14 @@
- settings/views-default.json
tasks:
- name: Validate CT matrix is provided
- name: Проверить, что матрица CT задана
ansible.builtin.assert:
that:
- proxmox_ct_matrix is defined
- proxmox_ct_matrix | length > 0
fail_msg: "Define proxmox_ct_matrix in group_vars/proxmox-matrix.yml"
fail_msg: "Задайте proxmox_ct_matrix в group_vars/proxmox-matrix.yml"
- name: Execute provisioning workflow for each CT
- name: Выполнить workflow создания для каждого CT
ansible.builtin.include_tasks: tasks/provision_ct_and_deploy_aw.yml
vars:
ct_id: "{{ item.id }}"
@@ -1,5 +1,5 @@
---
- name: Validate required per-CT variables
- name: Проверить обязательные переменные CT
ansible.builtin.assert:
that:
- ct_id is defined
@@ -27,14 +27,14 @@
- aw_server_log_dir is defined
- aw_server_user is defined
- aw_server_group is defined
fail_msg: "Missing required variables for CT provisioning/deploy."
fail_msg: "Не заданы обязательные переменные для создания CT и развёртывания."
- name: Build CT network string
- name: Сформировать сетевую строку CT
ansible.builtin.set_fact:
ct_net0: >-
name=eth0,bridge={{ ct_bridge }},ip={{ ct_ip }},gw={{ ct_gw }}{% if (ct_vlan | default('') | string | length) > 0 %},tag={{ ct_vlan }}{% endif %}
- name: Check whether CT already exists
- name: Проверить, существует ли CT
ansible.builtin.command:
argv:
- pct
@@ -44,7 +44,7 @@
failed_when: false
changed_when: false
- name: Create CT when absent
- name: Создать CT, если он отсутствует
ansible.builtin.command:
argv:
- pct
@@ -78,8 +78,9 @@
- --ostype
- debian
when: ct_status_check.rc != 0
no_log: true
- name: Check current CT runtime state
- name: Проверить текущее состояние CT
ansible.builtin.command:
argv:
- pct
@@ -88,7 +89,7 @@
register: ct_runtime_status
changed_when: false
- name: Start CT when stopped
- name: Запустить CT, если он остановлен
ansible.builtin.command:
argv:
- pct
@@ -96,20 +97,23 @@
- "{{ ct_id }}"
when: "'stopped' in ct_runtime_status.stdout"
- name: Ensure bootstrap directory on Proxmox host
- name: Создать bootstrap каталог на Proxmox host
ansible.builtin.file:
path: "{{ proxmox_bootstrap_dir }}"
path: "{{ item }}"
state: directory
mode: "0700"
loop:
- "{{ proxmox_bootstrap_dir }}"
- "{{ proxmox_bootstrap_dir }}/settings"
- name: Copy AW bootstrap files to Proxmox host temp
- name: Скопировать AW bootstrap файлы во временный каталог Proxmox host
ansible.builtin.copy:
src: "{{ aw_repo_root }}/aw-server/{{ item }}"
dest: "{{ proxmox_bootstrap_dir }}/{{ item }}"
mode: "0644"
loop: "{{ aw_bootstrap_files }}"
- name: Bootstrap CT OS dependencies
- name: Установить базовые зависимости ОС внутри CT
ansible.builtin.command:
argv:
- pct
@@ -122,12 +126,16 @@
set -euo pipefail
export DEBIAN_FRONTEND=noninteractive
apt-get update
apt-get install -y curl ca-certificates bash unzip xz-utils jq rsync openssh-server
mkdir -p /root/bootstrap /etc/activitywatch
apt-get install -y curl ca-certificates bash unzip xz-utils jq rsync openssh-server python3
mkdir -p /root/bootstrap/settings /etc/activitywatch
systemctl enable ssh || true
systemctl restart ssh || true
register: ct_bootstrap_result
retries: 10
delay: 6
until: ct_bootstrap_result.rc == 0
- name: Push bootstrap files into CT
- name: Передать bootstrap файлы внутрь CT
ansible.builtin.command:
argv:
- pct
@@ -137,7 +145,7 @@
- "/root/bootstrap/{{ item }}"
loop: "{{ aw_bootstrap_files }}"
- name: Write AW server env file on Proxmox host temp
- name: Записать AW server env во временный каталог Proxmox host
ansible.builtin.copy:
dest: "{{ proxmox_bootstrap_dir }}/aw-server.env"
mode: "0600"
@@ -151,8 +159,9 @@
AW_SERVER_LOG_DIR={{ aw_server_log_dir }}
AW_SERVER_USER={{ aw_server_user }}
AW_SERVER_GROUP={{ aw_server_group }}
no_log: true
- name: Push AW server env into CT
- name: Передать AW server env внутрь CT
ansible.builtin.command:
argv:
- pct
@@ -160,8 +169,9 @@
- "{{ ct_id }}"
- "{{ proxmox_bootstrap_dir }}/aw-server.env"
- /etc/activitywatch/aw-server.env
no_log: true
- name: Set mode for env inside CT
- name: Настроить права env файла внутри CT
ansible.builtin.command:
argv:
- pct
@@ -172,7 +182,7 @@
- "0600"
- /etc/activitywatch/aw-server.env
- name: Install server and apply RU patch inside CT
- name: Установить сервер и применить RU patch внутри CT
ansible.builtin.command:
argv:
- pct
@@ -188,7 +198,7 @@
bash /root/bootstrap/apply_webui_ru_patch.sh
systemctl restart activitywatch-server.service
- name: Validate AW API from inside CT
- name: Проверить AW API изнутри CT
ansible.builtin.command:
argv:
- pct
@@ -199,7 +209,7 @@
- -lc
- "curl -fsS http://127.0.0.1:{{ aw_server_port }}/api/0/info >/dev/null"
- name: Validate RU patch hooks in index
- name: Проверить hooks RU patch в index.html
ansible.builtin.command:
argv:
- pct
@@ -210,8 +220,8 @@
- -lc
- "grep -q 'ru-patch-v5.js' {{ aw_server_webui_dir }}/index.html && grep -q 'sw-cleanup.js' {{ aw_server_webui_dir }}/index.html"
- name: Show final endpoint
- name: Показать итоговый endpoint
ansible.builtin.debug:
msg:
- "CT {{ ct_id }} is provisioned and configured."
- "ActivityWatch endpoint: http://{{ ct_ip | regex_replace('/[0-9]+$', '') }}:{{ aw_server_port }}"
- "CT {{ ct_id }} создан и настроен."
- "Endpoint ActivityWatch: http://{{ ct_ip | regex_replace('/[0-9]+$', '') }}:{{ aw_server_port }}"
@@ -1,57 +1,57 @@
{
"version": 1,
"generatedAtUtc": "2026-04-27T01:14:21.9184268Z",
"server": {
"host": "10.10.10.13",
"port": 5600,
"scheme": "http"
},
"paths": {
"installRoot": "C:\\Program Files\\ActivityWatch-Phase2-admin",
"stateRoot": "C:\\ProgramData\\ActivityWatch\\phase2-admin",
"logsRoot": "C:\\ProgramData\\ActivityWatch\\phase2-admin\\logs",
"collectorScript": "C:\\ProgramData\\ActivityWatch\\phase2-admin\\browser-domains-native-collector.ps1",
"endpointCollectorScript": "C:\\ProgramData\\ActivityWatch\\phase2-admin\\dlp-endpoint-signals-collector.ps1",
"rulesPath": "C:\\ProgramData\\ActivityWatch\\phase2-admin\\web-category-rules.json",
"policyPath": "C:\\ProgramData\\ActivityWatch\\phase2-admin\\dlp-policy.json",
"launchScript": "C:\\ProgramData\\ActivityWatch\\phase2-admin\\launch-watchers.ps1",
"recoveryScript": "C:\\ProgramData\\ActivityWatch\\phase2-admin\\recovery-loop.ps1"
},
"collector": {
"pollSeconds": 5,
"pulseSeconds": 30
},
"collectors": {
"afkEnabled": true,
"windowEnabled": true
},
"logging": {
"localAgentLogsEnabled": false
},
"incidentCapture": {
"enabled": true,
"screenshotEnabled": true,
"artifactsRoot": "C:\\ProgramData\\ActivityWatch\\phase2-admin\\incident-artifacts"
},
"sessionEvents": {
"logonEnabled": true,
"bucketPrefix": "aw-session-events"
},
"recovery": {
"intervalSeconds": 180,
"taskName": "ActivityWatch Recovery"
},
"dlp": {
"incidentBucketPrefix": "aw-dlp-incidents",
"enabled": true
},
"package": {
"version": "v0.13.2"
},
"userTasks": [
{
"UserId": "SHARKON2025\\Администратор",
"LaunchTaskName": "ActivityWatch Launch [SHARKON2025_РђРґРјРёРЅРёСЃС_СЂР_С_РѕСЂ]"
}
]
}
{
"version": 1,
"generatedAtUtc": "2026-04-27T01:14:21.9184268Z",
"server": {
"host": "10.10.10.13",
"port": 5600,
"scheme": "http"
},
"paths": {
"installRoot": "C:\\Program Files\\AWatch-rus\\bin",
"stateRoot": "C:\\ProgramData\\AWatch-rus",
"logsRoot": "C:\\ProgramData\\AWatch-rus\\logs",
"collectorScript": "C:\\ProgramData\\AWatch-rus\\browser-domains-native-collector.ps1",
"endpointCollectorScript": "C:\\ProgramData\\AWatch-rus\\dlp-endpoint-signals-collector.ps1",
"rulesPath": "C:\\ProgramData\\AWatch-rus\\web-category-rules.json",
"policyPath": "C:\\ProgramData\\AWatch-rus\\dlp-policy.json",
"launchScript": "C:\\ProgramData\\AWatch-rus\\launch-watchers.ps1",
"recoveryScript": "C:\\ProgramData\\AWatch-rus\\recovery-loop.ps1"
},
"collector": {
"pollSeconds": 5,
"pulseSeconds": 30
},
"collectors": {
"afkEnabled": true,
"windowEnabled": true
},
"logging": {
"localAgentLogsEnabled": false
},
"incidentCapture": {
"enabled": true,
"screenshotEnabled": true,
"artifactsRoot": "C:\\ProgramData\\AWatch-rus\\incident-artifacts"
},
"sessionEvents": {
"logonEnabled": true,
"bucketPrefix": "aw-session-events"
},
"recovery": {
"intervalSeconds": 180,
"taskName": "ActivityWatch Recovery"
},
"dlp": {
"incidentBucketPrefix": "aw-dlp-incidents",
"enabled": true
},
"package": {
"version": "v0.13.2"
},
"userTasks": [
{
"UserId": "SHARKON2025\\Администратор",
"LaunchTaskName": "ActivityWatch Launch [SHARKON2025_РђРґРјРёРЅРёСЃС_СЂР_С_РѕСЂ]"
}
]
}
@@ -1,69 +1,69 @@
{
"version": 1,
"generatedAtUtc": "2026-04-27T01:09:42.4193209Z",
"server": {
"host": "10.10.10.13",
"port": 5600,
"scheme": "http"
},
"paths": {
"installRoot": "C:\\Program Files\\ActivityWatch-Phase2-u2u5",
"stateRoot": "C:\\ProgramData\\ActivityWatch\\phase2-u2u5",
"logsRoot": "C:\\ProgramData\\ActivityWatch\\phase2-u2u5\\logs",
"collectorScript": "C:\\ProgramData\\ActivityWatch\\phase2-u2u5\\browser-domains-native-collector.ps1",
"endpointCollectorScript": "C:\\ProgramData\\ActivityWatch\\phase2-u2u5\\dlp-endpoint-signals-collector.ps1",
"rulesPath": "C:\\ProgramData\\ActivityWatch\\phase2-u2u5\\web-category-rules.json",
"policyPath": "C:\\ProgramData\\ActivityWatch\\phase2-u2u5\\dlp-policy.json",
"launchScript": "C:\\ProgramData\\ActivityWatch\\phase2-u2u5\\launch-watchers.ps1",
"recoveryScript": "C:\\ProgramData\\ActivityWatch\\phase2-u2u5\\recovery-loop.ps1"
},
"collector": {
"pollSeconds": 5,
"pulseSeconds": 30
},
"collectors": {
"afkEnabled": true,
"windowEnabled": true
},
"logging": {
"localAgentLogsEnabled": false
},
"incidentCapture": {
"enabled": true,
"screenshotEnabled": true,
"artifactsRoot": "C:\\ProgramData\\ActivityWatch\\phase2-u2u5\\incident-artifacts"
},
"sessionEvents": {
"logonEnabled": true,
"bucketPrefix": "aw-session-events"
},
"recovery": {
"intervalSeconds": 180,
"taskName": "ActivityWatch Recovery"
},
"dlp": {
"incidentBucketPrefix": "aw-dlp-incidents",
"enabled": true
},
"package": {
"version": "v0.13.2"
},
"userTasks": [
{
"UserId": "SHARKON2025\\user2",
"LaunchTaskName": "ActivityWatch Launch [SHARKON2025_user2]"
},
{
"UserId": "SHARKON2025\\user3",
"LaunchTaskName": "ActivityWatch Launch [SHARKON2025_user3]"
},
{
"UserId": "SHARKON2025\\user4",
"LaunchTaskName": "ActivityWatch Launch [SHARKON2025_user4]"
},
{
"UserId": "SHARKON2025\\user5",
"LaunchTaskName": "ActivityWatch Launch [SHARKON2025_user5]"
}
]
}
{
"version": 1,
"generatedAtUtc": "2026-04-27T01:09:42.4193209Z",
"server": {
"host": "10.10.10.13",
"port": 5600,
"scheme": "http"
},
"paths": {
"installRoot": "C:\\Program Files\\AWatch-rus\\bin",
"stateRoot": "C:\\ProgramData\\AWatch-rus",
"logsRoot": "C:\\ProgramData\\AWatch-rus\\logs",
"collectorScript": "C:\\ProgramData\\AWatch-rus\\browser-domains-native-collector.ps1",
"endpointCollectorScript": "C:\\ProgramData\\AWatch-rus\\dlp-endpoint-signals-collector.ps1",
"rulesPath": "C:\\ProgramData\\AWatch-rus\\web-category-rules.json",
"policyPath": "C:\\ProgramData\\AWatch-rus\\dlp-policy.json",
"launchScript": "C:\\ProgramData\\AWatch-rus\\launch-watchers.ps1",
"recoveryScript": "C:\\ProgramData\\AWatch-rus\\recovery-loop.ps1"
},
"collector": {
"pollSeconds": 5,
"pulseSeconds": 30
},
"collectors": {
"afkEnabled": true,
"windowEnabled": true
},
"logging": {
"localAgentLogsEnabled": false
},
"incidentCapture": {
"enabled": true,
"screenshotEnabled": true,
"artifactsRoot": "C:\\ProgramData\\AWatch-rus\\incident-artifacts"
},
"sessionEvents": {
"logonEnabled": true,
"bucketPrefix": "aw-session-events"
},
"recovery": {
"intervalSeconds": 180,
"taskName": "ActivityWatch Recovery"
},
"dlp": {
"incidentBucketPrefix": "aw-dlp-incidents",
"enabled": true
},
"package": {
"version": "v0.13.2"
},
"userTasks": [
{
"UserId": "SHARKON2025\\user2",
"LaunchTaskName": "ActivityWatch Launch [SHARKON2025_user2]"
},
{
"UserId": "SHARKON2025\\user3",
"LaunchTaskName": "ActivityWatch Launch [SHARKON2025_user3]"
},
{
"UserId": "SHARKON2025\\user4",
"LaunchTaskName": "ActivityWatch Launch [SHARKON2025_user4]"
},
{
"UserId": "SHARKON2025\\user5",
"LaunchTaskName": "ActivityWatch Launch [SHARKON2025_user5]"
}
]
}
@@ -1,57 +1,57 @@
{
"version": 1,
"generatedAtUtc": "2026-04-27T01:09:38.9519788Z",
"server": {
"host": "10.10.10.13",
"port": 5600,
"scheme": "http"
},
"paths": {
"installRoot": "C:\\Program Files\\ActivityWatch-Phase2",
"stateRoot": "C:\\ProgramData\\ActivityWatch\\phase2-user1",
"logsRoot": "C:\\ProgramData\\ActivityWatch\\phase2-user1\\logs",
"collectorScript": "C:\\ProgramData\\ActivityWatch\\phase2-user1\\browser-domains-native-collector.ps1",
"endpointCollectorScript": "C:\\ProgramData\\ActivityWatch\\phase2-user1\\dlp-endpoint-signals-collector.ps1",
"rulesPath": "C:\\ProgramData\\ActivityWatch\\phase2-user1\\web-category-rules.json",
"policyPath": "C:\\ProgramData\\ActivityWatch\\phase2-user1\\dlp-policy.json",
"launchScript": "C:\\ProgramData\\ActivityWatch\\phase2-user1\\launch-watchers.ps1",
"recoveryScript": "C:\\ProgramData\\ActivityWatch\\phase2-user1\\recovery-loop.ps1"
},
"collector": {
"pollSeconds": 5,
"pulseSeconds": 30
},
"collectors": {
"afkEnabled": true,
"windowEnabled": true
},
"logging": {
"localAgentLogsEnabled": false
},
"incidentCapture": {
"enabled": true,
"screenshotEnabled": true,
"artifactsRoot": "C:\\ProgramData\\ActivityWatch\\phase2-user1\\incident-artifacts"
},
"sessionEvents": {
"logonEnabled": true,
"bucketPrefix": "aw-session-events"
},
"recovery": {
"intervalSeconds": 180,
"taskName": "ActivityWatch Recovery"
},
"dlp": {
"incidentBucketPrefix": "aw-dlp-incidents",
"enabled": true
},
"package": {
"version": "v0.13.2"
},
"userTasks": [
{
"UserId": "SHARKON2025\\user1",
"LaunchTaskName": "ActivityWatch Launch [SHARKON2025_user1]"
}
]
}
{
"version": 1,
"generatedAtUtc": "2026-04-27T01:09:38.9519788Z",
"server": {
"host": "10.10.10.13",
"port": 5600,
"scheme": "http"
},
"paths": {
"installRoot": "C:\\Program Files\\AWatch-rus\\bin",
"stateRoot": "C:\\ProgramData\\AWatch-rus",
"logsRoot": "C:\\ProgramData\\AWatch-rus\\logs",
"collectorScript": "C:\\ProgramData\\AWatch-rus\\browser-domains-native-collector.ps1",
"endpointCollectorScript": "C:\\ProgramData\\AWatch-rus\\dlp-endpoint-signals-collector.ps1",
"rulesPath": "C:\\ProgramData\\AWatch-rus\\web-category-rules.json",
"policyPath": "C:\\ProgramData\\AWatch-rus\\dlp-policy.json",
"launchScript": "C:\\ProgramData\\AWatch-rus\\launch-watchers.ps1",
"recoveryScript": "C:\\ProgramData\\AWatch-rus\\recovery-loop.ps1"
},
"collector": {
"pollSeconds": 5,
"pulseSeconds": 30
},
"collectors": {
"afkEnabled": true,
"windowEnabled": true
},
"logging": {
"localAgentLogsEnabled": false
},
"incidentCapture": {
"enabled": true,
"screenshotEnabled": true,
"artifactsRoot": "C:\\ProgramData\\AWatch-rus\\incident-artifacts"
},
"sessionEvents": {
"logonEnabled": true,
"bucketPrefix": "aw-session-events"
},
"recovery": {
"intervalSeconds": 180,
"taskName": "ActivityWatch Recovery"
},
"dlp": {
"incidentBucketPrefix": "aw-dlp-incidents",
"enabled": true
},
"package": {
"version": "v0.13.2"
},
"userTasks": [
{
"UserId": "SHARKON2025\\user1",
"LaunchTaskName": "ActivityWatch Launch [SHARKON2025_user1]"
}
]
}
@@ -5,7 +5,7 @@ function Assert-Administrator {
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
throw 'Run this script from an elevated PowerShell session.'
throw 'Запустите этот скрипт из PowerShell с правами администратора.'
}
}
@@ -64,7 +64,7 @@ function Get-ActivityWatchPackageRoot {
Select-Object -First 1
if (-not $afkBinary) {
throw "Cannot find aw-watcher-afk.exe under $ExpandedRoot."
throw "Не удалось найти aw-watcher-afk.exe в $ExpandedRoot."
}
return (Split-Path -Path (Split-Path -Path $afkBinary.FullName -Parent) -Parent)
@@ -130,7 +130,7 @@ function Get-ActivityWatchExecutableMap {
foreach ($entry in $map.GetEnumerator()) {
if (-not (Test-Path -LiteralPath $entry.Value)) {
throw "Missing required ActivityWatch binary: $($entry.Value)"
throw "Не найден обязательный исполняемый файл ActivityWatch: $($entry.Value)"
}
}
@@ -194,7 +194,7 @@ function Normalize-ActivityWatchUsers {
Sort-Object -Unique
if (-not $normalized -or $normalized.Count -eq 0) {
throw 'No target users resolved. Provide -Users or -UserListPath.'
throw 'Не удалось определить целевых пользователей. Укажите -Users или -UserListPath.'
}
return @($normalized)
@@ -243,6 +243,8 @@ function Copy-ActivityWatchCollectorAssets {
[Parameter(Mandatory = $true)]
[string]$EndpointCollectorScriptSource,
[Parameter(Mandatory = $true)]
[string]$SessionCollectorScriptSource,
[Parameter(Mandatory = $true)]
[string]$ExampleRulesSource,
[Parameter(Mandatory = $true)]
[string]$ExamplePolicySource,
@@ -256,6 +258,7 @@ function Copy-ActivityWatchCollectorAssets {
$collectorTarget = Join-Path $StateRoot 'browser-domains-native-collector.ps1'
$endpointCollectorTarget = Join-Path $StateRoot 'dlp-endpoint-signals-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'
$examplePolicyTarget = Join-Path $StateRoot 'dlp-policy.example.json'
@@ -263,6 +266,7 @@ function Copy-ActivityWatchCollectorAssets {
Copy-Item -LiteralPath $CollectorScriptSource -Destination $collectorTarget -Force
Copy-Item -LiteralPath $EndpointCollectorScriptSource -Destination $endpointCollectorTarget -Force
Copy-Item -LiteralPath $SessionCollectorScriptSource -Destination $sessionCollectorTarget -Force
Copy-Item -LiteralPath $ExampleRulesSource -Destination $exampleRulesTarget -Force
Copy-Item -LiteralPath $ExamplePolicySource -Destination $examplePolicyTarget -Force
@@ -282,6 +286,7 @@ function Copy-ActivityWatchCollectorAssets {
return [pscustomobject]@{
CollectorScript = $collectorTarget
EndpointCollectorScript = $endpointCollectorTarget
SessionCollectorScript = $sessionCollectorTarget
ExampleRules = $exampleRulesTarget
ActiveRules = $rulesTarget
ExamplePolicy = $examplePolicyTarget
@@ -308,6 +313,8 @@ function New-ActivityWatchDeploymentConfig {
[Parameter(Mandatory = $true)]
[string]$EndpointCollectorScript,
[Parameter(Mandatory = $true)]
[string]$SessionCollectorScript,
[Parameter(Mandatory = $true)]
[string]$RulesPath,
[Parameter(Mandatory = $true)]
[string]$PolicyPath,
@@ -349,6 +356,7 @@ function New-ActivityWatchDeploymentConfig {
logsRoot = $LogsRoot
collectorScript = $CollectorScript
endpointCollectorScript = $EndpointCollectorScript
sessionCollectorScript = $SessionCollectorScript
rulesPath = $RulesPath
policyPath = $PolicyPath
launchScript = $LaunchScriptPath
@@ -413,7 +421,7 @@ function Read-ActivityWatchDeploymentConfig {
)
if (-not (Test-Path -LiteralPath $Path)) {
throw "Deployment config not found: $Path"
throw "Конфигурация развёртывания не найдена: $Path"
}
return Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json
@@ -539,7 +547,7 @@ function Send-LogonMarkerIfNeeded {
`$stateRoot = [string]`$Config.paths.stateRoot
`$markerRoots = New-Object System.Collections.Generic.List[string]
if (-not [string]::IsNullOrWhiteSpace(`$env:LOCALAPPDATA)) {
`$markerRoots.Add((Join-Path `$env:LOCALAPPDATA 'ActivityWatch-Phase2\markers'))
`$markerRoots.Add((Join-Path `$env:LOCALAPPDATA 'AWatch-rus\markers'))
}
if (-not [string]::IsNullOrWhiteSpace(`$stateRoot)) {
`$markerRoots.Add((Join-Path `$stateRoot 'markers'))
@@ -585,7 +593,7 @@ function Send-LogonMarkerIfNeeded {
userId = "`$(`$env:USERDOMAIN)\`$(`$env:USERNAME)"
sessionId = `$SessionId
hostname = `$script:Hostname
source = 'launch-watchers-phase2'
source = 'launch-watchers-awatch-rus'
}
} | ConvertTo-Json -Depth 5 -Compress
@@ -631,6 +639,7 @@ function Start-CollectorScriptIfNeeded {
`$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 { '' }
`$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)
@@ -639,11 +648,11 @@ function Start-CollectorScriptIfNeeded {
`$windowEnabled = if (`$config.PSObject.Properties.Name -contains 'collectors' -and `$config.collectors.PSObject.Properties.Name -contains 'windowEnabled') { [bool]`$config.collectors.windowEnabled } else { `$true }
if (`$afkEnabled -and -not (Test-Path -LiteralPath `$afkExe)) {
throw "Missing aw-watcher-afk.exe: `$afkExe"
throw "Не найден aw-watcher-afk.exe: `$afkExe"
}
if (`$windowEnabled -and -not (Test-Path -LiteralPath `$windowExe)) {
throw "Missing aw-watcher-window.exe: `$windowExe"
throw "Не найден aw-watcher-window.exe: `$windowExe"
}
if (`$afkEnabled -and -not (Test-ProcessInSession -Name 'aw-watcher-afk' -SessionId `$sessionId)) {
@@ -661,6 +670,7 @@ catch {
}
Start-CollectorScriptIfNeeded -ScriptPath `$collectorScript -ConfigPath `$ConfigPath -PowerShellExe `$powershellExe -SessionId `$sessionId
Start-CollectorScriptIfNeeded -ScriptPath `$endpointCollectorScript -ConfigPath `$ConfigPath -PowerShellExe `$powershellExe -SessionId `$sessionId
Start-CollectorScriptIfNeeded -ScriptPath `$sessionCollectorScript -ConfigPath `$ConfigPath -PowerShellExe `$powershellExe -SessionId `$sessionId
"@
Set-Content -LiteralPath $Path -Value $content -Encoding UTF8
@@ -850,7 +860,7 @@ function Set-ActivityWatchScheduledTaskAction {
$taskCommand = ('"{0}" {1}' -f $Execute, $Arguments)
& schtasks.exe /Change /TN $TaskName /TR $taskCommand | Out-Null
if ($LASTEXITCODE -ne 0) {
throw "schtasks.exe /Change failed for $TaskName"
throw "schtasks.exe /Change завершился с ошибкой для $TaskName"
}
}
@@ -951,17 +961,17 @@ function Set-ActivityWatchAcl {
& icacls $InstallRoot /inheritance:r /grant:r '*S-1-5-18:(OI)(CI)(F)' '*S-1-5-32-544:(OI)(CI)(F)' '*S-1-5-32-545:(OI)(CI)(RX)' | Out-Null
if ($LASTEXITCODE -ne 0) {
throw "icacls failed for $InstallRoot"
throw "icacls завершился с ошибкой для $InstallRoot"
}
& icacls $StateRoot /inheritance:r /grant:r '*S-1-5-18:(OI)(CI)(F)' '*S-1-5-32-544:(OI)(CI)(F)' '*S-1-5-32-545:(OI)(CI)(RX)' | Out-Null
if ($LASTEXITCODE -ne 0) {
throw "icacls failed for $StateRoot"
throw "icacls завершился с ошибкой для $StateRoot"
}
& icacls $LogsRoot /inheritance:r /grant:r '*S-1-5-18:(OI)(CI)(F)' '*S-1-5-32-544:(OI)(CI)(F)' '*S-1-5-32-545:(OI)(CI)(M)' | Out-Null
if ($LASTEXITCODE -ne 0) {
throw "icacls failed for $LogsRoot"
throw "icacls завершился с ошибкой для $LogsRoot"
}
}
@@ -1,6 +1,6 @@
[CmdletBinding()]
param(
[string]$ConfigPath = 'C:\ProgramData\ActivityWatch\deployment-config.json',
[string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json',
[string]$ServerHost,
[int]$ServerPort,
[ValidateSet('http', 'https')]
@@ -49,18 +49,18 @@ function Get-DeploymentConfig {
}
$deploymentConfig = Get-DeploymentConfig -Path $ConfigPath
$resolvedServerHost = if ($ServerHost) { $ServerHost } elseif ($deploymentConfig) { [string]$deploymentConfig.server.host } else { throw 'ServerHost is required.' }
$resolvedServerHost = if ($ServerHost) { $ServerHost } elseif ($deploymentConfig) { [string]$deploymentConfig.server.host } else { throw 'Укажите ServerHost или подготовьте deployment-config.json.' }
$resolvedServerPort = if ($PSBoundParameters.ContainsKey('ServerPort')) { $ServerPort } elseif ($deploymentConfig) { [int]$deploymentConfig.server.port } else { 5600 }
$resolvedServerScheme = if ($ServerScheme) { $ServerScheme } elseif ($deploymentConfig) { [string]$deploymentConfig.server.scheme } else { 'http' }
$resolvedRulesPath = if ($RulesPath) { $RulesPath } elseif ($deploymentConfig) { [string]$deploymentConfig.paths.rulesPath } else { 'C:\ProgramData\ActivityWatch\web-category-rules.json' }
$resolvedPolicyPath = if ($PolicyPath) { $PolicyPath } elseif ($deploymentConfig) { [string]$deploymentConfig.paths.policyPath } else { 'C:\ProgramData\ActivityWatch\dlp-policy.json' }
$resolvedRulesPath = if ($RulesPath) { $RulesPath } elseif ($deploymentConfig) { [string]$deploymentConfig.paths.rulesPath } else { 'C:\ProgramData\AWatch-rus\web-category-rules.json' }
$resolvedPolicyPath = if ($PolicyPath) { $PolicyPath } elseif ($deploymentConfig) { [string]$deploymentConfig.paths.policyPath } else { 'C:\ProgramData\AWatch-rus\dlp-policy.json' }
$resolvedPollSeconds = if ($PSBoundParameters.ContainsKey('PollSeconds')) { $PollSeconds } elseif ($deploymentConfig) { [int]$deploymentConfig.collector.pollSeconds } else { 5 }
$resolvedPulseSeconds = if ($PSBoundParameters.ContainsKey('PulseSeconds')) { $PulseSeconds } elseif ($deploymentConfig) { [int]$deploymentConfig.collector.pulseSeconds } else { 30 }
$resolvedLogsRoot = if ($deploymentConfig) { [string]$deploymentConfig.paths.logsRoot } else { 'C:\ProgramData\ActivityWatch\logs' }
$resolvedLogsRoot = if ($deploymentConfig) { [string]$deploymentConfig.paths.logsRoot } else { 'C:\ProgramData\AWatch-rus\logs' }
$resolvedLogPath = if ($LogPath) { $LogPath } else { Join-Path $resolvedLogsRoot ("browser-domains-{0}.log" -f $env:USERNAME) }
$resolvedIncidentLogPath = if ($IncidentLogPath) { $IncidentLogPath } else { Join-Path $resolvedLogsRoot ("dlp-incidents-{0}.log" -f $env:USERNAME) }
$resolvedLocalAgentLogsEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'logging' -and $deploymentConfig.logging.PSObject.Properties.Name -contains 'localAgentLogsEnabled') { [bool]$deploymentConfig.logging.localAgentLogsEnabled } else { $true }
$resolvedIncidentArtifactsRoot = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'artifactsRoot') { [string]$deploymentConfig.incidentCapture.artifactsRoot } else { Join-Path $env:LOCALAPPDATA 'ActivityWatch-Phase2\\incident-artifacts' }
$resolvedIncidentArtifactsRoot = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'artifactsRoot') { [string]$deploymentConfig.incidentCapture.artifactsRoot } else { Join-Path $env:LOCALAPPDATA 'AWatch-rus\\incident-artifacts' }
$resolvedIncidentScreenshotEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'screenshotEnabled') { [bool]$deploymentConfig.incidentCapture.screenshotEnabled } else { $true }
if ($resolvedLocalAgentLogsEnabled -and -not (Test-Path -LiteralPath $resolvedLogsRoot)) {
@@ -158,12 +158,12 @@ function Get-HostFromUrl {
try {
$uri = [Uri]$Url
$host = $uri.Host.ToLowerInvariant()
if ($host.StartsWith('www.')) {
return $host.Substring(4)
$uriHost = $uri.Host.ToLowerInvariant()
if ($uriHost.StartsWith('www.')) {
return $uriHost.Substring(4)
}
return $host
return $uriHost
}
catch {
return $null
@@ -263,11 +263,11 @@ function Load-CustomCategoryRules {
if ($rules.Count -gt 0) {
$script:CategoryRules = @($rules) + @($script:CategoryRules)
Write-CollectorLog ("custom rules loaded: {0}" -f $rules.Count)
Write-CollectorLog ("пользовательские правила загружены: {0}" -f $rules.Count)
}
}
catch {
Write-CollectorLog ("custom rules load failed: {0}" -f $_.Exception.Message)
Write-CollectorLog ("не удалось загрузить пользовательские правила: {0}" -f $_.Exception.Message)
}
}
@@ -338,7 +338,7 @@ function Load-DlpPolicy {
param([string]$Path)
if (-not $Path -or -not (Test-Path -LiteralPath $Path)) {
Write-CollectorLog ("dlp policy not found, disabled: {0}" -f $Path)
Write-CollectorLog ("DLP-политика не найдена, DLP отключен: {0}" -f $Path)
return
}
@@ -372,7 +372,7 @@ function Load-DlpPolicy {
enabled = if ($rule.PSObject.Properties.Name -contains 'enabled') { [bool]$rule.enabled } else { $true }
action = if ($rule.action) { [string]$rule.action } else { [string]$script:DlpDefaults.action }
severity = if ($rule.severity) { [string]$rule.severity } else { [string]$script:DlpDefaults.severity }
message = if ($rule.message) { [string]$rule.message } else { "DLP rule matched: $($rule.id)" }
message = if ($rule.message) { [string]$rule.message } else { "Сработало DLP-правило: $($rule.id)" }
cooldownSeconds = if ($rule.cooldownSeconds) { [int]$rule.cooldownSeconds } else { [int]$script:DlpDefaults.cooldownSeconds }
when = [pscustomobject]@{
domains = if ($when.PSObject.Properties.Name -contains 'domains') { @($when.domains | ForEach-Object { ([string]$_).Trim().ToLowerInvariant() } | Where-Object { $_ }) } else { @() }
@@ -388,10 +388,10 @@ function Load-DlpPolicy {
}
$script:DlpRules = @($loaded)
Write-CollectorLog ("dlp policy loaded: enabled={0}, rules={1}" -f $script:DlpDefaults.enabled, $script:DlpRules.Count)
Write-CollectorLog ("DLP-политика загружена: включена={0}, правил={1}" -f $script:DlpDefaults.enabled, $script:DlpRules.Count)
}
catch {
Write-CollectorLog ("dlp policy parse failed: {0}" -f $_.Exception.Message)
Write-CollectorLog ("не удалось разобрать DLP-политику: {0}" -f $_.Exception.Message)
}
}
@@ -625,7 +625,7 @@ function Capture-IncidentScreenshot {
}
}
catch {
Write-CollectorLog ("screenshot capture failed: {0}" -f $_.Exception.Message)
Write-CollectorLog ("не удалось сделать снимок инцидента: {0}" -f $_.Exception.Message)
return @{}
}
}
@@ -775,7 +775,7 @@ function Send-CategoryHeartbeat {
Load-CustomCategoryRules -Path $resolvedRulesPath
Load-DlpPolicy -Path $resolvedPolicyPath
Write-CollectorLog ("collector started against {0}" -f $script:ApiBase)
Write-CollectorLog ("коллектор запущен для {0}" -f $script:ApiBase)
while ($true) {
try {
@@ -815,7 +815,7 @@ while ($true) {
}
}
catch {
Write-CollectorLog ("collector error: {0}" -f $_.Exception.Message)
Write-CollectorLog ("ошибка коллектора: {0}" -f $_.Exception.Message)
}
Start-Sleep -Seconds $resolvedPollSeconds
@@ -11,8 +11,8 @@ param(
[string]$Version = 'v0.13.2',
[string]$PackageUrl,
[string]$PackageZipPath,
[string]$InstallRoot = 'C:\Program Files\ActivityWatch',
[string]$StateRoot = 'C:\ProgramData\ActivityWatch',
[string]$InstallRoot = 'C:\Program Files\AWatch-rus\bin',
[string]$StateRoot = 'C:\ProgramData\AWatch-rus',
[int]$PollSeconds = 5,
[int]$PulseSeconds = 30,
[int]$RecoveryIntervalSeconds = 180,
@@ -44,6 +44,7 @@ $launchScriptPath = Join-Path $StateRoot 'launch-watchers.ps1'
$recoveryScriptPath = Join-Path $StateRoot 'recovery-loop.ps1'
$collectorSource = Join-Path $PSScriptRoot 'browser-domains-native-collector.ps1'
$endpointCollectorSource = Join-Path $PSScriptRoot 'dlp-endpoint-signals-collector.ps1'
$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'
@@ -57,6 +58,7 @@ Get-ActivityWatchExecutableMap -InstallRoot $InstallRoot | Out-Null
$assetResult = Copy-ActivityWatchCollectorAssets `
-CollectorScriptSource $collectorSource `
-EndpointCollectorScriptSource $endpointCollectorSource `
-SessionCollectorScriptSource $sessionCollectorSource `
-ExampleRulesSource $exampleRulesSource `
-ExamplePolicySource $examplePolicySource `
-StateRoot $StateRoot `
@@ -76,6 +78,7 @@ $config = New-ActivityWatchDeploymentConfig `
-LogsRoot $logsRoot `
-CollectorScript $assetResult.CollectorScript `
-EndpointCollectorScript $assetResult.EndpointCollectorScript `
-SessionCollectorScript $assetResult.SessionCollectorScript `
-RulesPath $assetResult.ActiveRules `
-PolicyPath $assetResult.ActivePolicy `
-PollSeconds $PollSeconds `
@@ -100,8 +103,8 @@ Register-ActivityWatchUserTasks -TaskDefinitions $taskDefinitions -LaunchScriptP
Register-ActivityWatchRecoveryTask -TaskName $config.recovery.taskName -RecoveryScriptPath $recoveryScriptPath -ConfigPath $configPath
Start-ActivityWatchTasks -TaskDefinitions $taskDefinitions -RecoveryTaskName $config.recovery.taskName
Write-Host 'ActivityWatch deployed for users:'
Write-Host 'ActivityWatch развёрнут для пользователей:'
$targetUsers | ForEach-Object { Write-Host " - $_" }
Write-Host "Server: ${ServerScheme}://$ServerHost`:$ServerPort"
Write-Host "State root: $StateRoot"
Write-Host "Policy file: $($assetResult.ActivePolicy)"
Write-Host "Сервер: ${ServerScheme}://$ServerHost`:$ServerPort"
Write-Host "Каталог данных: $StateRoot"
Write-Host "Файл DLP-политики: $($assetResult.ActivePolicy)"
@@ -11,8 +11,8 @@ param(
[string]$Version = 'v0.13.2',
[string]$PackageUrl,
[string]$PackageZipPath,
[string]$InstallRoot = 'C:\Program Files\ActivityWatch',
[string]$StateRoot = 'C:\ProgramData\ActivityWatch',
[string]$InstallRoot = 'C:\Program Files\AWatch-rus\bin',
[string]$StateRoot = 'C:\ProgramData\AWatch-rus',
[int]$PollSeconds = 5,
[int]$PulseSeconds = 30,
[int]$RecoveryIntervalSeconds = 180,
@@ -46,7 +46,7 @@ $hardeningScript = Join-Path $PSScriptRoot 'hardening-recovery.ps1'
$validationScript = Join-Path $PSScriptRoot 'validate-deployment.ps1'
if (-not (Test-Path -LiteralPath $deployScript)) {
throw "Missing script: $deployScript"
throw "Не найден скрипт: $deployScript"
}
& $deployScript `
@@ -118,7 +118,7 @@ $report = [ordered]@{
if ($ValidateAfterDeploy) {
if (-not (Test-Path -LiteralPath $validationScript)) {
throw "Missing script: $validationScript"
throw "Не найден скрипт: $validationScript"
}
$validation = & $validationScript -ConfigPath (Join-Path $StateRoot 'deployment-config.json')
@@ -132,6 +132,6 @@ if ($reportDirectory) {
$report | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $effectiveReportPath -Encoding UTF8
Write-Host 'ActivityWatch ensemble deploy completed.'
Write-Host "Users: $($resolvedUsers -join ', ')"
Write-Host "Report: $effectiveReportPath"
Write-Host 'Комплексное развёртывание ActivityWatch завершено.'
Write-Host "Пользователи: $($resolvedUsers -join ', ')"
Write-Host "Отчёт: $effectiveReportPath"
@@ -10,8 +10,8 @@ param(
[string]$Version = 'v0.13.2',
[string]$PackageUrl,
[string]$PackageZipPath,
[string]$InstallRoot = 'C:\Program Files\ActivityWatch',
[string]$StateRoot = 'C:\ProgramData\ActivityWatch',
[string]$InstallRoot = 'C:\Program Files\AWatch-rus\bin',
[string]$StateRoot = 'C:\ProgramData\AWatch-rus',
[int]$PollSeconds = 5,
[int]$PulseSeconds = 30,
[int]$RecoveryIntervalSeconds = 180,
@@ -42,6 +42,7 @@ $launchScriptPath = Join-Path $StateRoot 'launch-watchers.ps1'
$recoveryScriptPath = Join-Path $StateRoot 'recovery-loop.ps1'
$collectorSource = Join-Path $PSScriptRoot 'browser-domains-native-collector.ps1'
$endpointCollectorSource = Join-Path $PSScriptRoot 'dlp-endpoint-signals-collector.ps1'
$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'
@@ -55,6 +56,7 @@ Get-ActivityWatchExecutableMap -InstallRoot $InstallRoot | Out-Null
$assetResult = Copy-ActivityWatchCollectorAssets `
-CollectorScriptSource $collectorSource `
-EndpointCollectorScriptSource $endpointCollectorSource `
-SessionCollectorScriptSource $sessionCollectorSource `
-ExampleRulesSource $exampleRulesSource `
-ExamplePolicySource $examplePolicySource `
-StateRoot $StateRoot `
@@ -74,6 +76,7 @@ $config = New-ActivityWatchDeploymentConfig `
-LogsRoot $logsRoot `
-CollectorScript $assetResult.CollectorScript `
-EndpointCollectorScript $assetResult.EndpointCollectorScript `
-SessionCollectorScript $assetResult.SessionCollectorScript `
-RulesPath $assetResult.ActiveRules `
-PolicyPath $assetResult.ActivePolicy `
-PollSeconds $PollSeconds `
@@ -98,9 +101,9 @@ Register-ActivityWatchUserTasks -TaskDefinitions $taskDefinitions -LaunchScriptP
Register-ActivityWatchRecoveryTask -TaskName $config.recovery.taskName -RecoveryScriptPath $recoveryScriptPath -ConfigPath $configPath
Start-ActivityWatchTasks -TaskDefinitions $taskDefinitions -RecoveryTaskName $config.recovery.taskName
Write-Host "ActivityWatch deployed for $TargetUser"
Write-Host "Server: ${ServerScheme}://$ServerHost`:$ServerPort"
Write-Host "Install root: $InstallRoot"
Write-Host "State root: $StateRoot"
Write-Host "Rules file: $($assetResult.ActiveRules)"
Write-Host "Policy file: $($assetResult.ActivePolicy)"
Write-Host "ActivityWatch развёрнут для пользователя: $TargetUser"
Write-Host "Сервер: ${ServerScheme}://$ServerHost`:$ServerPort"
Write-Host "Каталог установки: $InstallRoot"
Write-Host "Каталог данных: $StateRoot"
Write-Host "Файл правил: $($assetResult.ActiveRules)"
Write-Host "Файл DLP-политики: $($assetResult.ActivePolicy)"
@@ -1,6 +1,6 @@
[CmdletBinding()]
param(
[string]$ConfigPath = 'C:\ProgramData\ActivityWatch\deployment-config.json',
[string]$ConfigPath = 'C:\ProgramData\AWatch-rus\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-phase2'
source = 'endpoint-signals-awatch-rus'
} + $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-phase2'
source = 'endpoint-signals-awatch-rus'
} + $Data + $captureData
} | ConvertTo-Json -Depth 7 -Compress
@@ -210,7 +210,7 @@ function Capture-IncidentScreenshot {
}
}
catch {
Write-EndpointLog ("screenshot capture failed: {0}" -f $_.Exception.Message)
Write-EndpointLog ("не удалось сделать снимок инцидента: {0}" -f $_.Exception.Message)
return @{}
}
}
@@ -246,7 +246,7 @@ function Load-DlpPolicy {
}
if (-not $Path -or -not (Test-Path -LiteralPath $Path)) {
Write-EndpointLog ("policy not found, using defaults: {0}" -f $Path)
Write-EndpointLog ("DLP-политика не найдена, используются значения по умолчанию: {0}" -f $Path)
return
}
@@ -266,7 +266,7 @@ function Load-DlpPolicy {
}
}
catch {
Write-EndpointLog ("policy parse failed: {0}" -f $_.Exception.Message)
Write-EndpointLog ("не удалось разобрать DLP-политику: {0}" -f $_.Exception.Message)
}
}
@@ -319,13 +319,13 @@ 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 { "Clipboard rule matched: $ruleId" }
$message = if ($rule.message) { [string]$rule.message } else { "Сработало правило буфера обмена: $ruleId" }
Send-DlpIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -SignalType 'clipboard' -Data @{
clipboardHash = $ClipboardHash
clipboardLength = $ClipboardText.Length
}
Write-EndpointLog ("incident clipboard rule={0} action={1} severity={2}" -f $ruleId, $action, $severity)
Write-EndpointLog ("инцидент буфера обмена правило={0} действие={1} важность={2}" -f $ruleId, $action, $severity)
}
}
@@ -347,13 +347,13 @@ 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 rule matched: $ruleId" }
$message = if ($rule.message) { [string]$rule.message } else { "Сработало правило USB-носителя: $ruleId" }
Send-DlpIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -SignalType 'usb_insert' -Data @{
driveLetter = $DriveLetter
volumeName = $VolumeName
}
Write-EndpointLog ("incident usb rule={0} action={1} severity={2} drive={3}" -f $ruleId, $action, $severity, $DriveLetter)
Write-EndpointLog ("инцидент USB правило={0} действие={1} важность={2} диск={3}" -f $ruleId, $action, $severity, $DriveLetter)
}
}
@@ -385,14 +385,14 @@ 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 { "Print rule matched: $ruleId" }
$message = if ($rule.message) { [string]$rule.message } else { "Сработало правило печати: $ruleId" }
Send-DlpIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -SignalType 'print_job' -Data @{
printerName = $PrinterName
documentName = $DocumentName
owner = $Owner
}
Write-EndpointLog ("incident print rule={0} action={1} severity={2} printer={3}" -f $ruleId, $action, $severity, $PrinterName)
Write-EndpointLog ("инцидент печати правило={0} действие={1} важность={2} принтер={3}" -f $ruleId, $action, $severity, $PrinterName)
}
}
@@ -402,6 +402,52 @@ 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 '' }
@@ -469,13 +515,50 @@ 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
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 { '' }
JobId = $jobId
DocumentName = $documentName
Owner = $owner
PortName = $portName
PrinterName = $printerName
SizeBytes = $sizeBytes
PageCount = $pageCount
PropertyValues = $propertyValues
}
}
@@ -488,7 +571,7 @@ function Get-PrintServiceDocumentFallback {
)
$preferred = [string]$EventSummary.DocumentName
if (-not (Test-LooksLikeMojibakeQuestionMarks -Value $preferred) -and $preferred -notmatch '^[0-9]+$') {
if (-not (Test-DocumentNameNeedsFallback -Value $preferred)) {
return $preferred
}
@@ -499,9 +582,10 @@ 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-LooksLikeMojibakeQuestionMarks -Value $candidate) { continue }
if (Test-DocumentNameNeedsFallback -Value $candidate) { continue }
if ($candidate -match '[\\/:]' -and $candidate -match '\.[A-Za-z0-9]{1,8}$') {
$pathCandidates.Add($candidate)
@@ -546,7 +630,7 @@ function Write-PrintServiceEventTrace {
}
Write-EndpointLog (
'printservice-307 phase={0} recordId={1} time={2} owner={3} printer={4} document={5} resolved={6} properties=[{7}] reason={8}' -f
'printservice-307 этап={0} recordId={1} время={2} владелец={3} принтер={4} документ={5} итоговыйДокумент={6} свойства=[{7}] причина={8}' -f
$Phase,
$EventSummary.RecordId,
$EventSummary.TimeCreated,
@@ -561,6 +645,7 @@ function Write-PrintServiceEventTrace {
function Get-BetterDocumentNameFromPrintServiceEvents {
param(
[string]$JobId,
[string]$Owner,
[string]$PrinterName
)
@@ -578,32 +663,41 @@ 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 'owner-mismatch-strict' -ResolvedDocument $resolvedDocument
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'несовпадение-владельца-strict' -ResolvedDocument $resolvedDocument
continue
}
if ($PrinterName -and -not $printerMatches) {
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'printer-mismatch-strict' -ResolvedDocument $resolvedDocument
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'несовпадение-принтера-strict' -ResolvedDocument $resolvedDocument
continue
}
}
else {
if ($Owner -and $PrinterName -and -not $ownerMatches -and -not $printerMatches) {
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'owner-and-printer-mismatch-relaxed' -ResolvedDocument $resolvedDocument
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
continue
}
}
if ([string]::IsNullOrWhiteSpace($resolvedDocument)) {
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason ('no-document-candidate-' + $pass) -ResolvedDocument ''
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason ('нет-кандидата-документа-' + $pass) -ResolvedDocument ''
continue
}
$matchReasonBase = if (Test-LooksLikeMojibakeQuestionMarks -Value $summary.DocumentName) { 'fallback-used' } else { 'direct' }
$matchReasonBase = if (Test-DocumentNameNeedsFallback -Value $summary.DocumentName) { 'использован-резервный-вариант' } else { 'напрямую' }
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'selected' -MatchReason ($matchReasonBase + '-' + $pass) -ResolvedDocument $resolvedDocument
return $resolvedDocument
}
@@ -616,15 +710,15 @@ function Get-BetterDocumentNameFromPrintServiceEvents {
}
$deploymentConfig = Get-DeploymentConfig -Path $ConfigPath
$resolvedServerHost = if ($ServerHost) { $ServerHost } elseif ($deploymentConfig) { [string]$deploymentConfig.server.host } else { throw 'ServerHost is required.' }
$resolvedServerHost = if ($ServerHost) { $ServerHost } elseif ($deploymentConfig) { [string]$deploymentConfig.server.host } else { throw 'Укажите ServerHost или подготовьте deployment-config.json.' }
$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' }
$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' }
$resolvedPollSeconds = if ($PSBoundParameters.ContainsKey('PollSeconds')) { $PollSeconds } elseif ($deploymentConfig) { [int]$deploymentConfig.collector.pollSeconds } else { 5 }
$resolvedLogsRoot = if ($deploymentConfig) { [string]$deploymentConfig.paths.logsRoot } else { 'C:\ProgramData\ActivityWatch\logs' }
$resolvedLogsRoot = if ($deploymentConfig) { [string]$deploymentConfig.paths.logsRoot } else { 'C:\ProgramData\AWatch-rus\logs' }
$resolvedLogPath = if ($LogPath) { $LogPath } else { Join-Path $resolvedLogsRoot ("endpoint-signals-{0}.log" -f $env:USERNAME) }
$resolvedLocalAgentLogsEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'logging' -and $deploymentConfig.logging.PSObject.Properties.Name -contains 'localAgentLogsEnabled') { [bool]$deploymentConfig.logging.localAgentLogsEnabled } else { $true }
$resolvedIncidentArtifactsRoot = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'artifactsRoot') { [string]$deploymentConfig.incidentCapture.artifactsRoot } else { Join-Path $env:LOCALAPPDATA 'ActivityWatch-Phase2\\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 'AWatch-rus\\incident-artifacts' }
$resolvedIncidentScreenshotEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'screenshotEnabled') { [bool]$deploymentConfig.incidentCapture.screenshotEnabled } else { $true }
if ($resolvedLocalAgentLogsEnabled -and -not (Test-Path -LiteralPath $resolvedLogsRoot)) {
@@ -648,7 +742,7 @@ $script:IncidentScreenshotEnabled = $resolvedIncidentScreenshotEnabled
$script:ScreenshotTypesLoaded = $false
Load-DlpPolicy -Path $resolvedPolicyPath
Write-EndpointLog ("endpoint collector started against {0}" -f $script:ApiBase)
Write-EndpointLog ("endpoint-коллектор запущен для {0}" -f $script:ApiBase)
while ($true) {
try {
@@ -709,13 +803,13 @@ while ($true) {
if ($script:SeenPrintJob.ContainsKey($jobId)) { continue }
$script:SeenPrintJob[$jobId] = (Get-Date).ToUniversalTime()
$printerName = [string]$job.Name
$printerName = Get-PrintJobPrinterName -JobName ([string]$job.Name) -FallbackPrinterName ([string]$job.DriverName)
$documentName = [string]$job.Document
$owner = [string]$job.Owner
$documentNameOriginal = $documentName
if (Test-LooksLikeMojibakeQuestionMarks -Value $documentName) {
$eventDocumentName = Get-BetterDocumentNameFromPrintServiceEvents -Owner $owner -PrinterName $printerName
if (Test-DocumentNameNeedsFallback -Value $documentName) {
$eventDocumentName = Get-BetterDocumentNameFromPrintServiceEvents -JobId $jobId -Owner $owner -PrinterName $printerName
if ($eventDocumentName) {
$documentName = $eventDocumentName
}
@@ -726,6 +820,7 @@ while ($true) {
documentName = $documentName
documentNameOriginal = $documentNameOriginal
owner = $owner
printJobId = $jobId
}
Evaluate-PrintRules -PrinterName $printerName -DocumentName $documentName -Owner $owner
}
@@ -789,7 +884,7 @@ while ($true) {
}
}
catch {
Write-EndpointLog ("collector error: {0}" -f $_.Exception.Message)
Write-EndpointLog ("ошибка коллектора: {0}" -f $_.Exception.Message)
}
Start-Sleep -Seconds $resolvedPollSeconds
@@ -1,6 +1,6 @@
[CmdletBinding()]
param(
[string]$ConfigPath = 'C:\ProgramData\ActivityWatch\deployment-config.json',
[string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json',
[string]$ServerHost,
[int]$ServerPort,
[ValidateSet('http', 'https')]
@@ -42,11 +42,11 @@ if (Test-Path -LiteralPath $ConfigPath) {
}
if (-not $existingConfig -and (-not $ServerHost)) {
throw 'deployment-config.json is missing. Provide -ServerHost and user parameters, or run a deploy script first.'
throw 'deployment-config.json отсутствует. Укажите -ServerHost и параметры пользователей либо сначала выполните скрипт развёртывания.'
}
$effectiveStateRoot = if ($StateRoot) { $StateRoot } elseif ($existingConfig) { [string]$existingConfig.paths.stateRoot } else { 'C:\ProgramData\ActivityWatch' }
$effectiveInstallRoot = if ($InstallRoot) { $InstallRoot } elseif ($existingConfig) { [string]$existingConfig.paths.installRoot } else { 'C:\Program Files\ActivityWatch' }
$effectiveStateRoot = if ($StateRoot) { $StateRoot } elseif ($existingConfig) { [string]$existingConfig.paths.stateRoot } else { 'C:\ProgramData\AWatch-rus' }
$effectiveInstallRoot = if ($InstallRoot) { $InstallRoot } elseif ($existingConfig) { [string]$existingConfig.paths.installRoot } else { 'C:\Program Files\AWatch-rus\bin' }
$effectiveLogsRoot = if ($existingConfig) { [string]$existingConfig.paths.logsRoot } else { Join-Path $effectiveStateRoot 'logs' }
$effectiveConfigPath = if ($ConfigPath) { $ConfigPath } else { Join-Path $effectiveStateRoot 'deployment-config.json' }
$effectiveLaunchScript = Join-Path $effectiveStateRoot 'launch-watchers.ps1'
@@ -78,7 +78,7 @@ elseif ($existingConfig) {
@($existingConfig.userTasks | ForEach-Object { [string]$_.userId })
}
else {
throw 'Target users are missing.'
throw 'Не указаны целевые пользователи.'
}
New-ActivityWatchDirectory -Path $effectiveStateRoot
@@ -96,6 +96,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') `
-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') `
-StateRoot $effectiveStateRoot `
@@ -115,6 +116,7 @@ $config = New-ActivityWatchDeploymentConfig `
-LogsRoot $effectiveLogsRoot `
-CollectorScript $effectiveCollector `
-EndpointCollectorScript $effectiveEndpointCollector `
-SessionCollectorScript $effectiveSessionCollector `
-RulesPath $effectiveRules `
-PolicyPath $effectivePolicy `
-PollSeconds $effectivePollSeconds `
@@ -139,6 +141,6 @@ Register-ActivityWatchUserTasks -TaskDefinitions $taskDefinitions -LaunchScriptP
Register-ActivityWatchRecoveryTask -TaskName $config.recovery.taskName -RecoveryScriptPath $effectiveRecoveryScript -ConfigPath $effectiveConfigPath
Start-ActivityWatchTasks -TaskDefinitions $taskDefinitions -RecoveryTaskName $config.recovery.taskName
Write-Host 'ActivityWatch hardening/recovery completed.'
Write-Host "Config: $effectiveConfigPath"
Write-Host "Users repaired: $($effectiveUsers -join ', ')"
Write-Host 'Укрепление и восстановление ActivityWatch завершены.'
Write-Host "Конфигурация: $effectiveConfigPath"
Write-Host "Пользователи восстановлены: $($effectiveUsers -join ', ')"
@@ -0,0 +1,214 @@
[CmdletBinding(SupportsShouldProcess = $true)]
param(
[string]$OldInstallRoot = 'C:\Program Files\ActivityWatch-Phase2',
[string]$OldStateRoot = 'C:\ProgramData\ActivityWatch-Phase2',
[string]$NewInstallRoot = 'C:\Program Files\AWatch-rus\bin',
[string]$NewStateRoot = 'C:\ProgramData\AWatch-rus',
[string]$ToolkitRoot = 'C:\Program Files\AWatch-rus\windows',
[switch]$SkipValidation
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$modulePath = Join-Path $PSScriptRoot 'ActivityWatch.Windows.Common.psm1'
Import-Module $modulePath -Force
Assert-Administrator
function Copy-DirectoryContents {
param(
[Parameter(Mandatory = $true)]
[string]$Source,
[Parameter(Mandatory = $true)]
[string]$Destination
)
if (-not (Test-Path -LiteralPath $Source)) {
return
}
New-ActivityWatchDirectory -Path $Destination
Copy-Item -Path (Join-Path $Source '*') -Destination $Destination -Recurse -Force
}
function Copy-IfExists {
param(
[Parameter(Mandatory = $true)]
[string]$Source,
[Parameter(Mandatory = $true)]
[string]$Destination
)
if (Test-Path -LiteralPath $Source) {
Copy-Item -LiteralPath $Source -Destination $Destination -Force
}
}
function Convert-PathValue {
param(
[AllowNull()]
[string]$Value
)
if ([string]::IsNullOrWhiteSpace($Value)) {
return $Value
}
return $Value.Replace($OldInstallRoot, $NewInstallRoot).Replace($OldStateRoot, $NewStateRoot)
}
function Stop-AWatchTaskSet {
foreach ($task in @(Get-ScheduledTask | Where-Object { $_.TaskName -eq 'ActivityWatch Recovery' -or $_.TaskName -like 'ActivityWatch Launch *' })) {
Stop-ScheduledTask -TaskName $task.TaskName -ErrorAction SilentlyContinue
}
}
function Get-ExistingAWatchConfig {
$newConfigPath = Join-Path $NewStateRoot 'deployment-config.json'
$oldConfigPath = Join-Path $OldStateRoot 'deployment-config.json'
if (Test-Path -LiteralPath $oldConfigPath) {
return [pscustomobject]@{
Path = $oldConfigPath
Config = Read-ActivityWatchDeploymentConfig -Path $oldConfigPath
}
}
if (Test-Path -LiteralPath $newConfigPath) {
return [pscustomobject]@{
Path = $newConfigPath
Config = Read-ActivityWatchDeploymentConfig -Path $newConfigPath
}
}
throw "Не найден deployment-config.json ни в $OldStateRoot, ни в $NewStateRoot."
}
function Update-AWatchConfigPaths {
param(
[Parameter(Mandatory = $true)]
[pscustomobject]$Config
)
$logsRoot = Join-Path $NewStateRoot 'logs'
$Config.paths.installRoot = $NewInstallRoot
$Config.paths.stateRoot = $NewStateRoot
$Config.paths.logsRoot = $logsRoot
$Config.paths.collectorScript = Join-Path $NewStateRoot 'browser-domains-native-collector.ps1'
$Config.paths.endpointCollectorScript = Join-Path $NewStateRoot 'dlp-endpoint-signals-collector.ps1'
if ($Config.paths.PSObject.Properties.Name -contains 'sessionCollectorScript') {
$Config.paths.sessionCollectorScript = Join-Path $NewStateRoot 'worktime-session-collector.ps1'
}
$Config.paths.rulesPath = Join-Path $NewStateRoot 'web-category-rules.json'
if ($Config.paths.PSObject.Properties.Name -contains 'policyPath') {
$Config.paths.policyPath = Join-Path $NewStateRoot 'dlp-policy.json'
}
$Config.paths.launchScript = Join-Path $NewStateRoot 'launch-watchers.ps1'
$Config.paths.recoveryScript = Join-Path $NewStateRoot 'recovery-loop.ps1'
if ($Config.PSObject.Properties.Name -contains 'incidentCapture' -and $Config.incidentCapture.PSObject.Properties.Name -contains 'artifactsRoot') {
$Config.incidentCapture.artifactsRoot = Convert-PathValue -Value ([string]$Config.incidentCapture.artifactsRoot)
}
return $Config
}
$existing = Get-ExistingAWatchConfig
$backupRoot = Join-Path $NewStateRoot ('migration-backups\' + (Get-Date -Format 'yyyyMMdd-HHmmss'))
$newConfigPath = Join-Path $NewStateRoot 'deployment-config.json'
$newLogsRoot = Join-Path $NewStateRoot 'logs'
$summary = [ordered]@{
sourceConfig = $existing.Path
oldInstallRoot = $OldInstallRoot
oldStateRoot = $OldStateRoot
newInstallRoot = $NewInstallRoot
newStateRoot = $NewStateRoot
backupRoot = $backupRoot
actions = @(
'stop ActivityWatch scheduled tasks',
'backup old/new install and state directories',
'copy old install/state contents to AWatch-rus paths',
'rewrite deployment-config.json paths',
'regenerate launcher/recovery scripts',
're-register scheduled tasks',
'run validate-deployment.ps1'
)
}
if ($WhatIfPreference) {
return [pscustomobject]$summary
}
if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Миграция ActivityWatch Windows/RDP путей в AWatch-rus')) {
New-ActivityWatchDirectory -Path $NewStateRoot
New-ActivityWatchDirectory -Path $backupRoot
Stop-AWatchTaskSet
foreach ($item in @(
@{ Source = $OldInstallRoot; Name = 'old-install' },
@{ Source = $OldStateRoot; Name = 'old-state' },
@{ Source = $NewInstallRoot; Name = 'new-install' },
@{ Source = $NewStateRoot; Name = 'new-state' }
)) {
if (Test-Path -LiteralPath $item.Source) {
Copy-Item -LiteralPath $item.Source -Destination (Join-Path $backupRoot $item.Name) -Recurse -Force
}
}
Copy-DirectoryContents -Source $OldInstallRoot -Destination $NewInstallRoot
Copy-DirectoryContents -Source $OldStateRoot -Destination $NewStateRoot
New-ActivityWatchDirectory -Path $newLogsRoot
foreach ($file in @(
'browser-domains-native-collector.ps1',
'dlp-endpoint-signals-collector.ps1',
'worktime-session-collector.ps1',
'web-category-rules.example.json',
'dlp-policy.example.json'
)) {
Copy-IfExists -Source (Join-Path $ToolkitRoot $file) -Destination (Join-Path $NewStateRoot $file)
}
Copy-IfExists -Source (Join-Path $OldStateRoot 'web-category-rules.json') -Destination (Join-Path $NewStateRoot 'web-category-rules.json')
Copy-IfExists -Source (Join-Path $OldStateRoot 'dlp-policy.json') -Destination (Join-Path $NewStateRoot 'dlp-policy.json')
if (-not (Test-Path -LiteralPath (Join-Path $NewStateRoot 'web-category-rules.json'))) {
Copy-IfExists -Source (Join-Path $NewStateRoot 'web-category-rules.example.json') -Destination (Join-Path $NewStateRoot 'web-category-rules.json')
}
if (-not (Test-Path -LiteralPath (Join-Path $NewStateRoot 'dlp-policy.json'))) {
Copy-IfExists -Source (Join-Path $NewStateRoot 'dlp-policy.example.json') -Destination (Join-Path $NewStateRoot 'dlp-policy.json')
}
$config = Update-AWatchConfigPaths -Config $existing.Config
Write-ActivityWatchDeploymentConfig -Config $config -Path $newConfigPath
Write-ActivityWatchLaunchScript -Path $config.paths.launchScript -ConfigPath $newConfigPath
Write-ActivityWatchRecoveryScript -Path $config.paths.recoveryScript -ConfigPath $newConfigPath
$taskDefinitions = @($config.userTasks)
Set-ActivityWatchAcl -InstallRoot $NewInstallRoot -StateRoot $NewStateRoot -LogsRoot $newLogsRoot
Register-ActivityWatchUserTasks -TaskDefinitions $taskDefinitions -LaunchScriptPath $config.paths.launchScript -ConfigPath $newConfigPath
Register-ActivityWatchRecoveryTask -TaskName $config.recovery.taskName -RecoveryScriptPath $config.paths.recoveryScript -ConfigPath $newConfigPath
Start-ActivityWatchTasks -TaskDefinitions $taskDefinitions -RecoveryTaskName $config.recovery.taskName
Start-Sleep -Seconds 5
if (-not $SkipValidation) {
$validateScript = Join-Path $ToolkitRoot 'validate-deployment.ps1'
if (-not (Test-Path -LiteralPath $validateScript)) {
$validateScript = Join-Path $PSScriptRoot 'validate-deployment.ps1'
}
$report = & $validateScript -ConfigPath $newConfigPath
if (-not [bool]$report.overallOk) {
throw "Миграция выполнена, но validation завершился ошибкой. Backup: $backupRoot"
}
}
[pscustomobject]@{
migrated = $true
backupRoot = $backupRoot
configPath = $newConfigPath
installRoot = $NewInstallRoot
stateRoot = $NewStateRoot
}
}
@@ -1,6 +1,6 @@
[CmdletBinding()]
param(
[string]$ConfigPath = 'C:\ProgramData\ActivityWatch\deployment-config.json'
[string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json'
)
Set-StrictMode -Version Latest
@@ -14,29 +14,48 @@ $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' }
$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' }
$launchScript = [string]$config.paths.launchScript
$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 }
$requiredFiles = @(
(Join-Path $installRoot 'aw-watcher-afk\aw-watcher-afk.exe'),
(Join-Path $installRoot 'aw-watcher-window\aw-watcher-window.exe'),
$collectorScript,
$endpointCollectorScript,
$sessionCollectorScript,
$rulesPath,
$policyPath,
$launchScript,
$recoveryScript,
$ConfigPath
)
if ($afkExpected) {
$requiredFiles += (Join-Path $installRoot 'aw-watcher-afk\aw-watcher-afk.exe')
}
if ($windowExpected) {
$requiredFiles += (Join-Path $installRoot 'aw-watcher-window\aw-watcher-window.exe')
}
$missingFiles = @(
$requiredFiles | Where-Object { -not (Test-Path -LiteralPath $_) }
)
$processNames = @('aw-watcher-afk', 'aw-watcher-window')
$runningProcesses = Get-Process -Name $processNames -ErrorAction SilentlyContinue | Select-Object Name, Id, SessionId
$processNames = @()
if ($afkExpected) { $processNames += 'aw-watcher-afk' }
if ($windowExpected) { $processNames += 'aw-watcher-window' }
$runningProcesses = @()
if ($processNames.Count -gt 0) {
$runningProcesses = Get-Process -Name $processNames -ErrorAction SilentlyContinue | Select-Object Name, Id, SessionId
}
$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) {
@@ -57,7 +76,7 @@ $tasks = foreach ($taskName in $taskNames) {
else {
[pscustomobject]@{
taskName = $taskName
state = 'Missing'
state = 'Отсутствует'
present = $false
}
}
@@ -80,8 +99,16 @@ $result = [ordered]@{
ok = [bool]($tasks.Count -gt 0 -and -not ($tasks | Where-Object { -not $_.present }))
}
processes = [ordered]@{
expected = $processNames
list = @($runningProcesses)
ok = [bool](($runningProcesses | Select-Object -ExpandProperty Name -Unique).Count -ge 2)
sessionCollectors = @($sessionCollectorProcesses)
ok = [bool](
(
($processNames.Count -eq 0) -or
(($runningProcesses | Select-Object -ExpandProperty Name -Unique).Count -ge $processNames.Count)
) -and
($sessionCollectorProcesses.Count -ge 1)
)
}
}
@@ -0,0 +1,154 @@
param(
[string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json',
[string]$Hostname,
[int]$PollSeconds = 30
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
function Get-Config {
param([string]$Path)
if (-not (Test-Path -LiteralPath $Path)) {
throw "Конфигурация не найдена: $Path"
}
Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json
}
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(
[Parameter(Mandatory = $true)][string]$ApiBase,
[Parameter(Mandatory = $true)][string]$BucketId,
[Parameter(Mandatory = $true)][string]$HostnameValue
)
try {
Invoke-RestMethod -Method Get -Uri "$ApiBase/buckets/$BucketId" | Out-Null
return
}
catch {
}
$body = @{
client = 'aw-worktime-session-collector'
type = 'aw.worktime.session'
hostname = $HostnameValue
} | ConvertTo-Json -Compress
Invoke-AwJsonPost -Uri "$ApiBase/buckets/$BucketId" -Json $body
}
function Get-SessionRecords {
$records = @()
try {
$lines = quser 2>$null
if (-not $lines) {
return @()
}
foreach ($line in ($lines | Select-Object -Skip 1)) {
$clean = ($line -replace '^\s*>?', '').Trim()
if (-not $clean) {
continue
}
$parts = $clean -split '\s+'
if ($parts.Count -lt 4) {
continue
}
$sessionName = ''
$sessionIdIndex = 2
if ($parts[1] -match '^\d+$') {
$sessionIdIndex = 1
}
else {
$sessionName = $parts[1]
}
$sessionId = 0
if ($parts[$sessionIdIndex] -match '^\d+$') {
$sessionId = [int]$parts[$sessionIdIndex]
}
$records += [pscustomobject]@{
username = $parts[0]
sessionName = $sessionName
sessionId = $sessionId
state = $parts[$sessionIdIndex + 1]
}
}
}
catch {
}
return $records
}
$cfg = Get-Config -Path $ConfigPath
$hostValue = if ($Hostname) { $Hostname } else { [string]$env:COMPUTERNAME }
$apiBase = '{0}://{1}:{2}/api/0' -f [string]$cfg.server.scheme, [string]$cfg.server.host, [string]$cfg.server.port
$bucketId = 'aw-worktime-sessions_' + $hostValue
$pulse = 120
$sleepSec = if ($PollSeconds -gt 0) {
$PollSeconds
}
elseif ($cfg.collector -and $cfg.collector.pollSeconds) {
[int]$cfg.collector.pollSeconds
}
else {
30
}
Ensure-Bucket -ApiBase $apiBase -BucketId $bucketId -HostnameValue $hostValue
while ($true) {
$now = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
$records = Get-SessionRecords
if (-not $records -or $records.Count -eq 0) {
$records = @([pscustomobject]@{
username = $env:USERNAME
sessionName = ''
sessionId = (Get-Process -Id $PID).SessionId
state = 'Unknown'
})
}
foreach ($rec in $records) {
$payload = @{
timestamp = $now
duration = 0
data = @{
username = [string]$rec.username
userId = "$($env:USERDOMAIN)\$($rec.username)"
sessionId = [int]$rec.sessionId
sessionName = [string]$rec.sessionName
state = [string]$rec.state
active = ($rec.state -match 'Active')
hostname = $hostValue
source = 'worktime-session-collector'
}
} | ConvertTo-Json -Depth 6 -Compress
try {
Invoke-AwJsonPost -Uri "$apiBase/buckets/$bucketId/heartbeat?pulsetime=$pulse" -Json $payload
}
catch {
}
}
Start-Sleep -Seconds $sleepSec
}
+157
View File
@@ -0,0 +1,157 @@
#!/usr/bin/env bash
set -euo pipefail
mkdir -p windows/installkit/innosetup
mkdir -p docs/windows
cat > windows/installkit/innosetup/innosetup-rdp-package-filelist.md <<'EOF'
# Inno Setup: файл-лист для Windows RDP deployment
Дата актуализации: 2026-05-02 (UTC).
## Что это за документ
Этот файл — **чеклист упаковки** для Inno Setup.
- Он описывает, **что класть** в инсталлятор.
- Он описывает, **что не класть** (генерируется уже на целевом хосте).
- Он **не меняет** текущие deploy-скрипты и логику проекта.
## Важное уточнение по единой Windows-директории
Чтобы исключить путаницу:
1. InnoSetup и Ansible используют один набор путей.
2. Toolkit лежит в `{app}\windows` = `C:\Program Files\AWatch-rus\windows`.
3. Бинарники ActivityWatch лежат в `C:\Program Files\AWatch-rus\bin`.
4. Runtime-конфиг, collectors, логи и отчёты лежат в `C:\ProgramData\AWatch-rus`.
## 1) Обязательные файлы для Inno Setup пакета
### 1.1 PowerShell-модуль
- `windows/ActivityWatch.Windows.Common.psd1`
- `windows/ActivityWatch.Windows.Common.psm1`
### 1.2 Скрипты деплоя и сопровождения
- `windows/deploy-single-user.ps1`
- `windows/deploy-domain-users.ps1`
- `windows/deploy-ensemble.ps1`
- `windows/hardening-recovery.ps1`
- `windows/validate-deployment.ps1`
- `windows/migrate-awatch-rus-paths.ps1`
### 1.3 Коллекторы
- `windows/worktime-session-collector.ps1` (RDP/session presence)
- `windows/browser-domains-native-collector.ps1`
- `windows/dlp-endpoint-signals-collector.ps1`
### 1.4 Шаблоны конфигурации
- `windows/web-category-rules.example.json`
- `windows/dlp-policy.example.json`
## 2) Бинарный payload ActivityWatch
Нужен один из двух режимов:
- **Online**: скрипты скачивают `activitywatch-<version>-windows-x86_64.zip` из GitHub Releases.
- **Offline**: ZIP добавляется в пакет (например `payload\activitywatch-v0.13.2-windows-x86_64.zip`) и передаётся через `-PackageZipPath`.
## 3) Что НЕ включать в installer как статические файлы
Эти файлы/папки появляются на целевом Windows-хосте во время/после деплоя:
- `C:\ProgramData\AWatch-rus\deployment-config.json`
- `C:\ProgramData\AWatch-rus\web-category-rules.json`
- `C:\ProgramData\AWatch-rus\dlp-policy.json`
- `C:\ProgramData\AWatch-rus\logs\*`
- `%LOCALAPPDATA%\AWatch-rus\incident-artifacts\*`
## 4) Опционально приложить в операторский install-kit
- `docs/windows/deployment.md`
- `docs/windows/validation.md`
- `docs/windows/troubleshooting.md`
- `docs/windows/ensemble.md`
## 5) Рекомендуемая структура внутри пакета
- `windows\ActivityWatch.Windows.Common.psd1`
- `windows\ActivityWatch.Windows.Common.psm1`
- `windows\deploy-single-user.ps1`
- `windows\deploy-domain-users.ps1`
- `windows\deploy-ensemble.ps1`
- `windows\hardening-recovery.ps1`
- `windows\validate-deployment.ps1`
- `windows\migrate-awatch-rus-paths.ps1`
- `windows\worktime-session-collector.ps1`
- `windows\browser-domains-native-collector.ps1`
- `windows\dlp-endpoint-signals-collector.ps1`
- `windows\web-category-rules.example.json`
- `windows\dlp-policy.example.json`
- `payload\activitywatch-v0.13.2-windows-x86_64.zip` (только для offline-режима)
## 6) Контроль перед сборкой .iss
1. Все файлы из раздела 1 присутствуют.
2. Выбран режим payload: online или offline.
3. Для offline-режима ZIP действительно лежит в `payload\`.
4. В .iss есть запуск нужного deploy-сценария (`deploy-ensemble.ps1` или `deploy-domain-users.ps1`).
5. После установки запускается `validate-deployment.ps1` с сохранением JSON-отчёта.
EOF
cat > docs/windows/innosetup-rdp-package-filelist.md <<'EOF'
windows/installkit/innosetup/innosetup-rdp-package-filelist.md
EOF
cat > windows/installkit/innosetup/AWatch-rus-InnoSetup.iss <<'EOF'
#define MyAppName "AWatch-rus InstallKit"
#define MyAppVersion "1.0.0"
#define MyAppPublisher "AWatch-rus"
#define MyAppExeName "powershell.exe"
[Setup]
AppId={{6D6A1F74-0F4F-4A57-B5E3-1C2C2F56C0E9}
AppName={#MyAppName}
AppVersion={#MyAppVersion}
AppPublisher={#MyAppPublisher}
DefaultDirName={autopf}\AWatch-rus
DefaultGroupName=AWatch-rus
OutputDir=.
OutputBaseFilename=AWatch-rus-InstallKit
Compression=lzma
SolidCompression=yes
ArchitecturesInstallIn64BitMode=x64
PrivilegesRequired=admin
[Languages]
Name: "russian"; MessagesFile: "compiler:Languages\Russian.isl"
[Files]
Source: "..\..\ActivityWatch.Windows.Common.psd1"; DestDir: "{app}\windows"; Flags: ignoreversion
Source: "..\..\ActivityWatch.Windows.Common.psm1"; DestDir: "{app}\windows"; Flags: ignoreversion
Source: "..\..\deploy-single-user.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion
Source: "..\..\deploy-domain-users.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion
Source: "..\..\deploy-ensemble.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion
Source: "..\..\hardening-recovery.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion
Source: "..\..\validate-deployment.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion
Source: "..\..\worktime-session-collector.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion
Source: "..\..\browser-domains-native-collector.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion
Source: "..\..\dlp-endpoint-signals-collector.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion
Source: "..\..\web-category-rules.example.json"; DestDir: "{app}\windows"; Flags: ignoreversion
Source: "..\..\dlp-policy.example.json"; DestDir: "{app}\windows"; Flags: ignoreversion
Source: "payload\activitywatch-v0.13.2-windows-x86_64.zip"; DestDir: "{app}\payload"; Flags: ignoreversion skipifsourcedoesntexist
Source: "innosetup-rdp-package-filelist.md"; DestDir: "{app}\windows\installkit\innosetup"; Flags: ignoreversion
[Run]
Filename: "powershell.exe"; Parameters: "-NoProfile -ExecutionPolicy Bypass -File ""{app}\windows\deploy-ensemble.ps1"""; Flags: runhidden
Filename: "powershell.exe"; Parameters: "-NoProfile -ExecutionPolicy Bypass -File ""{app}\windows\validate-deployment.ps1"""; Flags: runhidden
EOF
mkdir -p windows/installkit/innosetup/payload
touch windows/installkit/innosetup/payload/.gitkeep
echo "patched"
echo "windows/installkit/innosetup/innosetup-rdp-package-filelist.md"
echo "docs/windows/innosetup-rdp-package-filelist.md"
echo "windows/installkit/innosetup/AWatch-rus-InnoSetup.iss"
echo "windows/installkit/innosetup/payload/.gitkeep"
+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)
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env sh
set -eu
SERVER_HOST="10.10.10.13"
SERVER_PORT="5600"
POLL_INTERVAL="5"
AW_VERSION="0.13.2"
usage() {
cat <<'EOF'
Usage: install_aw_linux_remote_worker.sh [options]
Options:
--server-host HOST Remote AW server host (default: 10.10.10.13)
--server-port PORT Remote AW server port (default: 5600)
--poll-interval SEC Poll interval for Linux loggers (default: 5)
--version VERSION ActivityWatch version for GUI watcher bundle (default: 0.13.2)
-h, --help Show this help
EOF
}
while [ "$#" -gt 0 ]; do
case "$1" in
--server-host)
SERVER_HOST="$2"
shift 2
;;
--server-port)
SERVER_PORT="$2"
shift 2
;;
--poll-interval)
POLL_INTERVAL="$2"
shift 2
;;
--version)
AW_VERSION="$2"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown option: $1" >&2
usage >&2
exit 1
;;
esac
done
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
sh "${SCRIPT_DIR}/install_aw_linux_client.sh" \
--server-host "${SERVER_HOST}" \
--server-port "${SERVER_PORT}" \
--version "${AW_VERSION}"
sh "${SCRIPT_DIR}/install_aw_console_ssh_logger.sh" \
--server-host "${SERVER_HOST}" \
--server-port "${SERVER_PORT}" \
--poll-interval "${POLL_INTERVAL}"
sh "${SCRIPT_DIR}/install_aw_linux_web_category_logger.sh" \
--server-host "${SERVER_HOST}" \
--server-port "${SERVER_PORT}" \
--poll-interval "${POLL_INTERVAL}"
echo "Linux remote worker full-stack install completed."
echo "Expected buckets on AW server:"
echo " - aw-watcher-window_$(hostname -s)"
echo " - aw-watcher-afk_$(hostname -s)"
echo " - aw-console-commands_$(hostname -s)"
echo " - aw-ssh-sessions_$(hostname -s)"
echo " - aw-linux-web-context_$(hostname -s)"
echo " - aw-detmir-web-category_$(hostname -s)"
@@ -0,0 +1,367 @@
#!/usr/bin/env sh
set -eu
SERVER_HOST="10.10.10.13"
SERVER_PORT="5600"
POLL_INTERVAL="5"
INSTALL_ROOT="${HOME}/.local/opt/aw-linux-web-category"
BIN_DIR="${HOME}/.local/bin"
STATE_DIR="${HOME}/.local/state/aw-linux-web-category"
LOG_DIR="${HOME}/.local/state/aw-linux-web-category/logs"
CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/aw-linux-web-category"
SYSTEMD_USER_DIR="${HOME}/.config/systemd/user"
usage() {
cat <<'EOF'
Usage: install_aw_linux_web_category_logger.sh [options]
Options:
--server-host HOST AW server host (default: 10.10.10.13)
--server-port PORT AW server port (default: 5600)
--poll-interval SEC Poll interval in seconds (default: 5)
-h, --help Show this help
EOF
}
while [ "$#" -gt 0 ]; do
case "$1" in
--server-host)
SERVER_HOST="$2"
shift 2
;;
--server-port)
SERVER_PORT="$2"
shift 2
;;
--poll-interval)
POLL_INTERVAL="$2"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown option: $1" >&2
usage >&2
exit 1
;;
esac
done
mkdir -p "${INSTALL_ROOT}" "${BIN_DIR}" "${STATE_DIR}" "${LOG_DIR}" "${CONFIG_DIR}" "${SYSTEMD_USER_DIR}"
write_file() {
target="$1"
mkdir -p "$(dirname "${target}")"
cat > "${target}"
}
write_file "${CONFIG_DIR}/rules.json" <<'EOF'
{
"rules": [
{
"id": "proxmox-webui",
"categoryGroup": "work",
"category": "Администрирование",
"service": "proxmox",
"interface": "https",
"port": 8006,
"rootDomain": "proxmox-webui",
"windowClassRegex": "(?i)(firefox|chromium|chrome|brave|vivaldi|opera)",
"titleRegex": "(?i)proxmox\\s+virtual\\s+environment|\\bproxmox\\b|\\bnode\\b.*\\bsummary\\b|\\bvirtual machine\\b"
},
{
"id": "pfsense-webui",
"categoryGroup": "work",
"category": "Администрирование",
"service": "pfsense",
"interface": "https",
"port": 443,
"rootDomain": "pfsense-webui",
"windowClassRegex": "(?i)(firefox|chromium|chrome|brave|vivaldi|opera)",
"titleRegex": "(?i)\\bpfsense\\b|\\bfirewall\\b"
},
{
"id": "grafana-webui",
"categoryGroup": "work",
"category": "Администрирование",
"service": "grafana",
"interface": "https",
"port": 3000,
"rootDomain": "grafana-webui",
"windowClassRegex": "(?i)(firefox|chromium|chrome|brave|vivaldi|opera)",
"titleRegex": "(?i)\\bgrafana\\b|dashboard"
}
]
}
EOF
write_file "${INSTALL_ROOT}/config.json" <<EOF
{
"server_host": "${SERVER_HOST}",
"server_port": ${SERVER_PORT},
"poll_interval_seconds": ${POLL_INTERVAL},
"hostname": "$(hostname -s)",
"username": "$(id -un)",
"state_dir": "${STATE_DIR}",
"rules_path": "${CONFIG_DIR}/rules.json",
"raw_bucket": "aw-linux-web-context_$(hostname -s)",
"category_bucket": "aw-detmir-web-category_$(hostname -s)"
}
EOF
write_file "${INSTALL_ROOT}/collector.py" <<'EOF'
#!/usr/bin/env python3
import datetime as dt
import json
import os
import pathlib
import re
import socket
import subprocess
import time
import urllib.error
import urllib.request
def iso_now():
return dt.datetime.now(tz=dt.timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
class Collector:
def __init__(self, cfg):
self.cfg = cfg
self.server = f"http://{cfg['server_host']}:{cfg['server_port']}/api/0"
self.host = cfg.get("hostname") or socket.gethostname().split(".")[0]
self.user = cfg.get("username") or os.environ.get("USER", "unknown")
self.poll_interval = max(1, int(cfg.get("poll_interval_seconds", 5)))
self.state_dir = pathlib.Path(cfg["state_dir"]).expanduser()
self.state_dir.mkdir(parents=True, exist_ok=True)
self.raw_bucket = cfg.get("raw_bucket", f"aw-linux-web-context_{self.host}")
self.category_bucket = cfg.get("category_bucket", f"aw-detmir-web-category_{self.host}")
self.rules = self._load_rules(pathlib.Path(cfg["rules_path"]).expanduser())
self.ensured = set()
self.last_raw_key = None
self.last_category_key = None
def _load_rules(self, path):
if not path.exists():
return []
payload = json.loads(path.read_text(encoding="utf-8"))
rules = []
for item in payload.get("rules", []):
rules.append({
"id": item.get("id", "rule"),
"categoryGroup": item.get("categoryGroup", "work"),
"category": item.get("category", "Работа"),
"service": item.get("service", ""),
"interface": item.get("interface", "https"),
"port": item.get("port"),
"rootDomain": item.get("rootDomain", item.get("service", "web-ui")),
"windowClassRegex": re.compile(item.get("windowClassRegex", ".*")),
"titleRegex": re.compile(item.get("titleRegex", ".*"))
})
return rules
def _run(self, *cmd):
try:
return subprocess.check_output(cmd, text=True, stderr=subprocess.DEVNULL).strip()
except Exception:
return ""
def get_active_window(self):
win_id_line = self._run("xprop", "-root", "_NET_ACTIVE_WINDOW")
match = re.search(r"0x[0-9a-fA-F]+", win_id_line)
if not match:
return None
win_id = match.group(0)
title = self._run("xprop", "-id", win_id, "_NET_WM_NAME")
if not title:
title = self._run("xprop", "-id", win_id, "WM_NAME")
klass = self._run("xprop", "-id", win_id, "WM_CLASS")
title_match = re.search(r'=\s*"(?P<value>.*)"\s*$', title)
if title_match:
title = title_match.group("value")
else:
title = title.split("=", 1)[-1].strip().strip('"')
class_values = re.findall(r'"([^"]+)"', klass)
window_class = " ".join(class_values) if class_values else klass.split("=", 1)[-1].strip()
if not title and not window_class:
return None
return {
"windowId": win_id,
"title": title,
"windowClass": window_class
}
def ensure_bucket(self, bucket_id, bucket_type):
if bucket_id in self.ensured:
return True
payload = {"client": "aw-linux-web-category", "type": bucket_type, "hostname": self.host}
req = urllib.request.Request(
f"{self.server}/buckets/{bucket_id}",
data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=10):
self.ensured.add(bucket_id)
return True
except urllib.error.HTTPError as err:
if err.code in (304, 409):
self.ensured.add(bucket_id)
return True
return False
except urllib.error.URLError:
return False
def heartbeat(self, bucket_id, payload, bucket_type, pulse=60):
if not self.ensure_bucket(bucket_id, bucket_type):
return False
req = urllib.request.Request(
f"{self.server}/buckets/{bucket_id}/heartbeat?pulsetime={pulse}",
data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=10):
return True
except urllib.error.URLError:
return False
def match_rule(self, window):
title = window.get("title", "")
window_class = window.get("windowClass", "")
for rule in self.rules:
if rule["windowClassRegex"].search(window_class) and rule["titleRegex"].search(title):
return rule
return None
def run(self):
while True:
window = self.get_active_window()
if window:
raw_key = f"{window.get('windowClass','')}|{window.get('title','')}"
if raw_key != self.last_raw_key:
raw_event = {
"timestamp": iso_now(),
"duration": 0,
"data": {
"source": "x11_active_window",
"username": self.user,
"host": self.host,
"windowId": window.get("windowId", ""),
"windowClass": window.get("windowClass", ""),
"title": window.get("title", "")
}
}
self.heartbeat(self.raw_bucket, raw_event, "aw.linux.web.context", pulse=20)
self.last_raw_key = raw_key
rule = self.match_rule(window)
if rule:
category_key = rule["id"] + "|" + window.get("title", "")
if category_key != self.last_category_key:
event = {
"timestamp": iso_now(),
"duration": 0,
"data": {
"source": "linux_window_title_rule",
"username": self.user,
"host": self.host,
"windowClass": window.get("windowClass", ""),
"title": window.get("title", ""),
"categoryGroup": rule["categoryGroup"],
"category": rule["category"],
"categoryRule": rule["id"],
"service": rule["service"],
"interface": rule["interface"],
"port": rule["port"],
"rootDomain": rule["rootDomain"]
}
}
self.heartbeat(self.category_bucket, event, "aw.web.category", pulse=30)
self.last_category_key = category_key
time.sleep(self.poll_interval)
def main():
config_path = pathlib.Path(os.environ.get("AW_LINUX_WEB_CATEGORY_CONFIG", "~/.local/opt/aw-linux-web-category/config.json")).expanduser()
cfg = json.loads(config_path.read_text(encoding="utf-8"))
Collector(cfg).run()
if __name__ == "__main__":
main()
EOF
chmod 0755 "${INSTALL_ROOT}/collector.py"
write_file "${BIN_DIR}/aw-linux-web-category-start" <<EOF
#!/usr/bin/env sh
set -eu
export AW_LINUX_WEB_CATEGORY_CONFIG="${INSTALL_ROOT}/config.json"
mkdir -p "${LOG_DIR}"
if pgrep -u "$(id -u)" -f "aw-linux-web-category/collector.py" >/dev/null 2>&1; then
exit 0
fi
nohup "${INSTALL_ROOT}/collector.py" >> "${LOG_DIR}/collector.log" 2>&1 &
EOF
chmod 0755 "${BIN_DIR}/aw-linux-web-category-start"
write_file "${BIN_DIR}/aw-linux-web-category-stop" <<'EOF'
#!/usr/bin/env sh
set -eu
pkill -u "$(id -u)" -f "aw-linux-web-category/collector.py" || true
EOF
chmod 0755 "${BIN_DIR}/aw-linux-web-category-stop"
write_file "${BIN_DIR}/aw-linux-web-category-status" <<'EOF'
#!/usr/bin/env sh
set -eu
pgrep -a -u "$(id -u)" -f "aw-linux-web-category/collector.py" || true
EOF
chmod 0755 "${BIN_DIR}/aw-linux-web-category-status"
write_file "${SYSTEMD_USER_DIR}/aw-linux-web-category.service" <<EOF
[Unit]
Description=AW Linux web-category logger (user space)
After=default.target
[Service]
Type=simple
Environment=AW_LINUX_WEB_CATEGORY_CONFIG=${INSTALL_ROOT}/config.json
ExecStart=${INSTALL_ROOT}/collector.py
Restart=always
RestartSec=3
[Install]
WantedBy=default.target
EOF
started_with_systemd="0"
if command -v systemctl >/dev/null 2>&1; then
systemctl --user daemon-reload >/dev/null 2>&1 || true
systemctl --user enable --now aw-linux-web-category.service >/dev/null 2>&1 || true
if systemctl --user is-active --quiet aw-linux-web-category.service >/dev/null 2>&1; then
started_with_systemd="1"
fi
fi
if [ "${started_with_systemd}" != "1" ]; then
"${BIN_DIR}/aw-linux-web-category-start"
fi
echo "Installed AW Linux web-category logger"
echo "Server target: ${SERVER_HOST}:${SERVER_PORT}"
echo "Raw bucket: aw-linux-web-context_$(hostname -s)"
echo "Category bucket: aw-detmir-web-category_$(hostname -s)"
echo "Rules: ${CONFIG_DIR}/rules.json"
echo "Log file: ${LOG_DIR}/collector.log"
+45 -2
View File
@@ -67,7 +67,8 @@ cat > "$CONFIG_PATH" <<EOF
"access_log": "/var/log/pveproxy/access.log",
"tasks_index": "/var/log/pve/tasks/index",
"web_bucket": "aw-pve-webadmin-events_${HOST_SHORT}",
"task_bucket": "aw-pve-task-events_${HOST_SHORT}"
"task_bucket": "aw-pve-task-events_${HOST_SHORT}",
"category_bucket": "aw-detmir-web-category_${HOST_SHORT}"
}
EOF
@@ -133,6 +134,7 @@ class Collector:
self.poll = max(1, int(cfg.get("poll_interval_seconds", 5)))
self.web_bucket = cfg["web_bucket"]
self.task_bucket = cfg["task_bucket"]
self.category_bucket = cfg.get("category_bucket", f"aw-detmir-web-category_{self.host}")
self.access_log = pathlib.Path(cfg["access_log"])
self.tasks_index = pathlib.Path(cfg["tasks_index"])
self.state_dir = pathlib.Path(cfg["state_dir"])
@@ -235,6 +237,9 @@ class Collector:
},
}
self.heartbeat(self.web_bucket, event, "app.pve.webadmin.event")
category_event = self.classify_access_event(event)
if category_event:
self.heartbeat(self.category_bucket, category_event, "web.tab.current")
def process_tasks(self):
for line in self.read_new_lines(self.tasks_index, self.tasks_state):
@@ -260,6 +265,44 @@ class Collector:
}
self.heartbeat(self.task_bucket, event, "app.pve.task.event")
def classify_access_event(self, source_event: dict):
data = source_event.get("data", {})
path = str(data.get("path") or "")
user = str(data.get("user") or "")
remote_ip = str(data.get("remote_ip") or "")
method = str(data.get("method") or "")
status = int(data.get("status") or 0)
# Keep only meaningful API/UI actions for worktime.
if not path.startswith("/api2/"):
return None
if method == "GET" and ("/cluster/resources" in path or "/status/current" in path):
return None
category = "Администрирование"
category_group = "work"
if status in (401, 403):
category = "Безопасность"
title = f"Proxmox API {method} {path}"
return {
"timestamp": source_event.get("timestamp") or iso_now(),
"duration": 0,
"data": {
"source": "pve-webadmin-bridge",
"service": "proxmox",
"host": self.host,
"app": "proxmox-webui",
"title": title[:512],
"url": f"https://{self.host}:8006{path}",
"domain": self.host,
"rootDomain": "proxmox-webui",
"categoryGroup": category_group,
"category": category,
"user": user,
"remote_ip": remote_ip,
"method": method,
"status": status,
},
}
def run(self):
while True:
self.process_access()
@@ -303,4 +346,4 @@ systemctl --no-pager --full status aw-pve-webadmin-logger.service || true
echo "Installed aw-pve-webadmin-logger"
echo "Config: ${CONFIG_PATH}"
echo "Buckets: aw-pve-webadmin-events_${HOST_SHORT}, aw-pve-task-events_${HOST_SHORT}"
echo "Buckets: aw-pve-webadmin-events_${HOST_SHORT}, aw-pve-task-events_${HOST_SHORT}, aw-detmir-web-category_${HOST_SHORT}"
+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}"
+1 -1
View File
@@ -9,7 +9,7 @@ find aw-server proxmox -type f -name "*.sh" -print0 | xargs -0 -r -n1 bash -n
echo "[2/3] Shellcheck (if available)"
if command -v shellcheck >/dev/null 2>&1; then
find aw-server proxmox -type f -name "*.sh" -print0 | xargs -0 -r shellcheck
find aw-server proxmox -type f -name "*.sh" -print0 | xargs -0 -r shellcheck -e SC1007,SC1090,SC2016
else
echo "shellcheck not found, skipping."
fi
+80 -16
View File
@@ -5,7 +5,7 @@ function Assert-Administrator {
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
throw 'Run this script from an elevated PowerShell session.'
throw 'Запустите этот скрипт из PowerShell с правами администратора.'
}
}
@@ -49,7 +49,9 @@ function Get-ActivityWatchArchive {
}
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$archivePath = Join-Path $WorkingRoot ("activitywatch-{0}.zip" -f $Version.TrimStart('v'))
$stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
$suffix = ([guid]::NewGuid().Guid.Substring(0, 8))
$archivePath = Join-Path $WorkingRoot ("activitywatch-{0}-{1}-{2}.zip" -f $Version.TrimStart('v'), $stamp, $suffix)
Invoke-WebRequest -Uri $PackageUrl -OutFile $archivePath
return $archivePath
}
@@ -64,7 +66,7 @@ function Get-ActivityWatchPackageRoot {
Select-Object -First 1
if (-not $afkBinary) {
throw "Cannot find aw-watcher-afk.exe under $ExpandedRoot."
throw "Не удалось найти aw-watcher-afk.exe в $ExpandedRoot."
}
return (Split-Path -Path (Split-Path -Path $afkBinary.FullName -Parent) -Parent)
@@ -85,6 +87,16 @@ function Install-ActivityWatchPackage {
New-ActivityWatchDirectory -Path $WorkingRoot
New-ActivityWatchDirectory -Path $BackupRoot
# Ensure nothing is holding locks inside InstallRoot during upgrade.
foreach ($procName in @('aw-watcher-afk', 'aw-watcher-window', 'aw-server', 'aw-qt')) {
try {
Get-Process -Name $procName -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
}
catch {
}
}
Start-Sleep -Seconds 2
$extractRoot = Join-Path $WorkingRoot ('extract-' + [guid]::NewGuid().Guid)
if (Test-Path -LiteralPath $extractRoot) {
Remove-Item -LiteralPath $extractRoot -Recurse -Force
@@ -130,7 +142,7 @@ function Get-ActivityWatchExecutableMap {
foreach ($entry in $map.GetEnumerator()) {
if (-not (Test-Path -LiteralPath $entry.Value)) {
throw "Missing required ActivityWatch binary: $($entry.Value)"
throw "Не найден обязательный исполняемый файл ActivityWatch: $($entry.Value)"
}
}
@@ -194,7 +206,7 @@ function Normalize-ActivityWatchUsers {
Sort-Object -Unique
if (-not $normalized -or $normalized.Count -eq 0) {
throw 'No target users resolved. Provide -Users or -UserListPath.'
throw 'Не удалось определить целевых пользователей. Укажите -Users или -UserListPath.'
}
return @($normalized)
@@ -243,6 +255,8 @@ function Copy-ActivityWatchCollectorAssets {
[Parameter(Mandatory = $true)]
[string]$EndpointCollectorScriptSource,
[Parameter(Mandatory = $true)]
[string]$FileCollectorScriptSource,
[Parameter(Mandatory = $true)]
[string]$SessionCollectorScriptSource,
[Parameter(Mandatory = $true)]
[string]$ExampleRulesSource,
@@ -258,6 +272,7 @@ function Copy-ActivityWatchCollectorAssets {
$collectorTarget = Join-Path $StateRoot 'browser-domains-native-collector.ps1'
$endpointCollectorTarget = Join-Path $StateRoot 'dlp-endpoint-signals-collector.ps1'
$fileCollectorTarget = Join-Path $StateRoot 'file-operations-collector.ps1'
$sessionCollectorTarget = Join-Path $StateRoot 'worktime-session-collector.ps1'
$exampleRulesTarget = Join-Path $StateRoot 'web-category-rules.example.json'
$rulesTarget = Join-Path $StateRoot 'web-category-rules.json'
@@ -266,6 +281,7 @@ function Copy-ActivityWatchCollectorAssets {
Copy-Item -LiteralPath $CollectorScriptSource -Destination $collectorTarget -Force
Copy-Item -LiteralPath $EndpointCollectorScriptSource -Destination $endpointCollectorTarget -Force
Copy-Item -LiteralPath $FileCollectorScriptSource -Destination $fileCollectorTarget -Force
Copy-Item -LiteralPath $SessionCollectorScriptSource -Destination $sessionCollectorTarget -Force
Copy-Item -LiteralPath $ExampleRulesSource -Destination $exampleRulesTarget -Force
Copy-Item -LiteralPath $ExamplePolicySource -Destination $examplePolicyTarget -Force
@@ -286,6 +302,7 @@ function Copy-ActivityWatchCollectorAssets {
return [pscustomobject]@{
CollectorScript = $collectorTarget
EndpointCollectorScript = $endpointCollectorTarget
FileCollectorScript = $fileCollectorTarget
SessionCollectorScript = $sessionCollectorTarget
ExampleRules = $exampleRulesTarget
ActiveRules = $rulesTarget
@@ -313,6 +330,8 @@ function New-ActivityWatchDeploymentConfig {
[Parameter(Mandatory = $true)]
[string]$EndpointCollectorScript,
[Parameter(Mandatory = $true)]
[string]$FileCollectorScript,
[Parameter(Mandatory = $true)]
[string]$SessionCollectorScript,
[Parameter(Mandatory = $true)]
[string]$RulesPath,
@@ -326,6 +345,7 @@ function New-ActivityWatchDeploymentConfig {
[int]$RecoveryIntervalSeconds,
[bool]$AfkEnabled = $true,
[bool]$WindowEnabled = $true,
[bool]$FileOpsEnabled = $true,
[bool]$LocalAgentLogsEnabled = $true,
[bool]$IncidentCaptureEnabled = $true,
[bool]$IncidentScreenshotEnabled = $true,
@@ -356,6 +376,7 @@ function New-ActivityWatchDeploymentConfig {
logsRoot = $LogsRoot
collectorScript = $CollectorScript
endpointCollectorScript = $EndpointCollectorScript
fileCollectorScript = $FileCollectorScript
sessionCollectorScript = $SessionCollectorScript
rulesPath = $RulesPath
policyPath = $PolicyPath
@@ -369,6 +390,7 @@ function New-ActivityWatchDeploymentConfig {
collectors = [pscustomobject]@{
afkEnabled = $AfkEnabled
windowEnabled = $WindowEnabled
fileOpsEnabled = $FileOpsEnabled
}
logging = [pscustomobject]@{
localAgentLogsEnabled = $LocalAgentLogsEnabled
@@ -421,7 +443,7 @@ function Read-ActivityWatchDeploymentConfig {
)
if (-not (Test-Path -LiteralPath $Path)) {
throw "Deployment config not found: $Path"
throw "Конфигурация развёртывания не найдена: $Path"
}
return Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json
@@ -547,7 +569,7 @@ function Send-LogonMarkerIfNeeded {
`$stateRoot = [string]`$Config.paths.stateRoot
`$markerRoots = New-Object System.Collections.Generic.List[string]
if (-not [string]::IsNullOrWhiteSpace(`$env:LOCALAPPDATA)) {
`$markerRoots.Add((Join-Path `$env:LOCALAPPDATA 'ActivityWatch-Phase2\markers'))
`$markerRoots.Add((Join-Path `$env:LOCALAPPDATA 'AWatch-rus\markers'))
}
if (-not [string]::IsNullOrWhiteSpace(`$stateRoot)) {
`$markerRoots.Add((Join-Path `$stateRoot 'markers'))
@@ -593,7 +615,7 @@ function Send-LogonMarkerIfNeeded {
userId = "`$(`$env:USERDOMAIN)\`$(`$env:USERNAME)"
sessionId = `$SessionId
hostname = `$script:Hostname
source = 'launch-watchers-phase2'
source = 'launch-watchers-awatch-rus'
}
} | ConvertTo-Json -Depth 5 -Compress
@@ -614,6 +636,10 @@ function Start-CollectorScriptIfNeeded {
[int]`$SessionId
)
if ([string]::IsNullOrWhiteSpace(`$ScriptPath)) {
return
}
if (-not (Test-Path -LiteralPath `$ScriptPath)) {
return
}
@@ -634,25 +660,28 @@ 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 "Missing aw-watcher-afk.exe: `$afkExe"
throw "Не найден aw-watcher-afk.exe: `$afkExe"
}
if (`$windowEnabled -and -not (Test-Path -LiteralPath `$windowExe)) {
throw "Missing aw-watcher-window.exe: `$windowExe"
throw "Не найден aw-watcher-window.exe: `$windowExe"
}
if (`$afkEnabled -and -not (Test-ProcessInSession -Name 'aw-watcher-afk' -SessionId `$sessionId)) {
@@ -670,6 +699,9 @@ catch {
}
Start-CollectorScriptIfNeeded -ScriptPath `$collectorScript -ConfigPath `$ConfigPath -PowerShellExe `$powershellExe -SessionId `$sessionId
Start-CollectorScriptIfNeeded -ScriptPath `$endpointCollectorScript -ConfigPath `$ConfigPath -PowerShellExe `$powershellExe -SessionId `$sessionId
if (`$fileOpsEnabled) {
Start-CollectorScriptIfNeeded -ScriptPath `$fileCollectorScript -ConfigPath `$ConfigPath -PowerShellExe `$powershellExe -SessionId `$sessionId
}
Start-CollectorScriptIfNeeded -ScriptPath `$sessionCollectorScript -ConfigPath `$ConfigPath -PowerShellExe `$powershellExe -SessionId `$sessionId
"@
@@ -860,7 +892,7 @@ function Set-ActivityWatchScheduledTaskAction {
$taskCommand = ('"{0}" {1}' -f $Execute, $Arguments)
& schtasks.exe /Change /TN $TaskName /TR $taskCommand | Out-Null
if ($LASTEXITCODE -ne 0) {
throw "schtasks.exe /Change failed for $TaskName"
throw "schtasks.exe /Change завершился с ошибкой для $TaskName"
}
}
@@ -891,6 +923,37 @@ function Get-ActivityWatchScheduledTaskByCommand {
return $null
}
function Remove-StaleActivityWatchUserTasks {
param(
[Parameter(Mandatory = $true)]
[pscustomobject[]]$TaskDefinitions,
[Parameter(Mandatory = $true)]
[string]$LaunchScriptPath
)
$launcherPath = Get-ActivityWatchHiddenLauncherPath -ScriptPath $LaunchScriptPath
$desiredTaskNames = @($TaskDefinitions | ForEach-Object { [string]$_.LaunchTaskName })
foreach ($candidate in @(Get-ScheduledTask | Where-Object { $_.TaskName -like 'ActivityWatch Launch*' })) {
$taskName = [string]$candidate.TaskName
if ($desiredTaskNames -contains $taskName) {
continue
}
$usesCurrentLauncher = $false
foreach ($action in @($candidate.Actions)) {
if ([string]$action.Arguments -like "*$launcherPath*") {
$usesCurrentLauncher = $true
break
}
}
if ($usesCurrentLauncher) {
Remove-ActivityWatchScheduledTask -TaskName $taskName
}
}
}
function Register-ActivityWatchUserTasks {
param(
[Parameter(Mandatory = $true)]
@@ -904,6 +967,7 @@ function Register-ActivityWatchUserTasks {
$wscriptExe = Join-Path $env:SystemRoot 'System32\wscript.exe'
$launcherPath = Get-ActivityWatchHiddenLauncherPath -ScriptPath $LaunchScriptPath
Write-ActivityWatchHiddenPowerShellWrapper -Path $launcherPath -ScriptPath $LaunchScriptPath -ConfigPath $ConfigPath
Remove-StaleActivityWatchUserTasks -TaskDefinitions $TaskDefinitions -LaunchScriptPath $LaunchScriptPath
foreach ($definition in $TaskDefinitions) {
$action = New-ScheduledTaskAction -Execute $wscriptExe -Argument "//B //NoLogo `"$launcherPath`""
@@ -961,17 +1025,17 @@ function Set-ActivityWatchAcl {
& icacls $InstallRoot /inheritance:r /grant:r '*S-1-5-18:(OI)(CI)(F)' '*S-1-5-32-544:(OI)(CI)(F)' '*S-1-5-32-545:(OI)(CI)(RX)' | Out-Null
if ($LASTEXITCODE -ne 0) {
throw "icacls failed for $InstallRoot"
throw "icacls завершился с ошибкой для $InstallRoot"
}
& icacls $StateRoot /inheritance:r /grant:r '*S-1-5-18:(OI)(CI)(F)' '*S-1-5-32-544:(OI)(CI)(F)' '*S-1-5-32-545:(OI)(CI)(RX)' | Out-Null
if ($LASTEXITCODE -ne 0) {
throw "icacls failed for $StateRoot"
throw "icacls завершился с ошибкой для $StateRoot"
}
& icacls $LogsRoot /inheritance:r /grant:r '*S-1-5-18:(OI)(CI)(F)' '*S-1-5-32-544:(OI)(CI)(F)' '*S-1-5-32-545:(OI)(CI)(M)' | Out-Null
if ($LASTEXITCODE -ne 0) {
throw "icacls failed for $LogsRoot"
throw "icacls завершился с ошибкой для $LogsRoot"
}
}
+19 -19
View File
@@ -1,6 +1,6 @@
[CmdletBinding()]
param(
[string]$ConfigPath = 'C:\ProgramData\ActivityWatch\deployment-config.json',
[string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json',
[string]$ServerHost,
[int]$ServerPort,
[ValidateSet('http', 'https')]
@@ -49,18 +49,18 @@ function Get-DeploymentConfig {
}
$deploymentConfig = Get-DeploymentConfig -Path $ConfigPath
$resolvedServerHost = if ($ServerHost) { $ServerHost } elseif ($deploymentConfig) { [string]$deploymentConfig.server.host } else { throw 'ServerHost is required.' }
$resolvedServerHost = if ($ServerHost) { $ServerHost } elseif ($deploymentConfig) { [string]$deploymentConfig.server.host } else { throw 'Укажите ServerHost или подготовьте deployment-config.json.' }
$resolvedServerPort = if ($PSBoundParameters.ContainsKey('ServerPort')) { $ServerPort } elseif ($deploymentConfig) { [int]$deploymentConfig.server.port } else { 5600 }
$resolvedServerScheme = if ($ServerScheme) { $ServerScheme } elseif ($deploymentConfig) { [string]$deploymentConfig.server.scheme } else { 'http' }
$resolvedRulesPath = if ($RulesPath) { $RulesPath } elseif ($deploymentConfig) { [string]$deploymentConfig.paths.rulesPath } else { 'C:\ProgramData\ActivityWatch\web-category-rules.json' }
$resolvedPolicyPath = if ($PolicyPath) { $PolicyPath } elseif ($deploymentConfig) { [string]$deploymentConfig.paths.policyPath } else { 'C:\ProgramData\ActivityWatch\dlp-policy.json' }
$resolvedRulesPath = if ($RulesPath) { $RulesPath } elseif ($deploymentConfig) { [string]$deploymentConfig.paths.rulesPath } else { 'C:\ProgramData\AWatch-rus\web-category-rules.json' }
$resolvedPolicyPath = if ($PolicyPath) { $PolicyPath } elseif ($deploymentConfig) { [string]$deploymentConfig.paths.policyPath } else { 'C:\ProgramData\AWatch-rus\dlp-policy.json' }
$resolvedPollSeconds = if ($PSBoundParameters.ContainsKey('PollSeconds')) { $PollSeconds } elseif ($deploymentConfig) { [int]$deploymentConfig.collector.pollSeconds } else { 5 }
$resolvedPulseSeconds = if ($PSBoundParameters.ContainsKey('PulseSeconds')) { $PulseSeconds } elseif ($deploymentConfig) { [int]$deploymentConfig.collector.pulseSeconds } else { 30 }
$resolvedLogsRoot = if ($deploymentConfig) { [string]$deploymentConfig.paths.logsRoot } else { 'C:\ProgramData\ActivityWatch\logs' }
$resolvedLogsRoot = if ($deploymentConfig) { [string]$deploymentConfig.paths.logsRoot } else { 'C:\ProgramData\AWatch-rus\logs' }
$resolvedLogPath = if ($LogPath) { $LogPath } else { Join-Path $resolvedLogsRoot ("browser-domains-{0}.log" -f $env:USERNAME) }
$resolvedIncidentLogPath = if ($IncidentLogPath) { $IncidentLogPath } else { Join-Path $resolvedLogsRoot ("dlp-incidents-{0}.log" -f $env:USERNAME) }
$resolvedLocalAgentLogsEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'logging' -and $deploymentConfig.logging.PSObject.Properties.Name -contains 'localAgentLogsEnabled') { [bool]$deploymentConfig.logging.localAgentLogsEnabled } else { $true }
$resolvedIncidentArtifactsRoot = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'artifactsRoot') { [string]$deploymentConfig.incidentCapture.artifactsRoot } else { Join-Path $env:LOCALAPPDATA 'ActivityWatch-Phase2\\incident-artifacts' }
$resolvedIncidentArtifactsRoot = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'artifactsRoot') { [string]$deploymentConfig.incidentCapture.artifactsRoot } else { Join-Path $env:LOCALAPPDATA 'AWatch-rus\\incident-artifacts' }
$resolvedIncidentScreenshotEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'screenshotEnabled') { [bool]$deploymentConfig.incidentCapture.screenshotEnabled } else { $true }
if ($resolvedLocalAgentLogsEnabled -and -not (Test-Path -LiteralPath $resolvedLogsRoot)) {
@@ -158,12 +158,12 @@ function Get-HostFromUrl {
try {
$uri = [Uri]$Url
$host = $uri.Host.ToLowerInvariant()
if ($host.StartsWith('www.')) {
return $host.Substring(4)
$uriHost = $uri.Host.ToLowerInvariant()
if ($uriHost.StartsWith('www.')) {
return $uriHost.Substring(4)
}
return $host
return $uriHost
}
catch {
return $null
@@ -263,11 +263,11 @@ function Load-CustomCategoryRules {
if ($rules.Count -gt 0) {
$script:CategoryRules = @($rules) + @($script:CategoryRules)
Write-CollectorLog ("custom rules loaded: {0}" -f $rules.Count)
Write-CollectorLog ("пользовательские правила загружены: {0}" -f $rules.Count)
}
}
catch {
Write-CollectorLog ("custom rules load failed: {0}" -f $_.Exception.Message)
Write-CollectorLog ("не удалось загрузить пользовательские правила: {0}" -f $_.Exception.Message)
}
}
@@ -338,7 +338,7 @@ function Load-DlpPolicy {
param([string]$Path)
if (-not $Path -or -not (Test-Path -LiteralPath $Path)) {
Write-CollectorLog ("dlp policy not found, disabled: {0}" -f $Path)
Write-CollectorLog ("DLP-политика не найдена, DLP отключен: {0}" -f $Path)
return
}
@@ -372,7 +372,7 @@ function Load-DlpPolicy {
enabled = if ($rule.PSObject.Properties.Name -contains 'enabled') { [bool]$rule.enabled } else { $true }
action = if ($rule.action) { [string]$rule.action } else { [string]$script:DlpDefaults.action }
severity = if ($rule.severity) { [string]$rule.severity } else { [string]$script:DlpDefaults.severity }
message = if ($rule.message) { [string]$rule.message } else { "DLP rule matched: $($rule.id)" }
message = if ($rule.message) { [string]$rule.message } else { "Сработало DLP-правило: $($rule.id)" }
cooldownSeconds = if ($rule.cooldownSeconds) { [int]$rule.cooldownSeconds } else { [int]$script:DlpDefaults.cooldownSeconds }
when = [pscustomobject]@{
domains = if ($when.PSObject.Properties.Name -contains 'domains') { @($when.domains | ForEach-Object { ([string]$_).Trim().ToLowerInvariant() } | Where-Object { $_ }) } else { @() }
@@ -388,10 +388,10 @@ function Load-DlpPolicy {
}
$script:DlpRules = @($loaded)
Write-CollectorLog ("dlp policy loaded: enabled={0}, rules={1}" -f $script:DlpDefaults.enabled, $script:DlpRules.Count)
Write-CollectorLog ("DLP-политика загружена: включена={0}, правил={1}" -f $script:DlpDefaults.enabled, $script:DlpRules.Count)
}
catch {
Write-CollectorLog ("dlp policy parse failed: {0}" -f $_.Exception.Message)
Write-CollectorLog ("не удалось разобрать DLP-политику: {0}" -f $_.Exception.Message)
}
}
@@ -625,7 +625,7 @@ function Capture-IncidentScreenshot {
}
}
catch {
Write-CollectorLog ("screenshot capture failed: {0}" -f $_.Exception.Message)
Write-CollectorLog ("не удалось сделать снимок инцидента: {0}" -f $_.Exception.Message)
return @{}
}
}
@@ -775,7 +775,7 @@ function Send-CategoryHeartbeat {
Load-CustomCategoryRules -Path $resolvedRulesPath
Load-DlpPolicy -Path $resolvedPolicyPath
Write-CollectorLog ("collector started against {0}" -f $script:ApiBase)
Write-CollectorLog ("коллектор запущен для {0}" -f $script:ApiBase)
while ($true) {
try {
@@ -815,7 +815,7 @@ while ($true) {
}
}
catch {
Write-CollectorLog ("collector error: {0}" -f $_.Exception.Message)
Write-CollectorLog ("ошибка коллектора: {0}" -f $_.Exception.Message)
}
Start-Sleep -Seconds $resolvedPollSeconds
+11 -6
View File
@@ -11,13 +11,14 @@ param(
[string]$Version = 'v0.13.2',
[string]$PackageUrl,
[string]$PackageZipPath,
[string]$InstallRoot = 'C:\Program Files\ActivityWatch',
[string]$StateRoot = 'C:\ProgramData\ActivityWatch',
[string]$InstallRoot = 'C:\Program Files\AWatch-rus\bin',
[string]$StateRoot = 'C:\ProgramData\AWatch-rus',
[int]$PollSeconds = 5,
[int]$PulseSeconds = 30,
[int]$RecoveryIntervalSeconds = 180,
[bool]$AfkEnabled = $true,
[bool]$WindowEnabled = $true,
[bool]$FileOpsEnabled = $true,
[bool]$LocalAgentLogsEnabled = $false,
[bool]$IncidentCaptureEnabled = $true,
[bool]$IncidentScreenshotEnabled = $true,
@@ -44,6 +45,7 @@ $launchScriptPath = Join-Path $StateRoot 'launch-watchers.ps1'
$recoveryScriptPath = Join-Path $StateRoot 'recovery-loop.ps1'
$collectorSource = Join-Path $PSScriptRoot 'browser-domains-native-collector.ps1'
$endpointCollectorSource = Join-Path $PSScriptRoot 'dlp-endpoint-signals-collector.ps1'
$fileCollectorSource = Join-Path $PSScriptRoot 'file-operations-collector.ps1'
$sessionCollectorSource = Join-Path $PSScriptRoot 'worktime-session-collector.ps1'
$exampleRulesSource = Join-Path $PSScriptRoot 'web-category-rules.example.json'
$examplePolicySource = Join-Path $PSScriptRoot 'dlp-policy.example.json'
@@ -58,6 +60,7 @@ Get-ActivityWatchExecutableMap -InstallRoot $InstallRoot | Out-Null
$assetResult = Copy-ActivityWatchCollectorAssets `
-CollectorScriptSource $collectorSource `
-EndpointCollectorScriptSource $endpointCollectorSource `
-FileCollectorScriptSource $fileCollectorSource `
-SessionCollectorScriptSource $sessionCollectorSource `
-ExampleRulesSource $exampleRulesSource `
-ExamplePolicySource $examplePolicySource `
@@ -78,6 +81,7 @@ $config = New-ActivityWatchDeploymentConfig `
-LogsRoot $logsRoot `
-CollectorScript $assetResult.CollectorScript `
-EndpointCollectorScript $assetResult.EndpointCollectorScript `
-FileCollectorScript $assetResult.FileCollectorScript `
-SessionCollectorScript $assetResult.SessionCollectorScript `
-RulesPath $assetResult.ActiveRules `
-PolicyPath $assetResult.ActivePolicy `
@@ -86,6 +90,7 @@ $config = New-ActivityWatchDeploymentConfig `
-RecoveryIntervalSeconds $RecoveryIntervalSeconds `
-AfkEnabled $AfkEnabled `
-WindowEnabled $WindowEnabled `
-FileOpsEnabled $FileOpsEnabled `
-LocalAgentLogsEnabled $LocalAgentLogsEnabled `
-IncidentCaptureEnabled $IncidentCaptureEnabled `
-IncidentScreenshotEnabled $IncidentScreenshotEnabled `
@@ -103,8 +108,8 @@ Register-ActivityWatchUserTasks -TaskDefinitions $taskDefinitions -LaunchScriptP
Register-ActivityWatchRecoveryTask -TaskName $config.recovery.taskName -RecoveryScriptPath $recoveryScriptPath -ConfigPath $configPath
Start-ActivityWatchTasks -TaskDefinitions $taskDefinitions -RecoveryTaskName $config.recovery.taskName
Write-Host 'ActivityWatch deployed for users:'
Write-Host 'ActivityWatch развёрнут для пользователей:'
$targetUsers | ForEach-Object { Write-Host " - $_" }
Write-Host "Server: ${ServerScheme}://$ServerHost`:$ServerPort"
Write-Host "State root: $StateRoot"
Write-Host "Policy file: $($assetResult.ActivePolicy)"
Write-Host "Сервер: ${ServerScheme}://$ServerHost`:$ServerPort"
Write-Host "Каталог данных: $StateRoot"
Write-Host "Файл DLP-политики: $($assetResult.ActivePolicy)"
+11 -7
View File
@@ -11,13 +11,14 @@ param(
[string]$Version = 'v0.13.2',
[string]$PackageUrl,
[string]$PackageZipPath,
[string]$InstallRoot = 'C:\Program Files\ActivityWatch',
[string]$StateRoot = 'C:\ProgramData\ActivityWatch',
[string]$InstallRoot = 'C:\Program Files\AWatch-rus\bin',
[string]$StateRoot = 'C:\ProgramData\AWatch-rus',
[int]$PollSeconds = 5,
[int]$PulseSeconds = 30,
[int]$RecoveryIntervalSeconds = 180,
[bool]$AfkEnabled = $true,
[bool]$WindowEnabled = $true,
[bool]$FileOpsEnabled = $true,
[bool]$LocalAgentLogsEnabled = $false,
[bool]$IncidentCaptureEnabled = $true,
[bool]$IncidentScreenshotEnabled = $true,
@@ -46,7 +47,7 @@ $hardeningScript = Join-Path $PSScriptRoot 'hardening-recovery.ps1'
$validationScript = Join-Path $PSScriptRoot 'validate-deployment.ps1'
if (-not (Test-Path -LiteralPath $deployScript)) {
throw "Missing script: $deployScript"
throw "Не найден скрипт: $deployScript"
}
& $deployScript `
@@ -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,13 +115,14 @@ $report = [ordered]@{
collectors = [ordered]@{
afkEnabled = $AfkEnabled
windowEnabled = $WindowEnabled
fileOpsEnabled = $FileOpsEnabled
}
hardeningApplied = (-not $SkipHardening)
}
if ($ValidateAfterDeploy) {
if (-not (Test-Path -LiteralPath $validationScript)) {
throw "Missing script: $validationScript"
throw "Не найден скрипт: $validationScript"
}
$validation = & $validationScript -ConfigPath (Join-Path $StateRoot 'deployment-config.json')
@@ -132,6 +136,6 @@ if ($reportDirectory) {
$report | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $effectiveReportPath -Encoding UTF8
Write-Host 'ActivityWatch ensemble deploy completed.'
Write-Host "Users: $($resolvedUsers -join ', ')"
Write-Host "Report: $effectiveReportPath"
Write-Host 'Комплексное развёртывание ActivityWatch завершено.'
Write-Host "Пользователи: $($resolvedUsers -join ', ')"
Write-Host "Отчёт: $effectiveReportPath"
+8 -8
View File
@@ -10,8 +10,8 @@ param(
[string]$Version = 'v0.13.2',
[string]$PackageUrl,
[string]$PackageZipPath,
[string]$InstallRoot = 'C:\Program Files\ActivityWatch',
[string]$StateRoot = 'C:\ProgramData\ActivityWatch',
[string]$InstallRoot = 'C:\Program Files\AWatch-rus\bin',
[string]$StateRoot = 'C:\ProgramData\AWatch-rus',
[int]$PollSeconds = 5,
[int]$PulseSeconds = 30,
[int]$RecoveryIntervalSeconds = 180,
@@ -101,9 +101,9 @@ Register-ActivityWatchUserTasks -TaskDefinitions $taskDefinitions -LaunchScriptP
Register-ActivityWatchRecoveryTask -TaskName $config.recovery.taskName -RecoveryScriptPath $recoveryScriptPath -ConfigPath $configPath
Start-ActivityWatchTasks -TaskDefinitions $taskDefinitions -RecoveryTaskName $config.recovery.taskName
Write-Host "ActivityWatch deployed for $TargetUser"
Write-Host "Server: ${ServerScheme}://$ServerHost`:$ServerPort"
Write-Host "Install root: $InstallRoot"
Write-Host "State root: $StateRoot"
Write-Host "Rules file: $($assetResult.ActiveRules)"
Write-Host "Policy file: $($assetResult.ActivePolicy)"
Write-Host "ActivityWatch развёрнут для пользователя: $TargetUser"
Write-Host "Сервер: ${ServerScheme}://$ServerHost`:$ServerPort"
Write-Host "Каталог установки: $InstallRoot"
Write-Host "Каталог данных: $StateRoot"
Write-Host "Файл правил: $($assetResult.ActiveRules)"
Write-Host "Файл DLP-политики: $($assetResult.ActivePolicy)"
+128 -33
View File
@@ -1,6 +1,6 @@
[CmdletBinding()]
param(
[string]$ConfigPath = 'C:\ProgramData\ActivityWatch\deployment-config.json',
[string]$ConfigPath = 'C:\ProgramData\AWatch-rus\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-phase2'
source = 'endpoint-signals-awatch-rus'
} + $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-phase2'
source = 'endpoint-signals-awatch-rus'
} + $Data + $captureData
} | ConvertTo-Json -Depth 7 -Compress
@@ -210,7 +210,7 @@ function Capture-IncidentScreenshot {
}
}
catch {
Write-EndpointLog ("screenshot capture failed: {0}" -f $_.Exception.Message)
Write-EndpointLog ("не удалось сделать снимок инцидента: {0}" -f $_.Exception.Message)
return @{}
}
}
@@ -246,7 +246,7 @@ function Load-DlpPolicy {
}
if (-not $Path -or -not (Test-Path -LiteralPath $Path)) {
Write-EndpointLog ("policy not found, using defaults: {0}" -f $Path)
Write-EndpointLog ("DLP-политика не найдена, используются значения по умолчанию: {0}" -f $Path)
return
}
@@ -266,7 +266,7 @@ function Load-DlpPolicy {
}
}
catch {
Write-EndpointLog ("policy parse failed: {0}" -f $_.Exception.Message)
Write-EndpointLog ("не удалось разобрать DLP-политику: {0}" -f $_.Exception.Message)
}
}
@@ -319,13 +319,13 @@ 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 { "Clipboard rule matched: $ruleId" }
$message = if ($rule.message) { [string]$rule.message } else { "Сработало правило буфера обмена: $ruleId" }
Send-DlpIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -SignalType 'clipboard' -Data @{
clipboardHash = $ClipboardHash
clipboardLength = $ClipboardText.Length
}
Write-EndpointLog ("incident clipboard rule={0} action={1} severity={2}" -f $ruleId, $action, $severity)
Write-EndpointLog ("инцидент буфера обмена правило={0} действие={1} важность={2}" -f $ruleId, $action, $severity)
}
}
@@ -347,13 +347,13 @@ 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 rule matched: $ruleId" }
$message = if ($rule.message) { [string]$rule.message } else { "Сработало правило USB-носителя: $ruleId" }
Send-DlpIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -SignalType 'usb_insert' -Data @{
driveLetter = $DriveLetter
volumeName = $VolumeName
}
Write-EndpointLog ("incident usb rule={0} action={1} severity={2} drive={3}" -f $ruleId, $action, $severity, $DriveLetter)
Write-EndpointLog ("инцидент USB правило={0} действие={1} важность={2} диск={3}" -f $ruleId, $action, $severity, $DriveLetter)
}
}
@@ -385,14 +385,14 @@ 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 { "Print rule matched: $ruleId" }
$message = if ($rule.message) { [string]$rule.message } else { "Сработало правило печати: $ruleId" }
Send-DlpIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -SignalType 'print_job' -Data @{
printerName = $PrinterName
documentName = $DocumentName
owner = $Owner
}
Write-EndpointLog ("incident print rule={0} action={1} severity={2} printer={3}" -f $ruleId, $action, $severity, $PrinterName)
Write-EndpointLog ("инцидент печати правило={0} действие={1} важность={2} принтер={3}" -f $ruleId, $action, $severity, $PrinterName)
}
}
@@ -402,6 +402,52 @@ 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 '' }
@@ -469,13 +515,50 @@ 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
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 { '' }
JobId = $jobId
DocumentName = $documentName
Owner = $owner
PortName = $portName
PrinterName = $printerName
SizeBytes = $sizeBytes
PageCount = $pageCount
PropertyValues = $propertyValues
}
}
@@ -488,7 +571,7 @@ function Get-PrintServiceDocumentFallback {
)
$preferred = [string]$EventSummary.DocumentName
if (-not (Test-LooksLikeMojibakeQuestionMarks -Value $preferred) -and $preferred -notmatch '^[0-9]+$') {
if (-not (Test-DocumentNameNeedsFallback -Value $preferred)) {
return $preferred
}
@@ -499,9 +582,10 @@ 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-LooksLikeMojibakeQuestionMarks -Value $candidate) { continue }
if (Test-DocumentNameNeedsFallback -Value $candidate) { continue }
if ($candidate -match '[\\/:]' -and $candidate -match '\.[A-Za-z0-9]{1,8}$') {
$pathCandidates.Add($candidate)
@@ -546,7 +630,7 @@ function Write-PrintServiceEventTrace {
}
Write-EndpointLog (
'printservice-307 phase={0} recordId={1} time={2} owner={3} printer={4} document={5} resolved={6} properties=[{7}] reason={8}' -f
'printservice-307 этап={0} recordId={1} время={2} владелец={3} принтер={4} документ={5} итоговыйДокумент={6} свойства=[{7}] причина={8}' -f
$Phase,
$EventSummary.RecordId,
$EventSummary.TimeCreated,
@@ -561,6 +645,7 @@ function Write-PrintServiceEventTrace {
function Get-BetterDocumentNameFromPrintServiceEvents {
param(
[string]$JobId,
[string]$Owner,
[string]$PrinterName
)
@@ -578,32 +663,41 @@ 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 'owner-mismatch-strict' -ResolvedDocument $resolvedDocument
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'несовпадение-владельца-strict' -ResolvedDocument $resolvedDocument
continue
}
if ($PrinterName -and -not $printerMatches) {
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'printer-mismatch-strict' -ResolvedDocument $resolvedDocument
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'несовпадение-принтера-strict' -ResolvedDocument $resolvedDocument
continue
}
}
else {
if ($Owner -and $PrinterName -and -not $ownerMatches -and -not $printerMatches) {
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'owner-and-printer-mismatch-relaxed' -ResolvedDocument $resolvedDocument
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
continue
}
}
if ([string]::IsNullOrWhiteSpace($resolvedDocument)) {
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason ('no-document-candidate-' + $pass) -ResolvedDocument ''
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason ('нет-кандидата-документа-' + $pass) -ResolvedDocument ''
continue
}
$matchReasonBase = if (Test-LooksLikeMojibakeQuestionMarks -Value $summary.DocumentName) { 'fallback-used' } else { 'direct' }
$matchReasonBase = if (Test-DocumentNameNeedsFallback -Value $summary.DocumentName) { 'использован-резервный-вариант' } else { 'напрямую' }
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'selected' -MatchReason ($matchReasonBase + '-' + $pass) -ResolvedDocument $resolvedDocument
return $resolvedDocument
}
@@ -616,15 +710,15 @@ function Get-BetterDocumentNameFromPrintServiceEvents {
}
$deploymentConfig = Get-DeploymentConfig -Path $ConfigPath
$resolvedServerHost = if ($ServerHost) { $ServerHost } elseif ($deploymentConfig) { [string]$deploymentConfig.server.host } else { throw 'ServerHost is required.' }
$resolvedServerHost = if ($ServerHost) { $ServerHost } elseif ($deploymentConfig) { [string]$deploymentConfig.server.host } else { throw 'Укажите ServerHost или подготовьте deployment-config.json.' }
$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' }
$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' }
$resolvedPollSeconds = if ($PSBoundParameters.ContainsKey('PollSeconds')) { $PollSeconds } elseif ($deploymentConfig) { [int]$deploymentConfig.collector.pollSeconds } else { 5 }
$resolvedLogsRoot = if ($deploymentConfig) { [string]$deploymentConfig.paths.logsRoot } else { 'C:\ProgramData\ActivityWatch\logs' }
$resolvedLogsRoot = if ($deploymentConfig) { [string]$deploymentConfig.paths.logsRoot } else { 'C:\ProgramData\AWatch-rus\logs' }
$resolvedLogPath = if ($LogPath) { $LogPath } else { Join-Path $resolvedLogsRoot ("endpoint-signals-{0}.log" -f $env:USERNAME) }
$resolvedLocalAgentLogsEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'logging' -and $deploymentConfig.logging.PSObject.Properties.Name -contains 'localAgentLogsEnabled') { [bool]$deploymentConfig.logging.localAgentLogsEnabled } else { $true }
$resolvedIncidentArtifactsRoot = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'artifactsRoot') { [string]$deploymentConfig.incidentCapture.artifactsRoot } else { Join-Path $env:LOCALAPPDATA 'ActivityWatch-Phase2\\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 'AWatch-rus\\incident-artifacts' }
$resolvedIncidentScreenshotEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'screenshotEnabled') { [bool]$deploymentConfig.incidentCapture.screenshotEnabled } else { $true }
if ($resolvedLocalAgentLogsEnabled -and -not (Test-Path -LiteralPath $resolvedLogsRoot)) {
@@ -648,7 +742,7 @@ $script:IncidentScreenshotEnabled = $resolvedIncidentScreenshotEnabled
$script:ScreenshotTypesLoaded = $false
Load-DlpPolicy -Path $resolvedPolicyPath
Write-EndpointLog ("endpoint collector started against {0}" -f $script:ApiBase)
Write-EndpointLog ("endpoint-коллектор запущен для {0}" -f $script:ApiBase)
while ($true) {
try {
@@ -709,13 +803,13 @@ while ($true) {
if ($script:SeenPrintJob.ContainsKey($jobId)) { continue }
$script:SeenPrintJob[$jobId] = (Get-Date).ToUniversalTime()
$printerName = [string]$job.Name
$printerName = Get-PrintJobPrinterName -JobName ([string]$job.Name) -FallbackPrinterName ([string]$job.DriverName)
$documentName = [string]$job.Document
$owner = [string]$job.Owner
$documentNameOriginal = $documentName
if (Test-LooksLikeMojibakeQuestionMarks -Value $documentName) {
$eventDocumentName = Get-BetterDocumentNameFromPrintServiceEvents -Owner $owner -PrinterName $printerName
if (Test-DocumentNameNeedsFallback -Value $documentName) {
$eventDocumentName = Get-BetterDocumentNameFromPrintServiceEvents -JobId $jobId -Owner $owner -PrinterName $printerName
if ($eventDocumentName) {
$documentName = $eventDocumentName
}
@@ -726,6 +820,7 @@ while ($true) {
documentName = $documentName
documentNameOriginal = $documentNameOriginal
owner = $owner
printJobId = $jobId
}
Evaluate-PrintRules -PrinterName $printerName -DocumentName $documentName -Owner $owner
}
@@ -789,7 +884,7 @@ while ($true) {
}
}
catch {
Write-EndpointLog ("collector error: {0}" -f $_.Exception.Message)
Write-EndpointLog ("ошибка коллектора: {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 -8
View File
@@ -1,6 +1,6 @@
[CmdletBinding()]
param(
[string]$ConfigPath = 'C:\ProgramData\ActivityWatch\deployment-config.json',
[string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json',
[string]$ServerHost,
[int]$ServerPort,
[ValidateSet('http', 'https')]
@@ -42,11 +42,11 @@ if (Test-Path -LiteralPath $ConfigPath) {
}
if (-not $existingConfig -and (-not $ServerHost)) {
throw 'deployment-config.json is missing. Provide -ServerHost and user parameters, or run a deploy script first.'
throw 'deployment-config.json отсутствует. Укажите -ServerHost и параметры пользователей либо сначала выполните скрипт развёртывания.'
}
$effectiveStateRoot = if ($StateRoot) { $StateRoot } elseif ($existingConfig) { [string]$existingConfig.paths.stateRoot } else { 'C:\ProgramData\ActivityWatch' }
$effectiveInstallRoot = if ($InstallRoot) { $InstallRoot } elseif ($existingConfig) { [string]$existingConfig.paths.installRoot } else { 'C:\Program Files\ActivityWatch' }
$effectiveStateRoot = if ($StateRoot) { $StateRoot } elseif ($existingConfig) { [string]$existingConfig.paths.stateRoot } else { 'C:\ProgramData\AWatch-rus' }
$effectiveInstallRoot = if ($InstallRoot) { $InstallRoot } elseif ($existingConfig) { [string]$existingConfig.paths.installRoot } else { 'C:\Program Files\AWatch-rus\bin' }
$effectiveLogsRoot = if ($existingConfig) { [string]$existingConfig.paths.logsRoot } else { Join-Path $effectiveStateRoot 'logs' }
$effectiveConfigPath = if ($ConfigPath) { $ConfigPath } else { Join-Path $effectiveStateRoot 'deployment-config.json' }
$effectiveLaunchScript = Join-Path $effectiveStateRoot 'launch-watchers.ps1'
@@ -79,7 +79,7 @@ elseif ($existingConfig) {
@($existingConfig.userTasks | ForEach-Object { [string]$_.userId })
}
else {
throw 'Target users are missing.'
throw 'Не указаны целевые пользователи.'
}
New-ActivityWatchDirectory -Path $effectiveStateRoot
@@ -142,6 +142,6 @@ Register-ActivityWatchUserTasks -TaskDefinitions $taskDefinitions -LaunchScriptP
Register-ActivityWatchRecoveryTask -TaskName $config.recovery.taskName -RecoveryScriptPath $effectiveRecoveryScript -ConfigPath $effectiveConfigPath
Start-ActivityWatchTasks -TaskDefinitions $taskDefinitions -RecoveryTaskName $config.recovery.taskName
Write-Host 'ActivityWatch hardening/recovery completed.'
Write-Host "Config: $effectiveConfigPath"
Write-Host "Users repaired: $($effectiveUsers -join ', ')"
Write-Host 'Укрепление и восстановление ActivityWatch завершены.'
Write-Host "Конфигурация: $effectiveConfigPath"
Write-Host "Пользователи восстановлены: $($effectiveUsers -join ', ')"
-241
View File
@@ -1,241 +0,0 @@
#define MyAppName "AWatch-rus Windows Agent"
#define MyAppVersion "1.0.0"
#define MyAppPublisher "AWatch-rus"
#define MyAppURL "https://example.local/awatch-rus"
#define MyAppExeName "AWatch-rus-Setup.exe"
[Setup]
AppId={{7F2C4B63-2B8A-4F1C-BA12-66A7E2C0A0A1}
AppName={#MyAppName}
AppVersion={#MyAppVersion}
AppPublisher={#MyAppPublisher}
AppPublisherURL={#MyAppURL}
AppSupportURL={#MyAppURL}
AppUpdatesURL={#MyAppURL}
DefaultDirName={autopf}\AWatch-rus
DefaultGroupName=AWatch-rus
DisableProgramGroupPage=yes
PrivilegesRequired=admin
ArchitecturesInstallIn64BitMode=x64compatible
OutputDir=output
OutputBaseFilename={#MyAppExeName}
Compression=lzma2
SolidCompression=yes
WizardStyle=modern
SetupLogging=yes
; Optional production code signing. Configure SIGNTOOL_CMD in CI/local env
SignTool=byparam $q$zSIGNTOOL_CMD $f$q
DisableWelcomePage=no
AllowNoIcons=yes
UninstallDisplayIcon={app}\tools\validate-deployment.ps1
[Languages]
Name: "russian"; MessagesFile: "compiler:Languages\Russian.isl"
Name: "english"; MessagesFile: "compiler:Default.isl"
[Tasks]
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
[Files]
Source: "..\ActivityWatch.Windows.Common.psd1"; DestDir: "{app}\tools"; Flags: ignoreversion
Source: "..\ActivityWatch.Windows.Common.psm1"; DestDir: "{app}\tools"; Flags: ignoreversion
Source: "..\browser-domains-native-collector.ps1"; DestDir: "{app}\tools"; Flags: ignoreversion
Source: "..\dlp-endpoint-signals-collector.ps1"; DestDir: "{app}\tools"; Flags: ignoreversion
Source: "..\deploy-single-user.ps1"; DestDir: "{app}\tools"; Flags: ignoreversion
Source: "..\deploy-domain-users.ps1"; DestDir: "{app}\tools"; Flags: ignoreversion
Source: "..\deploy-ensemble.ps1"; DestDir: "{app}\tools"; Flags: ignoreversion
Source: "..\hardening-recovery.ps1"; DestDir: "{app}\tools"; Flags: ignoreversion
Source: "..\validate-deployment.ps1"; DestDir: "{app}\tools"; Flags: ignoreversion
Source: "..\web-category-rules.example.json"; DestDir: "{app}\config"; Flags: ignoreversion
Source: "..\dlp-policy.example.json"; DestDir: "{app}\config"; Flags: ignoreversion
Source: "README-INSTALLER.md"; DestDir: "{app}"; Flags: ignoreversion
Source: "Invoke-AWatchRusInstall.ps1"; DestDir: "{app}\tools"; Flags: ignoreversion
[Icons]
Name: "{autoprograms}\AWatch-rus\Run validation"; Filename: "powershell.exe"; Parameters: "-NoProfile -ExecutionPolicy Bypass -File ""{app}\tools\validate-deployment.ps1"" -ConfigPath ""C:\ProgramData\ActivityWatch\deployment-config.json"""; WorkingDir: "{app}\tools"
Name: "{autodesktop}\AWatch-rus Validation"; Filename: "powershell.exe"; Parameters: "-NoProfile -ExecutionPolicy Bypass -File ""{app}\tools\validate-deployment.ps1"" -ConfigPath ""C:\ProgramData\ActivityWatch\deployment-config.json"""; Tasks: desktopicon
[Run]
Filename: "powershell.exe"; Parameters: "-NoProfile -ExecutionPolicy Bypass -File ""{app}\tools\Invoke-AWatchRusInstall.ps1"" -ServerHost ""{code:GetServerHost}"" -ServerPort {code:GetServerPort} -Domain ""{code:GetDomain}"" -Users ""{code:GetUsers}"" -InstallRoot ""{code:GetInstallRoot}"" -StateRoot ""{code:GetStateRoot}"" -CustomRulesPath ""{app}\config\web-category-rules.example.json"" -CustomPolicyPath ""{app}\config\dlp-policy.example.json"" -AfkEnabled:{code:GetAfkEnabled} -WindowEnabled:{code:GetWindowEnabled}"; Flags: runhidden waituntilterminated
Filename: "powershell.exe"; Parameters: "-NoProfile -ExecutionPolicy Bypass -File ""{app}\tools\validate-deployment.ps1"" -ConfigPath ""{code:GetStateRoot}\deployment-config.json"""; Flags: postinstall shellexec skipifsilent
[UninstallRun]
Filename: "powershell.exe"; Parameters: "-NoProfile -ExecutionPolicy Bypass -Command ""Get-ScheduledTask -TaskName 'ActivityWatch Launch *' -ErrorAction SilentlyContinue | ForEach-Object { Unregister-ScheduledTask -TaskName $_.TaskName -Confirm:$false }; Unregister-ScheduledTask -TaskName 'ActivityWatch Recovery' -Confirm:$false -ErrorAction SilentlyContinue"""; Flags: runhidden waituntilterminated
[Code]
var
ConfigPage: TWizardPage;
ServerHostEdit: TEdit;
ServerPortEdit: TEdit;
DomainEdit: TEdit;
UsersEdit: TEdit;
InstallRootEdit: TEdit;
StateRootEdit: TEdit;
AfkCheck: TNewCheckBox;
WindowCheck: TNewCheckBox;
procedure ApplyCmdParamIfPresent(const ParamName: string; Edit: TEdit);
forward;
procedure ApplyCmdBoolIfPresent(const ParamName: string; Check: TNewCheckBox);
forward;
procedure InitializeWizard;
begin
ConfigPage := CreateCustomPage(wpSelectTasks,
'AWatch-rus configuration',
'Specify deployment settings passed to deploy-ensemble.ps1');
ServerHostEdit := TEdit.Create(ConfigPage);
ServerHostEdit.Parent := ConfigPage.Surface;
ServerHostEdit.Left := ScaleX(0);
ServerHostEdit.Top := ScaleY(8);
ServerHostEdit.Width := ScaleX(420);
ServerHostEdit.Text := 'aw.example.local';
ServerPortEdit := TEdit.Create(ConfigPage);
ServerPortEdit.Parent := ConfigPage.Surface;
ServerPortEdit.Left := ScaleX(0);
ServerPortEdit.Top := ScaleY(40);
ServerPortEdit.Width := ScaleX(120);
ServerPortEdit.Text := '5600';
DomainEdit := TEdit.Create(ConfigPage);
DomainEdit.Parent := ConfigPage.Surface;
DomainEdit.Left := ScaleX(0);
DomainEdit.Top := ScaleY(72);
DomainEdit.Width := ScaleX(260);
DomainEdit.Text := 'CONTOSO';
UsersEdit := TEdit.Create(ConfigPage);
UsersEdit.Parent := ConfigPage.Surface;
UsersEdit.Left := ScaleX(0);
UsersEdit.Top := ScaleY(104);
UsersEdit.Width := ScaleX(420);
UsersEdit.Text := 'user1,user2';
InstallRootEdit := TEdit.Create(ConfigPage);
InstallRootEdit.Parent := ConfigPage.Surface;
InstallRootEdit.Left := ScaleX(0);
InstallRootEdit.Top := ScaleY(136);
InstallRootEdit.Width := ScaleX(420);
InstallRootEdit.Text := 'C:\Program Files\ActivityWatch';
StateRootEdit := TEdit.Create(ConfigPage);
StateRootEdit.Parent := ConfigPage.Surface;
StateRootEdit.Left := ScaleX(0);
StateRootEdit.Top := ScaleY(168);
StateRootEdit.Width := ScaleX(420);
StateRootEdit.Text := 'C:\ProgramData\ActivityWatch';
AfkCheck := TNewCheckBox.Create(ConfigPage);
AfkCheck.Parent := ConfigPage.Surface;
AfkCheck.Top := ScaleY(200);
AfkCheck.Caption := 'Enable AFK watcher';
AfkCheck.Checked := True;
WindowCheck := TNewCheckBox.Create(ConfigPage);
WindowCheck.Parent := ConfigPage.Surface;
WindowCheck.Top := ScaleY(224);
WindowCheck.Caption := 'Enable Window watcher';
WindowCheck.Checked := True;
ApplyCmdParamIfPresent('SERVERHOST', ServerHostEdit);
ApplyCmdParamIfPresent('SERVERPORT', ServerPortEdit);
ApplyCmdParamIfPresent('DOMAIN', DomainEdit);
ApplyCmdParamIfPresent('USERS', UsersEdit);
ApplyCmdParamIfPresent('INSTALLROOT', InstallRootEdit);
ApplyCmdParamIfPresent('STATEROOT', StateRootEdit);
ApplyCmdBoolIfPresent('AFKENABLED', AfkCheck);
ApplyCmdBoolIfPresent('WINDOWENABLED', WindowCheck);
end;
procedure ApplyCmdParamIfPresent(const ParamName: string; Edit: TEdit);
var
V: string;
begin
V := ExpandConstant('{param:' + ParamName + '|}');
if Trim(V) <> '' then
Edit.Text := V;
end;
procedure ApplyCmdBoolIfPresent(const ParamName: string; Check: TNewCheckBox);
var
V: string;
begin
V := Lowercase(Trim(ExpandConstant('{param:' + ParamName + '|}')));
if (V = '1') or (V = 'true') or (V = 'yes') then
Check.Checked := True
else if (V = '0') or (V = 'false') or (V = 'no') then
Check.Checked := False;
end;
function NextButtonClick(CurPageID: Integer): Boolean;
var
PortNum: Integer;
begin
Result := True;
if CurPageID = ConfigPage.ID then
begin
if Trim(ServerHostEdit.Text) = '' then
begin
MsgBox('ServerHost is required.', mbError, MB_OK);
Result := False;
exit;
end;
PortNum := StrToIntDef(Trim(ServerPortEdit.Text), 0);
if (PortNum < 1) or (PortNum > 65535) then
begin
MsgBox('ServerPort must be in range 1..65535.', mbError, MB_OK);
Result := False;
exit;
end;
if Trim(UsersEdit.Text) = '' then
begin
MsgBox('Users list is required (comma-separated).', mbError, MB_OK);
Result := False;
exit;
end;
end;
end;
function GetServerHost(Param: string): string;
begin
Result := Trim(ServerHostEdit.Text);
end;
function GetServerPort(Param: string): string;
begin
Result := Trim(ServerPortEdit.Text);
end;
function GetDomain(Param: string): string;
begin
Result := Trim(DomainEdit.Text);
end;
function GetUsers(Param: string): string;
begin
Result := Trim(UsersEdit.Text);
end;
function GetInstallRoot(Param: string): string;
begin
Result := Trim(InstallRootEdit.Text);
end;
function GetStateRoot(Param: string): string;
begin
Result := Trim(StateRootEdit.Text);
end;
function GetAfkEnabled(Param: string): string;
begin
if AfkCheck.Checked then Result := '$true' else Result := '$false';
end;
function GetWindowEnabled(Param: string): string;
begin
if WindowCheck.Checked then Result := '$true' else Result := '$false';
end;
@@ -1,47 +0,0 @@
[CmdletBinding()]
param(
[Parameter(Mandatory=$true)][string]$ServerHost,
[int]$ServerPort = 5600,
[Parameter(Mandatory=$true)][string]$Domain,
[Parameter(Mandatory=$true)][string]$Users,
[string]$InstallRoot = 'C:\Program Files\ActivityWatch',
[string]$StateRoot = 'C:\ProgramData\ActivityWatch',
[string]$CustomRulesPath,
[string]$CustomPolicyPath,
[bool]$AfkEnabled = $true,
[bool]$WindowEnabled = $true
)
$ErrorActionPreference = 'Stop'
$scriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
$ensembleScript = Join-Path $scriptRoot 'deploy-ensemble.ps1'
if (-not (Test-Path -LiteralPath $ensembleScript)) {
throw "deploy-ensemble.ps1 not found at $ensembleScript"
}
$usersList = $Users.Split(',') | ForEach-Object { $_.Trim() } | Where-Object { $_ }
if (-not $usersList -or $usersList.Count -eq 0) {
throw 'Users must contain at least one username.'
}
$params = @{
ServerHost = $ServerHost
ServerPort = $ServerPort
Domain = $Domain
Users = $usersList
InstallRoot = $InstallRoot
StateRoot = $StateRoot
AfkEnabled = $AfkEnabled
WindowEnabled = $WindowEnabled
}
if ($CustomRulesPath -and (Test-Path -LiteralPath $CustomRulesPath)) {
$params['CustomRulesPath'] = $CustomRulesPath
}
if ($CustomPolicyPath -and (Test-Path -LiteralPath $CustomPolicyPath)) {
$params['CustomPolicyPath'] = $CustomPolicyPath
}
& $ensembleScript @params
-51
View File
@@ -1,51 +0,0 @@
# AWatch-rus Inno Setup installer skeleton
This directory contains a ready-to-build scaffold for a classic Setup.exe installer.
## Files
- `AWatch-rus-Setup.iss` — Inno Setup script.
- `Invoke-AWatchRusInstall.ps1` — wrapper that invokes `deploy-ensemble.ps1` with installer parameters.
- `build-installer.ps1` — one-click local build script for Inno Setup.
## Local build (one-click)
```powershell
cd .\windows\installer
.\build-installer.ps1
```
Optional custom path to compiler:
```powershell
.\build-installer.ps1 -IsccPath 'C:\Program Files (x86)\Inno Setup 6\ISCC.exe'
```
## Silent deployment parameters (SCCM/Intune/GPO)
Installer supports command-line parameters:
- `/SERVERHOST=aw.example.local`
- `/SERVERPORT=5600`
- `/DOMAIN=CONTOSO`
- `/USERS=user1,user2`
- `/INSTALLROOT="C:\Program Files\ActivityWatch"`
- `/STATEROOT="C:\ProgramData\ActivityWatch"`
- `/AFKENABLED=true|false`
- `/WINDOWENABLED=true|false`
Example:
```powershell
AWatch-rus-Setup.exe /VERYSILENT /SUPPRESSMSGBOXES /NORESTART `
/SERVERHOST=aw.example.local /SERVERPORT=5600 /DOMAIN=CONTOSO `
/USERS=user1,user2 /AFKENABLED=true /WINDOWENABLED=true
```
## CI build and optional code signing
A GitHub Actions workflow builds the installer on `windows-latest` and uploads `Setup.exe` as an artifact.
Optional production signing is enabled via `SignTool=byparam ...` in `.iss` and expects `SIGNTOOL_CMD`.
Set repository secret `SIGNTOOL_CMD` with your full sign command template.
## Behavior
- Requires Administrator privileges.
- Copies toolkit scripts into `{app}\tools`.
- Collects server/domain/users parameters via wizard page (or command-line silent params).
- Runs `Invoke-AWatchRusInstall.ps1` -> `deploy-ensemble.ps1`.
- Runs validation at the end.
- Uninstall step removes `ActivityWatch` scheduled tasks.
-33
View File
@@ -1,33 +0,0 @@
[CmdletBinding()]
param(
[string]$IsccPath = "C:\Program Files (x86)\Inno Setup 6\ISCC.exe",
[string]$IssPath = "$PSScriptRoot\AWatch-rus-Setup.iss",
[switch]$SkipClean
)
$ErrorActionPreference = 'Stop'
if (-not (Test-Path -LiteralPath $IssPath)) {
throw "ISS file not found: $IssPath"
}
if (-not (Test-Path -LiteralPath $IsccPath)) {
throw "ISCC.exe not found: $IsccPath"
}
$outputDir = Join-Path $PSScriptRoot 'output'
if ((-not $SkipClean) -and (Test-Path -LiteralPath $outputDir)) {
Remove-Item -LiteralPath $outputDir -Recurse -Force
}
Write-Host "Building installer from: $IssPath"
& $IsccPath $IssPath
if ($LASTEXITCODE -ne 0) {
throw "Inno Setup build failed with code: $LASTEXITCODE"
}
$artifact = Get-ChildItem -Path $outputDir -Filter '*.exe' -File | Sort-Object LastWriteTime -Descending | Select-Object -First 1
if (-not $artifact) {
throw "Build finished but no .exe artifact found in: $outputDir"
}
Write-Host "Installer artifact: $($artifact.FullName)"
+6
View File
@@ -0,0 +1,6 @@
# Build outputs
/*.exe
# Offline payload ZIPs should not be committed
/*.zip
/payload/*.zip
@@ -0,0 +1,212 @@
#define MyAppName "AWatch-rus InstallKit"
#define MyAppVersion "1.0.0"
#define MyAppPublisher "AWatch-rus"
#define AwDefaultServerHost "10.10.10.13"
#define AwDefaultServerPort "5600"
#define AwDefaultUsers "user1,user2,user3,user4,user5"
#define AwDefaultInstallRoot "C:\\Program Files\\AWatch-rus\\bin"
#define AwDefaultStateRoot "C:\\ProgramData\\AWatch-rus"
#define AwDefaultZipName "activitywatch-v0.13.2-windows-x86_64.zip"
[Setup]
AppId={{6D6A1F74-0F4F-4A57-B5E3-1C2C2F56C0E9}
AppName={#MyAppName}
AppVersion={#MyAppVersion}
AppPublisher={#MyAppPublisher}
DefaultDirName={autopf}\AWatch-rus
DefaultGroupName=AWatch-rus
OutputDir=.
OutputBaseFilename=AWatch-rus-InstallKit
Compression=lzma
SolidCompression=yes
ArchitecturesInstallIn64BitMode=x64compatible
PrivilegesRequired=admin
[Languages]
Name: "russian"; MessagesFile: "compiler:Languages\Russian.isl"
[Tasks]
Name: "deploy"; Description: "Запустить деплой после установки"; Flags: checkedonce
Name: "validate"; Description: "Запустить validate-deployment (через -ValidateAfterDeploy)"; Flags: checkedonce
[Files]
Source: "..\..\ActivityWatch.Windows.Common.psd1"; DestDir: "{app}\windows"; Flags: ignoreversion
Source: "..\..\ActivityWatch.Windows.Common.psm1"; DestDir: "{app}\windows"; Flags: ignoreversion
Source: "..\..\deploy-single-user.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion
Source: "..\..\deploy-domain-users.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion
Source: "..\..\deploy-ensemble.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion
Source: "..\..\hardening-recovery.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion
Source: "..\..\validate-deployment.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion
Source: "..\..\migrate-awatch-rus-paths.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion
Source: "..\..\worktime-session-collector.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion
Source: "..\..\browser-domains-native-collector.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion
Source: "..\..\dlp-endpoint-signals-collector.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion
Source: "..\..\web-category-rules.example.json"; DestDir: "{app}\windows"; Flags: ignoreversion
Source: "..\..\dlp-policy.example.json"; DestDir: "{app}\windows"; Flags: ignoreversion
; Offline payload (optional): place ZIP into windows/installkit/innosetup/payload/ before compiling.
Source: "payload\{#AwDefaultZipName}"; DestDir: "{app}\payload"; Flags: ignoreversion skipifsourcedoesntexist
Source: "innosetup-rdp-package-filelist.md"; DestDir: "{app}\windows\installkit\innosetup"; Flags: ignoreversion
[Run]
Filename: "powershell.exe"; Parameters: "{code:GetDeployEnsembleParams}"; Flags: runhidden; Tasks: deploy
[Code]
var
ServerHostPage: TInputQueryWizardPage;
UsersPage: TInputQueryWizardPage;
OptionsPage: TInputOptionWizardPage;
function NormalizeUserCsv(const UserCsv: string): string;
var
i: Integer;
s: string;
token: string;
begin
Result := '';
s := UserCsv;
while True do
begin
i := Pos(',', s);
if i = 0 then
begin
token := Trim(s);
s := '';
end
else
begin
token := Trim(Copy(s, 1, i - 1));
Delete(s, 1, i);
end;
if token <> '' then
begin
if Result <> '' then
Result := Result + ',';
Result := Result + token;
end;
if s = '' then
Break;
end;
end;
function BuildUsersPowerShellArg(const UserCsv: string): string;
var
i: Integer;
s: string;
token: string;
quoted: string;
begin
Result := '';
s := UserCsv;
while True do
begin
i := Pos(',', s);
if i = 0 then
begin
token := Trim(s);
s := '';
end
else
begin
token := Trim(Copy(s, 1, i - 1));
Delete(s, 1, i);
end;
if token <> '' then
begin
quoted := '"' + token + '"';
if Result <> '' then
Result := Result + ',';
Result := Result + quoted;
end;
if s = '' then
Break;
end;
if Result <> '' then
Result := '-Users ' + Result;
end;
function PayloadZipPath: string;
begin
Result := ExpandConstant('{app}\payload\{#AwDefaultZipName}');
end;
function HasPayloadZip: Boolean;
begin
Result := FileExists(ExpandConstant('{src}\payload\{#AwDefaultZipName}'));
end;
procedure InitializeWizard;
begin
ServerHostPage := CreateInputQueryPage(
wpSelectDir,
'Параметры AW сервера',
'Укажите сервер ActivityWatch (куда агенты будут отправлять данные).',
'Если нужно, измените host/port. По умолчанию — наша конфигурация.'
);
ServerHostPage.Add('ServerHost', False);
ServerHostPage.Add('ServerPort', False);
ServerHostPage.Values[0] := '{#AwDefaultServerHost}';
ServerHostPage.Values[1] := '{#AwDefaultServerPort}';
UsersPage := CreateInputQueryPage(
ServerHostPage.ID,
'Пользователи (RDP)',
'Перечень пользователей, для которых разворачиваем агенты.',
'Введите список через запятую. Пример: user1,user2,user3'
);
UsersPage.Add('Users (CSV)', False);
UsersPage.Values[0] := '{#AwDefaultUsers}';
OptionsPage := CreateInputOptionPage(
UsersPage.ID,
'Опции деплоя',
'Выберите опции для установки/валидации.',
'',
False,
False
);
OptionsPage.Add('Использовать offline payload (встроенный ZIP)');
OptionsPage.Add('Запустить validate-deployment после деплоя');
OptionsPage.Values[0] := HasPayloadZip;
OptionsPage.Values[1] := True;
end;
function GetDeployEnsembleParams(Param: string): string;
var
serverHost: string;
serverPort: string;
usersCsv: string;
usersArg: string;
zipArg: string;
validateArg: string;
begin
serverHost := Trim(ServerHostPage.Values[0]);
serverPort := Trim(ServerHostPage.Values[1]);
usersCsv := NormalizeUserCsv(UsersPage.Values[0]);
usersArg := BuildUsersPowerShellArg(usersCsv);
if usersArg = '' then
RaiseException('Users list is empty.');
zipArg := '';
if OptionsPage.Values[0] then
zipArg := ' -PackageZipPath "' + PayloadZipPath + '"';
validateArg := '';
if OptionsPage.Values[1] and WizardIsTaskSelected('validate') then
validateArg := ' -ValidateAfterDeploy';
Result :=
'-NoProfile -ExecutionPolicy Bypass -File "' + ExpandConstant('{app}\windows\deploy-ensemble.ps1') + '"' +
' -ServerHost "' + serverHost + '"' +
' -ServerPort ' + serverPort +
' ' + usersArg +
zipArg +
' -InstallRoot "{#AwDefaultInstallRoot}"' +
' -StateRoot "{#AwDefaultStateRoot}"' +
validateArg;
end;
+35
View File
@@ -0,0 +1,35 @@
# Build (Inno Setup)
## Prerequisites
- Windows machine with Inno Setup installed (`ISCC.exe` available).
## Offline vs Online payload
- **Offline (recommended for closed networks)**: put `activitywatch-v0.13.2-windows-x86_64.zip` into `payload\`.
- **Online**: leave `payload\` empty; the deploy script will download the ZIP from GitHub Releases.
## Compile
From this folder:
```bat
iscc AWatch-rus-InnoSetup.iss
```
The resulting installer `AWatch-rus-InstallKit.exe` is written to the same directory (by `OutputDir=.`).
## Compile from Linux (Wine)
```sh
./build_with_wine.sh
```
## Install-time parameters
The installer wizard asks for:
- `ServerHost` / `ServerPort` (defaults to our AW server `10.10.10.13:5600`)
- `Users` (CSV)
- Whether to use offline payload (auto-enabled when the ZIP exists at compile time)
- Whether to validate after deploy (`-ValidateAfterDeploy`, report written to `C:\ProgramData\AWatch-rus\ensemble-report-*.json`)
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
set -euo pipefail
KIT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
WINEPREFIX_DEFAULT="${HOME}/.wine-aw-inno"
WINEPREFIX="${WINEPREFIX:-$WINEPREFIX_DEFAULT}"
IS_EXE="${IS_EXE:-/tmp/innosetup.exe}"
ISCC_WIN='C:\InnoSetup\ISCC.exe'
cd "$KIT_DIR"
mkdir -p payload
ZIP_NAME="activitywatch-v0.13.2-windows-x86_64.zip"
# If someone dropped the ZIP in the kit root, stage it into payload/.
if [[ -f "$ZIP_NAME" && ! -f "payload/$ZIP_NAME" ]]; then
cp -f "$ZIP_NAME" "payload/$ZIP_NAME"
fi
export WINEPREFIX
export WINEDEBUG="${WINEDEBUG:--all}"
if [[ ! -f "${WINEPREFIX}/drive_c/InnoSetup/ISCC.exe" ]]; then
mkdir -p "$WINEPREFIX"
if [[ ! -f "$IS_EXE" ]]; then
curl -fsSL -o "$IS_EXE" https://jrsoftware.org/download.php/is.exe
fi
wineboot -u
wine "$IS_EXE" /VERYSILENT /SUPPRESSMSGBOXES /NORESTART /SP- /DIR="C:\InnoSetup"
fi
rm -f AWatch-rus-InstallKit.exe
wine "$ISCC_WIN" "AWatch-rus-InnoSetup.iss"
ls -la AWatch-rus-InstallKit.exe
sha256sum AWatch-rus-InstallKit.exe
@@ -0,0 +1,96 @@
# Inno Setup: файл-лист для Windows RDP deployment
Дата актуализации: 2026-05-02 (UTC).
## Что это за документ
Этот файл — **чеклист упаковки** для Inno Setup.
- Он описывает, **что класть** в инсталлятор.
- Он описывает, **что не класть** (генерируется уже на целевом хосте).
- Он **не меняет** текущие deploy-скрипты и логику проекта.
## Важное уточнение по единой Windows-директории
Чтобы исключить путаницу:
1. InnoSetup и Ansible используют один набор путей.
2. Toolkit лежит в `{app}\windows` = `C:\Program Files\AWatch-rus\windows`.
3. Бинарники ActivityWatch лежат в `C:\Program Files\AWatch-rus\bin`.
4. Runtime-конфиг, collectors, логи и отчёты лежат в `C:\ProgramData\AWatch-rus`.
## 1) Обязательные файлы для Inno Setup пакета
### 1.1 PowerShell-модуль
- `windows/ActivityWatch.Windows.Common.psd1`
- `windows/ActivityWatch.Windows.Common.psm1`
### 1.2 Скрипты деплоя и сопровождения
- `windows/deploy-single-user.ps1`
- `windows/deploy-domain-users.ps1`
- `windows/deploy-ensemble.ps1`
- `windows/hardening-recovery.ps1`
- `windows/validate-deployment.ps1`
- `windows/migrate-awatch-rus-paths.ps1`
### 1.3 Коллекторы
- `windows/worktime-session-collector.ps1` (RDP/session presence)
- `windows/browser-domains-native-collector.ps1`
- `windows/dlp-endpoint-signals-collector.ps1`
### 1.4 Шаблоны конфигурации
- `windows/web-category-rules.example.json`
- `windows/dlp-policy.example.json`
## 2) Бинарный payload ActivityWatch
Поддерживаются оба режима:
- **Online**: ZIP скачивается из GitHub Releases.
- **Offline**: ZIP кладётся в installer (`payload\activitywatch-v0.13.2-windows-x86_64.zip`) и передаётся в deploy через `-PackageZipPath`.
Для нашей закрытой среды обычно используется **offline-режим**.
## 3) Что НЕ включать в installer как статические файлы
Эти файлы/папки появляются на целевом Windows-хосте во время/после деплоя:
- `C:\ProgramData\AWatch-rus\deployment-config.json`
- `C:\ProgramData\AWatch-rus\web-category-rules.json`
- `C:\ProgramData\AWatch-rus\dlp-policy.json`
- `C:\ProgramData\AWatch-rus\logs\*`
- `%LOCALAPPDATA%\AWatch-rus\incident-artifacts\*`
## 4) Опционально приложить в операторский install-kit
- `docs/windows/deployment.md`
- `docs/windows/validation.md`
- `docs/windows/troubleshooting.md`
- `docs/windows/ensemble.md`
## 5) Рекомендуемая структура внутри пакета
- `windows\ActivityWatch.Windows.Common.psd1`
- `windows\ActivityWatch.Windows.Common.psm1`
- `windows\deploy-single-user.ps1`
- `windows\deploy-domain-users.ps1`
- `windows\deploy-ensemble.ps1`
- `windows\hardening-recovery.ps1`
- `windows\validate-deployment.ps1`
- `windows\migrate-awatch-rus-paths.ps1`
- `windows\worktime-session-collector.ps1`
- `windows\browser-domains-native-collector.ps1`
- `windows\dlp-endpoint-signals-collector.ps1`
- `windows\web-category-rules.example.json`
- `windows\dlp-policy.example.json`
- `payload\activitywatch-v0.13.2-windows-x86_64.zip` (только для offline-режима)
## 6) Контроль перед сборкой .iss
1. Все файлы из раздела 1 присутствуют.
2. В .iss не осталось вызова `deploy-ensemble.ps1` без параметров: нужны `-ServerHost` и `-Users`.
3. Для Windows/RDP используются единые пути:
- `InstallRoot = C:\Program Files\AWatch-rus\bin`
- `StateRoot = C:\ProgramData\AWatch-rus`
4. Для offline-режима ZIP лежит в `windows/installkit/innosetup/payload/` (имя: `activitywatch-v0.13.2-windows-x86_64.zip`).
5. Для проверки используется `-ValidateAfterDeploy` (отчёт `ensemble-report-*.json` пишется в `C:\ProgramData\AWatch-rus\`).
@@ -0,0 +1,6 @@
Place ActivityWatch ZIP here for offline installer builds.
Expected filename:
- `activitywatch-v0.13.2-windows-x86_64.zip`
If the ZIP is absent, the installer can still be built (online mode), but the deploy step will download the payload from GitHub Releases.
+243
View File
@@ -0,0 +1,243 @@
[CmdletBinding(SupportsShouldProcess = $true)]
param(
[string]$OldInstallRoot = 'C:\Program Files\ActivityWatch-Phase2',
[string]$OldStateRoot = 'C:\ProgramData\ActivityWatch-Phase2',
[string]$NewInstallRoot = 'C:\Program Files\AWatch-rus\bin',
[string]$NewStateRoot = 'C:\ProgramData\AWatch-rus',
[string]$ToolkitRoot = 'C:\Program Files\AWatch-rus\windows',
[switch]$SkipValidation
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$modulePath = Join-Path $PSScriptRoot 'ActivityWatch.Windows.Common.psm1'
Import-Module $modulePath -Force
Assert-Administrator
function Copy-DirectoryContents {
param(
[Parameter(Mandatory = $true)]
[string]$Source,
[Parameter(Mandatory = $true)]
[string]$Destination
)
if (-not (Test-Path -LiteralPath $Source)) {
return
}
New-ActivityWatchDirectory -Path $Destination
Copy-Item -Path (Join-Path $Source '*') -Destination $Destination -Recurse -Force
}
function Copy-IfExists {
param(
[Parameter(Mandatory = $true)]
[string]$Source,
[Parameter(Mandatory = $true)]
[string]$Destination
)
if (Test-Path -LiteralPath $Source) {
Copy-Item -LiteralPath $Source -Destination $Destination -Force
}
}
function Convert-PathValue {
param(
[AllowNull()]
[string]$Value
)
if ([string]::IsNullOrWhiteSpace($Value)) {
return $Value
}
return $Value.Replace($OldInstallRoot, $NewInstallRoot).Replace($OldStateRoot, $NewStateRoot)
}
function Stop-AWatchTaskSet {
foreach ($task in @(Get-ScheduledTask | Where-Object { $_.TaskName -eq 'ActivityWatch Recovery' -or $_.TaskName -like 'ActivityWatch Launch *' })) {
Stop-ScheduledTask -TaskName $task.TaskName -ErrorAction SilentlyContinue
}
}
function Get-ExistingAWatchConfig {
$newConfigPath = Join-Path $NewStateRoot 'deployment-config.json'
$oldConfigPath = Join-Path $OldStateRoot 'deployment-config.json'
if (Test-Path -LiteralPath $oldConfigPath) {
return [pscustomobject]@{
Path = $oldConfigPath
Config = Read-ActivityWatchDeploymentConfig -Path $oldConfigPath
}
}
if (Test-Path -LiteralPath $newConfigPath) {
return [pscustomobject]@{
Path = $newConfigPath
Config = Read-ActivityWatchDeploymentConfig -Path $newConfigPath
}
}
throw "Не найден deployment-config.json ни в $OldStateRoot, ни в $NewStateRoot."
}
function Update-AWatchConfigPaths {
param(
[Parameter(Mandatory = $true)]
[pscustomobject]$Config
)
$logsRoot = Join-Path $NewStateRoot 'logs'
$Config.paths.installRoot = $NewInstallRoot
$Config.paths.stateRoot = $NewStateRoot
$Config.paths.logsRoot = $logsRoot
$Config.paths.collectorScript = Join-Path $NewStateRoot 'browser-domains-native-collector.ps1'
$Config.paths.endpointCollectorScript = Join-Path $NewStateRoot 'dlp-endpoint-signals-collector.ps1'
if ($Config.paths.PSObject.Properties.Name -contains 'sessionCollectorScript') {
$Config.paths.sessionCollectorScript = Join-Path $NewStateRoot 'worktime-session-collector.ps1'
}
$Config.paths.rulesPath = Join-Path $NewStateRoot 'web-category-rules.json'
if ($Config.paths.PSObject.Properties.Name -contains 'policyPath') {
$Config.paths.policyPath = Join-Path $NewStateRoot 'dlp-policy.json'
}
$Config.paths.launchScript = Join-Path $NewStateRoot 'launch-watchers.ps1'
$Config.paths.recoveryScript = Join-Path $NewStateRoot 'recovery-loop.ps1'
if ($Config.PSObject.Properties.Name -contains 'incidentCapture' -and $Config.incidentCapture.PSObject.Properties.Name -contains 'artifactsRoot') {
$Config.incidentCapture.artifactsRoot = Convert-PathValue -Value ([string]$Config.incidentCapture.artifactsRoot)
}
return $Config
}
$existing = Get-ExistingAWatchConfig
$backupRoot = Join-Path $NewStateRoot ('migration-backups\' + (Get-Date -Format 'yyyyMMdd-HHmmss'))
$newConfigPath = Join-Path $NewStateRoot 'deployment-config.json'
$newLogsRoot = Join-Path $NewStateRoot 'logs'
$summary = [ordered]@{
sourceConfig = $existing.Path
oldInstallRoot = $OldInstallRoot
oldStateRoot = $OldStateRoot
newInstallRoot = $NewInstallRoot
newStateRoot = $NewStateRoot
backupRoot = $backupRoot
actions = @(
'stop ActivityWatch scheduled tasks',
'backup old/new install and state directories',
'copy old install/state contents to AWatch-rus paths',
'rewrite deployment-config.json paths',
'regenerate launcher/recovery scripts',
're-register scheduled tasks',
'run validate-deployment.ps1'
)
}
if ($WhatIfPreference) {
return [pscustomobject]$summary
}
if ($PSCmdlet.ShouldProcess($env:COMPUTERNAME, 'Миграция ActivityWatch Windows/RDP путей в AWatch-rus')) {
New-ActivityWatchDirectory -Path $NewStateRoot
New-ActivityWatchDirectory -Path $backupRoot
Stop-AWatchTaskSet
foreach ($item in @(
@{ Source = $OldInstallRoot; Name = 'old-install' },
@{ Source = $OldStateRoot; Name = 'old-state' },
@{ Source = $NewInstallRoot; Name = 'new-install' },
@{ Source = $NewStateRoot; Name = 'new-state' }
)) {
if (Test-Path -LiteralPath $item.Source) {
$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'"
}
}
}
Copy-DirectoryContents -Source $OldInstallRoot -Destination $NewInstallRoot
Copy-DirectoryContents -Source $OldStateRoot -Destination $NewStateRoot
New-ActivityWatchDirectory -Path $newLogsRoot
foreach ($file in @(
'browser-domains-native-collector.ps1',
'dlp-endpoint-signals-collector.ps1',
'worktime-session-collector.ps1',
'web-category-rules.example.json',
'dlp-policy.example.json'
)) {
Copy-IfExists -Source (Join-Path $ToolkitRoot $file) -Destination (Join-Path $NewStateRoot $file)
}
Copy-IfExists -Source (Join-Path $OldStateRoot 'web-category-rules.json') -Destination (Join-Path $NewStateRoot 'web-category-rules.json')
Copy-IfExists -Source (Join-Path $OldStateRoot 'dlp-policy.json') -Destination (Join-Path $NewStateRoot 'dlp-policy.json')
if (-not (Test-Path -LiteralPath (Join-Path $NewStateRoot 'web-category-rules.json'))) {
Copy-IfExists -Source (Join-Path $NewStateRoot 'web-category-rules.example.json') -Destination (Join-Path $NewStateRoot 'web-category-rules.json')
}
if (-not (Test-Path -LiteralPath (Join-Path $NewStateRoot 'dlp-policy.json'))) {
Copy-IfExists -Source (Join-Path $NewStateRoot 'dlp-policy.example.json') -Destination (Join-Path $NewStateRoot 'dlp-policy.json')
}
$config = Update-AWatchConfigPaths -Config $existing.Config
Write-ActivityWatchDeploymentConfig -Config $config -Path $newConfigPath
Write-ActivityWatchLaunchScript -Path $config.paths.launchScript -ConfigPath $newConfigPath
Write-ActivityWatchRecoveryScript -Path $config.paths.recoveryScript -ConfigPath $newConfigPath
$taskDefinitions = @($config.userTasks)
Set-ActivityWatchAcl -InstallRoot $NewInstallRoot -StateRoot $NewStateRoot -LogsRoot $newLogsRoot
Register-ActivityWatchUserTasks -TaskDefinitions $taskDefinitions -LaunchScriptPath $config.paths.launchScript -ConfigPath $newConfigPath
Register-ActivityWatchRecoveryTask -TaskName $config.recovery.taskName -RecoveryScriptPath $config.paths.recoveryScript -ConfigPath $newConfigPath
Start-ActivityWatchTasks -TaskDefinitions $taskDefinitions -RecoveryTaskName $config.recovery.taskName
Start-Sleep -Seconds 5
if (-not $SkipValidation) {
$validateScript = Join-Path $ToolkitRoot 'validate-deployment.ps1'
if (-not (Test-Path -LiteralPath $validateScript)) {
$validateScript = Join-Path $PSScriptRoot 'validate-deployment.ps1'
}
$report = & $validateScript -ConfigPath $newConfigPath
if (-not [bool]$report.overallOk) {
throw "Миграция выполнена, но validation завершился ошибкой. Backup: $backupRoot"
}
}
[pscustomobject]@{
migrated = $true
backupRoot = $backupRoot
configPath = $newConfigPath
installRoot = $NewInstallRoot
stateRoot = $NewStateRoot
}
}
+53 -21
View File
@@ -1,6 +1,6 @@
[CmdletBinding()]
param(
[string]$ConfigPath = 'C:\ProgramData\ActivityWatch\deployment-config.json'
[string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json'
)
Set-StrictMode -Version Latest
@@ -14,29 +14,50 @@ $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' }
$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' }
$launchScript = [string]$config.paths.launchScript
$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 }
$requiredFiles = @(
(Join-Path $installRoot 'aw-watcher-afk\aw-watcher-afk.exe'),
(Join-Path $installRoot 'aw-watcher-window\aw-watcher-window.exe'),
$collectorScript,
$endpointCollectorScript,
$sessionCollectorScript,
$rulesPath,
$policyPath,
$launchScript,
$recoveryScript,
$ConfigPath
)
if ($afkExpected) {
$requiredFiles += (Join-Path $installRoot 'aw-watcher-afk\aw-watcher-afk.exe')
}
if ($windowExpected) {
$requiredFiles += (Join-Path $installRoot 'aw-watcher-window\aw-watcher-window.exe')
}
$missingFiles = @(
$requiredFiles | Where-Object { -not (Test-Path -LiteralPath $_) }
)
$processNames = @('aw-watcher-afk', 'aw-watcher-window')
$runningProcesses = Get-Process -Name $processNames -ErrorAction SilentlyContinue | Select-Object Name, Id, SessionId
$processNames = @()
if ($afkExpected) { $processNames += 'aw-watcher-afk' }
if ($windowExpected) { $processNames += 'aw-watcher-window' }
$runningProcesses = @()
if ($processNames.Count -gt 0) {
$runningProcesses = Get-Process -Name $processNames -ErrorAction SilentlyContinue | Select-Object Name, Id, SessionId
}
$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) {
@@ -45,25 +66,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 = 'Missing'
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
@@ -80,8 +104,16 @@ $result = [ordered]@{
ok = [bool]($tasks.Count -gt 0 -and -not ($tasks | Where-Object { -not $_.present }))
}
processes = [ordered]@{
expected = $processNames
list = @($runningProcesses)
ok = [bool](($runningProcesses | Select-Object -ExpandProperty Name -Unique).Count -ge 2)
sessionCollectors = @($sessionCollectorProcesses)
ok = [bool](
(
($processNames.Count -eq 0) -or
($uniqueRunningProcessNames.Count -ge $processNames.Count)
) -and
($sessionCollectorProcesses.Count -ge 1)
)
}
}
+62 -28
View File
@@ -1,5 +1,5 @@
param(
[string]$ConfigPath = 'C:\ProgramData\ActivityWatch\deployment-config.json',
[string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json',
[string]$Hostname,
[int]$PollSeconds = 30
)
@@ -9,8 +9,12 @@ $ErrorActionPreference = 'Stop'
function Get-Config {
param([string]$Path)
if (-not (Test-Path -LiteralPath $Path)) { throw "Config not found: $Path" }
return Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json
if (-not (Test-Path -LiteralPath $Path)) {
throw "Конфигурация не найдена: $Path"
}
Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json
}
function Invoke-AwJsonPost {
@@ -18,6 +22,7 @@ function Invoke-AwJsonPost {
[Parameter(Mandatory = $true)][string]$Uri,
[Parameter(Mandatory = $true)][string]$Json
)
$bytes = [Text.Encoding]::UTF8.GetBytes($Json)
Invoke-RestMethod -Method Post -Uri $Uri -ContentType 'application/json; charset=utf-8' -Body $bytes | Out-Null
}
@@ -28,6 +33,7 @@ function Ensure-Bucket {
[Parameter(Mandatory = $true)][string]$BucketId,
[Parameter(Mandatory = $true)][string]$HostnameValue
)
try {
Invoke-RestMethod -Method Get -Uri "$ApiBase/buckets/$BucketId" | Out-Null
return
@@ -40,36 +46,55 @@ function Ensure-Bucket {
type = 'aw.worktime.session'
hostname = $HostnameValue
} | ConvertTo-Json -Compress
Invoke-AwJsonPost -Uri "$ApiBase/buckets/$BucketId" -Json $body
}
function Get-SessionRecords {
$records = @()
try {
$lines = (quser 2>$null)
if (-not $lines) { return @() }
foreach ($line in $lines | Select-Object -Skip 1) {
$lines = quser 2>$null
if (-not $lines) {
return @()
}
foreach ($line in ($lines | Select-Object -Skip 1)) {
$clean = ($line -replace '^\s*>?', '').Trim()
if (-not $clean) { continue }
$parts = $clean -split '\s+'
if ($parts.Count -lt 4) { continue }
$user = $parts[0]
$sessionName = $parts[1]
$sessionId = 0
if ($parts[2] -match '^\d+$') {
$sessionId = [int]$parts[2]
if (-not $clean) {
continue
}
$state = $parts[3]
$parts = $clean -split '\s+'
if ($parts.Count -lt 4) {
continue
}
$sessionName = ''
$sessionIdIndex = 2
if ($parts[1] -match '^\d+$') {
$sessionIdIndex = 1
}
else {
$sessionName = $parts[1]
}
$sessionId = 0
if ($parts[$sessionIdIndex] -match '^\d+$') {
$sessionId = [int]$parts[$sessionIdIndex]
}
$records += [pscustomobject]@{
username = $user
username = $parts[0]
sessionName = $sessionName
sessionId = $sessionId
state = $state
state = $parts[$sessionIdIndex + 1]
}
}
}
catch {
}
return $records
}
@@ -78,7 +103,15 @@ $hostValue = if ($Hostname) { $Hostname } else { [string]$env:COMPUTERNAME }
$apiBase = '{0}://{1}:{2}/api/0' -f [string]$cfg.server.scheme, [string]$cfg.server.host, [string]$cfg.server.port
$bucketId = 'aw-worktime-sessions_' + $hostValue
$pulse = 120
$sleepSec = if ($PollSeconds -gt 0) { $PollSeconds } elseif ($cfg.collector -and $cfg.collector.pollSeconds) { [int]$cfg.collector.pollSeconds } else { 30 }
$sleepSec = if ($PollSeconds -gt 0) {
$PollSeconds
}
elseif ($cfg.collector -and $cfg.collector.pollSeconds) {
[int]$cfg.collector.pollSeconds
}
else {
30
}
Ensure-Bucket -ApiBase $apiBase -BucketId $bucketId -HostnameValue $hostValue
@@ -87,10 +120,10 @@ while ($true) {
$records = Get-SessionRecords
if (-not $records -or $records.Count -eq 0) {
$records = @([pscustomobject]@{
username = $env:USERNAME
username = $env:USERNAME
sessionName = ''
sessionId = (Get-Process -Id $PID).SessionId
state = 'Unknown'
sessionId = (Get-Process -Id $PID).SessionId
state = 'Unknown'
})
}
@@ -99,16 +132,17 @@ while ($true) {
timestamp = $now
duration = 0
data = @{
username = [string]$rec.username
userId = "$($env:USERDOMAIN)\$($rec.username)"
sessionId = [int]$rec.sessionId
username = [string]$rec.username
userId = "$($env:USERDOMAIN)\$($rec.username)"
sessionId = [int]$rec.sessionId
sessionName = [string]$rec.sessionName
state = [string]$rec.state
active = ($rec.state -match 'Active')
hostname = $hostValue
source = 'worktime-session-collector'
state = [string]$rec.state
active = ($rec.state -match 'Active')
hostname = $hostValue
source = 'worktime-session-collector'
}
} | ConvertTo-Json -Depth 6 -Compress
try {
Invoke-AwJsonPost -Uri "$apiBase/buckets/$bucketId/heartbeat?pulsetime=$pulse" -Json $payload
}