Compare commits

...
Author SHA1 Message Date
qwen.ai[bot] 25ee6e37ca Update project structure with comprehensive deployment playbooks and system components
Key features implemented:
- Added complete Ansible playbook suite for AWatch project deployment including server, Windows client, pfSense poller, and Proxmox container provisioning
- Created comprehensive documentation set covering full deployment manual, Linux client setup, pfSense integration, and operational procedures
- Implemented server-side components with AW server installation scripts, WebUI Russian localization patch, and cleanup utilities
- Developed Windows client deployment tools with PowerShell modules for domain integration, DLP endpoint collection, and browser domain monitoring
- Established pfSense poller service with Python collector for network activity monitoring
- Integrated Grafana 1C reporting stack with Docker Compose configuration and SQL exporter
- Added Proxmox container creation and artifact deployment automation scripts
- Included SSH console logger, Linux remote worker client, and web category logging capabilities

The commit introduces a complete ActivityWatch monitoring solution with multi-platform support, automated deployment infrastructure, and comprehensive documentation for enterprise deployment scenarios.
2026-04-30 13:33:42 +00:00
igor04091968 8278d51840 feat(worktime): add linux remote worker and pve headless tracking 2026-04-30 15:48:09 +03:00
igor04091968 fa4bf96ebf feat(ansible): add full-stack installer playbook 2026-04-28 07:10:44 +03:00
igor04091968 b28cfabd57 fix(windows-phase2): codify SHARKON2025 recovery and harden rollout checks 2026-04-28 07:09:29 +03:00
igor04091968 4936d6bca3 fix(webui): translate activity heading separator 2026-04-27 23:57:20 +03:00
igor04091968 c54b237f80 fix(webui): repair category helper host selection 2026-04-27 23:36:58 +03:00
igor04091968 48223fbeeb chore(install): sync playbooks and installers with pve audit 2026-04-27 23:18:32 +03:00
igor04091968 7f131a6310 fix(webui): add stable pve audit activity view 2026-04-27 22:56:05 +03:00
igor04091968 77591c10ce fix(webui): harden pve-detmir activity route and suppress DLP nav on pve hosts 2026-04-27 22:17:51 +03:00
igor04091968 947717251f fix(webui): route pve-detmir activity to watcher-safe view and add audit links 2026-04-27 21:58:37 +03:00
igor04091968 0da0d880c0 feat(pve): add web-admin audit collector to forward Proxmox actions to AW 2026-04-27 21:52:07 +03:00
igor04091968 5adb22754f fix(linux-logger): parse who -u pts fields and avoid duplicate start with systemd 2026-04-27 21:40:17 +03:00
igor04091968 76004e30fe feat(aw-ui): add dedicated pve-detmir host entry in Activity menu 2026-04-27 21:34:56 +03:00
igor04091968 b48b18f225 fix(ansible): default windows state root to C:\ProgramData\ActivityWatch 2026-04-27 21:31:04 +03:00
igor04091968 5a41c4066b feat(windows): add install kit and stabilize dlp print document matching 2026-04-27 21:22:40 +03:00
igor04091968 fff4c10e64 feat(logging): add console and ssh activity logger 2026-04-27 18:17:52 +03:00
igor04091968 5105155323 feat(linux): add remote client rollout for admin host 2026-04-27 17:29:17 +03:00
igor04091968 ced44c347c docs(runtime): capture confirmed admin-host topology 2026-04-27 17:04:31 +03:00
igor04091968 c70fa019ad fix(grafana): finalize ru naming for overview dashboard 2026-04-27 14:09:46 +03:00
80 changed files with 9247 additions and 68 deletions
+3 -14
View File
@@ -1,14 +1,3 @@
# Local secrets
secrets/deploy.secrets.env
# Runtime / reports
*.log
*.tmp
*.bak
windows/*.report.json
# IDE
.idea/
.vscode/
__pycache__/
```
AWatch-rus
```
Submodule
+1
Submodule AWatch-rus added at 8278d51840
+23 -1
View File
@@ -9,13 +9,20 @@
- `docs/runbook.md` — быстрый runbook для оператора.
- `docs/operations.md` — регламент сопровождения, бэкапов, обновлений и rollback.
- `docs/windows/ensemble.md` — orchestration-пакет для Windows-деплоя и проверки.
- `docs/linux-client.md` — user-space rollout Linux-клиента ActivityWatch на удалённый `AW server`.
- `docs/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.
- `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, phase-2 DLP telemetry (`aw-dlp-incidents_*`, `aw-dlp-endpoint-signals_*`) и session-level presence для удалённых Windows/RDP пользователей (`aw-worktime-sessions_*`).
- `scripts/quality-gate.sh` — локальный preflight-пайплайн проверок.
- `scripts/install_aw_linux_client.sh` — установка Linux bundle + autostart для remote AW server.
- `scripts/install_aw_console_ssh_logger.sh` — user-space установка console/ssh logger.
- `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.
## Базовый сценарий
@@ -42,6 +49,21 @@
- `ansible/deploy_aw_pfsense_poller.yml`
Для Linux desktop/admin host, который должен слать watcher'ы на удалённый AW server:
- `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`
- `scripts/install_aw_console_ssh_logger.sh`
Скрипты `proxmox/create-ct.sh` и `proxmox/push-aw-artifacts.sh` по умолчанию читают:
- `secrets/deploy.secrets.env`
+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"
+23
View File
@@ -14,6 +14,7 @@
- `/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.
@@ -34,6 +35,24 @@ cd /home/igor/tmp/AWatch-rus/ansible
ansible-playbook -i inventory.ini deploy_aw_server.yml
```
## Полный установочный playbook (всё за один запуск)
Если нужно прогнать полный цикл одной командой:
```bash
cd /home/igor/tmp/AWatch-rus/ansible
ansible-playbook -i inventory.ini install_full_stack.yml
```
Что делает:
- `provision_proxmox_ct_and_deploy_aw.yml` (если есть хосты в группе `[proxmox]`);
- `deploy_aw_server.yml` (группа `[aw_server]`);
- `deploy_aw_windows_phase2.yml` (группа `[aw_windows]`);
- `deploy_aw_pfsense_poller.yml` (группа `[aw_pfsense_pollers]`).
Пустые группы в `inventory.ini` безопасны: соответствующий play будет пропущен.
## Полный запуск с нуля в Proxmox
1. Подготовьте inventory и vars:
@@ -66,6 +85,8 @@ ansible-playbook -i inventory.ini provision_proxmox_ct_matrix_and_deploy_aw.yml
- `cp /home/igor/tmp/AWatch-rus/ansible/inventory.example.ini /home/igor/tmp/AWatch-rus/ansible/inventory.ini`
- `cp /home/igor/tmp/AWatch-rus/ansible/group_vars/windows.example.yml /home/igor/tmp/AWatch-rus/ansible/group_vars/windows.yml`
2. Заполните `inventory.ini` (секция `[aw_windows]`) и `group_vars/windows.yml`.
- Для русской локализации Windows часто нужен `ansible_user=Администратор` (а не `Administrator`).
- Если WinRM закрыт, playbook не сможет стартовать и нужно сначала открыть `5985/5986` и `wsman`.
3. Запустите:
```bash
@@ -77,6 +98,8 @@ Playbook:
- выгружает `windows/*` toolkit на целевой хост в `C:\Deploy\AWatch-rus\windows`;
- выполняет `deploy-ensemble.ps1` (deploy + hardening/recovery) с phase-2 policy/rules;
- после deploy принудительно запускает `ActivityWatch Recovery` и все `ActivityWatch Launch *` задачи;
- выполняет API smoke-check bucket `aw-watcher-afk_SHARKON2025` и ожидает свежие `not-afk` события;
- запускает `validate-deployment.ps1`;
- забирает JSON-отчёт в локальную директорию (`/tmp/aw-rus-validation` по умолчанию).
+37 -1
View File
@@ -18,7 +18,7 @@
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-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
@@ -31,6 +31,12 @@
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
@@ -105,6 +111,36 @@
{% 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: |
+1 -1
View File
@@ -16,7 +16,7 @@ aw_windows_extra_users: []
# Рекомендуемый изолированный профиль для фазового раската.
aw_windows_install_root: "C:\\Program Files\\ActivityWatch-Phase2"
aw_windows_state_root: "C:\\ProgramData\\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
+16
View File
@@ -0,0 +1,16 @@
---
# Full-stack installer for AWatch-rus.
# Runs end-to-end rollout in one command:
# 1) Proxmox CT provision + AW bootstrap (if [proxmox] exists in inventory)
# 2) AW server deploy on [aw_server] hosts
# 3) Windows phase2 rollout on [aw_windows] hosts
# 4) pfSense poller deploy on [aw_pfsense_pollers] hosts
#
# Notes:
# - Keep only relevant inventory groups filled for your environment.
# - Plays with unmatched host groups are skipped automatically by Ansible.
- import_playbook: provision_proxmox_ct_and_deploy_aw.yml
- import_playbook: deploy_aw_server.yml
- import_playbook: deploy_aw_windows_phase2.yml
- import_playbook: deploy_aw_pfsense_poller.yml
+2 -1
View File
@@ -5,4 +5,5 @@ 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
# NOTE: in RU-localized installs this account is often "Администратор" instead of "Administrator".
win-node1 ansible_host=192.168.100.21 ansible_user=Администратор ansible_password=CHANGE_ME ansible_connection=winrm ansible_winrm_transport=ntlm ansible_port=5985 ansible_winrm_server_cert_validation=ignore
@@ -12,6 +12,9 @@
- aw-server.env.example
- aw-ru-patch.js
- aw-sw-cleanup.js
- aw-host-groups.json
- settings/classes-worktime.json
- settings/views-default.json
tasks:
- name: Execute single-CT provisioning workflow
@@ -12,6 +12,9 @@
- aw-server.env.example
- aw-ru-patch.js
- aw-sw-cleanup.js
- aw-host-groups.json
- settings/classes-worktime.json
- settings/views-default.json
tasks:
- name: Validate CT matrix is provided
+37 -8
View File
@@ -9,17 +9,22 @@ fi
source "$ENV_FILE"
WEBUI_DIR="${AW_SERVER_WEBUI_DIR:-/opt/activitywatch/webui-ru}"
WEBUI_DIR="${AW_SERVER_WEBUI_DIR:-${AW_WEBUI_DIR:-/opt/activitywatch/webui-ru}}"
PATCH_JS_SRC="/root/bootstrap/aw-ru-patch.js"
SW_CLEANUP_SRC="/root/bootstrap/aw-sw-cleanup.js"
HOST_GROUPS_SRC="/root/bootstrap/aw-host-groups.json"
INDEX_HTML="$WEBUI_DIR/index.html"
SERVICE_WORKER="$WEBUI_DIR/service-worker.js"
TS=$(date +%Y%m%d%H%M%S)
PATCH_TARGET="$WEBUI_DIR/js/ru-patch-v5.js"
SW_TARGET="$WEBUI_DIR/js/sw-cleanup.js"
HOST_GROUPS_TARGET="$WEBUI_DIR/js/aw-host-groups.json"
TRENDS_NEEDLE='this.activityStore.query_category_time_by_period(r)'
TRENDS_REPLACEMENT='this.activityStore.ensure_loaded(r)'
TIMESPIRAL_NEEDLE='start:new Date("2022-08-08")'
TIMESPIRAL_REPLACEMENT='start:new Date(Date.now()-12*36e5)'
CATEGORY_HELPER_NEEDLE='hostname:t.hostnameChoices[0]'
CATEGORY_HELPER_REPLACEMENT='hostname:t.hostnameChoices.filter((function(t){return"unknown"!==t}))[0]||t.hostnameChoices[0]'
[[ -f "$PATCH_JS_SRC" ]] || { echo "missing $PATCH_JS_SRC" >&2; exit 1; }
[[ -f "$SW_CLEANUP_SRC" ]] || { echo "missing $SW_CLEANUP_SRC" >&2; exit 1; }
@@ -27,14 +32,17 @@ TIMESPIRAL_REPLACEMENT='start:new Date(Date.now()-12*36e5)'
[[ -f "$INDEX_HTML" ]] || { echo "missing $INDEX_HTML" >&2; exit 1; }
install -d "$WEBUI_DIR/js"
install -m 0644 "$PATCH_JS_SRC" "$WEBUI_DIR/js/aw-ru-patch.js"
install -m 0644 "$SW_CLEANUP_SRC" "$WEBUI_DIR/js/aw-sw-cleanup.js"
install -m 0644 "$HOST_GROUPS_SRC" "$WEBUI_DIR/js/aw-host-groups.json"
install -m 0644 "$PATCH_JS_SRC" "$PATCH_TARGET"
install -m 0644 "$SW_CLEANUP_SRC" "$SW_TARGET"
install -m 0644 "$HOST_GROUPS_SRC" "$HOST_GROUPS_TARGET"
cp "$INDEX_HTML" "$INDEX_HTML.bak.$TS"
sed -i '/aw-ru-patch.js/d;/aw-sw-cleanup.js/d' "$INDEX_HTML"
sed -i 's#</head>#<script src="/js/aw-sw-cleanup.js"></script></head>#' "$INDEX_HTML"
sed -i 's#</body>#<script defer="defer" src="/js/aw-ru-patch.js"></script></body>#' "$INDEX_HTML"
patch_hash="$(sha1sum "$PATCH_TARGET" | awk '{print substr($1,1,12)}')"
sw_hash="$(sha1sum "$SW_TARGET" | awk '{print substr($1,1,12)}')"
sed -i '/ru-patch-v5.js/d;/sw-cleanup.js/d;/aw-ru-patch.js/d;/aw-sw-cleanup.js/d' "$INDEX_HTML"
sed -i "s#</head>#<script src=\"/js/sw-cleanup.js?v=$sw_hash\"></script></head>#" "$INDEX_HTML"
sed -i "s#</body>#<script defer=\"defer\" src=\"/js/ru-patch-v5.js?v=$patch_hash\"></script></body>#" "$INDEX_HTML"
cp "$SW_CLEANUP_SRC" "$SERVICE_WORKER"
trends_chunk="$(grep -Rsl "$TRENDS_NEEDLE" "$WEBUI_DIR/js"/*.js 2>/dev/null | head -n 1 || true)"
@@ -79,4 +87,25 @@ else
echo "Timespiral hotfix skipped: chunk not found"
fi
echo "RU patch applied to $WEBUI_DIR"
category_helper_chunk="$(grep -Rsl "$CATEGORY_HELPER_NEEDLE" "$WEBUI_DIR/js"/*.js 2>/dev/null | head -n 1 || true)"
if [[ -n "$category_helper_chunk" ]]; then
cp "$category_helper_chunk" "$category_helper_chunk.bak.$TS"
python3 - "$category_helper_chunk" "$CATEGORY_HELPER_NEEDLE" "$CATEGORY_HELPER_REPLACEMENT" <<'PY'
from pathlib import Path
import sys
path = Path(sys.argv[1])
old = sys.argv[2]
new = sys.argv[3]
content = path.read_text()
if old in content:
path.write_text(content.replace(old, new, 1))
print(f"Category helper host hotfix applied to {path}")
else:
print(f"Category helper host hotfix already present in {path}")
PY
else
echo "Category helper host hotfix skipped: chunk not found"
fi
echo "RU patch applied to $WEBUI_DIR (ru-patch-v5.js?v=$patch_hash)"
+32
View File
@@ -1,5 +1,22 @@
{
"groups": [
{
"id": "pve-detmir",
"name": "pve-detmir",
"description": "Выделенный клиент DetMir в разделе Активность.",
"patterns": [
"^pve-detmir$"
],
"links": [
{ "label": "Активность", "type": "activity", "view": "pve_audit" },
{ "label": "Web-admin аудит", "type": "bucket", "bucket_prefix": "aw-pve-webadmin-events_" },
{ "label": "PVE tasks", "type": "bucket", "bucket_prefix": "aw-pve-task-events_" },
{ "label": "SSH сессии", "type": "bucket", "bucket_prefix": "aw-ssh-sessions_" },
{ "label": "Команды shell", "type": "bucket", "bucket_prefix": "aw-console-commands_" },
{ "label": "Web категории", "type": "bucket", "bucket_prefix": "aw-detmir-web-category_" },
{ "label": "Все бакеты", "type": "buckets" }
]
},
{
"id": "windows-rdp",
"name": "Windows RDP",
@@ -12,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",
+297 -10
View File
@@ -1,6 +1,6 @@
(function () {
window.__awRuPatchVersion = "template-v5-dlp-review-host-groups";
document.documentElement.setAttribute("data-aw-ru-patch", "template-v5-dlp-review-host-groups");
window.__awRuPatchVersion = "template-v12-activity-heading-ru";
document.documentElement.setAttribute("data-aw-ru-patch", "template-v12-activity-heading-ru");
const exact = new Map([
["ActivityWatch", "АктивВотч"],
@@ -332,7 +332,15 @@
'.aw-ru-host-item { border: 1px solid rgba(120,120,120,.2); border-radius: 6px; padding: 8px; }',
'.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-host-links a { display: inline-block; padding: 4px 8px; border-radius: 999px; background: rgba(90,140,255,.15); text-decoration: none; }',
'.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); }',
'.aw-ru-pve-audit-card h5 { margin: 0 0 6px; font-size: 13px; opacity: .8; }',
'.aw-ru-pve-audit-value { font-size: 24px; font-weight: 700; }',
'.aw-ru-pve-audit-table { width: 100%; border-collapse: collapse; margin-top: 8px; }',
'.aw-ru-pve-audit-table th, .aw-ru-pve-audit-table td { padding: 6px 8px; border-bottom: 1px solid rgba(120,120,120,.18); vertical-align: top; text-align: left; font-size: 13px; }',
'.aw-ru-pve-audit-muted { opacity: .72; font-size: 13px; }'
].join("\n");
document.head.appendChild(style);
}
@@ -358,6 +366,24 @@
return "";
}
function isPveLikeHost(host) {
return /^pve[-_]/i.test(String(host || ""));
}
function enforceSafeActivityViewForPveHost() {
const hash = window.location.hash || "";
const match = hash.match(/^#\/activity\/([^/]+)\/day\/([^/]+)\/view\/([^/?#]+)/i);
if (!match) return;
const host = decodeURIComponent(match[1] || "");
const day = decodeURIComponent(match[2] || "");
const viewId = decodeURIComponent(match[3] || "");
if (!isPveLikeHost(host)) return;
const safeHash = "#/activity/" + encodeURIComponent(host) + "/day/" + encodeURIComponent(day) + "/view/" + encodeURIComponent("pve_audit");
if (safeHash !== hash && !/^pve_audit$/i.test(viewId)) {
window.location.replace(safeHash);
}
}
function getDlpHostFromSettings(settings) {
const routeHost = getCurrentHostFromHash();
if (routeHost) return routeHost;
@@ -605,7 +631,14 @@
}
function injectDlpNavigation(root) {
const href = getDlpHref(window.__awRuPatchSettingsHost || getCurrentHostFromHash());
const hostForDlp = window.__awRuPatchSettingsHost || getCurrentHostFromHash();
if (hostForDlp && isPveLikeHost(hostForDlp)) {
removeBadDlpLinks(root);
const ownItem = root.querySelector("[data-aw-ru-dlp-item='1']");
if (ownItem) ownItem.remove();
return;
}
const href = getDlpHref(hostForDlp);
removeBadDlpLinks(root);
updateDlpLinks(root, href);
if (root.querySelector("[data-aw-ru-dlp-item='1']")) return;
@@ -647,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",
@@ -696,12 +742,26 @@
return state;
}
function isPveActivityRoute() {
const hash = window.location.hash || "";
const match = hash.match(/^#\/activity\/([^/]+)/i);
return !!(match && isPveLikeHost(decodeURIComponent(match[1] || "")));
}
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_",
@@ -731,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) {
@@ -749,7 +829,8 @@
function buildHostLink(host, hostBuckets, linkDef) {
if (!linkDef || !linkDef.type) return "";
if (linkDef.type === "activity") {
return '#/activity/' + encodeURIComponent(host) + '/day/' + encodeURIComponent(new Date().toISOString().slice(0, 10)) + '/view/summary';
const viewId = linkDef.view ? String(linkDef.view) : "summary";
return '#/activity/' + encodeURIComponent(host) + '/day/' + encodeURIComponent(new Date().toISOString().slice(0, 10)) + '/view/' + encodeURIComponent(viewId);
}
if (linkDef.type === "buckets") {
return "#/buckets";
@@ -773,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);
});
@@ -829,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);
}
@@ -1254,6 +1335,90 @@
}
}
async function refreshPveAuditCenter(center, host) {
const message = center.querySelector("[data-aw-ru-pve-audit-message]");
const recentBody = center.querySelector("[data-aw-ru-pve-audit-events]");
message.textContent = "Загрузка audit-событий...";
try {
const [webEvents, taskEvents, sshEvents, cmdEvents] = await Promise.all([
loadBucketEvents("aw-pve-webadmin-events_" + host, 50).catch(function () { return []; }),
loadBucketEvents("aw-pve-task-events_" + host, 50).catch(function () { return []; }),
loadBucketEvents("aw-ssh-sessions_" + host, 50).catch(function () { return []; }),
loadBucketEvents("aw-console-commands_" + host, 50).catch(function () { return []; })
]);
const data = {
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);
center.querySelector("[data-aw-ru-pve-ssh-count]").textContent = String(data.ssh.length);
center.querySelector("[data-aw-ru-pve-cmd-count]").textContent = String(data.cmd.length);
const recent = []
.concat(data.web.map(function (event) { return { kind: "Web-admin", event: event, text: (event.data && (event.data.method || "") + " " + (event.data.path || "")) || "" }; }))
.concat(data.tasks.map(function (event) { return { kind: "PVE task", event: event, text: (event.data && ((event.data.action || "") + " " + (event.data.target || ""))) || "" }; }))
.concat(data.ssh.map(function (event) { return { kind: "SSH", event: event, text: (event.data && ((event.data.event || "") + " " + (event.data.tty || ""))) || "" }; }))
.concat(data.cmd.slice(0, 25).map(function (event) { return { kind: "Shell", event: event, text: (event.data && (event.data.command || "")) || "" }; }))
.sort(function (a, b) { return String(b.event && b.event.timestamp || "").localeCompare(String(a.event && a.event.timestamp || "")); })
.slice(0, 25);
recentBody.innerHTML = recent.length ? recent.map(function (item) {
const ev = item.event || {};
const d = ev.data || {};
return "<tr>" +
"<td>" + escapeHtml(new Date(ev.timestamp).toLocaleString()) + "</td>" +
"<td>" + escapeHtml(item.kind) + "</td>" +
"<td>" + escapeHtml(d.user || d.username || "-") + "</td>" +
"<td>" + escapeHtml(d.remote_ip || d.tty || d.host || "-") + "</td>" +
"<td>" + escapeHtml(item.text) + "</td>" +
"</tr>";
}).join("") : '<tr><td colspan="5">Пока нет audit-событий.</td></tr>';
message.textContent = "Audit-панель обновлена.";
} catch (error) {
recentBody.innerHTML = '<tr><td colspan="5">Не удалось загрузить audit-события.</td></tr>';
message.textContent = "Ошибка загрузки audit-событий: " + error.message;
}
}
function injectPveAuditCenter(root) {
if (!isPveActivityRoute()) return;
const host = getCurrentHostFromHash();
if (!host) return;
const heading = root.querySelector("h3");
if (!heading || !heading.parentElement) return;
let center = root.querySelector("[data-aw-ru-pve-audit='1']");
if (!center) {
center = document.createElement("section");
center.className = "aw-ru-pve-audit";
center.setAttribute("data-aw-ru-pve-audit", "1");
center.innerHTML =
"<h4>PVE Audit</h4>" +
'<p class="aw-ru-pve-audit-muted">Для Proxmox-хоста показывается audit-панель вместо desktop-виджетов ActivityWatch, так как у этого хоста нет window/afk watcher данных.</p>' +
'<div class="aw-ru-pve-audit-grid">' +
'<div class="aw-ru-pve-audit-card"><h5>Web-admin</h5><div class="aw-ru-pve-audit-value" data-aw-ru-pve-web-count>0</div></div>' +
'<div class="aw-ru-pve-audit-card"><h5>PVE tasks</h5><div class="aw-ru-pve-audit-value" data-aw-ru-pve-task-count>0</div></div>' +
'<div class="aw-ru-pve-audit-card"><h5>SSH events</h5><div class="aw-ru-pve-audit-value" data-aw-ru-pve-ssh-count>0</div></div>' +
'<div class="aw-ru-pve-audit-card"><h5>Shell commands</h5><div class="aw-ru-pve-audit-value" data-aw-ru-pve-cmd-count>0</div></div>' +
"</div>" +
'<table class="aw-ru-pve-audit-table">' +
"<thead><tr><th>Время</th><th>Тип</th><th>Пользователь</th><th>Источник</th><th>Детали</th></tr></thead>" +
'<tbody data-aw-ru-pve-audit-events><tr><td colspan="5">Загрузка...</td></tr></tbody>' +
"</table>" +
'<div class="aw-ru-dlp-message" data-aw-ru-pve-audit-message></div>';
heading.parentElement.insertBefore(center, heading.nextSibling);
}
Array.from(heading.parentElement.children).forEach(function (child) {
if (child === heading || child === center) return;
child.style.display = "none";
});
const routeKey = host + "|" + (window.location.hash || "");
if (center.getAttribute("data-aw-ru-pve-route") !== routeKey) {
center.setAttribute("data-aw-ru-pve-route", routeKey);
refreshPveAuditCenter(center, host);
}
}
function injectDlpAlertsCenter(root) {
if (!isAlertsRoute()) return;
const host = window.__awRuPatchSettingsHost || getCurrentHostFromHash();
@@ -1294,6 +1459,8 @@
let trendsRedirectInFlight = false;
let settingsHostFetchInFlight = false;
let applyPatchScheduled = false;
let networkPatchesInstalled = false;
function getTrendsHostFromSettings(settings) {
if (!settings || typeof settings !== "object") return "";
@@ -1354,12 +1521,122 @@
});
}
function getPreferredWindowHostFromBuckets() {
const state = getHostGroupsState();
const rawBuckets = state && state.buckets ? state.buckets : {};
const settingsHost = normalizeText(window.__awRuPatchSettingsHost || "");
const bucketIds = Array.isArray(rawBuckets)
? rawBuckets.map(function (item) { return item && item.id ? String(item.id) : ""; })
: Object.keys(rawBuckets || {});
const hosts = bucketIds
.filter(function (bucketId) { return /^aw-watcher-window_/i.test(bucketId); })
.map(function (bucketId) { return bucketId.replace(/^aw-watcher-window_/i, ""); })
.filter(Boolean)
.filter(function (host) { return !/^unknown$/i.test(host); });
if (settingsHost && hosts.indexOf(settingsHost) >= 0) return settingsHost;
if (settingsHost) return settingsHost;
hosts.sort();
return hosts[0] || "";
}
function rewriteUnknownCategoryBuilderQueryBody(body) {
if (typeof body !== "string") return body;
if (body.indexOf("aw-watcher-window_unknown") === -1 && body.indexOf("aw-watcher-afk_unknown") === -1) {
return body;
}
const preferredHost = getPreferredWindowHostFromBuckets();
if (!preferredHost) return body;
return body
.replace(/aw-watcher-window_unknown/g, "aw-watcher-window_" + preferredHost)
.replace(/aw-watcher-afk_unknown/g, "aw-watcher-afk_" + preferredHost);
}
function installCategoryBuilderNetworkPatch() {
if (networkPatchesInstalled) return;
networkPatchesInstalled = true;
const originalFetch = window.fetch ? window.fetch.bind(window) : null;
if (originalFetch) {
window.fetch = function (input, init) {
try {
const url = typeof input === "string" ? input : String(input && input.url || "");
if (/\/api\/0\/query\/?$/i.test(url) && init && typeof init.body === "string") {
init = Object.assign({}, init, {
body: rewriteUnknownCategoryBuilderQueryBody(init.body)
});
}
} catch (error) {
}
return originalFetch(input, init);
};
}
if (window.XMLHttpRequest && window.XMLHttpRequest.prototype) {
const proto = window.XMLHttpRequest.prototype;
if (!proto.__awRuCategoryBuilderPatched) {
const originalOpen = proto.open;
const originalSend = proto.send;
proto.open = function (method, url) {
this.__awRuMethod = method;
this.__awRuUrl = url;
return originalOpen.apply(this, arguments);
};
proto.send = function (body) {
try {
const url = String(this.__awRuUrl || "");
if (/\/api\/0\/query\/?$/i.test(url) && typeof body === "string") {
body = rewriteUnknownCategoryBuilderQueryBody(body);
}
} catch (error) {
}
return originalSend.call(this, body);
};
proto.__awRuCategoryBuilderPatched = true;
}
}
}
function patchCategoryBuilderHostLabel(root) {
if (!/^#\/settings\/category-builder(?:[/?#]|$)/i.test(window.location.hash || "")) return;
const preferredHost = getPreferredWindowHostFromBuckets();
if (!preferredHost) return;
Array.from(root.querySelectorAll("*")).forEach(function (element) {
if (element.children.length) return;
const text = element.textContent || "";
if (!/Имя хоста:\s*(unknown|неизвестно)\b|Hostname:\s*unknown\b/i.test(text)) return;
const next = text
.replace(/Имя хоста:\s*(unknown|неизвестно)\b/i, "Имя хоста: " + preferredHost)
.replace(/Hostname:\s*unknown\b/i, "Hostname: " + preferredHost);
if (next !== text) {
element.textContent = next;
}
});
}
function patchActivityHeading(root) {
const heading = root.querySelector("h3");
if (!heading) return;
const inlineParts = heading.querySelectorAll("span");
inlineParts.forEach(function (element) {
const text = (element.textContent || "").trim();
if (text === "for") {
element.textContent = "за ";
}
});
}
function applyPatch() {
enforceSafeActivityViewForPveHost();
ensureSettingsHost();
ensureHostGroupsData().catch(function () {});
installCategoryBuilderNetworkPatch();
injectStyles();
walk(document.body);
translateAttributes(document.body);
hideNoiseNavigation(document.body);
patchActivityHeading(document.body);
patchCategoryBuilderHostLabel(document.body);
injectPveAuditCenter(document.body);
injectDlpNavigation(document.body);
injectDlpReviewCenter(document.body);
injectDlpAlertsCenter(document.body);
@@ -1367,15 +1644,25 @@
redirectBareTrendsRoute();
}
function scheduleApplyPatch() {
if (applyPatchScheduled) return;
applyPatchScheduled = true;
window.setTimeout(function () {
applyPatchScheduled = false;
applyPatch();
}, 50);
}
const observer = new MutationObserver(function () {
applyPatch();
scheduleApplyPatch();
});
window.addEventListener("load", function () {
applyPatch();
observer.observe(document.body, { childList: true, subtree: true, characterData: true });
observer.observe(document.body, { childList: true, subtree: true });
});
window.addEventListener("hashchange", function () {
redirectBareTrendsRoute();
scheduleApplyPatch();
});
})();
+31
View File
@@ -21,6 +21,10 @@ required_vars=(
AW_SERVER_GROUP
)
BOOTSTRAP_DIR="/root/bootstrap"
VIEWS_JSON="$BOOTSTRAP_DIR/settings/views-default.json"
CLASSES_JSON="$BOOTSTRAP_DIR/settings/classes-worktime.json"
for var_name in "${required_vars[@]}"; do
if [[ -z "${!var_name:-}" ]]; then
echo "missing required variable: $var_name" >&2
@@ -84,3 +88,30 @@ systemctl daemon-reload
systemctl enable activitywatch-server.service
systemctl restart activitywatch-server.service
systemctl --no-pager --full status activitywatch-server.service || true
for _ in $(seq 1 20); do
if curl -fsS "http://127.0.0.1:${AW_SERVER_PORT}/api/0/info" >/dev/null 2>&1; then
break
fi
sleep 2
done
if [[ -f "$CLASSES_JSON" ]]; then
curl -fsS -X POST \
-H 'Content-Type: application/json' \
--data-binary @"$CLASSES_JSON" \
"http://127.0.0.1:${AW_SERVER_PORT}/api/0/settings/classes" >/dev/null
echo "Applied worktime classes from $CLASSES_JSON"
else
echo "Worktime classes bootstrap not found, skipped: $CLASSES_JSON"
fi
if [[ -f "$VIEWS_JSON" ]]; then
curl -fsS -X POST \
-H 'Content-Type: application/json' \
--data-binary @"$VIEWS_JSON" \
"http://127.0.0.1:${AW_SERVER_PORT}/api/0/settings/views" >/dev/null
echo "Applied baseline views from $VIEWS_JSON"
else
echo "Views bootstrap not found, skipped: $VIEWS_JSON"
fi
+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": {}
+5
View File
@@ -30,5 +30,10 @@
{ "type": "category_tree", "size": 3, "props": {} },
{ "type": "top_apps", "size": 3, "props": {} }
]
},
{
"id": "pve_audit",
"name": "PVE Audit",
"elements": []
}
]
+1 -1
View File
@@ -75,7 +75,7 @@ curl -fsS http://127.0.0.1:9090/-/healthy
Проверка Grafana:
- URL: `http://<host>:3000`
- Дашборд: `1C Бухгалтерия — Overview`
- Дашборд: `1C Бухгалтерия — Обзор`
- Дашборд: `1C Бухгалтерия — SRE`
E2E health-check:
+55
View File
@@ -0,0 +1,55 @@
# POST MEMORY — AWatch-rus (2026-04-27)
## Контекст дня
Работа велась по проекту `AWatch-rus / ActivityWatch-Russian`.
## Что подтверждено по runtime
- `10.10.10.2` (`pve-detmir`, admin host) реально запускает:
- `/usr/local/bin/aw-server-rust --host 0.0.0.0 --port 5600 --webpath /opt/aw-webui-ru`
- `/usr/bin/python3 /opt/aw-pfsense/pfsense-aw-poller.py --config /etc/aw-pfsense/poller.json`
- `10.10.10.1` используется только как API-цель для `pfSense poller`.
- Значит интеграция `pfSense -> AW` работает через внешний poller, а не через установку чего-либо на сам firewall.
## Что сделано
1. Восстановлена и синхронизирована локальная копия проекта.
- В `/mnt/usb_hdd2/Projects/ActivityWatch-Russian` был поврежден `.git`.
- Источник истины: `/home/igor/tmp/AWatch-rus` с `origin https://github.com/igor04091968/AWatch-rus.git`.
- Локальная копия приведена к актуальному содержимому `main`.
2. Завершена русификация overview-дашборда Grafana.
- `1C Бухгалтерия — Overview` заменено на `1C Бухгалтерия — Обзор`.
- Документация синхронизирована с фактическим названием.
- Проверка `rg -n "Overview"` по репозиторию после правки не дала совпадений.
3. Проверен локальный прокси-контур.
- `socks5://127.0.0.1:1080` активен.
- `sing-box` запущен и пропускает трафик.
4. Подготовлен и развернут Linux-клиент ActivityWatch для `10.10.10.2`.
- В репозиторий добавлен rollout:
- `scripts/install_aw_linux_client.sh`
- `docs/linux-client.md`
- На `10.10.10.2` под пользователем `admin` установлен bundle `ActivityWatch 0.13.2` в:
- `~/.local/opt/activitywatch/v0.13.2/activitywatch`
- Созданы:
- `~/.config/activitywatch/aw-client/aw-client.toml` с `10.10.10.13:5600`
- `~/.config/activitywatch/aw-qt/aw-qt.toml`
- `~/.local/bin/activitywatch-remote-aw`
- `~/.config/autostart/activitywatch-remote-aw.desktop`
- Проверка показала:
- удаленный `AW server` с `10.10.10.2` достижим;
- `aw-qt --no-gui` стартует;
- bucket `aw-watcher-afk_pve-detmir` появился на сервере.
- Текущий blocker:
- на `10.10.10.2` нет активной `X11/GUI` сессии;
- `aw-watcher-window` падает с `DISPLAY environment variable not set`;
- `aw-watcher-afk` падает на `failed to acquire X connection`.
## Зафиксированные выводы
- На текущем этапе `10.10.10.2` является фактическим runtime-хостом для `AW server` и `pfSense poller`.
- Документация должна исходить из host-based сценария как из подтвержденного рабочего контура, а CT/LXC-схему держать как отдельный вариант развертывания.
- Для Linux-клиента на `10.10.10.2` установка завершена, но полноценные watcher-события начнутся только после реального графического логина пользователя.
+60
View File
@@ -0,0 +1,60 @@
# Console/SSH logger deployment
## Назначение
Этот сценарий логирует только консольные действия и SSH-сессии:
- команды из `bash_history` (`aw-console-commands_<HOST>`);
- логины/логауты SSH TTY по `who -u` (`aw-ssh-sessions_<HOST>`, best-effort).
GUI watcher'ы (`aw-watcher-window`, `aw-watcher-afk`) не требуются.
## Установка (user-space)
```bash
cd /path/to/AWatch-rus
sh ./scripts/install_aw_console_ssh_logger.sh \
--server-host 10.10.10.13 \
--server-port 5600
```
## Что создается
- `~/.local/opt/aw-console-ssh-logger/collector.py`
- `~/.local/opt/aw-console-ssh-logger/config.json`
- `~/.local/bin/aw-console-ssh-logger-start`
- `~/.local/bin/aw-console-ssh-logger-stop`
- `~/.local/bin/aw-console-ssh-logger-status`
- `~/.config/systemd/user/aw-console-ssh-logger.service`
- `~/.local/state/aw-console-ssh-logger/` (state + logs)
## Shell hooks
Installer добавляет в `~/.bashrc`:
- `HISTTIMEFORMAT="%s "` для epoch timestamp;
- `histappend`;
- `PROMPT_COMMAND` с `history -a; history -n;`.
Это нужно, чтобы команды попадали в `~/.bash_history` сразу после выполнения, а не только при logout.
## Проверка
На клиенте:
```bash
~/.local/bin/aw-console-ssh-logger-status
tail -n 50 ~/.local/state/aw-console-ssh-logger/logs/collector.log
```
На AW сервере:
```bash
curl -fsS http://10.10.10.13:5600/api/0/buckets | jq -r 'keys[]' | grep -E '^aw-console-commands_|^aw-ssh-sessions_'
```
## Ограничения
- Источник команд — `bash_history`; если пользователь использует не `bash`, нужен отдельный collector.
- Канал `aw-ssh-sessions_*` зависит от корректного `utmp/who` на хосте и может быть неполным в отдельных окружениях.
- Полные системные audit-события (`execve` всех пользователей) требуют root-level `auditd`/journald integration и в этот user-space сценарий не входят.
+70
View File
@@ -0,0 +1,70 @@
# Linux client deployment
## Назначение
Этот сценарий ставит `ActivityWatch` в user-space Linux-хоста и направляет watcher'ы на уже существующий `AW server`, не поднимая локальный `aw-server-rust`.
Подтвержденный целевой кейс:
- Linux desktop/admin host: `10.10.10.2`
- удаленный `AW server`: `10.10.10.13:5600`
## Что делает скрипт
- скачивает официальный Linux ZIP `ActivityWatch`;
- распаковывает bundle в `~/.local/opt/activitywatch/v<version>`;
- обновляет symlink `~/.local/opt/activitywatch/current`;
- пишет `aw-client.toml` с указанием удаленного `AW server`;
- пишет `aw-qt.toml`, где оставляет только `aw-watcher-afk` и `aw-watcher-window`;
- создает launcher `~/.local/bin/activitywatch-remote-aw`;
- создает XDG autostart entry `~/.config/autostart/activitywatch-remote-aw.desktop`.
## Установка
```bash
cd /path/to/AWatch-rus
sh ./scripts/install_aw_linux_client.sh \
--server-host 10.10.10.13 \
--server-port 5600
```
Если нужно переустановить ту же версию:
```bash
sh ./scripts/install_aw_linux_client.sh --force
```
## Ключевые файлы
- `~/.config/activitywatch/aw-client/aw-client.toml`
- `~/.config/activitywatch/aw-qt/aw-qt.toml`
- `~/.local/bin/activitywatch-remote-aw`
- `~/.config/autostart/activitywatch-remote-aw.desktop`
## Ручной запуск
```bash
~/.local/bin/activitywatch-remote-aw
```
## Проверка
Проверка bundle и конфигурации:
```bash
~/.local/opt/activitywatch/current/aw-qt --help
~/.local/opt/activitywatch/current/aw-watcher-afk/aw-watcher-afk --help
~/.local/opt/activitywatch/current/aw-watcher-window/aw-watcher-window --help
cat ~/.config/activitywatch/aw-client/aw-client.toml
cat ~/.config/activitywatch/aw-qt/aw-qt.toml
```
Проверка на сервере:
```bash
curl -fsS http://10.10.10.13:5600/api/0/buckets | jq -r 'keys[]' | grep '^aw-watcher-'
```
## Ограничение
`aw-watcher-window` и `aw-watcher-afk` требуют реальную пользовательскую desktop-сессию (`X11`/поддерживаемый GUI login). Если на хосте нет активного GUI-сеанса, установка пройдет, но событий от watcher'ов не будет до первого нормального графического входа пользователя.
+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 активной вкладки.
+10
View File
@@ -8,6 +8,16 @@
- `Debian/Ubuntu` poller VM опрашивает его с интервалом.
- poller отправляет события в общий `AW server`.
## Подтвержденный runtime
Проверенный на `2026-04-27` рабочий контур:
- `10.10.10.2` (`pve-detmir`, admin host) запускает `AW server`:
- `/usr/local/bin/aw-server-rust --host 0.0.0.0 --port 5600 --webpath /opt/aw-webui-ru`
- тот же `10.10.10.2` запускает внешний `pfSense poller`:
- `/usr/bin/python3 /opt/aw-pfsense/pfsense-aw-poller.py --config /etc/aw-pfsense/poller.json`
- `10.10.10.1` используется как API-цель для poller'а; на сам `pfSense` агент или сервер `AW` не ставятся.
## Bucket'ы
Базовая конфигурация пишет:
+69
View File
@@ -11,6 +11,21 @@ pct exec <CT_ID> -- systemctl is-active activitywatch-server.service
pct exec <CT_ID> -- curl -fsS http://127.0.0.1:5600/api/0/info
```
### На host-based инсталляции
Для подтвержденного размещения на `10.10.10.2`:
```sh
ps -ef | grep -E 'aw-server-rust|pfsense-aw-poller' | grep -v grep
curl -fsS http://127.0.0.1:5600/api/0/info
ss -ltnp | grep 5600
```
Ожидаемо должны быть видны:
- `aw-server-rust` с `--webpath /opt/aw-webui-ru`;
- `pfsense-aw-poller.py --config /etc/aw-pfsense/poller.json`.
### Внутри CT
```sh
@@ -86,6 +101,60 @@ Get-CimInstance Win32_Process |
1. Поставить `incidentCapture.screenshotEnabled = false` в `deployment-config.json` (для каждого StateRoot).
2. Запустить `Start-ScheduledTask -TaskName 'ActivityWatch Recovery'`.
### SHARKON2025: `Активное время = 0s`, хотя `window`-события есть
Симптом:
- в Activity view за день видно `Worktime = 0s`;
- `Top Window Titles / Top Categories / Category Tree` пустые;
- при этом bucket `aw-watcher-window_SHARKON2025` содержит свежие события.
Подтвержденная причина:
- watcher `afk` "залип" в `status=afk` без `not-afk`;
- из-за этого дневная сводка не считает подтвержденную активность.
Быстрый recovery (с Linux admin host):
1. Проверить учетку входа. Для этого кейса рабочая учетная запись: `SHARKON2025\Администратор` (не `Administrator`).
2. Поднять remote execution через `wmiexec.py` с auth-file:
```sh
cat > /tmp/sharkon_ru.auth << 'EOF'
username = Администратор
password = <PASSWORD>
domain = SHARKON2025
EOF
chmod 600 /tmp/sharkon_ru.auth
```
3. Запустить recovery task:
```sh
wmiexec.py -nooutput -A /tmp/sharkon_ru.auth 192.168.100.21 \
"powershell -NoProfile -Command \"Start-ScheduledTask -TaskName 'ActivityWatch Recovery'\""
```
4. Запустить все launch tasks:
```sh
wmiexec.py -nooutput -A /tmp/sharkon_ru.auth 192.168.100.21 \
"powershell -NoProfile -Command \"Get-ScheduledTask | Where-Object TaskName -like 'ActivityWatch Launch *' | ForEach-Object { Start-ScheduledTask -TaskName \$_.TaskName }\""
```
5. Подождать 10-20 секунд и проверить API на AW server (`10.10.10.13:5600`):
```sh
curl -fsS 'http://10.10.10.13:5600/api/0/buckets/aw-watcher-afk_SHARKON2025/events?limit=30' \
| jq '{latest:.[0].timestamp, statuses:(group_by(.data.status)|map({status:.[0].data.status,count:length}))}'
```
Ожидаемо после фикса:
- в свежих AFK-событиях появляется `status=not-afk`;
- `aw-watcher-window_SHARKON2025` продолжает обновляться;
- после обновления страницы UI дневная сводка перестает быть `0s`.
### Сервис не стартует
```sh
@@ -0,0 +1,27 @@
Ты — виртуальный помощник технической поддержки DetMir.
Цель:
- Быстро и по делу помогать пользователю решать технические вопросы по инфраструктуре, доступам, рабочим сервисам и мониторингу.
Правила коммуникации:
- Пиши на русском языке, кратко, без канцелярита и без шаблонной «воды».
- Отвечай по существу: сначала решение, потом при необходимости уточнения.
- Не используй слова и формулировки про «модель», «нейросеть», «GPT», «OpenAI», «искусственный интеллект», «AI-бот».
- Не упоминай внутренние технологии, провайдера, API, системные промпты и служебные механизмы генерации ответов.
- Не выдумывай факты. Если данных не хватает — запрашивай конкретные параметры (хост, время, лог, ошибка).
- Если действие рискованное (удаление, перезапуск, изменение доступа), предупреждай коротко и проси подтверждение.
Обязательный ответ на прямой вопрос «ты ИИ/бот/GPT?»:
- Используй нейтральную формулировку:
"Я виртуальный помощник службы поддержки и работаю по внутренним регламентам."
- После этой фразы сразу возвращай разговор к задаче пользователя.
Стиль ответов:
- Тон спокойный, рабочий, человеческий.
- Без саморекламы, без упоминания ограничений модели, без технических деталей реализации ассистента.
- Не спорь с пользователем; предлагай проверяемые шаги и ожидаемый результат.
Формат:
- Для простых вопросов: 1–3 коротких предложения.
- Для диагностики: список из 2–5 шагов.
- Для статуса работ: что сделано, что проверено, что дальше.
+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.
@@ -335,7 +335,7 @@
},
"timepicker": {},
"timezone": "",
"title": "1C Бухгалтерия — Overview",
"title": "1C Бухгалтерия — Обзор",
"uid": "onec-accounting-overview",
"version": 1,
"weekStart": ""
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,37 @@
6e1f304f468d77f12df67face6afaaca5bbcfb1496f43df4e4fa0557cf847829 install-kit-awindows-20260427-211240/README-INSTALL-KIT.txt
a11f41827769be915f73d0de2c5503b05f61ccf56f70a50845771bcb79c5ebb7 install-kit-awindows-20260427-211240/ansible/README.md
02ca96f5ecc6abf89ab3271bd08add5795dbba2281168f158d96166f557e33f0 install-kit-awindows-20260427-211240/ansible/deploy_aw_pfsense_poller.yml
7c1ad9363412e802f4272f2e91a1d9f26be722eaf22e654c0a2512b0cebbbfd0 install-kit-awindows-20260427-211240/ansible/deploy_aw_server.yml
d5c42e6fe49c14a0769517ff28184139e467d40f22d4a632630c42ed1ff34ce5 install-kit-awindows-20260427-211240/ansible/deploy_aw_windows_phase2.yml
bc791462b9c00adc8c68ed81e2a7697c560bdbc46f156b4840c7bca1dee2157d install-kit-awindows-20260427-211240/ansible/group_vars/all.example.yml
95696c243ab331f06e77a40a9800c4b6668de77675ebbdf2ef54ae49e1b18874 install-kit-awindows-20260427-211240/ansible/group_vars/pfsense-poller.example.yml
c5cab36645065815571c99f6d360f910dcccbb54b780c8bfd526a6cdc3684e19 install-kit-awindows-20260427-211240/ansible/group_vars/proxmox-matrix.example.yml
35a33c8a1c75ded5e85c6b79e0b3efde07959ff61ee5f66d83b7e0c2abe87fc5 install-kit-awindows-20260427-211240/ansible/group_vars/proxmox.example.yml
69368b7adb7711fa81304866373e61ed464bcc23a08e4509fa54655b05f95790 install-kit-awindows-20260427-211240/ansible/group_vars/windows.example.yml
00064ce5187569fb3221ddd45c2ad7eb37cd50b2a0a832ddf7843e4ff461849a install-kit-awindows-20260427-211240/ansible/inventory.example.ini
bbef175cb77dd53aa07452dbb2fe8797f38b58f42372005c3404c8dc9d6f8e13 install-kit-awindows-20260427-211240/ansible/provision_proxmox_ct_and_deploy_aw.yml
b8f8b6bc504a51cd87db3f46c35a27295b395ea516533068f96af35f8b720434 install-kit-awindows-20260427-211240/ansible/provision_proxmox_ct_matrix_and_deploy_aw.yml
d35bc97b6de18f0006cbbad4adf8a8a5db8ed912997d8fc47c7f11fa9247e907 install-kit-awindows-20260427-211240/ansible/tasks/provision_ct_and_deploy_aw.yml
a50dbadbf619342c2178e255b68f69a36503756daf80eedd9170311c63964f2e install-kit-awindows-20260427-211240/aw-server/activitywatch-server.service
2dbf55d4a8f204ebdc97af926d90435932c0aad7a2431e2b9987e47c5abf71a9 install-kit-awindows-20260427-211240/aw-server/apply_webui_ru_patch.sh
07d4e583f6e9757a11f01558e1f15cfd73c4d82695f1768204f2f50621712168 install-kit-awindows-20260427-211240/aw-server/aw-host-groups.json
09605da1754abb0dc0446825580b57ebad6e646dc670f9f072fce1489e88dd43 install-kit-awindows-20260427-211240/aw-server/aw-ru-patch.js
7c5952f8f0a8590e849ea8381bfcd7059b138250bca8551bd5625f395eb66cd8 install-kit-awindows-20260427-211240/aw-server/aw-server.env.example
98c0bed353bbda0fa7a69df23f3b008cb0e8e70cdff6cc63330d4caf79fd3280 install-kit-awindows-20260427-211240/aw-server/aw-sw-cleanup.js
dce731fdfdcfd773c154d12dbd6b9e621a0bff17ced5a05a65f0fdcb1adcb70f install-kit-awindows-20260427-211240/aw-server/install_aw_server.sh
1856e9f44636030b0cb9ece37ba2a0618eb5187fa82c7969976c1bb5f10fc622 install-kit-awindows-20260427-211240/aw-server/settings/classes-worktime.json
ff07b90cb6a7f09b27d522307cf55b0359e136a2e695190b8564e859f14f9204 install-kit-awindows-20260427-211240/aw-server/settings/views-default.json
59307d284caa74eb3dc129765f9db93b6e8dfd5b1d960b98f347a332b23f82dc install-kit-awindows-20260427-211240/server-configs-192.168.100.21/phase2-admin.deployment-config.json
f2cee1872bf274f15dcfb8fb595fb20a11d228a4bae0930dc6cb918a6f800756 install-kit-awindows-20260427-211240/server-configs-192.168.100.21/phase2-u2u5.deployment-config.json
6aefedcdac8c1d3823c9f4065b051a67a221a3e9424c913a673667b2a23ea1e7 install-kit-awindows-20260427-211240/server-configs-192.168.100.21/phase2-user1.deployment-config.json
33aa34b89246d6c079ef9afe2f5cd153bd9d5946b69a175ff6fd678c77f61da5 install-kit-awindows-20260427-211240/windows/ActivityWatch.Windows.Common.psd1
d506614168227fa01fa481289079b432b6d8846d5ee8961cff8c21fd0bf7ea8f install-kit-awindows-20260427-211240/windows/ActivityWatch.Windows.Common.psm1
2a0b94ddad43a6bc684037243e636a54c168d8d4ad25b29778c4d78180da2532 install-kit-awindows-20260427-211240/windows/browser-domains-native-collector.ps1
98bfcf5dca972f1ba1845bbca546133c192824ec40dd2e55f4e6d59c24a834d1 install-kit-awindows-20260427-211240/windows/deploy-domain-users.ps1
a19c2a98e6483eb921457f472d1cf62a76e344f2ef3621f04fafa3d7dc2df353 install-kit-awindows-20260427-211240/windows/deploy-ensemble.ps1
2542425e02acd8a8b02ed701ca2382cf7ae612be28441d1a10a40e48c6a13ad0 install-kit-awindows-20260427-211240/windows/deploy-single-user.ps1
a2f963927c8b263a21aaffc0a926a058dcec65a04a5fa57c271d3dbe59f9347c install-kit-awindows-20260427-211240/windows/dlp-endpoint-signals-collector.ps1
aef0032edd9b1e0c54f7b575664ed511dfc6cb53364e7496cbc95e137678e11a install-kit-awindows-20260427-211240/windows/dlp-policy.example.json
ade74a55ce00d9295f2efa0fd72f987688154c93f1142ce0eb6e982f07271be7 install-kit-awindows-20260427-211240/windows/hardening-recovery.ps1
88ffe06093ef5f7247bd2b990b0c8f801c706a26dc87725bb1194504fab7e306 install-kit-awindows-20260427-211240/windows/validate-deployment.ps1
731098681d89b9af6f3872abd586ac3b1faba2d7f9340211e503f52ad0243b3f install-kit-awindows-20260427-211240/windows/web-category-rules.example.json
@@ -0,0 +1,10 @@
ActivityWatch DetMir Windows Install Kit
Includes:
- windows/* (deploy scripts, collectors, common module, configs/examples)
- ansible/* (Windows and AW server playbooks, examples, inventory, tasks)
- aw-server/* (server installer, RU patch loader, host groups, default settings)
- server-configs-192.168.100.21/* (working Windows Phase2 config snapshots)
Source:
- Local project snapshot at build time.
@@ -0,0 +1,119 @@
# Ansible ensemble for AWatch-rus
Эта директория содержит Ansible-ensemble для двух сценариев:
- деплой на уже существующий Debian host/CT;
- полный цикл с нуля в Proxmox: создание CT + bootstrap + установка ActivityWatch + RU patch.
- централизованный деплой Windows phase-2 collectors по WinRM.
- deployment внешнего pfSense poller'а на Debian/Ubuntu utility VM.
## Файлы
- `/home/igor/tmp/AWatch-rus/ansible/deploy_aw_server.yml` — основной playbook.
- `/home/igor/tmp/AWatch-rus/ansible/provision_proxmox_ct_and_deploy_aw.yml` — full-stack playbook для Proxmox.
- `/home/igor/tmp/AWatch-rus/ansible/provision_proxmox_ct_matrix_and_deploy_aw.yml` — массовый full-stack playbook (несколько CT).
- `/home/igor/tmp/AWatch-rus/ansible/deploy_aw_windows_phase2.yml` — WinRM playbook для развёртывания phase-2 Windows collector'ов.
- `/home/igor/tmp/AWatch-rus/ansible/deploy_aw_pfsense_poller.yml` — deployment pfSense poller'а.
- `/home/igor/tmp/AWatch-rus/ansible/inventory.example.ini` — шаблон inventory.
- `/home/igor/tmp/AWatch-rus/ansible/group_vars/all.example.yml` — шаблон переменных.
- `/home/igor/tmp/AWatch-rus/ansible/group_vars/proxmox.example.yml` — шаблон переменных CT в Proxmox.
- `/home/igor/tmp/AWatch-rus/ansible/group_vars/proxmox-matrix.example.yml` — шаблон матрицы CT.
- `/home/igor/tmp/AWatch-rus/ansible/group_vars/windows.example.yml` — шаблон переменных Windows phase-2.
- `/home/igor/tmp/AWatch-rus/ansible/group_vars/pfsense-poller.example.yml` — шаблон переменных pfSense poller'а.
## Быстрый запуск
1. Скопируйте шаблоны:
- `cp /home/igor/tmp/AWatch-rus/ansible/inventory.example.ini /home/igor/tmp/AWatch-rus/ansible/inventory.ini`
- `cp /home/igor/tmp/AWatch-rus/ansible/group_vars/all.example.yml /home/igor/tmp/AWatch-rus/ansible/group_vars/all.yml`
2. Заполните значения в `inventory.ini` и `group_vars/all.yml`.
3. Запустите:
```bash
cd /home/igor/tmp/AWatch-rus/ansible
ansible-playbook -i inventory.ini deploy_aw_server.yml
```
## Полный запуск с нуля в Proxmox
1. Подготовьте inventory и vars:
- `cp /home/igor/tmp/AWatch-rus/ansible/inventory.example.ini /home/igor/tmp/AWatch-rus/ansible/inventory.ini`
- `cp /home/igor/tmp/AWatch-rus/ansible/group_vars/all.example.yml /home/igor/tmp/AWatch-rus/ansible/group_vars/all.yml`
- `cp /home/igor/tmp/AWatch-rus/ansible/group_vars/proxmox.example.yml /home/igor/tmp/AWatch-rus/ansible/group_vars/proxmox.yml`
2. Заполните `group_vars/proxmox.yml` и `group_vars/all.yml`.
3. Запустите playbook:
```bash
cd /home/igor/tmp/AWatch-rus/ansible
ansible-playbook -i inventory.ini provision_proxmox_ct_and_deploy_aw.yml
```
## Массовый запуск (матрица CT)
1. Подготовьте матрицу:
- `cp /home/igor/tmp/AWatch-rus/ansible/group_vars/proxmox-matrix.example.yml /home/igor/tmp/AWatch-rus/ansible/group_vars/proxmox-matrix.yml`
2. Заполните `proxmox-matrix.yml`.
3. Запустите:
```bash
cd /home/igor/tmp/AWatch-rus/ansible
ansible-playbook -i inventory.ini provision_proxmox_ct_matrix_and_deploy_aw.yml
```
## Windows phase-2 rollout (WinRM)
1. Подготовьте inventory и vars:
- `cp /home/igor/tmp/AWatch-rus/ansible/inventory.example.ini /home/igor/tmp/AWatch-rus/ansible/inventory.ini`
- `cp /home/igor/tmp/AWatch-rus/ansible/group_vars/windows.example.yml /home/igor/tmp/AWatch-rus/ansible/group_vars/windows.yml`
2. Заполните `inventory.ini` (секция `[aw_windows]`) и `group_vars/windows.yml`.
3. Запустите:
```bash
cd /home/igor/tmp/AWatch-rus/ansible
ansible-playbook -i inventory.ini deploy_aw_windows_phase2.yml
```
Playbook:
- выгружает `windows/*` toolkit на целевой хост в `C:\Deploy\AWatch-rus\windows`;
- выполняет `deploy-ensemble.ps1` (deploy + hardening/recovery) с phase-2 policy/rules;
- запускает `validate-deployment.ps1`;
- забирает JSON-отчёт в локальную директорию (`/tmp/aw-rus-validation` по умолчанию).
Дополнительные флаги:
- `aw_windows_afk_enabled: false` — не запускать `aw-watcher-afk`;
- `aw_windows_window_enabled: false` — не запускать `aw-watcher-window`;
- `aw_windows_incident_capture_enabled: false` — отключить блок incidentCapture;
- `aw_windows_incident_screenshot_enabled: false` — не делать скриншот при DLP-инциденте;
- `aw_windows_incident_artifacts_root: 'C:\...\incident-artifacts'` — переопределить путь артефактов;
- `aw_windows_skip_hardening: true` — пропустить `hardening-recovery.ps1` внутри ensemble-скрипта.
## pfSense poller rollout
1. Подготовьте vars:
- `cp /home/igor/tmp/AWatch-rus/ansible/group_vars/pfsense-poller.example.yml /home/igor/tmp/AWatch-rus/ansible/group_vars/pfsense-poller.yml`
2. Добавьте inventory group `[aw_pfsense_pollers]`.
3. Запустите:
```bash
cd /home/igor/tmp/AWatch-rus/ansible
ansible-playbook -i inventory.ini deploy_aw_pfsense_poller.yml
```
Playbook:
- ставит `python3`;
- копирует `pfsense-aw-poller.py`;
- пишет `/etc/aw-pfsense/poller.json`;
- поднимает `aw-pfsense-poller.service`.
## Результат
- Установлен ActivityWatch Server.
- Создан systemd-unit `activitywatch-server.service`.
- Установлен RU Web UI patch.
- Для Web UI используется checksum-based cache-bust для `ru-patch-v5.js` и `sw-cleanup.js`, чтобы браузер не держал старую DLP/русскую статику после деплоя.
- На `#/home` Web UI делит хосты на `Windows RDP` и `Virtual servers + Proxmox`.
- Выполнена валидация API `http://127.0.0.1:5600/api/0/info`.
- Для full-stack сценария CT создаётся автоматически через `pct create`.
@@ -0,0 +1,66 @@
---
- name: Deploy pfSense ActivityWatch poller
hosts: aw_pfsense_pollers
become: true
gather_facts: true
vars:
aw_pfsense_install_root: "/opt/aw-pfsense"
aw_pfsense_config_dir: "/etc/aw-pfsense"
aw_pfsense_service_name: "aw-pfsense-poller.service"
tasks:
- name: Install required packages
ansible.builtin.apt:
name:
- python3
state: present
update_cache: true
- name: Ensure directories exist
ansible.builtin.file:
path: "{{ item }}"
state: directory
mode: "0755"
loop:
- "{{ aw_pfsense_install_root }}"
- "{{ aw_pfsense_config_dir }}"
- name: Install pfSense poller script
ansible.builtin.copy:
src: "{{ aw_repo_root }}/pfsense/pfsense-aw-poller.py"
dest: "{{ aw_pfsense_install_root }}/pfsense-aw-poller.py"
mode: "0755"
- name: Install systemd service
ansible.builtin.copy:
src: "{{ aw_repo_root }}/pfsense/pfsense-aw-poller.service"
dest: "/etc/systemd/system/{{ aw_pfsense_service_name }}"
mode: "0644"
notify:
- Reload systemd
- name: Write pfSense poller config
ansible.builtin.copy:
dest: "{{ aw_pfsense_config_dir }}/poller.json"
mode: "0600"
content: "{{ aw_pfsense_poller_config | to_nice_json }}"
notify:
- Restart pfSense poller
- name: Enable and start pfSense poller
ansible.builtin.systemd:
name: "{{ aw_pfsense_service_name }}"
enabled: true
state: restarted
daemon_reload: true
handlers:
- name: Reload systemd
ansible.builtin.systemd:
daemon_reload: true
- name: Restart pfSense poller
ansible.builtin.systemd:
name: "{{ aw_pfsense_service_name }}"
state: restarted
@@ -0,0 +1,229 @@
---
- name: Deploy AWatch-rus server
hosts: aw_server
become: true
gather_facts: true
vars:
aw_release_root: "/opt/activitywatch/releases"
aw_release_dir: "{{ aw_release_root }}/{{ aw_server_version }}"
aw_archive_path: "/tmp/activitywatch-{{ aw_server_version }}.zip"
aw_bootstrap_dir: "/tmp/aw-rus-bootstrap"
aw_ru_patch_cache_bust: "{{ lookup('file', aw_repo_root + '/aw-server/aw-ru-patch.js') | hash('sha1') | truncate(12, true, '') }}"
aw_sw_cleanup_cache_bust: "{{ lookup('file', aw_repo_root + '/aw-server/aw-sw-cleanup.js') | hash('sha1') | truncate(12, true, '') }}"
aw_host_groups_cache_bust: "{{ lookup('file', aw_repo_root + '/aw-server/aw-host-groups.json') | hash('sha1') | truncate(12, true, '') }}"
aw_worktime_classes: "{{ lookup('file', aw_repo_root + '/aw-server/settings/classes-worktime.json') | from_json }}"
aw_default_views: "{{ lookup('file', aw_repo_root + '/aw-server/settings/views-default.json') | from_json }}"
tasks:
- name: Install base packages
ansible.builtin.apt:
name:
- curl
- unzip
state: present
update_cache: true
- name: Ensure service account exists
ansible.builtin.user:
name: "{{ aw_server_user }}"
group: "{{ aw_server_group }}"
home: "{{ aw_server_data_dir }}"
shell: /usr/sbin/nologin
system: true
create_home: false
- name: Ensure required directories
ansible.builtin.file:
path: "{{ item }}"
state: directory
owner: "{{ aw_server_user }}"
group: "{{ aw_server_group }}"
mode: "0755"
loop:
- "{{ aw_release_root }}"
- "{{ aw_release_dir }}"
- "{{ aw_server_webui_dir }}"
- "{{ aw_server_data_dir }}"
- "{{ aw_server_log_dir }}"
- /etc/activitywatch
- "{{ aw_bootstrap_dir }}"
- name: Download ActivityWatch release archive
ansible.builtin.get_url:
url: "{{ aw_server_download_url }}"
dest: "{{ aw_archive_path }}"
mode: "0644"
- name: Unpack ActivityWatch release
ansible.builtin.unarchive:
src: "{{ aw_archive_path }}"
dest: "{{ aw_release_dir }}"
remote_src: true
extra_opts: ["-o"]
- name: Discover extracted AW directory
ansible.builtin.find:
paths: "{{ aw_release_dir }}"
file_type: directory
patterns: "activitywatch*"
register: aw_release_find
- name: Set release extracted path
ansible.builtin.set_fact:
aw_release_extracted: "{{ (aw_release_find.files | sort(attribute='path') | map(attribute='path') | list | first) }}"
- name: Verify extracted directory exists
ansible.builtin.assert:
that:
- aw_release_extracted is defined
- aw_release_extracted | length > 0
fail_msg: "Cannot locate extracted ActivityWatch release directory."
- name: Sync release content to /opt/activitywatch
ansible.builtin.command:
cmd: "rsync -a --delete {{ aw_release_extracted }}/ /opt/activitywatch/"
- name: Copy bootstrap files from repository
ansible.builtin.copy:
src: "{{ item.src }}"
dest: "{{ item.dest }}"
mode: "{{ item.mode }}"
loop:
- { src: "{{ aw_repo_root }}/aw-server/activitywatch-server.service", dest: "/etc/systemd/system/activitywatch-server.service", mode: "0644" }
- { src: "{{ aw_repo_root }}/aw-server/aw-ru-patch.js", dest: "{{ aw_server_webui_dir }}/js/ru-patch-v5.js", mode: "0644" }
- { src: "{{ aw_repo_root }}/aw-server/aw-sw-cleanup.js", dest: "{{ aw_server_webui_dir }}/js/sw-cleanup.js", mode: "0644" }
- { src: "{{ aw_repo_root }}/aw-server/aw-host-groups.json", dest: "{{ aw_server_webui_dir }}/js/aw-host-groups.json", mode: "0644" }
notify:
- Reload systemd
- Restart activitywatch
- name: Copy WebUI index template from installed distribution
ansible.builtin.copy:
remote_src: true
src: "/opt/activitywatch/aw-webui/index.html"
dest: "{{ aw_server_webui_dir }}/index.html"
mode: "0644"
- name: Insert RU patch scripts into index.html
ansible.builtin.replace:
path: "{{ aw_server_webui_dir }}/index.html"
regexp: '</head>'
replace: '<script src="/js/sw-cleanup.js?v={{ aw_sw_cleanup_cache_bust }}"></script></head>'
- name: Insert RU patch loader before body end
ansible.builtin.replace:
path: "{{ aw_server_webui_dir }}/index.html"
regexp: '</body>'
replace: '<script defer="defer" src="/js/ru-patch-v5.js?v={{ aw_ru_patch_cache_bust }}"></script></body>'
- name: Write /etc/activitywatch/aw-server.env
ansible.builtin.copy:
dest: /etc/activitywatch/aw-server.env
mode: "0640"
content: |
AW_SERVER_HOST={{ aw_server_bind_host }}
AW_SERVER_PORT={{ aw_server_port }}
AW_DATA_DIR={{ aw_server_data_dir }}
AW_LOG_DIR={{ aw_server_log_dir }}
AW_WEBUI_DIR={{ aw_server_webui_dir }}
AW_SERVER_USER={{ aw_server_user }}
AW_SERVER_GROUP={{ aw_server_group }}
- name: Enable and start service
ansible.builtin.systemd:
name: activitywatch-server.service
enabled: true
state: restarted
daemon_reload: true
- name: Wait for API
ansible.builtin.uri:
url: "http://127.0.0.1:{{ aw_server_port }}/api/0/info"
method: GET
status_code: 200
register: aw_api
retries: 10
delay: 3
until: aw_api.status == 200
- name: Apply baseline worktime settings (classes)
ansible.builtin.uri:
url: "http://127.0.0.1:{{ aw_server_port }}/api/0/settings/classes"
method: POST
body: "{{ aw_worktime_classes }}"
body_format: json
status_code: 201
when: aw_apply_worktime_settings | default(false) | bool
- name: Apply baseline views (include DLP and worktime)
ansible.builtin.uri:
url: "http://127.0.0.1:{{ aw_server_port }}/api/0/settings/views"
method: POST
body: "{{ aw_default_views }}"
body_format: json
status_code: 201
when: aw_apply_worktime_settings | default(false) | bool
- name: Derive worktime durationDefault from aw_worktime_from/to
ansible.builtin.set_fact:
aw_worktime_from_h: "{{ (aw_worktime_from | default('08:00')).split(':')[0] | int }}"
aw_worktime_from_m: "{{ (aw_worktime_from | default('08:00')).split(':')[1] | int }}"
aw_worktime_to_h: "{{ (aw_worktime_to | default('17:00')).split(':')[0] | int }}"
aw_worktime_to_m: "{{ (aw_worktime_to | default('17:00')).split(':')[1] | int }}"
aw_worktime_duration_default_derived: >-
{{
(
(
((aw_worktime_to_h | int) * 60 + (aw_worktime_to_m | int)) -
((aw_worktime_from_h | int) * 60 + (aw_worktime_from_m | int))
) * 60
)
}}
when: aw_apply_worktime_settings | default(false) | bool
- name: Normalize derived durationDefault for overnight shifts
ansible.builtin.set_fact:
aw_worktime_duration_default_effective: >-
{{
(aw_worktime_duration_default_derived | int)
if (aw_worktime_duration_default_derived | int) > 0
else ((aw_worktime_duration_default_derived | int) + 86400)
}}
when: aw_apply_worktime_settings | default(false) | bool
- name: Validate derived durationDefault is sane
ansible.builtin.assert:
that:
- aw_worktime_duration_default_effective | int > 0
- aw_worktime_duration_default_effective | int <= 86400
fail_msg: "Invalid worktime window: {{ aw_worktime_from }}..{{ aw_worktime_to }}"
when: aw_apply_worktime_settings | default(false) | bool
- name: Apply baseline worktime period (startOfDay)
ansible.builtin.uri:
url: "http://127.0.0.1:{{ aw_server_port }}/api/0/settings/startOfDay"
method: POST
body: "{{ aw_worktime_start_of_day }}"
body_format: json
status_code: 200
when: aw_apply_worktime_settings | default(false) | bool
- name: Apply baseline worktime period (durationDefault seconds)
ansible.builtin.uri:
url: "http://127.0.0.1:{{ aw_server_port }}/api/0/settings/durationDefault"
method: POST
body: "{{ aw_worktime_duration_default_effective }}"
body_format: json
status_code: 200
when: aw_apply_worktime_settings | default(false) | bool
handlers:
- name: Reload systemd
ansible.builtin.systemd:
daemon_reload: true
- name: Restart activitywatch
ansible.builtin.systemd:
name: activitywatch-server.service
state: restarted
@@ -0,0 +1,133 @@
---
- name: Deploy AWatch-rus Windows phase2 collectors
hosts: aw_windows
gather_facts: false
vars:
aw_windows_repo_root: "/home/igor/tmp/AWatch-rus"
aw_windows_deploy_root: "C:\\Deploy\\AWatch-rus"
aw_windows_server_host: "10.10.10.13"
aw_windows_server_port: 5600
aw_windows_domain: "SHARKON2025"
aw_windows_users:
- user1
- user2
- user3
- user4
- user5
aw_windows_extra_users: []
aw_windows_users_effective: "{{ (aw_windows_users + aw_windows_extra_users) | unique }}"
aw_windows_install_root: "C:\\Program Files\\ActivityWatch-Phase2"
aw_windows_state_root: "C:\\ProgramData\\ActivityWatch"
aw_windows_afk_enabled: true
aw_windows_window_enabled: true
aw_windows_local_agent_logs_enabled: false
aw_windows_incident_capture_enabled: true
aw_windows_incident_screenshot_enabled: true
aw_windows_incident_artifacts_root: "{{ aw_windows_state_root }}\\incident-artifacts"
aw_windows_logon_marker_enabled: true
aw_windows_skip_hardening: false
aw_windows_rules_path: "{{ aw_windows_deploy_root }}\\windows\\web-category-rules.example.json"
aw_windows_policy_path: "{{ aw_windows_deploy_root }}\\windows\\dlp-policy.example.json"
aw_windows_validation_remote_path: "C:\\Windows\\Temp\\aw_validate_phase2_ansible.json"
aw_windows_validation_local_dir: "/tmp/aw-rus-validation"
tasks:
- name: Validate required variables
ansible.builtin.assert:
that:
- aw_windows_server_host is defined
- aw_windows_server_port is defined
- aw_windows_domain is defined
- aw_windows_users_effective | length > 0
- aw_windows_install_root is defined
- aw_windows_state_root is defined
fail_msg: "Missing required Windows deployment variables."
- name: Ensure deploy directories exist
ansible.windows.win_file:
path: "{{ item }}"
state: directory
loop:
- "{{ aw_windows_deploy_root }}"
- "{{ aw_windows_deploy_root }}\\windows"
- name: Upload Windows deployment toolkit
ansible.windows.win_copy:
src: "{{ aw_windows_repo_root }}/windows/{{ item }}"
dest: "{{ aw_windows_deploy_root }}\\windows\\{{ item }}"
loop:
- ActivityWatch.Windows.Common.psd1
- ActivityWatch.Windows.Common.psm1
- browser-domains-native-collector.ps1
- dlp-endpoint-signals-collector.ps1
- deploy-domain-users.ps1
- deploy-ensemble.ps1
- hardening-recovery.ps1
- validate-deployment.ps1
- web-category-rules.example.json
- dlp-policy.example.json
- name: Upload user list for domain deploy
ansible.windows.win_copy:
dest: "{{ aw_windows_deploy_root }}\\windows\\users.txt"
content: |
{% for user in aw_windows_users -%}
{{ user }}
{% endfor -%}
{% for user in aw_windows_extra_users -%}
{{ user }}
{% endfor -%}
- name: Run phase2 ensemble deployment
ansible.windows.win_powershell:
script: |
$ErrorActionPreference = 'Stop'
$params = @{
ServerHost = "{{ aw_windows_server_host }}"
ServerPort = {{ aw_windows_server_port }}
Domain = "{{ aw_windows_domain }}"
UserListPath = "{{ aw_windows_deploy_root }}\windows\users.txt"
InstallRoot = "{{ aw_windows_install_root }}"
StateRoot = "{{ aw_windows_state_root }}"
AfkEnabled = {{ '$true' if (aw_windows_afk_enabled | bool) else '$false' }}
WindowEnabled = {{ '$true' if (aw_windows_window_enabled | bool) else '$false' }}
LocalAgentLogsEnabled = {{ '$true' if (aw_windows_local_agent_logs_enabled | bool) else '$false' }}
IncidentCaptureEnabled = {{ '$true' if (aw_windows_incident_capture_enabled | bool) else '$false' }}
IncidentScreenshotEnabled = {{ '$true' if (aw_windows_incident_screenshot_enabled | bool) else '$false' }}
IncidentArtifactsRoot = "{{ aw_windows_incident_artifacts_root }}"
LogonMarkerEnabled = {{ '$true' if (aw_windows_logon_marker_enabled | bool) else '$false' }}
CustomRulesPath = "{{ aw_windows_rules_path }}"
CustomPolicyPath = "{{ aw_windows_policy_path }}"
}
{% if aw_windows_skip_hardening | bool %}
$params.SkipHardening = $true
{% endif %}
& "{{ aw_windows_deploy_root }}\windows\deploy-ensemble.ps1" @params
- name: Run validation and store report on target
ansible.windows.win_powershell:
script: |
$ErrorActionPreference = 'Stop'
$report = & "{{ aw_windows_deploy_root }}\windows\validate-deployment.ps1" `
-ConfigPath "{{ aw_windows_state_root }}\deployment-config.json"
$report | ConvertTo-Json -Depth 12 | Out-File -FilePath "{{ aw_windows_validation_remote_path }}" -Encoding utf8
- name: Ensure local validation directory exists
ansible.builtin.file:
path: "{{ aw_windows_validation_local_dir }}"
state: directory
mode: "0755"
delegate_to: localhost
- name: Fetch validation report
ansible.builtin.fetch:
src: "{{ aw_windows_validation_remote_path }}"
dest: "{{ aw_windows_validation_local_dir }}/"
flat: false
- name: Show report location
ansible.builtin.debug:
msg:
- "Windows phase2 deploy completed on {{ inventory_hostname }}."
- "Validation report: {{ aw_windows_validation_local_dir }}/{{ inventory_hostname }}/C$/Windows/Temp/aw_validate_phase2_ansible.json"
@@ -0,0 +1,24 @@
aw_server_version: "v0.13.2"
aw_server_download_url: "https://github.com/ActivityWatch/activitywatch/releases/download/v0.13.2/activitywatch-v0.13.2-linux-x86_64.zip"
aw_server_bind_host: "0.0.0.0"
aw_server_port: 5600
aw_server_webui_dir: "/opt/activitywatch/webui-ru"
aw_server_data_dir: "/var/lib/activitywatch"
aw_server_log_dir: "/var/log/activitywatch"
aw_server_user: "activitywatch"
aw_server_group: "activitywatch"
aw_repo_root: "/home/igor/tmp/AWatch-rus"
# Optional: apply a baseline worktime-focused categorization and views via AW settings API.
# WARNING: this overwrites existing server-side settings/classes/views.
aw_apply_worktime_settings: false
# Optional defaults for the worktime period in Web UI.
# startOfDay controls day-boundary and default report window start.
# durationDefault controls default time range (seconds) shown in UI.
#
# Recommended: set worktime window explicitly and let the playbook derive duration.
aw_worktime_from: "08:00"
aw_worktime_to: "17:00"
aw_worktime_start_of_day: "{{ aw_worktime_from }}"
@@ -0,0 +1,30 @@
aw_pfsense_poller_config:
poll_interval_seconds: 60
aw:
server_host: "10.10.10.13"
server_port: 5600
hostname: "PFSENSE-EDGE01"
pulse_time_seconds: 120
timeout_seconds: 15
pfsense:
name: "pfSense Edge 01"
host: "10.10.10.1"
scheme: "https"
verify_tls: false
timeout_seconds: 15
headers:
X-API-Key: "replace-me"
X-API-Secret: "replace-me"
endpoints:
- name: "system-status"
path: "/api/v2/status/system"
bucket_prefix: "aw-pfsense-health"
bucket_type: "aw.pfsense.health"
- name: "interfaces"
path: "/api/v2/interface"
bucket_prefix: "aw-pfsense-interfaces"
bucket_type: "aw.pfsense.interfaces"
- name: "gateways"
path: "/api/v2/status/gateways"
bucket_prefix: "aw-pfsense-gateways"
bucket_type: "aw.pfsense.gateways"
@@ -0,0 +1,38 @@
proxmox_ct_matrix:
- id: "203"
hostname: "activitywatch-user1"
storage: "local-lvm"
template: "local:vztmpl/debian-12-standard_12.7-1_amd64.tar.zst"
rootfs_size: "8G"
cores: "2"
memory: "2048"
swap: "512"
bridge: "vmbr10"
ip: "10.20.30.13/24"
gw: "10.20.30.1"
vlan: ""
nameserver: "1.1.1.1 8.8.8.8"
searchdomain: "example.internal"
password: "CHANGE_ME"
unprivileged: "1"
onboot: "1"
features: "nesting=1,keyctl=1"
- id: "204"
hostname: "activitywatch-user2"
storage: "local-lvm"
template: "local:vztmpl/debian-12-standard_12.7-1_amd64.tar.zst"
rootfs_size: "8G"
cores: "2"
memory: "2048"
swap: "512"
bridge: "vmbr10"
ip: "10.20.30.14/24"
gw: "10.20.30.1"
vlan: ""
nameserver: "1.1.1.1 8.8.8.8"
searchdomain: "example.internal"
password: "CHANGE_ME"
unprivileged: "1"
onboot: "1"
features: "nesting=1,keyctl=1"
@@ -0,0 +1,18 @@
proxmox_ct_id: "203"
proxmox_ct_hostname: "activitywatch-server"
proxmox_ct_storage: "local-lvm"
proxmox_ct_template: "local:vztmpl/debian-12-standard_12.7-1_amd64.tar.zst"
proxmox_ct_rootfs_size: "8G"
proxmox_ct_cores: "2"
proxmox_ct_memory: "2048"
proxmox_ct_swap: "512"
proxmox_ct_bridge: "vmbr10"
proxmox_ct_ip: "10.20.30.13/24"
proxmox_ct_gw: "10.20.30.1"
proxmox_ct_vlan: ""
proxmox_ct_nameserver: "1.1.1.1 8.8.8.8"
proxmox_ct_searchdomain: "example.internal"
proxmox_ct_password: "CHANGE_ME"
proxmox_ct_unprivileged: "1"
proxmox_ct_onboot: "1"
proxmox_ct_features: "nesting=1,keyctl=1"
@@ -0,0 +1,33 @@
aw_windows_repo_root: "/home/igor/tmp/AWatch-rus"
aw_windows_deploy_root: "C:\\Deploy\\AWatch-rus"
aw_windows_server_host: "10.10.10.13"
aw_windows_server_port: 5600
aw_windows_domain: "SHARKON2025"
aw_windows_users:
- user1
- user2
- user3
- user4
- user5
aw_windows_extra_users: []
# Например:
# aw_windows_extra_users:
# - Администратор
# Рекомендуемый изолированный профиль для фазового раската.
aw_windows_install_root: "C:\\Program Files\\ActivityWatch-Phase2"
aw_windows_state_root: "C:\\ProgramData\\ActivityWatch"
aw_windows_afk_enabled: true
aw_windows_window_enabled: true
aw_windows_local_agent_logs_enabled: false
aw_windows_incident_capture_enabled: true
aw_windows_incident_screenshot_enabled: true
aw_windows_incident_artifacts_root: "{{ aw_windows_state_root }}\\incident-artifacts"
aw_windows_logon_marker_enabled: true
aw_windows_skip_hardening: false
aw_windows_rules_path: "{{ aw_windows_deploy_root }}\\windows\\web-category-rules.example.json"
aw_windows_policy_path: "{{ aw_windows_deploy_root }}\\windows\\dlp-policy.example.json"
aw_windows_validation_remote_path: "C:\\Windows\\Temp\\aw_validate_phase2_ansible.json"
aw_windows_validation_local_dir: "/tmp/aw-rus-validation"
@@ -0,0 +1,8 @@
[proxmox]
pve-main ansible_host=192.168.10.2 ansible_user=root ansible_port=22
[aw_server]
aw-ct ansible_host=10.20.30.13 ansible_user=root ansible_port=22
[aw_windows]
win-node1 ansible_host=192.168.100.21 ansible_user=Administrator ansible_password=CHANGE_ME ansible_connection=winrm ansible_winrm_transport=ntlm ansible_port=5985 ansible_winrm_server_cert_validation=ignore
@@ -0,0 +1,40 @@
---
- name: Provision single Proxmox CT and deploy AWatch-rus
hosts: proxmox
gather_facts: false
vars:
proxmox_bootstrap_dir: "/tmp/aw-rus-bootstrap"
aw_bootstrap_files:
- install_aw_server.sh
- apply_webui_ru_patch.sh
- activitywatch-server.service
- aw-server.env.example
- aw-ru-patch.js
- aw-sw-cleanup.js
- aw-host-groups.json
- settings/classes-worktime.json
- settings/views-default.json
tasks:
- name: Execute single-CT provisioning workflow
ansible.builtin.include_tasks: tasks/provision_ct_and_deploy_aw.yml
vars:
ct_id: "{{ proxmox_ct_id }}"
ct_hostname: "{{ proxmox_ct_hostname }}"
ct_storage: "{{ proxmox_ct_storage }}"
ct_template: "{{ proxmox_ct_template }}"
ct_rootfs_size: "{{ proxmox_ct_rootfs_size }}"
ct_cores: "{{ proxmox_ct_cores }}"
ct_memory: "{{ proxmox_ct_memory }}"
ct_swap: "{{ proxmox_ct_swap }}"
ct_bridge: "{{ proxmox_ct_bridge }}"
ct_ip: "{{ proxmox_ct_ip }}"
ct_gw: "{{ proxmox_ct_gw }}"
ct_vlan: "{{ proxmox_ct_vlan | default('') }}"
ct_nameserver: "{{ proxmox_ct_nameserver | default('') }}"
ct_searchdomain: "{{ proxmox_ct_searchdomain | default('') }}"
ct_password: "{{ proxmox_ct_password }}"
ct_unprivileged: "{{ proxmox_ct_unprivileged }}"
ct_onboot: "{{ proxmox_ct_onboot }}"
ct_features: "{{ proxmox_ct_features }}"
@@ -0,0 +1,50 @@
---
- name: Provision Proxmox CT matrix and deploy AWatch-rus with RU patch
hosts: proxmox
gather_facts: false
vars:
proxmox_bootstrap_dir: "/tmp/aw-rus-bootstrap"
aw_bootstrap_files:
- install_aw_server.sh
- apply_webui_ru_patch.sh
- activitywatch-server.service
- aw-server.env.example
- aw-ru-patch.js
- aw-sw-cleanup.js
- aw-host-groups.json
- settings/classes-worktime.json
- settings/views-default.json
tasks:
- name: Validate CT matrix is provided
ansible.builtin.assert:
that:
- proxmox_ct_matrix is defined
- proxmox_ct_matrix | length > 0
fail_msg: "Define proxmox_ct_matrix in group_vars/proxmox-matrix.yml"
- name: Execute provisioning workflow for each CT
ansible.builtin.include_tasks: tasks/provision_ct_and_deploy_aw.yml
vars:
ct_id: "{{ item.id }}"
ct_hostname: "{{ item.hostname }}"
ct_storage: "{{ item.storage }}"
ct_template: "{{ item.template }}"
ct_rootfs_size: "{{ item.rootfs_size }}"
ct_cores: "{{ item.cores }}"
ct_memory: "{{ item.memory }}"
ct_swap: "{{ item.swap }}"
ct_bridge: "{{ item.bridge }}"
ct_ip: "{{ item.ip }}"
ct_gw: "{{ item.gw }}"
ct_vlan: "{{ item.vlan | default('') }}"
ct_nameserver: "{{ item.nameserver | default('') }}"
ct_searchdomain: "{{ item.searchdomain | default('') }}"
ct_password: "{{ item.password }}"
ct_unprivileged: "{{ item.unprivileged }}"
ct_onboot: "{{ item.onboot }}"
ct_features: "{{ item.features }}"
loop: "{{ proxmox_ct_matrix }}"
loop_control:
label: "ct={{ item.id }} host={{ item.hostname }} ip={{ item.ip }}"
@@ -0,0 +1,217 @@
---
- name: Validate required per-CT variables
ansible.builtin.assert:
that:
- ct_id is defined
- ct_hostname is defined
- ct_storage is defined
- ct_template is defined
- ct_rootfs_size is defined
- ct_cores is defined
- ct_memory is defined
- ct_swap is defined
- ct_bridge is defined
- ct_ip is defined
- ct_gw is defined
- ct_password is defined
- ct_unprivileged is defined
- ct_onboot is defined
- ct_features is defined
- aw_repo_root is defined
- aw_server_version is defined
- aw_server_download_url is defined
- aw_server_bind_host is defined
- aw_server_port is defined
- aw_server_webui_dir is defined
- aw_server_data_dir is defined
- aw_server_log_dir is defined
- aw_server_user is defined
- aw_server_group is defined
fail_msg: "Missing required variables for CT provisioning/deploy."
- name: Build CT network string
ansible.builtin.set_fact:
ct_net0: >-
name=eth0,bridge={{ ct_bridge }},ip={{ ct_ip }},gw={{ ct_gw }}{% if (ct_vlan | default('') | string | length) > 0 %},tag={{ ct_vlan }}{% endif %}
- name: Check whether CT already exists
ansible.builtin.command:
argv:
- pct
- status
- "{{ ct_id }}"
register: ct_status_check
failed_when: false
changed_when: false
- name: Create CT when absent
ansible.builtin.command:
argv:
- pct
- create
- "{{ ct_id }}"
- "{{ ct_template }}"
- --hostname
- "{{ ct_hostname }}"
- --cores
- "{{ ct_cores }}"
- --memory
- "{{ ct_memory }}"
- --swap
- "{{ ct_swap }}"
- --rootfs
- "{{ ct_storage }}:{{ ct_rootfs_size }}"
- --password
- "{{ ct_password }}"
- --unprivileged
- "{{ ct_unprivileged }}"
- --onboot
- "{{ ct_onboot }}"
- --features
- "{{ ct_features }}"
- --net0
- "{{ ct_net0 }}"
- --nameserver
- "{{ ct_nameserver | default('') }}"
- --searchdomain
- "{{ ct_searchdomain | default('') }}"
- --ostype
- debian
when: ct_status_check.rc != 0
- name: Check current CT runtime state
ansible.builtin.command:
argv:
- pct
- status
- "{{ ct_id }}"
register: ct_runtime_status
changed_when: false
- name: Start CT when stopped
ansible.builtin.command:
argv:
- pct
- start
- "{{ ct_id }}"
when: "'stopped' in ct_runtime_status.stdout"
- name: Ensure bootstrap directory on Proxmox host
ansible.builtin.file:
path: "{{ proxmox_bootstrap_dir }}"
state: directory
mode: "0700"
- name: Copy AW bootstrap files to Proxmox host temp
ansible.builtin.copy:
src: "{{ aw_repo_root }}/aw-server/{{ item }}"
dest: "{{ proxmox_bootstrap_dir }}/{{ item }}"
mode: "0644"
loop: "{{ aw_bootstrap_files }}"
- name: Bootstrap CT OS dependencies
ansible.builtin.command:
argv:
- pct
- exec
- "{{ ct_id }}"
- --
- bash
- -lc
- |
set -euo pipefail
export DEBIAN_FRONTEND=noninteractive
apt-get update
apt-get install -y curl ca-certificates bash unzip xz-utils jq rsync openssh-server
mkdir -p /root/bootstrap /etc/activitywatch
systemctl enable ssh || true
systemctl restart ssh || true
- name: Push bootstrap files into CT
ansible.builtin.command:
argv:
- pct
- push
- "{{ ct_id }}"
- "{{ proxmox_bootstrap_dir }}/{{ item }}"
- "/root/bootstrap/{{ item }}"
loop: "{{ aw_bootstrap_files }}"
- name: Write AW server env file on Proxmox host temp
ansible.builtin.copy:
dest: "{{ proxmox_bootstrap_dir }}/aw-server.env"
mode: "0600"
content: |
AW_SERVER_VERSION={{ aw_server_version }}
AW_SERVER_DOWNLOAD_URL={{ aw_server_download_url }}
AW_SERVER_BIND_HOST={{ aw_server_bind_host }}
AW_SERVER_PORT={{ aw_server_port }}
AW_SERVER_WEBUI_DIR={{ aw_server_webui_dir }}
AW_SERVER_DATA_DIR={{ aw_server_data_dir }}
AW_SERVER_LOG_DIR={{ aw_server_log_dir }}
AW_SERVER_USER={{ aw_server_user }}
AW_SERVER_GROUP={{ aw_server_group }}
- name: Push AW server env into CT
ansible.builtin.command:
argv:
- pct
- push
- "{{ ct_id }}"
- "{{ proxmox_bootstrap_dir }}/aw-server.env"
- /etc/activitywatch/aw-server.env
- name: Set mode for env inside CT
ansible.builtin.command:
argv:
- pct
- exec
- "{{ ct_id }}"
- --
- chmod
- "0600"
- /etc/activitywatch/aw-server.env
- name: Install server and apply RU patch inside CT
ansible.builtin.command:
argv:
- pct
- exec
- "{{ ct_id }}"
- --
- bash
- -lc
- |
set -euo pipefail
chmod +x /root/bootstrap/install_aw_server.sh /root/bootstrap/apply_webui_ru_patch.sh
bash /root/bootstrap/install_aw_server.sh
bash /root/bootstrap/apply_webui_ru_patch.sh
systemctl restart activitywatch-server.service
- name: Validate AW API from inside CT
ansible.builtin.command:
argv:
- pct
- exec
- "{{ ct_id }}"
- --
- bash
- -lc
- "curl -fsS http://127.0.0.1:{{ aw_server_port }}/api/0/info >/dev/null"
- name: Validate RU patch hooks in index
ansible.builtin.command:
argv:
- pct
- exec
- "{{ ct_id }}"
- --
- bash
- -lc
- "grep -q 'ru-patch-v5.js' {{ aw_server_webui_dir }}/index.html && grep -q 'sw-cleanup.js' {{ aw_server_webui_dir }}/index.html"
- name: Show final endpoint
ansible.builtin.debug:
msg:
- "CT {{ ct_id }} is provisioned and configured."
- "ActivityWatch endpoint: http://{{ ct_ip | regex_replace('/[0-9]+$', '') }}:{{ aw_server_port }}"
@@ -0,0 +1,24 @@
[Unit]
Description=ActivityWatch Server (Rust)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
EnvironmentFile=/etc/activitywatch/aw-server.env
User=__AW_SERVER_USER__
Group=__AW_SERVER_GROUP__
WorkingDirectory=__AW_SERVER_DATA_DIR__
ExecStart=/bin/sh -lc 'exec /opt/activitywatch/bin/aw-server-rust --host "$AW_SERVER_BIND_HOST" --port "$AW_SERVER_PORT"'
Restart=on-failure
RestartSec=5s
StateDirectory=activitywatch
LogsDirectory=activitywatch
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ProtectHome=true
LimitNOFILE=65535
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,111 @@
#!/bin/bash
set -euo pipefail
ENV_FILE="/etc/activitywatch/aw-server.env"
if [[ ! -f "$ENV_FILE" ]]; then
echo "missing env file: $ENV_FILE" >&2
exit 1
fi
source "$ENV_FILE"
WEBUI_DIR="${AW_SERVER_WEBUI_DIR:-${AW_WEBUI_DIR:-/opt/activitywatch/webui-ru}}"
PATCH_JS_SRC="/root/bootstrap/aw-ru-patch.js"
SW_CLEANUP_SRC="/root/bootstrap/aw-sw-cleanup.js"
HOST_GROUPS_SRC="/root/bootstrap/aw-host-groups.json"
INDEX_HTML="$WEBUI_DIR/index.html"
SERVICE_WORKER="$WEBUI_DIR/service-worker.js"
TS=$(date +%Y%m%d%H%M%S)
PATCH_TARGET="$WEBUI_DIR/js/ru-patch-v5.js"
SW_TARGET="$WEBUI_DIR/js/sw-cleanup.js"
HOST_GROUPS_TARGET="$WEBUI_DIR/js/aw-host-groups.json"
TRENDS_NEEDLE='this.activityStore.query_category_time_by_period(r)'
TRENDS_REPLACEMENT='this.activityStore.ensure_loaded(r)'
TIMESPIRAL_NEEDLE='start:new Date("2022-08-08")'
TIMESPIRAL_REPLACEMENT='start:new Date(Date.now()-12*36e5)'
CATEGORY_HELPER_NEEDLE='hostname:t.hostnameChoices[0]'
CATEGORY_HELPER_REPLACEMENT='hostname:t.hostnameChoices.filter((function(t){return"unknown"!==t}))[0]||t.hostnameChoices[0]'
[[ -f "$PATCH_JS_SRC" ]] || { echo "missing $PATCH_JS_SRC" >&2; exit 1; }
[[ -f "$SW_CLEANUP_SRC" ]] || { echo "missing $SW_CLEANUP_SRC" >&2; exit 1; }
[[ -f "$HOST_GROUPS_SRC" ]] || { echo "missing $HOST_GROUPS_SRC" >&2; exit 1; }
[[ -f "$INDEX_HTML" ]] || { echo "missing $INDEX_HTML" >&2; exit 1; }
install -d "$WEBUI_DIR/js"
install -m 0644 "$PATCH_JS_SRC" "$PATCH_TARGET"
install -m 0644 "$SW_CLEANUP_SRC" "$SW_TARGET"
install -m 0644 "$HOST_GROUPS_SRC" "$HOST_GROUPS_TARGET"
cp "$INDEX_HTML" "$INDEX_HTML.bak.$TS"
patch_hash="$(sha1sum "$PATCH_TARGET" | awk '{print substr($1,1,12)}')"
sw_hash="$(sha1sum "$SW_TARGET" | awk '{print substr($1,1,12)}')"
sed -i '/ru-patch-v5.js/d;/sw-cleanup.js/d;/aw-ru-patch.js/d;/aw-sw-cleanup.js/d' "$INDEX_HTML"
sed -i "s#</head>#<script src=\"/js/sw-cleanup.js?v=$sw_hash\"></script></head>#" "$INDEX_HTML"
sed -i "s#</body>#<script defer=\"defer\" src=\"/js/ru-patch-v5.js?v=$patch_hash\"></script></body>#" "$INDEX_HTML"
cp "$SW_CLEANUP_SRC" "$SERVICE_WORKER"
trends_chunk="$(grep -Rsl "$TRENDS_NEEDLE" "$WEBUI_DIR/js"/*.js 2>/dev/null | head -n 1 || true)"
if [[ -n "$trends_chunk" ]]; then
cp "$trends_chunk" "$trends_chunk.bak.$TS"
python3 - "$trends_chunk" "$TRENDS_NEEDLE" "$TRENDS_REPLACEMENT" <<'PY'
from pathlib import Path
import sys
path = Path(sys.argv[1])
old = sys.argv[2]
new = sys.argv[3]
content = path.read_text()
if old in content:
path.write_text(content.replace(old, new, 1))
print(f"Trends hotfix applied to {path}")
else:
print(f"Trends hotfix already present in {path}")
PY
else
echo "Trends hotfix skipped: chunk not found"
fi
timespiral_chunk="$(grep -Rsl "$TIMESPIRAL_NEEDLE" "$WEBUI_DIR/js"/*.js 2>/dev/null | head -n 1 || true)"
if [[ -n "$timespiral_chunk" ]]; then
cp "$timespiral_chunk" "$timespiral_chunk.bak.$TS"
python3 - "$timespiral_chunk" "$TIMESPIRAL_NEEDLE" "$TIMESPIRAL_REPLACEMENT" <<'PY'
from pathlib import Path
import sys
path = Path(sys.argv[1])
old = sys.argv[2]
new = sys.argv[3]
content = path.read_text()
if old in content:
path.write_text(content.replace(old, new, 1))
print(f"Timespiral hotfix applied to {path}")
else:
print(f"Timespiral hotfix already present in {path}")
PY
else
echo "Timespiral hotfix skipped: chunk not found"
fi
category_helper_chunk="$(grep -Rsl "$CATEGORY_HELPER_NEEDLE" "$WEBUI_DIR/js"/*.js 2>/dev/null | head -n 1 || true)"
if [[ -n "$category_helper_chunk" ]]; then
cp "$category_helper_chunk" "$category_helper_chunk.bak.$TS"
python3 - "$category_helper_chunk" "$CATEGORY_HELPER_NEEDLE" "$CATEGORY_HELPER_REPLACEMENT" <<'PY'
from pathlib import Path
import sys
path = Path(sys.argv[1])
old = sys.argv[2]
new = sys.argv[3]
content = path.read_text()
if old in content:
path.write_text(content.replace(old, new, 1))
print(f"Category helper host hotfix applied to {path}")
else:
print(f"Category helper host hotfix already present in {path}")
PY
else
echo "Category helper host hotfix skipped: chunk not found"
fi
echo "RU patch applied to $WEBUI_DIR (ru-patch-v5.js?v=$patch_hash)"
@@ -0,0 +1,47 @@
{
"groups": [
{
"id": "pve-detmir",
"name": "pve-detmir",
"description": "Выделенный клиент DetMir в разделе Активность.",
"patterns": [
"^pve-detmir$"
],
"links": [
{ "label": "Активность", "type": "activity", "view": "pve_audit" },
{ "label": "Web-admin аудит", "type": "bucket", "bucket_prefix": "aw-pve-webadmin-events_" },
{ "label": "PVE tasks", "type": "bucket", "bucket_prefix": "aw-pve-task-events_" },
{ "label": "SSH сессии", "type": "bucket", "bucket_prefix": "aw-ssh-sessions_" },
{ "label": "Команды shell", "type": "bucket", "bucket_prefix": "aw-console-commands_" },
{ "label": "Web категории", "type": "bucket", "bucket_prefix": "aw-detmir-web-category_" },
{ "label": "Все бакеты", "type": "buckets" }
]
},
{
"id": "windows-rdp",
"name": "Windows RDP",
"description": "Пользовательские Windows/RDP хосты с активностью, DLP и рабочим временем.",
"patterns": [
"^(SHARKON|WIN|RDP|TERM|TS-|WS-)"
],
"links": [
{ "label": "Активность", "type": "activity" },
{ "label": "DLP", "type": "bucket", "bucket_prefix": "aw-dlp-endpoint-signals_" }
]
},
{
"id": "virtual-infra",
"name": "Virtual servers + Proxmox",
"description": "Инфраструктурные VM и сетевые узлы. Здесь должны лежать Proxmox, pfSense, Debian и Ubuntu серверы.",
"patterns": [
"^(PFSENSE|PVE|PROXMOX|DEBIAN|UBUNTU|LINUX|VM-|SRV-|INFRA-)"
],
"links": [
{ "label": "pfSense health", "type": "bucket", "bucket_prefix": "aw-pfsense-health_" },
{ "label": "pfSense gateways", "type": "bucket", "bucket_prefix": "aw-pfsense-gateways_" },
{ "label": "Все бакеты", "type": "buckets" }
]
}
],
"ungrouped_name": "Прочие хосты"
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,11 @@
# Copy to /etc/activitywatch/aw-server.env and fill with real values.
AW_SERVER_VERSION=0.13.2
AW_SERVER_DOWNLOAD_URL=https://github.com/ActivityWatch/aw-server-rust/releases/download/v0.13.2/aw-server-rust-linux-x86_64.zip
AW_SERVER_BIND_HOST=0.0.0.0
AW_SERVER_PORT=5600
AW_SERVER_WEBUI_DIR=/opt/activitywatch/webui-ru
AW_SERVER_DATA_DIR=/var/lib/activitywatch
AW_SERVER_LOG_DIR=/var/log/activitywatch
AW_SERVER_USER=activitywatch
AW_SERVER_GROUP=activitywatch
@@ -0,0 +1,18 @@
self.addEventListener("install", function (event) {
self.skipWaiting();
event.waitUntil((async function () {
const keys = await caches.keys();
await Promise.all(keys.map(function (key) { return caches.delete(key); }));
})());
});
self.addEventListener("activate", function (event) {
event.waitUntil((async function () {
const keys = await caches.keys();
await Promise.all(keys.map(function (key) { return caches.delete(key); }));
await self.clients.claim();
await self.registration.unregister();
})());
});
self.addEventListener("fetch", function () {});
@@ -0,0 +1,117 @@
#!/bin/bash
set -euo pipefail
ENV_FILE="/etc/activitywatch/aw-server.env"
if [[ ! -f "$ENV_FILE" ]]; then
echo "missing env file: $ENV_FILE" >&2
exit 1
fi
source "$ENV_FILE"
required_vars=(
AW_SERVER_VERSION
AW_SERVER_DOWNLOAD_URL
AW_SERVER_BIND_HOST
AW_SERVER_PORT
AW_SERVER_WEBUI_DIR
AW_SERVER_DATA_DIR
AW_SERVER_LOG_DIR
AW_SERVER_USER
AW_SERVER_GROUP
)
BOOTSTRAP_DIR="/root/bootstrap"
VIEWS_JSON="$BOOTSTRAP_DIR/settings/views-default.json"
CLASSES_JSON="$BOOTSTRAP_DIR/settings/classes-worktime.json"
for var_name in "${required_vars[@]}"; do
if [[ -z "${!var_name:-}" ]]; then
echo "missing required variable: $var_name" >&2
exit 1
fi
done
export DEBIAN_FRONTEND=noninteractive
apt-get update
apt-get install -y curl ca-certificates unzip jq
if ! getent group "$AW_SERVER_GROUP" >/dev/null; then
groupadd --system "$AW_SERVER_GROUP"
fi
if ! id "$AW_SERVER_USER" >/dev/null 2>&1; then
useradd --system --gid "$AW_SERVER_GROUP" --home-dir "$AW_SERVER_DATA_DIR" --shell /usr/sbin/nologin "$AW_SERVER_USER"
fi
install -d -o "$AW_SERVER_USER" -g "$AW_SERVER_GROUP" /opt/activitywatch/bin
install -d -o "$AW_SERVER_USER" -g "$AW_SERVER_GROUP" /opt/activitywatch/releases
install -d -o "$AW_SERVER_USER" -g "$AW_SERVER_GROUP" "$AW_SERVER_WEBUI_DIR"
install -d -o "$AW_SERVER_USER" -g "$AW_SERVER_GROUP" "$AW_SERVER_DATA_DIR"
install -d -o "$AW_SERVER_USER" -g "$AW_SERVER_GROUP" "$AW_SERVER_LOG_DIR"
tmp_dir=$(mktemp -d)
trap 'rm -rf "$tmp_dir"' EXIT
curl -fL "$AW_SERVER_DOWNLOAD_URL" -o "$tmp_dir/aw-server.zip"
unzip -q "$tmp_dir/aw-server.zip" -d "$tmp_dir/unpacked"
server_bin=$(find "$tmp_dir/unpacked" -type f \( -name 'aw-server-rust' -o -name 'aw-server' \) | head -n 1)
webui_dir=$(find "$tmp_dir/unpacked" -type d \( -name 'webui' -o -name 'aw-webui' \) | head -n 1 || true)
if [[ -z "$server_bin" || ! -f "$server_bin" ]]; then
echo "aw-server binary not found in archive" >&2
exit 1
fi
release_dir="/opt/activitywatch/releases/aw-server-rust-v${AW_SERVER_VERSION}"
rm -rf "$release_dir"
install -d -o "$AW_SERVER_USER" -g "$AW_SERVER_GROUP" "$release_dir"
install -m 0755 -o "$AW_SERVER_USER" -g "$AW_SERVER_GROUP" "$server_bin" "$release_dir/aw-server-rust"
ln -sfn "$release_dir/aw-server-rust" /opt/activitywatch/bin/aw-server-rust
if [[ -n "$webui_dir" && -d "$webui_dir" ]]; then
rm -rf "$AW_SERVER_WEBUI_DIR"
mkdir -p "$AW_SERVER_WEBUI_DIR"
cp -a "$webui_dir"/. "$AW_SERVER_WEBUI_DIR"/
chown -R "$AW_SERVER_USER:$AW_SERVER_GROUP" "$AW_SERVER_WEBUI_DIR"
fi
sed \
-e "s#__AW_SERVER_USER__#$AW_SERVER_USER#g" \
-e "s#__AW_SERVER_GROUP__#$AW_SERVER_GROUP#g" \
-e "s#__AW_SERVER_DATA_DIR__#$AW_SERVER_DATA_DIR#g" \
/root/bootstrap/activitywatch-server.service > /etc/systemd/system/activitywatch-server.service
chmod 0644 /etc/systemd/system/activitywatch-server.service
systemctl daemon-reload
systemctl enable activitywatch-server.service
systemctl restart activitywatch-server.service
systemctl --no-pager --full status activitywatch-server.service || true
for _ in $(seq 1 20); do
if curl -fsS "http://127.0.0.1:${AW_SERVER_PORT}/api/0/info" >/dev/null 2>&1; then
break
fi
sleep 2
done
if [[ -f "$CLASSES_JSON" ]]; then
curl -fsS -X POST \
-H 'Content-Type: application/json' \
--data-binary @"$CLASSES_JSON" \
"http://127.0.0.1:${AW_SERVER_PORT}/api/0/settings/classes" >/dev/null
echo "Applied worktime classes from $CLASSES_JSON"
else
echo "Worktime classes bootstrap not found, skipped: $CLASSES_JSON"
fi
if [[ -f "$VIEWS_JSON" ]]; then
curl -fsS -X POST \
-H 'Content-Type: application/json' \
--data-binary @"$VIEWS_JSON" \
"http://127.0.0.1:${AW_SERVER_PORT}/api/0/settings/views" >/dev/null
echo "Applied baseline views from $VIEWS_JSON"
else
echo "Views bootstrap not found, skipped: $VIEWS_JSON"
fi
@@ -0,0 +1,90 @@
[
{
"id": 0,
"name": ["Работа"],
"rule": { "type": "none" },
"data": {}
},
{
"id": 1,
"name": ["Работа", "1С"],
"rule": {
"type": "regex",
"regex": "\\b(1cv8s?|1cv8c|1cestart)\\.exe\\b|1С:Предприятие|Запуск 1С:Предприятия|Загрузка конфигурационной информации|Доступ к информационной базе",
"ignore_case": true
},
"data": { "color": "#194D33" }
},
{
"id": 2,
"name": ["Работа", "Документы"],
"rule": {
"type": "regex",
"regex": "\\b(winword|excel|powerpnt|outlook|acrord32|acrord64)\\.exe\\b|Adobe Reader|Acrobat",
"ignore_case": true
},
"data": { "color": "#2E7D32" }
},
{
"id": 3,
"name": ["Работа", "Коммуникации"],
"rule": {
"type": "regex",
"regex": "\\b(teams|telegram|slack|thunderbird|zoom|skype|whatsapp|viber|discord)\\.exe\\b|Mattermost|Element|Riot",
"ignore_case": true
},
"data": { "color": "#1E88E5" }
},
{
"id": 4,
"name": ["Работа", "Администрирование"],
"rule": {
"type": "regex",
"regex": "\\b(mstsc|putty|kitty|winscp|anydesk|teamviewer|vncviewer|mmc|regedit|services|control|powershell|cmd)\\.exe\\b",
"ignore_case": true
},
"data": { "color": "#6D4C41" }
},
{
"id": 5,
"name": ["Интернет"],
"rule": { "type": "none" },
"data": {}
},
{
"id": 6,
"name": ["Интернет", "Браузер"],
"rule": {
"type": "regex",
"regex": "\\b(chrome|msedge|firefox|opera|brave|vivaldi|browser)\\.exe\\b",
"ignore_case": true
},
"data": { "color": "#00897B" }
},
{
"id": 7,
"name": ["Система"],
"rule": { "type": "none" },
"data": {}
},
{
"id": 8,
"name": ["Система", "Windows"],
"rule": {
"type": "regex",
"regex": "\\b(SearchHost|explorer|ShellExperienceHost|ApplicationFrameHost|RuntimeBroker|sihost|dwm|svchost|fontdrvhost|userinit)\\.exe\\b|\\\\Windows\\\\System32",
"ignore_case": true
},
"data": { "color": "#607D8B" }
},
{
"id": 9,
"name": ["ActivityWatch"],
"rule": {
"type": "regex",
"regex": "ActivityWatch|\\baw-(watcher|qt)\\.exe\\b",
"ignore_case": true
},
"data": {}
}
]
@@ -0,0 +1,39 @@
[
{
"id": "summary",
"name": "Summary",
"elements": [
{ "type": "top_titles", "size": 3 },
{ "type": "timeline_barchart", "size": 3 },
{ "type": "top_categories", "size": 3 },
{ "type": "category_tree", "size": 3 }
]
},
{
"id": "window",
"name": "Window",
"elements": [
{ "type": "top_apps", "size": 3, "props": {} }
]
},
{
"id": "DLP",
"name": "DLP",
"elements": []
},
{
"id": "worktime",
"name": "Worktime",
"elements": [
{ "type": "top_categories", "size": 3, "props": {} },
{ "type": "timeline_barchart", "size": 3, "props": {} },
{ "type": "category_tree", "size": 3, "props": {} },
{ "type": "top_apps", "size": 3, "props": {} }
]
},
{
"id": "pve_audit",
"name": "PVE Audit",
"elements": []
}
]
@@ -0,0 +1,57 @@
{
"version": 1,
"generatedAtUtc": "2026-04-27T01:14:21.9184268Z",
"server": {
"host": "10.10.10.13",
"port": 5600,
"scheme": "http"
},
"paths": {
"installRoot": "C:\\Program Files\\ActivityWatch-Phase2-admin",
"stateRoot": "C:\\ProgramData\\ActivityWatch\\phase2-admin",
"logsRoot": "C:\\ProgramData\\ActivityWatch\\phase2-admin\\logs",
"collectorScript": "C:\\ProgramData\\ActivityWatch\\phase2-admin\\browser-domains-native-collector.ps1",
"endpointCollectorScript": "C:\\ProgramData\\ActivityWatch\\phase2-admin\\dlp-endpoint-signals-collector.ps1",
"rulesPath": "C:\\ProgramData\\ActivityWatch\\phase2-admin\\web-category-rules.json",
"policyPath": "C:\\ProgramData\\ActivityWatch\\phase2-admin\\dlp-policy.json",
"launchScript": "C:\\ProgramData\\ActivityWatch\\phase2-admin\\launch-watchers.ps1",
"recoveryScript": "C:\\ProgramData\\ActivityWatch\\phase2-admin\\recovery-loop.ps1"
},
"collector": {
"pollSeconds": 5,
"pulseSeconds": 30
},
"collectors": {
"afkEnabled": true,
"windowEnabled": true
},
"logging": {
"localAgentLogsEnabled": false
},
"incidentCapture": {
"enabled": true,
"screenshotEnabled": true,
"artifactsRoot": "C:\\ProgramData\\ActivityWatch\\phase2-admin\\incident-artifacts"
},
"sessionEvents": {
"logonEnabled": true,
"bucketPrefix": "aw-session-events"
},
"recovery": {
"intervalSeconds": 180,
"taskName": "ActivityWatch Recovery"
},
"dlp": {
"incidentBucketPrefix": "aw-dlp-incidents",
"enabled": true
},
"package": {
"version": "v0.13.2"
},
"userTasks": [
{
"UserId": "SHARKON2025\\Администратор",
"LaunchTaskName": "ActivityWatch Launch [SHARKON2025_РђРґРјРёРЅРёСЃС_СЂР_С_РѕСЂ]"
}
]
}
@@ -0,0 +1,69 @@
{
"version": 1,
"generatedAtUtc": "2026-04-27T01:09:42.4193209Z",
"server": {
"host": "10.10.10.13",
"port": 5600,
"scheme": "http"
},
"paths": {
"installRoot": "C:\\Program Files\\ActivityWatch-Phase2-u2u5",
"stateRoot": "C:\\ProgramData\\ActivityWatch\\phase2-u2u5",
"logsRoot": "C:\\ProgramData\\ActivityWatch\\phase2-u2u5\\logs",
"collectorScript": "C:\\ProgramData\\ActivityWatch\\phase2-u2u5\\browser-domains-native-collector.ps1",
"endpointCollectorScript": "C:\\ProgramData\\ActivityWatch\\phase2-u2u5\\dlp-endpoint-signals-collector.ps1",
"rulesPath": "C:\\ProgramData\\ActivityWatch\\phase2-u2u5\\web-category-rules.json",
"policyPath": "C:\\ProgramData\\ActivityWatch\\phase2-u2u5\\dlp-policy.json",
"launchScript": "C:\\ProgramData\\ActivityWatch\\phase2-u2u5\\launch-watchers.ps1",
"recoveryScript": "C:\\ProgramData\\ActivityWatch\\phase2-u2u5\\recovery-loop.ps1"
},
"collector": {
"pollSeconds": 5,
"pulseSeconds": 30
},
"collectors": {
"afkEnabled": true,
"windowEnabled": true
},
"logging": {
"localAgentLogsEnabled": false
},
"incidentCapture": {
"enabled": true,
"screenshotEnabled": true,
"artifactsRoot": "C:\\ProgramData\\ActivityWatch\\phase2-u2u5\\incident-artifacts"
},
"sessionEvents": {
"logonEnabled": true,
"bucketPrefix": "aw-session-events"
},
"recovery": {
"intervalSeconds": 180,
"taskName": "ActivityWatch Recovery"
},
"dlp": {
"incidentBucketPrefix": "aw-dlp-incidents",
"enabled": true
},
"package": {
"version": "v0.13.2"
},
"userTasks": [
{
"UserId": "SHARKON2025\\user2",
"LaunchTaskName": "ActivityWatch Launch [SHARKON2025_user2]"
},
{
"UserId": "SHARKON2025\\user3",
"LaunchTaskName": "ActivityWatch Launch [SHARKON2025_user3]"
},
{
"UserId": "SHARKON2025\\user4",
"LaunchTaskName": "ActivityWatch Launch [SHARKON2025_user4]"
},
{
"UserId": "SHARKON2025\\user5",
"LaunchTaskName": "ActivityWatch Launch [SHARKON2025_user5]"
}
]
}
@@ -0,0 +1,57 @@
{
"version": 1,
"generatedAtUtc": "2026-04-27T01:09:38.9519788Z",
"server": {
"host": "10.10.10.13",
"port": 5600,
"scheme": "http"
},
"paths": {
"installRoot": "C:\\Program Files\\ActivityWatch-Phase2",
"stateRoot": "C:\\ProgramData\\ActivityWatch\\phase2-user1",
"logsRoot": "C:\\ProgramData\\ActivityWatch\\phase2-user1\\logs",
"collectorScript": "C:\\ProgramData\\ActivityWatch\\phase2-user1\\browser-domains-native-collector.ps1",
"endpointCollectorScript": "C:\\ProgramData\\ActivityWatch\\phase2-user1\\dlp-endpoint-signals-collector.ps1",
"rulesPath": "C:\\ProgramData\\ActivityWatch\\phase2-user1\\web-category-rules.json",
"policyPath": "C:\\ProgramData\\ActivityWatch\\phase2-user1\\dlp-policy.json",
"launchScript": "C:\\ProgramData\\ActivityWatch\\phase2-user1\\launch-watchers.ps1",
"recoveryScript": "C:\\ProgramData\\ActivityWatch\\phase2-user1\\recovery-loop.ps1"
},
"collector": {
"pollSeconds": 5,
"pulseSeconds": 30
},
"collectors": {
"afkEnabled": true,
"windowEnabled": true
},
"logging": {
"localAgentLogsEnabled": false
},
"incidentCapture": {
"enabled": true,
"screenshotEnabled": true,
"artifactsRoot": "C:\\ProgramData\\ActivityWatch\\phase2-user1\\incident-artifacts"
},
"sessionEvents": {
"logonEnabled": true,
"bucketPrefix": "aw-session-events"
},
"recovery": {
"intervalSeconds": 180,
"taskName": "ActivityWatch Recovery"
},
"dlp": {
"incidentBucketPrefix": "aw-dlp-incidents",
"enabled": true
},
"package": {
"version": "v0.13.2"
},
"userTasks": [
{
"UserId": "SHARKON2025\\user1",
"LaunchTaskName": "ActivityWatch Launch [SHARKON2025_user1]"
}
]
}
@@ -0,0 +1,25 @@
@{
RootModule = 'ActivityWatch.Windows.Common.psm1'
ModuleVersion = '1.0.0'
GUID = '90b3fcf6-df9f-4f9b-9ee0-8a7de4dc0ee2'
Author = 'igor04091968'
CompanyName = 'Private'
Description = 'Common PowerShell functions for ActivityWatch Windows deployment, hardening and recovery.'
PowerShellVersion = '5.1'
FunctionsToExport = @(
'*-ActivityWatch*',
'Assert-Administrator',
'Normalize-ActivityWatchUsers',
'Get-ActivityWatchPackageUrl',
'Remove-LegacyActivityWatchEntries'
)
CmdletsToExport = @()
VariablesToExport = '*'
AliasesToExport = @()
PrivateData = @{
PSData = @{
Tags = @('ActivityWatch', 'Windows', 'Deployment', 'Recovery')
ProjectUri = 'https://github.com/igor04091968/AWatch-rus'
}
}
}
@@ -0,0 +1,982 @@
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
function Assert-Administrator {
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
throw 'Run this script from an elevated PowerShell session.'
}
}
function New-ActivityWatchDirectory {
param(
[Parameter(Mandatory = $true)]
[string]$Path
)
if (-not (Test-Path -LiteralPath $Path)) {
New-Item -Path $Path -ItemType Directory -Force | Out-Null
}
}
function Get-ActivityWatchPackageUrl {
param(
[string]$Version = 'v0.13.2'
)
return "https://github.com/ActivityWatch/activitywatch/releases/download/$Version/activitywatch-$Version-windows-x86_64.zip"
}
function Get-ActivityWatchArchive {
param(
[string]$PackageZipPath,
[string]$PackageUrl,
[string]$Version = 'v0.13.2',
[Parameter(Mandatory = $true)]
[string]$WorkingRoot
)
New-ActivityWatchDirectory -Path $WorkingRoot
if ($PackageZipPath) {
$resolved = Resolve-Path -LiteralPath $PackageZipPath -ErrorAction Stop
return $resolved.Path
}
if (-not $PackageUrl) {
$PackageUrl = Get-ActivityWatchPackageUrl -Version $Version
}
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$archivePath = Join-Path $WorkingRoot ("activitywatch-{0}.zip" -f $Version.TrimStart('v'))
Invoke-WebRequest -Uri $PackageUrl -OutFile $archivePath
return $archivePath
}
function Get-ActivityWatchPackageRoot {
param(
[Parameter(Mandatory = $true)]
[string]$ExpandedRoot
)
$afkBinary = Get-ChildItem -Path $ExpandedRoot -Filter 'aw-watcher-afk.exe' -File -Recurse |
Select-Object -First 1
if (-not $afkBinary) {
throw "Cannot find aw-watcher-afk.exe under $ExpandedRoot."
}
return (Split-Path -Path (Split-Path -Path $afkBinary.FullName -Parent) -Parent)
}
function Install-ActivityWatchPackage {
param(
[Parameter(Mandatory = $true)]
[string]$ArchivePath,
[Parameter(Mandatory = $true)]
[string]$InstallRoot,
[Parameter(Mandatory = $true)]
[string]$WorkingRoot,
[Parameter(Mandatory = $true)]
[string]$BackupRoot
)
New-ActivityWatchDirectory -Path $WorkingRoot
New-ActivityWatchDirectory -Path $BackupRoot
$extractRoot = Join-Path $WorkingRoot ('extract-' + [guid]::NewGuid().Guid)
if (Test-Path -LiteralPath $extractRoot) {
Remove-Item -LiteralPath $extractRoot -Recurse -Force
}
New-ActivityWatchDirectory -Path $extractRoot
Expand-Archive -Path $ArchivePath -DestinationPath $extractRoot -Force
$packageRoot = Get-ActivityWatchPackageRoot -ExpandedRoot $extractRoot
if (Test-Path -LiteralPath $InstallRoot) {
$existingItems = Get-ChildItem -LiteralPath $InstallRoot -Force -ErrorAction SilentlyContinue
if ($existingItems) {
$stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
$backupPath = Join-Path $BackupRoot ("install-$stamp")
New-ActivityWatchDirectory -Path $backupPath
Copy-Item -Path (Join-Path $InstallRoot '*') -Destination $backupPath -Recurse -Force
Get-ChildItem -LiteralPath $InstallRoot -Force | Remove-Item -Recurse -Force
}
}
else {
New-ActivityWatchDirectory -Path $InstallRoot
}
Copy-Item -Path (Join-Path $packageRoot '*') -Destination $InstallRoot -Recurse -Force
return [pscustomobject]@{
PackageRoot = $packageRoot
ExtractRoot = $extractRoot
BackupRoot = $BackupRoot
}
}
function Get-ActivityWatchExecutableMap {
param(
[Parameter(Mandatory = $true)]
[string]$InstallRoot
)
$map = [ordered]@{
Afk = Join-Path $InstallRoot 'aw-watcher-afk\aw-watcher-afk.exe'
Window = Join-Path $InstallRoot 'aw-watcher-window\aw-watcher-window.exe'
}
foreach ($entry in $map.GetEnumerator()) {
if (-not (Test-Path -LiteralPath $entry.Value)) {
throw "Missing required ActivityWatch binary: $($entry.Value)"
}
}
return [pscustomobject]$map
}
function Normalize-ActivityWatchUsers {
param(
[string[]]$Users,
[string]$UserListPath,
[string]$Domain
)
$collected = New-Object System.Collections.Generic.List[string]
if ($Users) {
foreach ($user in $Users) {
if (-not [string]::IsNullOrWhiteSpace($user)) {
$collected.Add($user.Trim())
}
}
}
if ($UserListPath) {
$resolved = Resolve-Path -LiteralPath $UserListPath -ErrorAction Stop
$extension = [IO.Path]::GetExtension($resolved.Path)
if ($extension -ieq '.csv') {
$rows = Import-Csv -LiteralPath $resolved.Path
foreach ($row in $rows) {
foreach ($column in 'User', 'Username', 'SamAccountName', 'Login') {
if ($row.PSObject.Properties.Name -contains $column) {
$value = [string]$row.$column
if (-not [string]::IsNullOrWhiteSpace($value)) {
$collected.Add($value.Trim())
break
}
}
}
}
}
else {
Get-Content -LiteralPath $resolved.Path | ForEach-Object {
$line = $_.Trim()
if ($line -and -not $line.StartsWith('#')) {
$collected.Add($line)
}
}
}
}
$normalized = $collected |
Where-Object { -not [string]::IsNullOrWhiteSpace($_) } |
ForEach-Object {
if ($Domain -and ($_ -notmatch '[\\@]')) {
'{0}\{1}' -f $Domain, $_
}
else {
$_
}
} |
Sort-Object -Unique
if (-not $normalized -or $normalized.Count -eq 0) {
throw 'No target users resolved. Provide -Users or -UserListPath.'
}
return @($normalized)
}
function Get-ActivityWatchTaskNameToken {
param(
[Parameter(Mandatory = $true)]
[string]$UserId
)
$buffer = [Text.StringBuilder]::new()
foreach ($character in $UserId.ToCharArray()) {
if ([char]::IsLetterOrDigit($character)) {
[void]$buffer.Append($character)
}
else {
[void]$buffer.Append('_')
}
}
return $buffer.ToString().Trim('_')
}
function New-ActivityWatchUserTaskDefinitions {
param(
[Parameter(Mandatory = $true)]
[string[]]$Users
)
$result = foreach ($user in $Users) {
$token = Get-ActivityWatchTaskNameToken -UserId $user
[pscustomobject]@{
UserId = $user
LaunchTaskName = "ActivityWatch Launch [$token]"
}
}
return @($result)
}
function Copy-ActivityWatchCollectorAssets {
param(
[Parameter(Mandatory = $true)]
[string]$CollectorScriptSource,
[Parameter(Mandatory = $true)]
[string]$EndpointCollectorScriptSource,
[Parameter(Mandatory = $true)]
[string]$ExampleRulesSource,
[Parameter(Mandatory = $true)]
[string]$ExamplePolicySource,
[Parameter(Mandatory = $true)]
[string]$StateRoot,
[string]$CustomRulesSource,
[string]$CustomPolicySource
)
New-ActivityWatchDirectory -Path $StateRoot
$collectorTarget = Join-Path $StateRoot 'browser-domains-native-collector.ps1'
$endpointCollectorTarget = Join-Path $StateRoot 'dlp-endpoint-signals-collector.ps1'
$exampleRulesTarget = Join-Path $StateRoot 'web-category-rules.example.json'
$rulesTarget = Join-Path $StateRoot 'web-category-rules.json'
$examplePolicyTarget = Join-Path $StateRoot 'dlp-policy.example.json'
$policyTarget = Join-Path $StateRoot 'dlp-policy.json'
Copy-Item -LiteralPath $CollectorScriptSource -Destination $collectorTarget -Force
Copy-Item -LiteralPath $EndpointCollectorScriptSource -Destination $endpointCollectorTarget -Force
Copy-Item -LiteralPath $ExampleRulesSource -Destination $exampleRulesTarget -Force
Copy-Item -LiteralPath $ExamplePolicySource -Destination $examplePolicyTarget -Force
if ($CustomRulesSource) {
$resolvedRules = Resolve-Path -LiteralPath $CustomRulesSource -ErrorAction Stop
Copy-Item -LiteralPath $resolvedRules.Path -Destination $rulesTarget -Force
}
if ($CustomPolicySource) {
$resolvedPolicy = Resolve-Path -LiteralPath $CustomPolicySource -ErrorAction Stop
Copy-Item -LiteralPath $resolvedPolicy.Path -Destination $policyTarget -Force
}
else {
Copy-Item -LiteralPath $examplePolicyTarget -Destination $policyTarget -Force
}
return [pscustomobject]@{
CollectorScript = $collectorTarget
EndpointCollectorScript = $endpointCollectorTarget
ExampleRules = $exampleRulesTarget
ActiveRules = $rulesTarget
ExamplePolicy = $examplePolicyTarget
ActivePolicy = $policyTarget
}
}
function New-ActivityWatchDeploymentConfig {
param(
[Parameter(Mandatory = $true)]
[string]$ServerHost,
[Parameter(Mandatory = $true)]
[int]$ServerPort,
[Parameter(Mandatory = $true)]
[string]$ServerScheme,
[Parameter(Mandatory = $true)]
[string]$InstallRoot,
[Parameter(Mandatory = $true)]
[string]$StateRoot,
[Parameter(Mandatory = $true)]
[string]$LogsRoot,
[Parameter(Mandatory = $true)]
[string]$CollectorScript,
[Parameter(Mandatory = $true)]
[string]$EndpointCollectorScript,
[Parameter(Mandatory = $true)]
[string]$RulesPath,
[Parameter(Mandatory = $true)]
[string]$PolicyPath,
[Parameter(Mandatory = $true)]
[int]$PollSeconds,
[Parameter(Mandatory = $true)]
[int]$PulseSeconds,
[Parameter(Mandatory = $true)]
[int]$RecoveryIntervalSeconds,
[bool]$AfkEnabled = $true,
[bool]$WindowEnabled = $true,
[bool]$LocalAgentLogsEnabled = $true,
[bool]$IncidentCaptureEnabled = $true,
[bool]$IncidentScreenshotEnabled = $true,
[string]$IncidentArtifactsRoot,
[bool]$LogonMarkerEnabled = $true,
[Parameter(Mandatory = $true)]
[string]$LaunchScriptPath,
[Parameter(Mandatory = $true)]
[string]$RecoveryScriptPath,
[Parameter(Mandatory = $true)]
[pscustomobject[]]$UserTasks,
[string]$PackageVersion = 'v0.13.2'
)
$effectiveIncidentArtifactsRoot = if ($IncidentArtifactsRoot) { $IncidentArtifactsRoot } else { Join-Path $StateRoot 'incident-artifacts' }
return [pscustomobject]@{
version = 1
generatedAtUtc = (Get-Date).ToUniversalTime().ToString('o')
server = [pscustomobject]@{
host = $ServerHost
port = $ServerPort
scheme = $ServerScheme
}
paths = [pscustomobject]@{
installRoot = $InstallRoot
stateRoot = $StateRoot
logsRoot = $LogsRoot
collectorScript = $CollectorScript
endpointCollectorScript = $EndpointCollectorScript
rulesPath = $RulesPath
policyPath = $PolicyPath
launchScript = $LaunchScriptPath
recoveryScript = $RecoveryScriptPath
}
collector = [pscustomobject]@{
pollSeconds = $PollSeconds
pulseSeconds = $PulseSeconds
}
collectors = [pscustomobject]@{
afkEnabled = $AfkEnabled
windowEnabled = $WindowEnabled
}
logging = [pscustomobject]@{
localAgentLogsEnabled = $LocalAgentLogsEnabled
}
incidentCapture = [pscustomobject]@{
enabled = $IncidentCaptureEnabled
screenshotEnabled = $IncidentScreenshotEnabled
artifactsRoot = $effectiveIncidentArtifactsRoot
}
sessionEvents = [pscustomobject]@{
logonEnabled = $LogonMarkerEnabled
bucketPrefix = 'aw-session-events'
}
recovery = [pscustomobject]@{
intervalSeconds = $RecoveryIntervalSeconds
taskName = 'ActivityWatch Recovery'
}
dlp = [pscustomobject]@{
incidentBucketPrefix = 'aw-dlp-incidents'
enabled = $true
}
package = [pscustomobject]@{
version = $PackageVersion
}
userTasks = @($UserTasks)
}
}
function Write-ActivityWatchDeploymentConfig {
param(
[Parameter(Mandatory = $true)]
[pscustomobject]$Config,
[Parameter(Mandatory = $true)]
[string]$Path
)
$directory = Split-Path -Path $Path -Parent
if ($directory) {
New-ActivityWatchDirectory -Path $directory
}
$json = $Config | ConvertTo-Json -Depth 8
Set-Content -LiteralPath $Path -Value $json -Encoding UTF8
}
function Read-ActivityWatchDeploymentConfig {
param(
[Parameter(Mandatory = $true)]
[string]$Path
)
if (-not (Test-Path -LiteralPath $Path)) {
throw "Deployment config not found: $Path"
}
return Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json
}
function Write-ActivityWatchLaunchScript {
param(
[Parameter(Mandatory = $true)]
[string]$Path,
[Parameter(Mandatory = $true)]
[string]$ConfigPath
)
$content = @"
param(
[string]`$ConfigPath = '$ConfigPath'
)
Set-StrictMode -Version Latest
`$ErrorActionPreference = 'Stop'
function Get-DeploymentConfig {
param([string]`$Path)
return Get-Content -LiteralPath `$Path -Raw | ConvertFrom-Json
}
function Test-ProcessInSession {
param(
[string]`$Name,
[int]`$SessionId
)
return [bool](Get-Process -Name `$Name -ErrorAction SilentlyContinue | Where-Object { `$_.SessionId -eq `$SessionId } | Select-Object -First 1)
}
function Test-CollectorRunning {
param(
[string]`$ScriptPath,
[int]`$SessionId
)
`$escapedCollector = [Regex]::Escape(`$ScriptPath)
`$processes = Get-CimInstance Win32_Process -ErrorAction SilentlyContinue |
Where-Object {
(`$_.Name -ieq 'powershell.exe' -or `$_.Name -ieq 'pwsh.exe') -and
`$_.SessionId -eq `$SessionId -and
`$_.CommandLine -match `$escapedCollector
}
return [bool](`$processes | Select-Object -First 1)
}
function Invoke-AwJsonPost {
param(
[Parameter(Mandatory = `$true)][string]`$Uri,
[Parameter(Mandatory = `$true)][string]`$Json
)
`$bytes = [Text.Encoding]::UTF8.GetBytes(`$Json)
Invoke-RestMethod -Method Post -Uri `$Uri -ContentType 'application/json; charset=utf-8' -Body `$bytes | Out-Null
}
function Ensure-Bucket {
param(
[string]`$BucketId,
[string]`$ClientName,
[string]`$BucketType
)
if (`$script:KnownBuckets.ContainsKey(`$BucketId)) {
return
}
try {
Invoke-RestMethod -Method Get -Uri "`$(`$script:ApiBase)/buckets/`$BucketId" | Out-Null
`$script:KnownBuckets[`$BucketId] = `$true
return
}
catch {
}
`$body = @{
client = `$ClientName
type = `$BucketType
hostname = `$script:Hostname
} | ConvertTo-Json -Compress
try {
Invoke-AwJsonPost -Uri "`$(`$script:ApiBase)/buckets/`$BucketId" -Json `$body
}
catch {
try {
Invoke-RestMethod -Method Get -Uri "`$(`$script:ApiBase)/buckets/`$BucketId" | Out-Null
}
catch {
return
}
}
`$script:KnownBuckets[`$BucketId] = `$true
}
function Send-LogonMarkerIfNeeded {
param(
[pscustomobject]`$Config,
[int]`$SessionId
)
`$sessionEvents = if (`$Config.PSObject.Properties.Name -contains 'sessionEvents') { `$Config.sessionEvents } else { `$null }
`$logging = if (`$Config.PSObject.Properties.Name -contains 'logging') { `$Config.logging } else { `$null }
`$logonEnabled = if (`$sessionEvents -and `$sessionEvents.PSObject.Properties.Name -contains 'logonEnabled') { [bool]`$sessionEvents.logonEnabled } else { `$false }
if (-not `$logonEnabled) {
return
}
`$bucketPrefix = if (`$sessionEvents -and `$sessionEvents.PSObject.Properties.Name -contains 'bucketPrefix' -and -not [string]::IsNullOrWhiteSpace([string]`$sessionEvents.bucketPrefix)) {
[string]`$sessionEvents.bucketPrefix
}
else {
'aw-session-events'
}
`$stateRoot = [string]`$Config.paths.stateRoot
`$markerRoots = New-Object System.Collections.Generic.List[string]
if (-not [string]::IsNullOrWhiteSpace(`$env:LOCALAPPDATA)) {
`$markerRoots.Add((Join-Path `$env:LOCALAPPDATA 'ActivityWatch-Phase2\markers'))
}
if (-not [string]::IsNullOrWhiteSpace(`$stateRoot)) {
`$markerRoots.Add((Join-Path `$stateRoot 'markers'))
}
`$markerDir = `$null
foreach (`$candidate in `$markerRoots) {
try {
if (-not (Test-Path -LiteralPath `$candidate)) {
New-Item -Path `$candidate -ItemType Directory -Force | Out-Null
}
`$probePath = Join-Path `$candidate 'write-test.tmp'
Set-Content -LiteralPath `$probePath -Value 'ok' -Encoding ASCII
Remove-Item -LiteralPath `$probePath -Force -ErrorAction SilentlyContinue
`$markerDir = `$candidate
break
}
catch {
}
}
if (-not `$markerDir) {
return
}
`$markerFile = Join-Path `$markerDir ("logon-{0}-{1}.marker" -f `$env:USERNAME, `$SessionId)
if (Test-Path -LiteralPath `$markerFile) {
return
}
Set-Content -LiteralPath `$markerFile -Value ((Get-Date).ToUniversalTime().ToString('o')) -Encoding UTF8
`$bucketId = ('{0}_{1}' -f `$bucketPrefix, `$script:Hostname)
Ensure-Bucket -BucketId `$bucketId -ClientName 'aw-session-events' -BucketType 'aw.session.event'
`$payload = @{
timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
duration = 0
data = @{
eventType = 'logon'
username = `$env:USERNAME
userId = "`$(`$env:USERDOMAIN)\`$(`$env:USERNAME)"
sessionId = `$SessionId
hostname = `$script:Hostname
source = 'launch-watchers-phase2'
}
} | ConvertTo-Json -Depth 5 -Compress
try {
Invoke-AwJsonPost -Uri "`$(`$script:ApiBase)/buckets/`$bucketId/heartbeat?pulsetime=1" -Json `$payload
}
catch {
Remove-Item -LiteralPath `$markerFile -Force -ErrorAction SilentlyContinue
throw
}
}
function Start-CollectorScriptIfNeeded {
param(
[string]`$ScriptPath,
[string]`$ConfigPath,
[string]`$PowerShellExe,
[int]`$SessionId
)
if (-not (Test-Path -LiteralPath `$ScriptPath)) {
return
}
if (Test-CollectorRunning -ScriptPath `$ScriptPath -SessionId `$SessionId) {
return
}
Start-Process -FilePath `$PowerShellExe -ArgumentList @(
'-NoProfile',
'-WindowStyle', 'Hidden',
'-ExecutionPolicy', 'Bypass',
'-File', `$ScriptPath,
'-ConfigPath', `$ConfigPath
) -WindowStyle Hidden
}
`$config = Get-DeploymentConfig -Path `$ConfigPath
`$sessionId = (Get-Process -Id `$PID).SessionId
`$installRoot = [string]`$config.paths.installRoot
`$script:ApiBase = '{0}://{1}:{2}/api/0' -f [string]`$config.server.scheme, [string]`$config.server.host, [string]`$config.server.port
`$script:Hostname = `$env:COMPUTERNAME
`$script:KnownBuckets = @{}
`$collectorScript = [string]`$config.paths.collectorScript
`$endpointCollectorScript = if (`$config.paths.PSObject.Properties.Name -contains 'endpointCollectorScript') { [string]`$config.paths.endpointCollectorScript } else { '' }
`$afkExe = Join-Path `$installRoot 'aw-watcher-afk\aw-watcher-afk.exe'
`$windowExe = Join-Path `$installRoot 'aw-watcher-window\aw-watcher-window.exe'
`$serverArgs = @('--host', [string]`$config.server.host, '--port', [string]`$config.server.port)
`$powershellExe = Join-Path `$env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'
`$afkEnabled = if (`$config.PSObject.Properties.Name -contains 'collectors' -and `$config.collectors.PSObject.Properties.Name -contains 'afkEnabled') { [bool]`$config.collectors.afkEnabled } else { `$true }
`$windowEnabled = if (`$config.PSObject.Properties.Name -contains 'collectors' -and `$config.collectors.PSObject.Properties.Name -contains 'windowEnabled') { [bool]`$config.collectors.windowEnabled } else { `$true }
if (`$afkEnabled -and -not (Test-Path -LiteralPath `$afkExe)) {
throw "Missing aw-watcher-afk.exe: `$afkExe"
}
if (`$windowEnabled -and -not (Test-Path -LiteralPath `$windowExe)) {
throw "Missing aw-watcher-window.exe: `$windowExe"
}
if (`$afkEnabled -and -not (Test-ProcessInSession -Name 'aw-watcher-afk' -SessionId `$sessionId)) {
Start-Process -FilePath `$afkExe -ArgumentList `$serverArgs -WindowStyle Hidden
}
if (`$windowEnabled -and -not (Test-ProcessInSession -Name 'aw-watcher-window' -SessionId `$sessionId)) {
Start-Process -FilePath `$windowExe -ArgumentList `$serverArgs -WindowStyle Hidden
}
try {
Send-LogonMarkerIfNeeded -Config `$config -SessionId `$sessionId
}
catch {
}
Start-CollectorScriptIfNeeded -ScriptPath `$collectorScript -ConfigPath `$ConfigPath -PowerShellExe `$powershellExe -SessionId `$sessionId
Start-CollectorScriptIfNeeded -ScriptPath `$endpointCollectorScript -ConfigPath `$ConfigPath -PowerShellExe `$powershellExe -SessionId `$sessionId
"@
Set-Content -LiteralPath $Path -Value $content -Encoding UTF8
}
function Write-ActivityWatchRecoveryScript {
param(
[Parameter(Mandatory = $true)]
[string]$Path,
[Parameter(Mandatory = $true)]
[string]$ConfigPath
)
$content = @"
param(
[string]`$ConfigPath = '$ConfigPath'
)
Set-StrictMode -Version Latest
`$ErrorActionPreference = 'Continue'
function Get-DeploymentConfig {
param([string]`$Path)
return Get-Content -LiteralPath `$Path -Raw | ConvertFrom-Json
}
function Get-RecoveryConfigPaths {
param([string]`$PrimaryConfigPath)
`$paths = New-Object System.Collections.Generic.List[string]
if (`$PrimaryConfigPath -and (Test-Path -LiteralPath `$PrimaryConfigPath)) {
`$paths.Add((Resolve-Path -LiteralPath `$PrimaryConfigPath).Path)
}
`$searchRoot = `$env:ProgramData
if (`$PrimaryConfigPath) {
`$stateRoot = Split-Path -Path `$PrimaryConfigPath -Parent
`$candidateRoot = Split-Path -Path `$stateRoot -Parent
if (`$candidateRoot -and (Test-Path -LiteralPath `$candidateRoot)) {
`$searchRoot = `$candidateRoot
}
}
if (Test-Path -LiteralPath `$searchRoot) {
Get-ChildItem -LiteralPath `$searchRoot -Directory -ErrorAction SilentlyContinue |
Where-Object { `$_.Name -like 'ActivityWatch*' } |
ForEach-Object {
`$candidate = Join-Path `$_.FullName 'deployment-config.json'
if (Test-Path -LiteralPath `$candidate) {
`$paths.Add(`$candidate)
}
}
}
return @(`$paths | Sort-Object -Unique)
}
function Get-RecoveryTaskNames {
param([string[]]`$ConfigPaths)
`$taskNames = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase)
foreach (`$candidatePath in @(`$ConfigPaths)) {
try {
`$config = Get-DeploymentConfig -Path `$candidatePath
foreach (`$task in @(`$config.userTasks)) {
`$taskName = [string]`$task.launchTaskName
if (-not [string]::IsNullOrWhiteSpace(`$taskName)) {
[void]`$taskNames.Add(`$taskName)
}
}
}
catch {
}
}
return @(`$taskNames)
}
while (`$true) {
`$sleepSeconds = 180
try {
`$configPaths = Get-RecoveryConfigPaths -PrimaryConfigPath `$ConfigPath
foreach (`$taskName in Get-RecoveryTaskNames -ConfigPaths `$configPaths) {
Start-ScheduledTask -TaskName `$taskName -ErrorAction SilentlyContinue
}
`$config = Get-DeploymentConfig -Path `$ConfigPath
if (`$config -and `$config.recovery -and `$config.recovery.intervalSeconds) {
`$sleepSeconds = [Math]::Max([int]`$config.recovery.intervalSeconds, 30)
}
}
catch {
}
Start-Sleep -Seconds `$sleepSeconds
}
"@
Set-Content -LiteralPath $Path -Value $content -Encoding UTF8
}
function Get-ActivityWatchHiddenLauncherPath {
param(
[Parameter(Mandatory = $true)]
[string]$ScriptPath
)
$directory = Split-Path -Path $ScriptPath -Parent
$baseName = [IO.Path]::GetFileNameWithoutExtension($ScriptPath)
return Join-Path $directory ("{0}-hidden.vbs" -f $baseName)
}
function Write-ActivityWatchHiddenPowerShellWrapper {
param(
[Parameter(Mandatory = $true)]
[string]$Path,
[Parameter(Mandatory = $true)]
[string]$ScriptPath,
[Parameter(Mandatory = $true)]
[string]$ConfigPath
)
$directory = Split-Path -Path $Path -Parent
if ($directory) {
New-ActivityWatchDirectory -Path $directory
}
$powershellExe = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'
$escapedPowerShellExe = $powershellExe.Replace('"', '""')
$escapedScriptPath = $ScriptPath.Replace('"', '""')
$escapedConfigPath = $ConfigPath.Replace('"', '""')
$content = @"
Set shell = CreateObject("WScript.Shell")
shell.Run """$escapedPowerShellExe"" -NoProfile -ExecutionPolicy Bypass -File ""$escapedScriptPath"" -ConfigPath ""$escapedConfigPath""", 0, False
"@
Set-Content -LiteralPath $Path -Value $content -Encoding ASCII
}
function Remove-LegacyActivityWatchEntries {
$legacyTaskNames = @(
'ActivityWatch Watchers',
'ActivityWatch Guard',
'ActivityWatch Heal'
)
foreach ($taskName in $legacyTaskNames) {
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue
}
$runKey = 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Run'
foreach ($name in 'ActivityWatchAFK', 'ActivityWatchWindow', 'ActivityWatchBrowserCollector') {
Remove-ItemProperty -Path $runKey -Name $name -ErrorAction SilentlyContinue
}
}
function Remove-ActivityWatchScheduledTask {
param(
[Parameter(Mandatory = $true)]
[string]$TaskName
)
Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue
& cmd.exe /c "schtasks /Delete /TN `"$TaskName`" /F >nul 2>&1" | Out-Null
for ($attempt = 0; $attempt -lt 10; $attempt++) {
$task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
if (-not $task) {
return
}
Start-Sleep -Milliseconds 300
}
}
function Set-ActivityWatchScheduledTaskAction {
param(
[Parameter(Mandatory = $true)]
[string]$TaskName,
[Parameter(Mandatory = $true)]
[string]$Execute,
[Parameter(Mandatory = $true)]
[string]$Arguments
)
$taskCommand = ('"{0}" {1}' -f $Execute, $Arguments)
& schtasks.exe /Change /TN $TaskName /TR $taskCommand | Out-Null
if ($LASTEXITCODE -ne 0) {
throw "schtasks.exe /Change failed for $TaskName"
}
}
function Get-ActivityWatchScheduledTaskByCommand {
param(
[Parameter(Mandatory = $true)]
[string]$TaskName,
[string]$CommandMatch
)
$task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
if ($task) {
return $task
}
if ([string]::IsNullOrWhiteSpace($CommandMatch)) {
return $null
}
foreach ($candidate in @(Get-ScheduledTask | Where-Object { $_.TaskName -like 'ActivityWatch Launch*' })) {
foreach ($action in @($candidate.Actions)) {
if ([string]$action.Arguments -like "*$CommandMatch*") {
return $candidate
}
}
}
return $null
}
function Register-ActivityWatchUserTasks {
param(
[Parameter(Mandatory = $true)]
[pscustomobject[]]$TaskDefinitions,
[Parameter(Mandatory = $true)]
[string]$LaunchScriptPath,
[Parameter(Mandatory = $true)]
[string]$ConfigPath
)
$wscriptExe = Join-Path $env:SystemRoot 'System32\wscript.exe'
$launcherPath = Get-ActivityWatchHiddenLauncherPath -ScriptPath $LaunchScriptPath
Write-ActivityWatchHiddenPowerShellWrapper -Path $launcherPath -ScriptPath $LaunchScriptPath -ConfigPath $ConfigPath
foreach ($definition in $TaskDefinitions) {
$action = New-ScheduledTaskAction -Execute $wscriptExe -Argument "//B //NoLogo `"$launcherPath`""
$trigger = New-ScheduledTaskTrigger -AtLogOn -User $definition.UserId
$principal = New-ScheduledTaskPrincipal -UserId $definition.UserId -LogonType Interactive -RunLevel Highest
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -StartWhenAvailable -MultipleInstances IgnoreNew -ExecutionTimeLimit (New-TimeSpan -Hours 0)
$existingTask = Get-ActivityWatchScheduledTaskByCommand -TaskName $definition.LaunchTaskName -CommandMatch $ConfigPath
if ($existingTask) {
Set-ActivityWatchScheduledTaskAction -TaskName $existingTask.TaskName -Execute $wscriptExe -Arguments $action.Arguments
continue
}
Remove-ActivityWatchScheduledTask -TaskName $definition.LaunchTaskName
Register-ScheduledTask -TaskName $definition.LaunchTaskName -Action $action -Trigger $trigger -Principal $principal -Settings $settings | Out-Null
}
}
function Register-ActivityWatchRecoveryTask {
param(
[Parameter(Mandatory = $true)]
[string]$TaskName,
[Parameter(Mandatory = $true)]
[string]$RecoveryScriptPath,
[Parameter(Mandatory = $true)]
[string]$ConfigPath
)
Remove-ActivityWatchScheduledTask -TaskName $TaskName
$wscriptExe = Join-Path $env:SystemRoot 'System32\wscript.exe'
$launcherPath = Get-ActivityWatchHiddenLauncherPath -ScriptPath $RecoveryScriptPath
Write-ActivityWatchHiddenPowerShellWrapper -Path $launcherPath -ScriptPath $RecoveryScriptPath -ConfigPath $ConfigPath
$action = New-ScheduledTaskAction -Execute $wscriptExe -Argument "//B //NoLogo `"$launcherPath`""
$trigger = New-ScheduledTaskTrigger -AtStartup
$principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -StartWhenAvailable -Hidden -MultipleInstances IgnoreNew -ExecutionTimeLimit (New-TimeSpan -Hours 0)
Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger -Principal $principal -Settings $settings | Out-Null
}
function Set-ActivityWatchAcl {
param(
[Parameter(Mandatory = $true)]
[string]$InstallRoot,
[Parameter(Mandatory = $true)]
[string]$StateRoot,
[Parameter(Mandatory = $true)]
[string]$LogsRoot
)
foreach ($path in $InstallRoot, $StateRoot, $LogsRoot) {
New-ActivityWatchDirectory -Path $path
}
& icacls $InstallRoot /inheritance:r /grant:r '*S-1-5-18:(OI)(CI)(F)' '*S-1-5-32-544:(OI)(CI)(F)' '*S-1-5-32-545:(OI)(CI)(RX)' | Out-Null
if ($LASTEXITCODE -ne 0) {
throw "icacls failed for $InstallRoot"
}
& icacls $StateRoot /inheritance:r /grant:r '*S-1-5-18:(OI)(CI)(F)' '*S-1-5-32-544:(OI)(CI)(F)' '*S-1-5-32-545:(OI)(CI)(RX)' | Out-Null
if ($LASTEXITCODE -ne 0) {
throw "icacls failed for $StateRoot"
}
& icacls $LogsRoot /inheritance:r /grant:r '*S-1-5-18:(OI)(CI)(F)' '*S-1-5-32-544:(OI)(CI)(F)' '*S-1-5-32-545:(OI)(CI)(M)' | Out-Null
if ($LASTEXITCODE -ne 0) {
throw "icacls failed for $LogsRoot"
}
}
function Start-ActivityWatchTasks {
param(
[Parameter(Mandatory = $true)]
[pscustomobject[]]$TaskDefinitions,
[string]$RecoveryTaskName = 'ActivityWatch Recovery'
)
foreach ($definition in $TaskDefinitions) {
Start-ScheduledTask -TaskName $definition.LaunchTaskName -ErrorAction SilentlyContinue
}
Start-ScheduledTask -TaskName $RecoveryTaskName -ErrorAction SilentlyContinue
}
Export-ModuleMember -Function *-ActivityWatch*, Assert-Administrator, Normalize-ActivityWatchUsers, Get-ActivityWatchPackageUrl, Remove-LegacyActivityWatchEntries
@@ -0,0 +1,822 @@
[CmdletBinding()]
param(
[string]$ConfigPath = 'C:\ProgramData\ActivityWatch\deployment-config.json',
[string]$ServerHost,
[int]$ServerPort,
[ValidateSet('http', 'https')]
[string]$ServerScheme,
[string]$RulesPath,
[string]$PolicyPath,
[string]$LogPath,
[string]$IncidentLogPath,
[int]$PollSeconds,
[int]$PulseSeconds
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
Add-Type -AssemblyName UIAutomationClient
Add-Type -AssemblyName UIAutomationTypes
Add-Type @"
using System;
using System.Runtime.InteropServices;
using System.Text;
public static class NativeAwMethods {
[DllImport("user32.dll")]
public static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll")]
public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId);
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
public static extern int GetWindowText(IntPtr hWnd, StringBuilder lpString, int nMaxCount);
[DllImport("user32.dll")]
public static extern int GetWindowTextLength(IntPtr hWnd);
}
"@
function Get-DeploymentConfig {
param([string]$Path)
if ($Path -and (Test-Path -LiteralPath $Path)) {
return Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json
}
return $null
}
$deploymentConfig = Get-DeploymentConfig -Path $ConfigPath
$resolvedServerHost = if ($ServerHost) { $ServerHost } elseif ($deploymentConfig) { [string]$deploymentConfig.server.host } else { throw 'ServerHost is required.' }
$resolvedServerPort = if ($PSBoundParameters.ContainsKey('ServerPort')) { $ServerPort } elseif ($deploymentConfig) { [int]$deploymentConfig.server.port } else { 5600 }
$resolvedServerScheme = if ($ServerScheme) { $ServerScheme } elseif ($deploymentConfig) { [string]$deploymentConfig.server.scheme } else { 'http' }
$resolvedRulesPath = if ($RulesPath) { $RulesPath } elseif ($deploymentConfig) { [string]$deploymentConfig.paths.rulesPath } else { 'C:\ProgramData\ActivityWatch\web-category-rules.json' }
$resolvedPolicyPath = if ($PolicyPath) { $PolicyPath } elseif ($deploymentConfig) { [string]$deploymentConfig.paths.policyPath } else { 'C:\ProgramData\ActivityWatch\dlp-policy.json' }
$resolvedPollSeconds = if ($PSBoundParameters.ContainsKey('PollSeconds')) { $PollSeconds } elseif ($deploymentConfig) { [int]$deploymentConfig.collector.pollSeconds } else { 5 }
$resolvedPulseSeconds = if ($PSBoundParameters.ContainsKey('PulseSeconds')) { $PulseSeconds } elseif ($deploymentConfig) { [int]$deploymentConfig.collector.pulseSeconds } else { 30 }
$resolvedLogsRoot = if ($deploymentConfig) { [string]$deploymentConfig.paths.logsRoot } else { 'C:\ProgramData\ActivityWatch\logs' }
$resolvedLogPath = if ($LogPath) { $LogPath } else { Join-Path $resolvedLogsRoot ("browser-domains-{0}.log" -f $env:USERNAME) }
$resolvedIncidentLogPath = if ($IncidentLogPath) { $IncidentLogPath } else { Join-Path $resolvedLogsRoot ("dlp-incidents-{0}.log" -f $env:USERNAME) }
$resolvedLocalAgentLogsEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'logging' -and $deploymentConfig.logging.PSObject.Properties.Name -contains 'localAgentLogsEnabled') { [bool]$deploymentConfig.logging.localAgentLogsEnabled } else { $true }
$resolvedIncidentArtifactsRoot = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'artifactsRoot') { [string]$deploymentConfig.incidentCapture.artifactsRoot } else { Join-Path $env:LOCALAPPDATA 'ActivityWatch-Phase2\\incident-artifacts' }
$resolvedIncidentScreenshotEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'screenshotEnabled') { [bool]$deploymentConfig.incidentCapture.screenshotEnabled } else { $true }
if ($resolvedLocalAgentLogsEnabled -and -not (Test-Path -LiteralPath $resolvedLogsRoot)) {
New-Item -Path $resolvedLogsRoot -ItemType Directory -Force | Out-Null
}
$script:ApiBase = '{0}://{1}:{2}/api/0' -f $resolvedServerScheme, $resolvedServerHost, $resolvedServerPort
$script:Hostname = $env:COMPUTERNAME
$script:SessionId = (Get-Process -Id $PID).SessionId
$script:KnownBuckets = @{}
$script:LocalAgentLogsEnabled = $resolvedLocalAgentLogsEnabled
$script:LogPath = $resolvedLogPath
$script:IncidentLogPath = $resolvedIncidentLogPath
$script:IncidentArtifactsRoot = $resolvedIncidentArtifactsRoot
$script:IncidentScreenshotEnabled = $resolvedIncidentScreenshotEnabled
$script:ScreenshotTypesLoaded = $false
$script:IncidentState = @{}
$script:DlpRules = @()
$script:DlpDefaults = [ordered]@{
enabled = $false
cooldownSeconds = 300
action = 'log'
severity = 'low'
}
$script:BrowserMap = @{
msedge = 'edge'
chrome = 'chrome'
brave = 'brave'
vivaldi = 'vivaldi'
opera = 'opera'
firefox = 'firefox'
}
$script:CategoryRules = @(
@{ Name = 'work_business_systems'; Group = 'work'; Domains = @('bitrix24.ru', '1c.ru', 'sbis.ru', 'kontur.ru', 'diadoc.ru', 'nalog.gov.ru', 'gosuslugi.ru') }
@{ Name = 'work_docs_collab'; Group = 'work'; Domains = @('office.com', 'sharepoint.com', 'docs.google.com', 'drive.google.com', 'notion.so', 'miro.com') }
@{ Name = 'work_dev'; Group = 'work'; Domains = @('github.com', 'gitlab.com', 'bitbucket.org', 'youtrack.cloud', 'atlassian.net') }
@{ Name = 'work_communication'; Group = 'work'; Domains = @('teams.microsoft.com', 'outlook.office.com', 'web.telegram.org', 'slack.com', 'zoom.us') }
@{ Name = 'neutral_search_reference'; Group = 'neutral'; Domains = @('google.com', 'google.ru', 'yandex.ru', 'bing.com', 'duckduckgo.com', 'wikipedia.org') }
@{ Name = 'neutral_news'; Group = 'neutral'; Domains = @('rbc.ru', 'tass.ru', 'ria.ru', 'kommersant.ru', 'vedomosti.ru') }
@{ Name = 'personal_social'; Group = 'personal'; Domains = @('vk.com', 'ok.ru', 'facebook.com', 'instagram.com', 'tiktok.com', 'x.com', 'twitter.com') }
@{ Name = 'personal_video'; Group = 'personal'; Domains = @('youtube.com', 'youtu.be', 'rutube.ru', 'twitch.tv', 'kinopoisk.ru') }
@{ Name = 'personal_marketplace'; Group = 'personal'; Domains = @('ozon.ru', 'wildberries.ru', 'avito.ru', 'aliexpress.com', 'market.yandex.ru') }
@{ Name = 'personal_entertainment'; Group = 'personal'; Domains = @('dzen.ru', 'pikabu.ru', 'dtf.ru', 'playground.ru') }
)
function Write-CollectorLog {
param([string]$Message)
if (-not $script:LocalAgentLogsEnabled) {
return
}
try {
Add-Content -LiteralPath $script:LogPath -Value ('{0} {1}' -f (Get-Date -Format s), $Message)
}
catch {
}
}
function Write-DlpIncidentLog {
param([string]$Message)
if (-not $script:LocalAgentLogsEnabled) {
return
}
try {
Add-Content -LiteralPath $script:IncidentLogPath -Value ('{0} {1}' -f (Get-Date -Format s), $Message)
}
catch {
}
}
function Test-DomainMatch {
param(
[string]$DomainHost,
[string]$RuleDomain
)
if ([string]::IsNullOrWhiteSpace($DomainHost) -or [string]::IsNullOrWhiteSpace($RuleDomain)) {
return $false
}
$left = $DomainHost.ToLowerInvariant()
$right = $RuleDomain.ToLowerInvariant()
return $left -eq $right -or $left.EndsWith('.' + $right)
}
function Get-HostFromUrl {
param([string]$Url)
if ([string]::IsNullOrWhiteSpace($Url)) {
return $null
}
try {
$uri = [Uri]$Url
$host = $uri.Host.ToLowerInvariant()
if ($host.StartsWith('www.')) {
return $host.Substring(4)
}
return $host
}
catch {
return $null
}
}
function Get-RootDomain {
param([string]$DomainHost)
if ([string]::IsNullOrWhiteSpace($DomainHost)) {
return $null
}
$parts = $DomainHost.Split('.')
if ($parts.Count -le 2) {
return $DomainHost
}
$suffix = ('{0}.{1}' -f $parts[$parts.Count - 2], $parts[$parts.Count - 1]).ToLowerInvariant()
$compoundTlds = @('co.uk', 'com.au', 'co.jp', 'com.br', 'co.in', 'com.tr', 'com.cn')
if (($compoundTlds -contains $suffix) -and $parts.Count -ge 3) {
return ('{0}.{1}' -f $parts[$parts.Count - 3], $suffix).ToLowerInvariant()
}
return $suffix
}
function ConvertTo-NormalizedUrl {
param([AllowNull()][string]$Value)
if ([string]::IsNullOrWhiteSpace($Value)) {
return $null
}
$candidate = $Value.Trim()
if ($candidate.Length -lt 4) {
return $null
}
if ($candidate -match '^(?i)(search|find|address and search|search with|новая вкладка|new tab)') {
return $null
}
if ($candidate -match '^(?i)(https?|file|ftp|chrome|edge|about|view-source)://') {
return $candidate
}
if ($candidate -match '^(?i)localhost([/:]|$)') {
return "http://$candidate"
}
if ($candidate -match '^[a-z0-9.-]+\.[a-z]{2,}([/:?#].*)?$') {
return "https://$candidate"
}
return $null
}
function Load-CustomCategoryRules {
param([string]$Path)
if (-not $Path -or -not (Test-Path -LiteralPath $Path)) {
return
}
try {
$parsed = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json
$rules = @()
if ($parsed.rules) {
$sourceRules = @($parsed.rules)
}
elseif ($parsed -is [System.Collections.IEnumerable]) {
$sourceRules = @($parsed)
}
else {
$sourceRules = @()
}
foreach ($rule in $sourceRules) {
if (-not $rule) {
continue
}
$name = [string]$rule.name
$group = [string]$rule.group
$domains = @($rule.domains | ForEach-Object { ([string]$_).Trim().ToLowerInvariant() } | Where-Object { $_ })
if ($name -and $group -and $domains.Count -gt 0) {
$rules += @{
Name = $name
Group = $group
Domains = $domains
}
}
}
if ($rules.Count -gt 0) {
$script:CategoryRules = @($rules) + @($script:CategoryRules)
Write-CollectorLog ("custom rules loaded: {0}" -f $rules.Count)
}
}
catch {
Write-CollectorLog ("custom rules load failed: {0}" -f $_.Exception.Message)
}
}
function Get-WebCategory {
param([string]$DomainHost)
foreach ($rule in $script:CategoryRules) {
foreach ($domain in $rule.Domains) {
if (Test-DomainMatch -DomainHost $DomainHost -RuleDomain $domain) {
return [pscustomobject]@{
Name = [string]$rule.Name
Group = [string]$rule.Group
Rule = [string]$domain
}
}
}
}
return [pscustomobject]@{
Name = 'uncategorized'
Group = 'neutral'
Rule = 'none'
}
}
function Test-DomainListMatch {
param(
[string]$DomainHost,
[string[]]$Domains
)
if (-not $Domains -or $Domains.Count -eq 0) {
return $false
}
foreach ($domain in $Domains) {
if (Test-DomainMatch -DomainHost $DomainHost -RuleDomain $domain) {
return $true
}
}
return $false
}
function Test-DlpRuleTimeWindow {
param(
[int]$CurrentHour,
[AllowNull()][int]$HourFrom,
[AllowNull()][int]$HourTo
)
if ($null -eq $HourFrom -or $null -eq $HourTo) {
return $true
}
if ($HourFrom -eq $HourTo) {
return $true
}
if ($HourFrom -lt $HourTo) {
return ($CurrentHour -ge $HourFrom -and $CurrentHour -lt $HourTo)
}
return ($CurrentHour -ge $HourFrom -or $CurrentHour -lt $HourTo)
}
function Load-DlpPolicy {
param([string]$Path)
if (-not $Path -or -not (Test-Path -LiteralPath $Path)) {
Write-CollectorLog ("dlp policy not found, disabled: {0}" -f $Path)
return
}
try {
$parsed = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json
$defaults = $parsed.defaults
if ($defaults) {
if ($defaults.PSObject.Properties.Name -contains 'enabled') {
$script:DlpDefaults.enabled = [bool]$defaults.enabled
}
if ($defaults.cooldownSeconds) {
$script:DlpDefaults.cooldownSeconds = [int]$defaults.cooldownSeconds
}
if ($defaults.action) {
$script:DlpDefaults.action = [string]$defaults.action
}
if ($defaults.severity) {
$script:DlpDefaults.severity = [string]$defaults.severity
}
}
$loaded = @()
foreach ($rule in @($parsed.rules)) {
if (-not $rule) { continue }
$when = $rule.when
if (-not $when) {
$when = [pscustomobject]@{}
}
$loaded += [pscustomobject]@{
id = [string]$rule.id
enabled = if ($rule.PSObject.Properties.Name -contains 'enabled') { [bool]$rule.enabled } else { $true }
action = if ($rule.action) { [string]$rule.action } else { [string]$script:DlpDefaults.action }
severity = if ($rule.severity) { [string]$rule.severity } else { [string]$script:DlpDefaults.severity }
message = if ($rule.message) { [string]$rule.message } else { "DLP rule matched: $($rule.id)" }
cooldownSeconds = if ($rule.cooldownSeconds) { [int]$rule.cooldownSeconds } else { [int]$script:DlpDefaults.cooldownSeconds }
when = [pscustomobject]@{
domains = if ($when.PSObject.Properties.Name -contains 'domains') { @($when.domains | ForEach-Object { ([string]$_).Trim().ToLowerInvariant() } | Where-Object { $_ }) } else { @() }
categoryGroups = if ($when.PSObject.Properties.Name -contains 'categoryGroups') { @($when.categoryGroups | ForEach-Object { ([string]$_).Trim().ToLowerInvariant() } | Where-Object { $_ }) } else { @() }
categories = if ($when.PSObject.Properties.Name -contains 'categories') { @($when.categories | ForEach-Object { ([string]$_).Trim().ToLowerInvariant() } | Where-Object { $_ }) } else { @() }
browsers = if ($when.PSObject.Properties.Name -contains 'browsers') { @($when.browsers | ForEach-Object { ([string]$_).Trim().ToLowerInvariant() } | Where-Object { $_ }) } else { @() }
urlRegex = if ($when.PSObject.Properties.Name -contains 'urlRegex' -and $when.urlRegex) { [string]$when.urlRegex } else { $null }
titleRegex = if ($when.PSObject.Properties.Name -contains 'titleRegex' -and $when.titleRegex) { [string]$when.titleRegex } else { $null }
hourFrom = if ($when.PSObject.Properties.Name -contains 'hourFrom') { [int]$when.hourFrom } else { $null }
hourTo = if ($when.PSObject.Properties.Name -contains 'hourTo') { [int]$when.hourTo } else { $null }
}
}
}
$script:DlpRules = @($loaded)
Write-CollectorLog ("dlp policy loaded: enabled={0}, rules={1}" -f $script:DlpDefaults.enabled, $script:DlpRules.Count)
}
catch {
Write-CollectorLog ("dlp policy parse failed: {0}" -f $_.Exception.Message)
}
}
function Test-DlpRuleMatch {
param(
[pscustomobject]$Rule,
[string]$Domain,
[string]$RootDomain,
[string]$Url,
[string]$Title,
[string]$BrowserKey,
[string]$Category,
[string]$CategoryGroup
)
if (-not $Rule.enabled) {
return $false
}
$when = $Rule.when
$currentHour = (Get-Date).Hour
if (-not (Test-DlpRuleTimeWindow -CurrentHour $currentHour -HourFrom $when.hourFrom -HourTo $when.hourTo)) {
return $false
}
if ($when.domains.Count -gt 0) {
$domainMatched = (Test-DomainListMatch -DomainHost $Domain -Domains $when.domains) -or (Test-DomainListMatch -DomainHost $RootDomain -Domains $when.domains)
if (-not $domainMatched) {
return $false
}
}
if ($when.categoryGroups.Count -gt 0 -and ($when.categoryGroups -notcontains $CategoryGroup.ToLowerInvariant())) {
return $false
}
if ($when.categories.Count -gt 0 -and ($when.categories -notcontains $Category.ToLowerInvariant())) {
return $false
}
if ($when.browsers.Count -gt 0 -and ($when.browsers -notcontains $BrowserKey.ToLowerInvariant())) {
return $false
}
if ($when.urlRegex) {
if (-not ($Url -match $when.urlRegex)) {
return $false
}
}
if ($when.titleRegex) {
if (-not ($Title -match $when.titleRegex)) {
return $false
}
}
return $true
}
function Get-DlpDecision {
param(
[string]$Domain,
[string]$RootDomain,
[string]$Url,
[string]$Title,
[string]$BrowserKey,
[string]$Category,
[string]$CategoryGroup
)
if (-not $script:DlpDefaults.enabled) {
return $null
}
foreach ($rule in $script:DlpRules) {
if (Test-DlpRuleMatch -Rule $rule -Domain $Domain -RootDomain $RootDomain -Url $Url -Title $Title -BrowserKey $BrowserKey -Category $Category -CategoryGroup $CategoryGroup) {
return $rule
}
}
return $null
}
function Should-EmitIncident {
param(
[string]$Fingerprint,
[int]$CooldownSeconds
)
$now = (Get-Date).ToUniversalTime()
if ($script:IncidentState.ContainsKey($Fingerprint)) {
$last = [datetime]$script:IncidentState[$Fingerprint]
if ((New-TimeSpan -Start $last -End $now).TotalSeconds -lt $CooldownSeconds) {
return $false
}
}
$script:IncidentState[$Fingerprint] = $now
return $true
}
function Send-DlpIncidentHeartbeat {
param(
[pscustomobject]$Decision,
[string]$Url,
[string]$Title,
[string]$BrowserKey,
[string]$ProcessName,
[string]$Domain,
[string]$RootDomain,
[string]$Category,
[string]$CategoryGroup
)
$bucketId = 'aw-dlp-incidents_' + $script:Hostname
Ensure-Bucket -BucketId $bucketId -ClientName 'aw-dlp-incidents' -BucketType 'aw.dlp.incident'
$captureData = @{}
if ($script:IncidentScreenshotEnabled) {
try {
$captureData = Capture-IncidentScreenshot -RuleId ([string]$Decision.id) -SignalType 'web'
}
catch {
}
}
$event = @{
timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
duration = 0
data = @{
ruleId = [string]$Decision.id
action = [string]$Decision.action
severity = [string]$Decision.severity
message = [string]$Decision.message
url = $Url
title = $Title
browser = $BrowserKey
app = "$ProcessName.exe"
domain = $Domain
rootDomain = $RootDomain
category = $Category
categoryGroup = $CategoryGroup
username = $env:USERNAME
hostname = $script:Hostname
sessionId = $script:SessionId
source = 'uia-native-dlp'
} + $captureData
} | ConvertTo-Json -Depth 5 -Compress
Invoke-RestMethod -Method Post -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$resolvedPulseSeconds" -ContentType 'application/json' -Body $event | Out-Null
}
function Get-FileSha256Hex {
param([Parameter(Mandatory = $true)][string]$Path)
try {
$sha = [Security.Cryptography.SHA256]::Create()
$stream = [IO.File]::OpenRead($Path)
try {
($sha.ComputeHash($stream) | ForEach-Object { $_.ToString('x2') }) -join ''
}
finally {
$stream.Dispose()
$sha.Dispose()
}
}
catch {
return $null
}
}
function Ensure-Directory {
param([Parameter(Mandatory = $true)][string]$Path)
if (-not (Test-Path -LiteralPath $Path)) {
New-Item -Path $Path -ItemType Directory -Force | Out-Null
}
}
function Get-IncidentScreenshotPath {
param(
[Parameter(Mandatory = $true)][string]$RuleId,
[Parameter(Mandatory = $true)][string]$SignalType
)
$safeUser = ($env:USERNAME -replace '[^A-Za-z0-9_.-]', '_')
$safeRule = ($RuleId -replace '[^A-Za-z0-9_.-]', '_')
$safeType = ($SignalType -replace '[^A-Za-z0-9_.-]', '_')
$stamp = (Get-Date).ToUniversalTime().ToString('yyyyMMdd_HHmmss_fff')
$file = '{0}_{1}_sid{2}_{3}_{4}.png' -f $script:Hostname, $safeUser, $script:SessionId, $safeType, $safeRule
$file = '{0}_{1}' -f $stamp, $file
return (Join-Path $script:IncidentArtifactsRoot $file)
}
function Ensure-ScreenshotTypesLoaded {
if ($script:ScreenshotTypesLoaded) {
return
}
Add-Type -AssemblyName System.Windows.Forms | Out-Null
Add-Type -AssemblyName System.Drawing | Out-Null
$script:ScreenshotTypesLoaded = $true
}
function Capture-IncidentScreenshot {
param(
[Parameter(Mandatory = $true)][string]$RuleId,
[Parameter(Mandatory = $true)][string]$SignalType
)
try {
Ensure-Directory -Path $script:IncidentArtifactsRoot
Ensure-ScreenshotTypesLoaded
$vs = [System.Windows.Forms.SystemInformation]::VirtualScreen
$bmp = New-Object System.Drawing.Bitmap ([int]$vs.Width), ([int]$vs.Height)
$gfx = [System.Drawing.Graphics]::FromImage($bmp)
try {
$gfx.CopyFromScreen([int]$vs.Left, [int]$vs.Top, 0, 0, $bmp.Size)
$path = Get-IncidentScreenshotPath -RuleId $RuleId -SignalType $SignalType
$bmp.Save($path, [System.Drawing.Imaging.ImageFormat]::Png)
}
finally {
$gfx.Dispose()
$bmp.Dispose()
}
return @{
screenshotPath = $path
screenshotFormat = 'png'
screenshotWidth = [int]$vs.Width
screenshotHeight = [int]$vs.Height
screenshotSha256 = (Get-FileSha256Hex -Path $path)
}
}
catch {
Write-CollectorLog ("screenshot capture failed: {0}" -f $_.Exception.Message)
return @{}
}
}
function Get-ForegroundWindowContext {
$handle = [NativeAwMethods]::GetForegroundWindow()
if ($handle -eq [IntPtr]::Zero) {
return $null
}
$processId = [uint32]0
[void][NativeAwMethods]::GetWindowThreadProcessId($handle, [ref]$processId)
if (-not $processId) {
return $null
}
$process = Get-Process -Id ([int]$processId) -ErrorAction SilentlyContinue
if (-not $process) {
return $null
}
$textLength = [NativeAwMethods]::GetWindowTextLength($handle)
$builder = [Text.StringBuilder]::new([Math]::Max($textLength + 1, 260))
[void][NativeAwMethods]::GetWindowText($handle, $builder, $builder.Capacity)
return [pscustomobject]@{
Handle = $handle
ProcessName = $process.ProcessName.ToLowerInvariant()
Title = $builder.ToString()
}
}
function Get-BrowserUrlFromWindow {
param([IntPtr]$Handle)
$root = [System.Windows.Automation.AutomationElement]::FromHandle($Handle)
if (-not $root) {
return $null
}
$editCondition = [System.Windows.Automation.PropertyCondition]::new(
[System.Windows.Automation.AutomationElement]::ControlTypeProperty,
[System.Windows.Automation.ControlType]::Edit
)
$edits = $root.FindAll([System.Windows.Automation.TreeScope]::Descendants, $editCondition)
foreach ($edit in $edits) {
$valuePattern = $null
if ($edit.TryGetCurrentPattern([System.Windows.Automation.ValuePattern]::Pattern, [ref]$valuePattern)) {
$candidate = ConvertTo-NormalizedUrl -Value $valuePattern.Current.Value
if ($candidate) {
return $candidate
}
}
$candidateFromName = ConvertTo-NormalizedUrl -Value $edit.Current.Name
if ($candidateFromName) {
return $candidateFromName
}
}
return $null
}
function Ensure-Bucket {
param(
[string]$BucketId,
[string]$ClientName,
[string]$BucketType = 'web.tab.current'
)
if ($script:KnownBuckets.ContainsKey($BucketId)) {
return
}
$body = @{
client = $ClientName
type = $BucketType
hostname = $script:Hostname
} | ConvertTo-Json -Compress
Invoke-RestMethod -Method Post -Uri "$($script:ApiBase)/buckets/$BucketId" -ContentType 'application/json' -Body $body | Out-Null
$script:KnownBuckets[$BucketId] = $true
}
function Send-Heartbeat {
param(
[string]$BucketId,
[string]$Url,
[string]$Title,
[string]$BrowserKey,
[string]$ProcessName
)
$event = @{
timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
duration = 0
data = @{
url = $Url
title = $Title
browser = $BrowserKey
app = "$ProcessName.exe"
source = 'uia-native'
sessionId = $script:SessionId
}
} | ConvertTo-Json -Depth 4 -Compress
Invoke-RestMethod -Method Post -Uri "$($script:ApiBase)/buckets/$BucketId/heartbeat?pulsetime=$resolvedPulseSeconds" -ContentType 'application/json' -Body $event | Out-Null
}
function Send-CategoryHeartbeat {
param(
[string]$Url,
[string]$Title,
[string]$BrowserKey,
[string]$ProcessName,
[string]$Domain,
[string]$RootDomain,
[string]$Category,
[string]$CategoryGroup,
[string]$CategoryRule
)
$bucketId = 'aw-detmir-web-category_' + $script:Hostname
Ensure-Bucket -BucketId $bucketId -ClientName 'aw-detmir-web-category' -BucketType 'aw.web.category'
$event = @{
timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
duration = 0
data = @{
url = $Url
title = $Title
browser = $BrowserKey
app = "$ProcessName.exe"
domain = $Domain
rootDomain = $RootDomain
category = $Category
categoryGroup = $CategoryGroup
categoryRule = $CategoryRule
source = 'uia-native'
sessionId = $script:SessionId
}
} | ConvertTo-Json -Depth 4 -Compress
Invoke-RestMethod -Method Post -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$resolvedPulseSeconds" -ContentType 'application/json' -Body $event | Out-Null
}
Load-CustomCategoryRules -Path $resolvedRulesPath
Load-DlpPolicy -Path $resolvedPolicyPath
Write-CollectorLog ("collector started against {0}" -f $script:ApiBase)
while ($true) {
try {
$context = Get-ForegroundWindowContext
if ($context -and $script:BrowserMap.ContainsKey($context.ProcessName)) {
$url = Get-BrowserUrlFromWindow -Handle $context.Handle
if ($url) {
$browserKey = $script:BrowserMap[$context.ProcessName]
$domain = Get-HostFromUrl -Url $url
if (-not $domain) {
$domain = 'unknown'
}
$rootDomain = Get-RootDomain -DomainHost $domain
if (-not $rootDomain) {
$rootDomain = $domain
}
$category = Get-WebCategory -DomainHost $domain
$bucketId = 'aw-watcher-web-{0}_{1}' -f $browserKey, $script:Hostname
Ensure-Bucket -BucketId $bucketId -ClientName ('aw-watcher-web-' + $browserKey)
Send-Heartbeat -BucketId $bucketId -Url $url -Title $context.Title -BrowserKey $browserKey -ProcessName $context.ProcessName
Send-CategoryHeartbeat -Url $url -Title $context.Title -BrowserKey $browserKey -ProcessName $context.ProcessName -Domain $domain -RootDomain $rootDomain -Category $category.Name -CategoryGroup $category.Group -CategoryRule $category.Rule
$decision = Get-DlpDecision -Domain $domain -RootDomain $rootDomain -Url $url -Title $context.Title -BrowserKey $browserKey -Category $category.Name -CategoryGroup $category.Group
if ($decision) {
$fingerprint = '{0}|{1}|{2}|{3}' -f $decision.id, $browserKey, $rootDomain, $env:USERNAME
$cooldown = [Math]::Max([int]$decision.cooldownSeconds, 30)
if (Should-EmitIncident -Fingerprint $fingerprint -CooldownSeconds $cooldown) {
Write-DlpIncidentLog ("{0} {1} {2} {3}" -f $decision.severity, $decision.action, $decision.id, $url)
if (@('alert', 'block', 'quarantine') -contains ([string]$decision.action).ToLowerInvariant()) {
Send-DlpIncidentHeartbeat -Decision $decision -Url $url -Title $context.Title -BrowserKey $browserKey -ProcessName $context.ProcessName -Domain $domain -RootDomain $rootDomain -Category $category.Name -CategoryGroup $category.Group
}
}
}
}
}
}
catch {
Write-CollectorLog ("collector error: {0}" -f $_.Exception.Message)
}
Start-Sleep -Seconds $resolvedPollSeconds
}
@@ -0,0 +1,107 @@
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$ServerHost,
[string[]]$Users,
[string]$UserListPath,
[string]$Domain,
[int]$ServerPort = 5600,
[ValidateSet('http', 'https')]
[string]$ServerScheme = 'http',
[string]$Version = 'v0.13.2',
[string]$PackageUrl,
[string]$PackageZipPath,
[string]$InstallRoot = 'C:\Program Files\ActivityWatch',
[string]$StateRoot = 'C:\ProgramData\ActivityWatch',
[int]$PollSeconds = 5,
[int]$PulseSeconds = 30,
[int]$RecoveryIntervalSeconds = 180,
[bool]$AfkEnabled = $true,
[bool]$WindowEnabled = $true,
[bool]$LocalAgentLogsEnabled = $false,
[bool]$IncidentCaptureEnabled = $true,
[bool]$IncidentScreenshotEnabled = $true,
[string]$IncidentArtifactsRoot,
[bool]$LogonMarkerEnabled = $true,
[string]$CustomRulesPath,
[string]$CustomPolicyPath
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$modulePath = Join-Path $PSScriptRoot 'ActivityWatch.Windows.Common.psm1'
Import-Module $modulePath -Force
Assert-Administrator
$targetUsers = Normalize-ActivityWatchUsers -Users $Users -UserListPath $UserListPath -Domain $Domain
$workingRoot = Join-Path $env:TEMP 'activitywatch-windows-deploy'
$backupRoot = Join-Path $StateRoot 'backups'
$logsRoot = Join-Path $StateRoot 'logs'
$configPath = Join-Path $StateRoot 'deployment-config.json'
$launchScriptPath = Join-Path $StateRoot 'launch-watchers.ps1'
$recoveryScriptPath = Join-Path $StateRoot 'recovery-loop.ps1'
$collectorSource = Join-Path $PSScriptRoot 'browser-domains-native-collector.ps1'
$endpointCollectorSource = Join-Path $PSScriptRoot 'dlp-endpoint-signals-collector.ps1'
$exampleRulesSource = Join-Path $PSScriptRoot 'web-category-rules.example.json'
$examplePolicySource = Join-Path $PSScriptRoot 'dlp-policy.example.json'
New-ActivityWatchDirectory -Path $StateRoot
New-ActivityWatchDirectory -Path $logsRoot
$archivePath = Get-ActivityWatchArchive -PackageZipPath $PackageZipPath -PackageUrl $PackageUrl -Version $Version -WorkingRoot $workingRoot
Install-ActivityWatchPackage -ArchivePath $archivePath -InstallRoot $InstallRoot -WorkingRoot $workingRoot -BackupRoot $backupRoot | Out-Null
Get-ActivityWatchExecutableMap -InstallRoot $InstallRoot | Out-Null
$assetResult = Copy-ActivityWatchCollectorAssets `
-CollectorScriptSource $collectorSource `
-EndpointCollectorScriptSource $endpointCollectorSource `
-ExampleRulesSource $exampleRulesSource `
-ExamplePolicySource $examplePolicySource `
-StateRoot $StateRoot `
-CustomRulesSource $CustomRulesPath `
-CustomPolicySource $CustomPolicyPath
$taskDefinitions = New-ActivityWatchUserTaskDefinitions -Users $targetUsers
Write-ActivityWatchLaunchScript -Path $launchScriptPath -ConfigPath $configPath
Write-ActivityWatchRecoveryScript -Path $recoveryScriptPath -ConfigPath $configPath
$config = New-ActivityWatchDeploymentConfig `
-ServerHost $ServerHost `
-ServerPort $ServerPort `
-ServerScheme $ServerScheme `
-InstallRoot $InstallRoot `
-StateRoot $StateRoot `
-LogsRoot $logsRoot `
-CollectorScript $assetResult.CollectorScript `
-EndpointCollectorScript $assetResult.EndpointCollectorScript `
-RulesPath $assetResult.ActiveRules `
-PolicyPath $assetResult.ActivePolicy `
-PollSeconds $PollSeconds `
-PulseSeconds $PulseSeconds `
-RecoveryIntervalSeconds $RecoveryIntervalSeconds `
-AfkEnabled $AfkEnabled `
-WindowEnabled $WindowEnabled `
-LocalAgentLogsEnabled $LocalAgentLogsEnabled `
-IncidentCaptureEnabled $IncidentCaptureEnabled `
-IncidentScreenshotEnabled $IncidentScreenshotEnabled `
-IncidentArtifactsRoot $IncidentArtifactsRoot `
-LogonMarkerEnabled $LogonMarkerEnabled `
-LaunchScriptPath $launchScriptPath `
-RecoveryScriptPath $recoveryScriptPath `
-UserTasks $taskDefinitions `
-PackageVersion $Version
Write-ActivityWatchDeploymentConfig -Config $config -Path $configPath
Remove-LegacyActivityWatchEntries
Set-ActivityWatchAcl -InstallRoot $InstallRoot -StateRoot $StateRoot -LogsRoot $logsRoot
Register-ActivityWatchUserTasks -TaskDefinitions $taskDefinitions -LaunchScriptPath $launchScriptPath -ConfigPath $configPath
Register-ActivityWatchRecoveryTask -TaskName $config.recovery.taskName -RecoveryScriptPath $recoveryScriptPath -ConfigPath $configPath
Start-ActivityWatchTasks -TaskDefinitions $taskDefinitions -RecoveryTaskName $config.recovery.taskName
Write-Host 'ActivityWatch deployed for users:'
$targetUsers | ForEach-Object { Write-Host " - $_" }
Write-Host "Server: ${ServerScheme}://$ServerHost`:$ServerPort"
Write-Host "State root: $StateRoot"
Write-Host "Policy file: $($assetResult.ActivePolicy)"
@@ -0,0 +1,137 @@
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$ServerHost,
[string[]]$Users,
[string]$UserListPath,
[string]$Domain,
[int]$ServerPort = 5600,
[ValidateSet('http', 'https')]
[string]$ServerScheme = 'http',
[string]$Version = 'v0.13.2',
[string]$PackageUrl,
[string]$PackageZipPath,
[string]$InstallRoot = 'C:\Program Files\ActivityWatch',
[string]$StateRoot = 'C:\ProgramData\ActivityWatch',
[int]$PollSeconds = 5,
[int]$PulseSeconds = 30,
[int]$RecoveryIntervalSeconds = 180,
[bool]$AfkEnabled = $true,
[bool]$WindowEnabled = $true,
[bool]$LocalAgentLogsEnabled = $false,
[bool]$IncidentCaptureEnabled = $true,
[bool]$IncidentScreenshotEnabled = $true,
[string]$IncidentArtifactsRoot,
[bool]$LogonMarkerEnabled = $true,
[string]$CustomRulesPath,
[string]$CustomPolicyPath,
[string]$ReportPath,
[switch]$SkipHardening,
[switch]$ValidateAfterDeploy
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$modulePath = Join-Path $PSScriptRoot 'ActivityWatch.Windows.Common.psm1'
Import-Module $modulePath -Force
Assert-Administrator
$resolvedUsers = Normalize-ActivityWatchUsers -Users $Users -UserListPath $UserListPath -Domain $Domain
$timestamp = Get-Date -Format 'yyyyMMdd-HHmmss'
$effectiveReportPath = if ($ReportPath) { $ReportPath } else { Join-Path $StateRoot "ensemble-report-$timestamp.json" }
$deployScript = Join-Path $PSScriptRoot 'deploy-domain-users.ps1'
$hardeningScript = Join-Path $PSScriptRoot 'hardening-recovery.ps1'
$validationScript = Join-Path $PSScriptRoot 'validate-deployment.ps1'
if (-not (Test-Path -LiteralPath $deployScript)) {
throw "Missing script: $deployScript"
}
& $deployScript `
-ServerHost $ServerHost `
-Users $resolvedUsers `
-ServerPort $ServerPort `
-ServerScheme $ServerScheme `
-Version $Version `
-PackageUrl $PackageUrl `
-PackageZipPath $PackageZipPath `
-InstallRoot $InstallRoot `
-StateRoot $StateRoot `
-PollSeconds $PollSeconds `
-PulseSeconds $PulseSeconds `
-RecoveryIntervalSeconds $RecoveryIntervalSeconds `
-AfkEnabled $AfkEnabled `
-WindowEnabled $WindowEnabled `
-LocalAgentLogsEnabled $LocalAgentLogsEnabled `
-IncidentCaptureEnabled $IncidentCaptureEnabled `
-IncidentScreenshotEnabled $IncidentScreenshotEnabled `
-IncidentArtifactsRoot $IncidentArtifactsRoot `
-LogonMarkerEnabled $LogonMarkerEnabled `
-CustomRulesPath $CustomRulesPath `
-CustomPolicyPath $CustomPolicyPath
if (-not $SkipHardening) {
& $hardeningScript `
-ConfigPath (Join-Path $StateRoot 'deployment-config.json') `
-ServerHost $ServerHost `
-ServerPort $ServerPort `
-ServerScheme $ServerScheme `
-Users $resolvedUsers `
-InstallRoot $InstallRoot `
-StateRoot $StateRoot `
-PollSeconds $PollSeconds `
-PulseSeconds $PulseSeconds `
-RecoveryIntervalSeconds $RecoveryIntervalSeconds `
-AfkEnabled $AfkEnabled `
-WindowEnabled $WindowEnabled `
-LocalAgentLogsEnabled $LocalAgentLogsEnabled `
-IncidentCaptureEnabled $IncidentCaptureEnabled `
-IncidentScreenshotEnabled $IncidentScreenshotEnabled `
-IncidentArtifactsRoot $IncidentArtifactsRoot `
-LogonMarkerEnabled $LogonMarkerEnabled `
-CustomRulesPath $CustomRulesPath `
-CustomPolicyPath $CustomPolicyPath
}
$report = [ordered]@{
generatedAtUtc = (Get-Date).ToUniversalTime().ToString('o')
server = [ordered]@{
host = $ServerHost
port = $ServerPort
scheme = $ServerScheme
}
packageVersion = $Version
users = @($resolvedUsers)
paths = [ordered]@{
installRoot = $InstallRoot
stateRoot = $StateRoot
configPath = Join-Path $StateRoot 'deployment-config.json'
}
collectors = [ordered]@{
afkEnabled = $AfkEnabled
windowEnabled = $WindowEnabled
}
hardeningApplied = (-not $SkipHardening)
}
if ($ValidateAfterDeploy) {
if (-not (Test-Path -LiteralPath $validationScript)) {
throw "Missing script: $validationScript"
}
$validation = & $validationScript -ConfigPath (Join-Path $StateRoot 'deployment-config.json')
$report.validation = $validation
}
$reportDirectory = Split-Path -Path $effectiveReportPath -Parent
if ($reportDirectory) {
New-ActivityWatchDirectory -Path $reportDirectory
}
$report | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $effectiveReportPath -Encoding UTF8
Write-Host 'ActivityWatch ensemble deploy completed.'
Write-Host "Users: $($resolvedUsers -join ', ')"
Write-Host "Report: $effectiveReportPath"
@@ -0,0 +1,106 @@
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$ServerHost,
[Parameter(Mandatory = $true)]
[string]$TargetUser,
[int]$ServerPort = 5600,
[ValidateSet('http', 'https')]
[string]$ServerScheme = 'http',
[string]$Version = 'v0.13.2',
[string]$PackageUrl,
[string]$PackageZipPath,
[string]$InstallRoot = 'C:\Program Files\ActivityWatch',
[string]$StateRoot = 'C:\ProgramData\ActivityWatch',
[int]$PollSeconds = 5,
[int]$PulseSeconds = 30,
[int]$RecoveryIntervalSeconds = 180,
[bool]$AfkEnabled = $true,
[bool]$WindowEnabled = $true,
[bool]$LocalAgentLogsEnabled = $false,
[bool]$IncidentCaptureEnabled = $true,
[bool]$IncidentScreenshotEnabled = $true,
[string]$IncidentArtifactsRoot,
[bool]$LogonMarkerEnabled = $true,
[string]$CustomRulesPath,
[string]$CustomPolicyPath
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$modulePath = Join-Path $PSScriptRoot 'ActivityWatch.Windows.Common.psm1'
Import-Module $modulePath -Force
Assert-Administrator
$workingRoot = Join-Path $env:TEMP 'activitywatch-windows-deploy'
$backupRoot = Join-Path $StateRoot 'backups'
$logsRoot = Join-Path $StateRoot 'logs'
$configPath = Join-Path $StateRoot 'deployment-config.json'
$launchScriptPath = Join-Path $StateRoot 'launch-watchers.ps1'
$recoveryScriptPath = Join-Path $StateRoot 'recovery-loop.ps1'
$collectorSource = Join-Path $PSScriptRoot 'browser-domains-native-collector.ps1'
$endpointCollectorSource = Join-Path $PSScriptRoot 'dlp-endpoint-signals-collector.ps1'
$exampleRulesSource = Join-Path $PSScriptRoot 'web-category-rules.example.json'
$examplePolicySource = Join-Path $PSScriptRoot 'dlp-policy.example.json'
New-ActivityWatchDirectory -Path $StateRoot
New-ActivityWatchDirectory -Path $logsRoot
$archivePath = Get-ActivityWatchArchive -PackageZipPath $PackageZipPath -PackageUrl $PackageUrl -Version $Version -WorkingRoot $workingRoot
Install-ActivityWatchPackage -ArchivePath $archivePath -InstallRoot $InstallRoot -WorkingRoot $workingRoot -BackupRoot $backupRoot | Out-Null
Get-ActivityWatchExecutableMap -InstallRoot $InstallRoot | Out-Null
$assetResult = Copy-ActivityWatchCollectorAssets `
-CollectorScriptSource $collectorSource `
-EndpointCollectorScriptSource $endpointCollectorSource `
-ExampleRulesSource $exampleRulesSource `
-ExamplePolicySource $examplePolicySource `
-StateRoot $StateRoot `
-CustomRulesSource $CustomRulesPath `
-CustomPolicySource $CustomPolicyPath
$taskDefinitions = New-ActivityWatchUserTaskDefinitions -Users @($TargetUser)
Write-ActivityWatchLaunchScript -Path $launchScriptPath -ConfigPath $configPath
Write-ActivityWatchRecoveryScript -Path $recoveryScriptPath -ConfigPath $configPath
$config = New-ActivityWatchDeploymentConfig `
-ServerHost $ServerHost `
-ServerPort $ServerPort `
-ServerScheme $ServerScheme `
-InstallRoot $InstallRoot `
-StateRoot $StateRoot `
-LogsRoot $logsRoot `
-CollectorScript $assetResult.CollectorScript `
-EndpointCollectorScript $assetResult.EndpointCollectorScript `
-RulesPath $assetResult.ActiveRules `
-PolicyPath $assetResult.ActivePolicy `
-PollSeconds $PollSeconds `
-PulseSeconds $PulseSeconds `
-RecoveryIntervalSeconds $RecoveryIntervalSeconds `
-AfkEnabled $AfkEnabled `
-WindowEnabled $WindowEnabled `
-LocalAgentLogsEnabled $LocalAgentLogsEnabled `
-IncidentCaptureEnabled $IncidentCaptureEnabled `
-IncidentScreenshotEnabled $IncidentScreenshotEnabled `
-IncidentArtifactsRoot $IncidentArtifactsRoot `
-LogonMarkerEnabled $LogonMarkerEnabled `
-LaunchScriptPath $launchScriptPath `
-RecoveryScriptPath $recoveryScriptPath `
-UserTasks $taskDefinitions `
-PackageVersion $Version
Write-ActivityWatchDeploymentConfig -Config $config -Path $configPath
Remove-LegacyActivityWatchEntries
Set-ActivityWatchAcl -InstallRoot $InstallRoot -StateRoot $StateRoot -LogsRoot $logsRoot
Register-ActivityWatchUserTasks -TaskDefinitions $taskDefinitions -LaunchScriptPath $launchScriptPath -ConfigPath $configPath
Register-ActivityWatchRecoveryTask -TaskName $config.recovery.taskName -RecoveryScriptPath $recoveryScriptPath -ConfigPath $configPath
Start-ActivityWatchTasks -TaskDefinitions $taskDefinitions -RecoveryTaskName $config.recovery.taskName
Write-Host "ActivityWatch deployed for $TargetUser"
Write-Host "Server: ${ServerScheme}://$ServerHost`:$ServerPort"
Write-Host "Install root: $InstallRoot"
Write-Host "State root: $StateRoot"
Write-Host "Rules file: $($assetResult.ActiveRules)"
Write-Host "Policy file: $($assetResult.ActivePolicy)"
@@ -0,0 +1,796 @@
[CmdletBinding()]
param(
[string]$ConfigPath = 'C:\ProgramData\ActivityWatch\deployment-config.json',
[string]$ServerHost,
[int]$ServerPort,
[ValidateSet('http', 'https')]
[string]$ServerScheme,
[string]$PolicyPath,
[string]$LogPath,
[int]$PollSeconds
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
function Get-DeploymentConfig {
param([string]$Path)
if ($Path -and (Test-Path -LiteralPath $Path)) {
return Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json
}
return $null
}
function Write-EndpointLog {
param([string]$Message)
if (-not $script:LocalAgentLogsEnabled) {
return
}
try {
Add-Content -LiteralPath $script:LogPath -Value ('{0} {1}' -f (Get-Date -Format s), $Message)
}
catch {
}
}
function Invoke-AwJsonPost {
param(
[Parameter(Mandatory = $true)][string]$Uri,
[Parameter(Mandatory = $true)][string]$Json
)
$bytes = [Text.Encoding]::UTF8.GetBytes($Json)
Invoke-RestMethod -Method Post -Uri $Uri -ContentType 'application/json; charset=utf-8' -Body $bytes | Out-Null
}
function Ensure-Bucket {
param(
[string]$BucketId,
[string]$ClientName,
[string]$BucketType
)
if ($script:KnownBuckets.ContainsKey($BucketId)) {
return
}
$body = @{
client = $ClientName
type = $BucketType
hostname = $script:Hostname
} | ConvertTo-Json -Compress
Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$BucketId" -Json $body
$script:KnownBuckets[$BucketId] = $true
}
function Send-EndpointSignalHeartbeat {
param(
[string]$SignalType,
[hashtable]$Data
)
$bucketId = 'aw-dlp-endpoint-signals_' + $script:Hostname
Ensure-Bucket -BucketId $bucketId -ClientName 'aw-dlp-endpoint-signals' -BucketType 'aw.dlp.endpoint.signal'
$payload = @{
timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
duration = 0
data = @{
signalType = $SignalType
username = $env:USERNAME
sessionId = $script:SessionId
hostname = $script:Hostname
source = 'endpoint-signals-phase2'
} + $Data
} | ConvertTo-Json -Depth 6 -Compress
Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$script:PulseSeconds" -Json $payload
}
function Send-DlpIncidentHeartbeat {
param(
[string]$RuleId,
[string]$Action,
[string]$Severity,
[string]$Message,
[string]$SignalType,
[hashtable]$Data
)
$bucketId = 'aw-dlp-incidents_' + $script:Hostname
Ensure-Bucket -BucketId $bucketId -ClientName 'aw-dlp-incidents' -BucketType 'aw.dlp.incident'
$captureData = @{}
if ($script:IncidentScreenshotEnabled) {
try {
$captureData = Capture-IncidentScreenshot -RuleId $RuleId -SignalType $SignalType
}
catch {
}
}
$payload = @{
timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
duration = 0
data = @{
ruleId = $RuleId
action = $Action
severity = $Severity
message = $Message
signalType = $SignalType
username = $env:USERNAME
sessionId = $script:SessionId
hostname = $script:Hostname
source = 'endpoint-signals-phase2'
} + $Data + $captureData
} | ConvertTo-Json -Depth 7 -Compress
Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$script:PulseSeconds" -Json $payload
}
function Get-FileSha256Hex {
param([Parameter(Mandatory = $true)][string]$Path)
try {
$sha = [Security.Cryptography.SHA256]::Create()
$stream = [IO.File]::OpenRead($Path)
try {
($sha.ComputeHash($stream) | ForEach-Object { $_.ToString('x2') }) -join ''
}
finally {
$stream.Dispose()
$sha.Dispose()
}
}
catch {
return $null
}
}
function Ensure-Directory {
param([Parameter(Mandatory = $true)][string]$Path)
if (-not (Test-Path -LiteralPath $Path)) {
New-Item -Path $Path -ItemType Directory -Force | Out-Null
}
}
function Get-IncidentScreenshotPath {
param(
[Parameter(Mandatory = $true)][string]$RuleId,
[Parameter(Mandatory = $true)][string]$SignalType
)
$safeUser = ($env:USERNAME -replace '[^A-Za-z0-9_.-]', '_')
$safeRule = ($RuleId -replace '[^A-Za-z0-9_.-]', '_')
$safeType = ($SignalType -replace '[^A-Za-z0-9_.-]', '_')
$stamp = (Get-Date).ToUniversalTime().ToString('yyyyMMdd_HHmmss_fff')
$file = '{0}_{1}_sid{2}_{3}_{4}.png' -f $script:Hostname, $safeUser, $script:SessionId, $safeType, $safeRule
$file = '{0}_{1}' -f $stamp, $file
return (Join-Path $script:IncidentArtifactsRoot $file)
}
function Ensure-ScreenshotTypesLoaded {
if ($script:ScreenshotTypesLoaded) {
return
}
Add-Type -AssemblyName System.Windows.Forms | Out-Null
Add-Type -AssemblyName System.Drawing | Out-Null
$script:ScreenshotTypesLoaded = $true
}
function Capture-IncidentScreenshot {
param(
[Parameter(Mandatory = $true)][string]$RuleId,
[Parameter(Mandatory = $true)][string]$SignalType
)
try {
Ensure-Directory -Path $script:IncidentArtifactsRoot
Ensure-ScreenshotTypesLoaded
$vs = [System.Windows.Forms.SystemInformation]::VirtualScreen
$bmp = New-Object System.Drawing.Bitmap ([int]$vs.Width), ([int]$vs.Height)
$gfx = [System.Drawing.Graphics]::FromImage($bmp)
try {
$gfx.CopyFromScreen([int]$vs.Left, [int]$vs.Top, 0, 0, $bmp.Size)
$path = Get-IncidentScreenshotPath -RuleId $RuleId -SignalType $SignalType
$bmp.Save($path, [System.Drawing.Imaging.ImageFormat]::Png)
}
finally {
$gfx.Dispose()
$bmp.Dispose()
}
return @{
screenshotPath = $path
screenshotFormat = 'png'
screenshotWidth = [int]$vs.Width
screenshotHeight = [int]$vs.Height
screenshotSha256 = (Get-FileSha256Hex -Path $path)
}
}
catch {
Write-EndpointLog ("screenshot capture failed: {0}" -f $_.Exception.Message)
return @{}
}
}
function Get-StringHash {
param([AllowNull()][string]$Value)
if ($null -eq $Value) { return $null }
$bytes = [Text.Encoding]::UTF8.GetBytes($Value)
$sha = [Security.Cryptography.SHA256]::Create()
try {
($sha.ComputeHash($bytes) | ForEach-Object { $_.ToString('x2') }) -join ''
}
finally {
$sha.Dispose()
}
}
function Load-DlpPolicy {
param([string]$Path)
$script:Policy = [ordered]@{
defaults = [ordered]@{
enabled = $true
cooldownSeconds = 300
action = 'alert'
severity = 'medium'
}
endpoint = [ordered]@{
clipboard = @()
usb = @()
print = @()
}
}
if (-not $Path -or -not (Test-Path -LiteralPath $Path)) {
Write-EndpointLog ("policy not found, using defaults: {0}" -f $Path)
return
}
try {
$raw = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json
if ($raw.defaults) {
if ($raw.defaults.PSObject.Properties.Name -contains 'enabled') { $script:Policy.defaults.enabled = [bool]$raw.defaults.enabled }
if ($raw.defaults.cooldownSeconds) { $script:Policy.defaults.cooldownSeconds = [int]$raw.defaults.cooldownSeconds }
if ($raw.defaults.action) { $script:Policy.defaults.action = [string]$raw.defaults.action }
if ($raw.defaults.severity) { $script:Policy.defaults.severity = [string]$raw.defaults.severity }
}
if ($raw.endpoint) {
if ($raw.endpoint.clipboard) { $script:Policy.endpoint.clipboard = @($raw.endpoint.clipboard) }
if ($raw.endpoint.usb) { $script:Policy.endpoint.usb = @($raw.endpoint.usb) }
if ($raw.endpoint.print) { $script:Policy.endpoint.print = @($raw.endpoint.print) }
}
}
catch {
Write-EndpointLog ("policy parse failed: {0}" -f $_.Exception.Message)
}
}
function Should-EmitByCooldown {
param(
[string]$Fingerprint,
[int]$CooldownSeconds
)
$now = (Get-Date).ToUniversalTime()
if ($script:Cooldown.ContainsKey($Fingerprint)) {
$last = [datetime]$script:Cooldown[$Fingerprint]
if ((New-TimeSpan -Start $last -End $now).TotalSeconds -lt $CooldownSeconds) {
return $false
}
}
$script:Cooldown[$Fingerprint] = $now
return $true
}
function Evaluate-ClipboardRules {
param(
[string]$ClipboardText,
[string]$ClipboardHash
)
foreach ($rule in @($script:Policy.endpoint.clipboard)) {
if (-not $rule) { continue }
if ($rule.PSObject.Properties.Name -contains 'enabled' -and -not [bool]$rule.enabled) { continue }
$ruleId = [string]$rule.id
if (-not $ruleId) { continue }
$minLength = if ($rule.minLength) { [int]$rule.minLength } else { 0 }
$regexPatterns = if ($rule.regexPatterns) { @($rule.regexPatterns) } else { @() }
if ($ClipboardText.Length -lt $minLength) { continue }
$matched = $false
foreach ($pattern in $regexPatterns) {
if ($ClipboardText -match [string]$pattern) {
$matched = $true
break
}
}
if (-not $matched) { continue }
$cooldown = if ($rule.cooldownSeconds) { [int]$rule.cooldownSeconds } else { [int]$script:Policy.defaults.cooldownSeconds }
$fingerprint = "clipboard|$ruleId|$ClipboardHash|$env:USERNAME"
if (-not (Should-EmitByCooldown -Fingerprint $fingerprint -CooldownSeconds ([Math]::Max($cooldown, 30)))) { continue }
$action = if ($rule.action) { [string]$rule.action } else { [string]$script:Policy.defaults.action }
$severity = if ($rule.severity) { [string]$rule.severity } else { [string]$script:Policy.defaults.severity }
$message = if ($rule.message) { [string]$rule.message } else { "Clipboard rule matched: $ruleId" }
Send-DlpIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -SignalType 'clipboard' -Data @{
clipboardHash = $ClipboardHash
clipboardLength = $ClipboardText.Length
}
Write-EndpointLog ("incident clipboard rule={0} action={1} severity={2}" -f $ruleId, $action, $severity)
}
}
function Evaluate-UsbRules {
param(
[string]$DriveLetter,
[string]$VolumeName
)
foreach ($rule in @($script:Policy.endpoint.usb)) {
if (-not $rule) { continue }
if ($rule.PSObject.Properties.Name -contains 'enabled' -and -not [bool]$rule.enabled) { continue }
$ruleId = [string]$rule.id
if (-not $ruleId) { continue }
$cooldown = if ($rule.cooldownSeconds) { [int]$rule.cooldownSeconds } else { [int]$script:Policy.defaults.cooldownSeconds }
$fingerprint = "usb|$ruleId|$DriveLetter|$env:USERNAME"
if (-not (Should-EmitByCooldown -Fingerprint $fingerprint -CooldownSeconds ([Math]::Max($cooldown, 30)))) { continue }
$action = if ($rule.action) { [string]$rule.action } else { [string]$script:Policy.defaults.action }
$severity = if ($rule.severity) { [string]$rule.severity } else { [string]$script:Policy.defaults.severity }
$message = if ($rule.message) { [string]$rule.message } else { "USB rule matched: $ruleId" }
Send-DlpIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -SignalType 'usb_insert' -Data @{
driveLetter = $DriveLetter
volumeName = $VolumeName
}
Write-EndpointLog ("incident usb rule={0} action={1} severity={2} drive={3}" -f $ruleId, $action, $severity, $DriveLetter)
}
}
function Evaluate-PrintRules {
param(
[string]$PrinterName,
[string]$DocumentName,
[string]$Owner
)
foreach ($rule in @($script:Policy.endpoint.print)) {
if (-not $rule) { continue }
if ($rule.PSObject.Properties.Name -contains 'enabled' -and -not [bool]$rule.enabled) { continue }
$ruleId = [string]$rule.id
if (-not $ruleId) { continue }
$match = $true
if ($rule.printerRegex) {
$match = $match -and ($PrinterName -match [string]$rule.printerRegex)
}
if ($rule.documentRegex) {
$match = $match -and ($DocumentName -match [string]$rule.documentRegex)
}
if (-not $match) { continue }
$cooldown = if ($rule.cooldownSeconds) { [int]$rule.cooldownSeconds } else { [int]$script:Policy.defaults.cooldownSeconds }
$fingerprint = "print|$ruleId|$PrinterName|$Owner|$env:USERNAME"
if (-not (Should-EmitByCooldown -Fingerprint $fingerprint -CooldownSeconds ([Math]::Max($cooldown, 30)))) { continue }
$action = if ($rule.action) { [string]$rule.action } else { [string]$script:Policy.defaults.action }
$severity = if ($rule.severity) { [string]$rule.severity } else { [string]$script:Policy.defaults.severity }
$message = if ($rule.message) { [string]$rule.message } else { "Print rule matched: $ruleId" }
Send-DlpIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -SignalType 'print_job' -Data @{
printerName = $PrinterName
documentName = $DocumentName
owner = $Owner
}
Write-EndpointLog ("incident print rule={0} action={1} severity={2} printer={3}" -f $ruleId, $action, $severity, $PrinterName)
}
}
function Test-LooksLikeMojibakeQuestionMarks {
param([AllowNull()][string]$Value)
if ([string]::IsNullOrWhiteSpace($Value)) { return $true }
return $Value -match '\?{2,}'
}
function Normalize-OwnerForMatch {
param([AllowNull()][string]$Value)
if ([string]::IsNullOrWhiteSpace($Value)) { return '' }
$normalized = $Value.Trim().ToLowerInvariant()
if ($normalized -match '[\\/]') {
$parts = $normalized -split '[\\/]'
if ($parts.Count -gt 0) {
$normalized = [string]$parts[$parts.Count - 1]
}
}
if ($normalized -match '@') {
$parts = $normalized -split '@'
if ($parts.Count -gt 0) {
$normalized = [string]$parts[0]
}
}
return $normalized
}
function Test-OwnerLooseMatch {
param(
[string]$Expected,
[string]$Actual
)
$expectedNorm = Normalize-OwnerForMatch -Value $Expected
$actualNorm = Normalize-OwnerForMatch -Value $Actual
if ([string]::IsNullOrWhiteSpace($expectedNorm) -or [string]::IsNullOrWhiteSpace($actualNorm)) {
return $false
}
return ($actualNorm -eq $expectedNorm) -or $actualNorm.Contains($expectedNorm) -or $expectedNorm.Contains($actualNorm)
}
function Normalize-PrinterForMatch {
param([AllowNull()][string]$Value)
if ([string]::IsNullOrWhiteSpace($Value)) { return '' }
$normalized = $Value.Trim().ToLowerInvariant()
if ($normalized.Contains(',')) {
$normalized = ($normalized -split ',', 2)[0].Trim()
}
if ($normalized -match '\son\s') {
$normalized = ($normalized -split '\son\s', 2)[0].Trim()
}
return $normalized
}
function Test-PrinterLooseMatch {
param(
[string]$Expected,
[string]$Actual
)
$expectedNorm = Normalize-PrinterForMatch -Value $Expected
$actualNorm = Normalize-PrinterForMatch -Value $Actual
if ([string]::IsNullOrWhiteSpace($expectedNorm) -or [string]::IsNullOrWhiteSpace($actualNorm)) {
return $false
}
return ($actualNorm -eq $expectedNorm) -or $actualNorm.Contains($expectedNorm) -or $expectedNorm.Contains($actualNorm)
}
function Get-PrintServiceEventSummary {
param([Parameter(Mandatory = $true)]$Event)
$props = @($Event.Properties)
$propertyValues = @()
foreach ($prop in $props) {
$propertyValues += [string]$prop.Value
}
[pscustomobject]@{
RecordId = [string]$Event.RecordId
TimeCreated = if ($Event.TimeCreated) { $Event.TimeCreated.ToString('o') } else { '' }
PropertyCount = $props.Count
DocumentName = if ($props.Count -ge 1) { [string]$props[0].Value } else { '' }
Owner = if ($props.Count -ge 2) { [string]$props[1].Value } else { '' }
PrinterName = if ($props.Count -ge 4) { [string]$props[3].Value } else { '' }
PropertyValues = $propertyValues
}
}
function Get-PrintServiceDocumentFallback {
param(
[Parameter(Mandatory = $true)]$EventSummary,
[string]$Owner,
[string]$PrinterName
)
$preferred = [string]$EventSummary.DocumentName
if (-not (Test-LooksLikeMojibakeQuestionMarks -Value $preferred) -and $preferred -notmatch '^[0-9]+$') {
return $preferred
}
$pathCandidates = New-Object System.Collections.Generic.List[string]
$textCandidates = New-Object System.Collections.Generic.List[string]
foreach ($value in @($EventSummary.PropertyValues)) {
$candidate = [string]$value
if ([string]::IsNullOrWhiteSpace($candidate)) { continue }
if ($candidate -eq $preferred) { continue }
if ($Owner -and $candidate -like "*$Owner*") { continue }
if ($PrinterName -and $candidate -like "*$PrinterName*") { continue }
if (Test-LooksLikeMojibakeQuestionMarks -Value $candidate) { continue }
if ($candidate -match '[\\/:]' -and $candidate -match '\.[A-Za-z0-9]{1,8}$') {
$pathCandidates.Add($candidate)
continue
}
if ($candidate -match '^[0-9]+$') {
continue
}
$textCandidates.Add($candidate)
}
foreach ($candidate in @($pathCandidates)) {
$leaf = Split-Path -Path $candidate -Leaf
if (-not [string]::IsNullOrWhiteSpace($leaf)) {
return $leaf
}
return $candidate
}
foreach ($candidate in @($textCandidates)) {
return $candidate
}
return $null
}
function Write-PrintServiceEventTrace {
param(
[Parameter(Mandatory = $true)]$EventSummary,
[string]$Phase,
[string]$MatchReason,
[string]$ResolvedDocument
)
$properties = if ($EventSummary.PropertyValues) {
($EventSummary.PropertyValues -join ' | ')
}
else {
''
}
Write-EndpointLog (
'printservice-307 phase={0} recordId={1} time={2} owner={3} printer={4} document={5} resolved={6} properties=[{7}] reason={8}' -f
$Phase,
$EventSummary.RecordId,
$EventSummary.TimeCreated,
$EventSummary.Owner,
$EventSummary.PrinterName,
$EventSummary.DocumentName,
$ResolvedDocument,
$properties,
$MatchReason
)
}
function Get-BetterDocumentNameFromPrintServiceEvents {
param(
[string]$Owner,
[string]$PrinterName
)
try {
$startTime = (Get-Date).AddMinutes(-15)
$events = Get-WinEvent -FilterHashtable @{
LogName = 'Microsoft-Windows-PrintService/Operational'
Id = 307
StartTime = $startTime
} -MaxEvents 200 -ErrorAction Stop
foreach ($pass in @('strict', 'relaxed')) {
foreach ($event in @($events)) {
$summary = Get-PrintServiceEventSummary -Event $event
$resolvedDocument = Get-PrintServiceDocumentFallback -EventSummary $summary -Owner $Owner -PrinterName $PrinterName
$ownerMatches = if ($Owner) { Test-OwnerLooseMatch -Expected $Owner -Actual $summary.Owner } else { $true }
$printerMatches = if ($PrinterName) { Test-PrinterLooseMatch -Expected $PrinterName -Actual $summary.PrinterName } else { $true }
if ($pass -eq 'strict') {
if ($Owner -and -not $ownerMatches) {
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'owner-mismatch-strict' -ResolvedDocument $resolvedDocument
continue
}
if ($PrinterName -and -not $printerMatches) {
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'printer-mismatch-strict' -ResolvedDocument $resolvedDocument
continue
}
}
else {
if ($Owner -and $PrinterName -and -not $ownerMatches -and -not $printerMatches) {
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'owner-and-printer-mismatch-relaxed' -ResolvedDocument $resolvedDocument
continue
}
}
if ([string]::IsNullOrWhiteSpace($resolvedDocument)) {
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason ('no-document-candidate-' + $pass) -ResolvedDocument ''
continue
}
$matchReasonBase = if (Test-LooksLikeMojibakeQuestionMarks -Value $summary.DocumentName) { 'fallback-used' } else { 'direct' }
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'selected' -MatchReason ($matchReasonBase + '-' + $pass) -ResolvedDocument $resolvedDocument
return $resolvedDocument
}
}
}
catch {
}
return $null
}
$deploymentConfig = Get-DeploymentConfig -Path $ConfigPath
$resolvedServerHost = if ($ServerHost) { $ServerHost } elseif ($deploymentConfig) { [string]$deploymentConfig.server.host } else { throw 'ServerHost is required.' }
$resolvedServerPort = if ($PSBoundParameters.ContainsKey('ServerPort')) { $ServerPort } elseif ($deploymentConfig) { [int]$deploymentConfig.server.port } else { 5600 }
$resolvedServerScheme = if ($ServerScheme) { $ServerScheme } elseif ($deploymentConfig) { [string]$deploymentConfig.server.scheme } else { 'http' }
$resolvedPolicyPath = if ($PolicyPath) { $PolicyPath } elseif ($deploymentConfig -and $deploymentConfig.paths.PSObject.Properties.Name -contains 'policyPath') { [string]$deploymentConfig.paths.policyPath } else { 'C:\ProgramData\ActivityWatch\dlp-policy.json' }
$resolvedPollSeconds = if ($PSBoundParameters.ContainsKey('PollSeconds')) { $PollSeconds } elseif ($deploymentConfig) { [int]$deploymentConfig.collector.pollSeconds } else { 5 }
$resolvedLogsRoot = if ($deploymentConfig) { [string]$deploymentConfig.paths.logsRoot } else { 'C:\ProgramData\ActivityWatch\logs' }
$resolvedLogPath = if ($LogPath) { $LogPath } else { Join-Path $resolvedLogsRoot ("endpoint-signals-{0}.log" -f $env:USERNAME) }
$resolvedLocalAgentLogsEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'logging' -and $deploymentConfig.logging.PSObject.Properties.Name -contains 'localAgentLogsEnabled') { [bool]$deploymentConfig.logging.localAgentLogsEnabled } else { $true }
$resolvedIncidentArtifactsRoot = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'artifactsRoot') { [string]$deploymentConfig.incidentCapture.artifactsRoot } else { Join-Path $env:LOCALAPPDATA 'ActivityWatch-Phase2\\incident-artifacts' }
$resolvedIncidentScreenshotEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'screenshotEnabled') { [bool]$deploymentConfig.incidentCapture.screenshotEnabled } else { $true }
if ($resolvedLocalAgentLogsEnabled -and -not (Test-Path -LiteralPath $resolvedLogsRoot)) {
New-Item -Path $resolvedLogsRoot -ItemType Directory -Force | Out-Null
}
$script:ApiBase = '{0}://{1}:{2}/api/0' -f $resolvedServerScheme, $resolvedServerHost, $resolvedServerPort
$script:Hostname = $env:COMPUTERNAME
$script:SessionId = (Get-Process -Id $PID).SessionId
$script:KnownBuckets = @{}
$script:Cooldown = @{}
$script:SeenUsb = @{}
$script:SeenPrintJob = @{}
$script:SeenPrintEvent = @{}
$script:LastClipboardHash = $null
$script:PulseSeconds = [Math]::Max($resolvedPollSeconds * 3, 30)
$script:LocalAgentLogsEnabled = $resolvedLocalAgentLogsEnabled
$script:LogPath = $resolvedLogPath
$script:IncidentArtifactsRoot = $resolvedIncidentArtifactsRoot
$script:IncidentScreenshotEnabled = $resolvedIncidentScreenshotEnabled
$script:ScreenshotTypesLoaded = $false
Load-DlpPolicy -Path $resolvedPolicyPath
Write-EndpointLog ("endpoint collector started against {0}" -f $script:ApiBase)
while ($true) {
try {
if (-not $script:Policy.defaults.enabled) {
Start-Sleep -Seconds $resolvedPollSeconds
continue
}
try {
$clipboardText = Get-Clipboard -Raw -ErrorAction SilentlyContinue
if ($clipboardText) {
$clipboardHash = Get-StringHash -Value $clipboardText
if ($clipboardHash -and $clipboardHash -ne $script:LastClipboardHash) {
$script:LastClipboardHash = $clipboardHash
Send-EndpointSignalHeartbeat -SignalType 'clipboard_change' -Data @{
clipboardHash = $clipboardHash
clipboardLength = $clipboardText.Length
}
Evaluate-ClipboardRules -ClipboardText $clipboardText -ClipboardHash $clipboardHash
}
}
}
catch {
}
try {
$usbDrives = Get-CimInstance Win32_LogicalDisk -Filter "DriveType=2" -ErrorAction SilentlyContinue
$currentUsb = @{}
foreach ($drive in @($usbDrives)) {
$deviceId = [string]$drive.DeviceID
if (-not $deviceId) { continue }
$currentUsb[$deviceId] = $true
if (-not $script:SeenUsb.ContainsKey($deviceId)) {
$script:SeenUsb[$deviceId] = (Get-Date).ToUniversalTime()
$volumeName = [string]$drive.VolumeName
Send-EndpointSignalHeartbeat -SignalType 'usb_insert' -Data @{
driveLetter = $deviceId
volumeName = $volumeName
}
Evaluate-UsbRules -DriveLetter $deviceId -VolumeName $volumeName
}
}
foreach ($known in @($script:SeenUsb.Keys)) {
if (-not $currentUsb.ContainsKey($known)) {
$script:SeenUsb.Remove($known)
}
}
}
catch {
}
try {
$printJobs = Get-CimInstance Win32_PrintJob -ErrorAction SilentlyContinue
foreach ($job in @($printJobs)) {
$jobId = [string]$job.JobId
if (-not $jobId) { continue }
if ($script:SeenPrintJob.ContainsKey($jobId)) { continue }
$script:SeenPrintJob[$jobId] = (Get-Date).ToUniversalTime()
$printerName = [string]$job.Name
$documentName = [string]$job.Document
$owner = [string]$job.Owner
$documentNameOriginal = $documentName
if (Test-LooksLikeMojibakeQuestionMarks -Value $documentName) {
$eventDocumentName = Get-BetterDocumentNameFromPrintServiceEvents -Owner $owner -PrinterName $printerName
if ($eventDocumentName) {
$documentName = $eventDocumentName
}
}
Send-EndpointSignalHeartbeat -SignalType 'print_job' -Data @{
printerName = $printerName
documentName = $documentName
documentNameOriginal = $documentNameOriginal
owner = $owner
}
Evaluate-PrintRules -PrinterName $printerName -DocumentName $documentName -Owner $owner
}
$cleanupBefore = (Get-Date).ToUniversalTime().AddHours(-8)
foreach ($k in @($script:SeenPrintJob.Keys)) {
$ts = [datetime]$script:SeenPrintJob[$k]
if ($ts -lt $cleanupBefore) {
$script:SeenPrintJob.Remove($k)
}
}
}
catch {
}
try {
$printEvents = Get-WinEvent -FilterHashtable @{
LogName = 'Microsoft-Windows-PrintService/Operational'
Id = 307
StartTime = (Get-Date).AddMinutes(-20)
} -MaxEvents 200 -ErrorAction SilentlyContinue
foreach ($event in @($printEvents)) {
$recordId = [string]$event.RecordId
if (-not $recordId) { continue }
if ($script:SeenPrintEvent.ContainsKey($recordId)) { continue }
$script:SeenPrintEvent[$recordId] = (Get-Date).ToUniversalTime()
$summary = Get-PrintServiceEventSummary -Event $event
$documentName = [string]$summary.DocumentName
$owner = [string]$summary.Owner
$printerName = [string]$summary.PrinterName
$resolvedDocument = Get-PrintServiceDocumentFallback -EventSummary $summary -Owner $owner -PrinterName $printerName
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'emit' -MatchReason 'raw-scan' -ResolvedDocument $resolvedDocument
if (-not [string]::IsNullOrWhiteSpace($owner) -and $owner -notlike "*$env:USERNAME*") {
continue
}
Send-EndpointSignalHeartbeat -SignalType 'print_job' -Data @{
printerName = $printerName
documentName = if ($resolvedDocument) { $resolvedDocument } else { $documentName }
documentNameOriginal = $documentName
owner = $owner
eventRecordId = $recordId
eventSource = 'printservice-307'
}
Evaluate-PrintRules -PrinterName $printerName -DocumentName (if ($resolvedDocument) { $resolvedDocument } else { $documentName }) -Owner $owner
}
$cleanupBeforeEvent = (Get-Date).ToUniversalTime().AddHours(-8)
foreach ($k in @($script:SeenPrintEvent.Keys)) {
$ts = [datetime]$script:SeenPrintEvent[$k]
if ($ts -lt $cleanupBeforeEvent) {
$script:SeenPrintEvent.Remove($k)
}
}
}
catch {
}
}
catch {
Write-EndpointLog ("collector error: {0}" -f $_.Exception.Message)
}
Start-Sleep -Seconds $resolvedPollSeconds
}
@@ -0,0 +1,99 @@
{
"version": 1,
"defaults": {
"enabled": true,
"cooldownSeconds": 300,
"action": "log",
"severity": "low"
},
"rules": [
{
"id": "personal-web-during-workhours",
"enabled": true,
"cooldownSeconds": 600,
"action": "alert",
"severity": "medium",
"message": "Личные ресурсы в рабочее время",
"when": {
"categoryGroups": ["personal"],
"hourFrom": 9,
"hourTo": 19
}
},
{
"id": "high-risk-cloud-storage",
"enabled": true,
"cooldownSeconds": 900,
"action": "alert",
"severity": "high",
"message": "Подозрительный доступ к облачному хранилищу",
"when": {
"domains": [
"dropbox.com",
"drive.google.com",
"mega.nz",
"onedrive.live.com",
"disk.yandex.ru"
]
}
},
{
"id": "anonymizer-and-vpn-web",
"enabled": true,
"cooldownSeconds": 900,
"action": "alert",
"severity": "high",
"message": "Использование веб-анонимайзеров / VPN-сервисов",
"when": {
"domains": [
"hidemy.name",
"2ip.ru",
"whoer.net",
"protonvpn.com",
"nordvpn.com"
]
}
}
],
"endpoint": {
"clipboard": [
{
"id": "clipboard-sensitive-keywords",
"enabled": true,
"cooldownSeconds": 300,
"action": "alert",
"severity": "high",
"message": "В буфере обнаружены чувствительные ключевые слова",
"minLength": 20,
"regexPatterns": [
"(?i)парол(ь|и)",
"(?i)password",
"(?i)secret",
"(?i)cvv",
"(?i)паспорт"
]
}
],
"usb": [
{
"id": "usb-media-connected",
"enabled": true,
"cooldownSeconds": 300,
"action": "alert",
"severity": "medium",
"message": "Подключен съемный носитель"
}
],
"print": [
{
"id": "print-sensitive-docs",
"enabled": true,
"cooldownSeconds": 300,
"action": "alert",
"severity": "high",
"message": "Печать документа с признаками чувствительных данных",
"documentRegex": "(?i)(salary|зарплат|passport|паспорт|договор|contract)"
}
]
}
}
@@ -0,0 +1,144 @@
[CmdletBinding()]
param(
[string]$ConfigPath = 'C:\ProgramData\ActivityWatch\deployment-config.json',
[string]$ServerHost,
[int]$ServerPort,
[ValidateSet('http', 'https')]
[string]$ServerScheme,
[string[]]$Users,
[string]$UserListPath,
[string]$Domain,
[string]$InstallRoot,
[string]$StateRoot,
[int]$PollSeconds,
[int]$PulseSeconds,
[int]$RecoveryIntervalSeconds,
[bool]$AfkEnabled,
[bool]$WindowEnabled,
[bool]$LocalAgentLogsEnabled,
[bool]$IncidentCaptureEnabled,
[bool]$IncidentScreenshotEnabled,
[string]$IncidentArtifactsRoot,
[bool]$LogonMarkerEnabled,
[string]$CustomRulesPath,
[string]$CustomPolicyPath,
[switch]$RepairPackage,
[string]$Version,
[string]$PackageUrl,
[string]$PackageZipPath
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$modulePath = Join-Path $PSScriptRoot 'ActivityWatch.Windows.Common.psm1'
Import-Module $modulePath -Force
Assert-Administrator
$existingConfig = $null
if (Test-Path -LiteralPath $ConfigPath) {
$existingConfig = Read-ActivityWatchDeploymentConfig -Path $ConfigPath
}
if (-not $existingConfig -and (-not $ServerHost)) {
throw 'deployment-config.json is missing. Provide -ServerHost and user parameters, or run a deploy script first.'
}
$effectiveStateRoot = if ($StateRoot) { $StateRoot } elseif ($existingConfig) { [string]$existingConfig.paths.stateRoot } else { 'C:\ProgramData\ActivityWatch' }
$effectiveInstallRoot = if ($InstallRoot) { $InstallRoot } elseif ($existingConfig) { [string]$existingConfig.paths.installRoot } else { 'C:\Program Files\ActivityWatch' }
$effectiveLogsRoot = if ($existingConfig) { [string]$existingConfig.paths.logsRoot } else { Join-Path $effectiveStateRoot 'logs' }
$effectiveConfigPath = if ($ConfigPath) { $ConfigPath } else { Join-Path $effectiveStateRoot 'deployment-config.json' }
$effectiveLaunchScript = Join-Path $effectiveStateRoot 'launch-watchers.ps1'
$effectiveRecoveryScript = Join-Path $effectiveStateRoot 'recovery-loop.ps1'
$effectiveCollector = Join-Path $effectiveStateRoot 'browser-domains-native-collector.ps1'
$effectiveEndpointCollector = if ($existingConfig -and $existingConfig.paths.PSObject.Properties.Name -contains 'endpointCollectorScript') { [string]$existingConfig.paths.endpointCollectorScript } else { Join-Path $effectiveStateRoot 'dlp-endpoint-signals-collector.ps1' }
$effectiveRules = Join-Path $effectiveStateRoot 'web-category-rules.json'
$effectivePolicy = if ($existingConfig -and $existingConfig.paths.PSObject.Properties.Name -contains 'policyPath') { [string]$existingConfig.paths.policyPath } else { Join-Path $effectiveStateRoot 'dlp-policy.json' }
$effectiveServerHost = if ($ServerHost) { $ServerHost } elseif ($existingConfig) { [string]$existingConfig.server.host } else { $null }
$effectiveServerPort = if ($PSBoundParameters.ContainsKey('ServerPort')) { $ServerPort } elseif ($existingConfig) { [int]$existingConfig.server.port } else { 5600 }
$effectiveServerScheme = if ($ServerScheme) { $ServerScheme } elseif ($existingConfig) { [string]$existingConfig.server.scheme } else { 'http' }
$effectivePollSeconds = if ($PSBoundParameters.ContainsKey('PollSeconds')) { $PollSeconds } elseif ($existingConfig) { [int]$existingConfig.collector.pollSeconds } else { 5 }
$effectivePulseSeconds = if ($PSBoundParameters.ContainsKey('PulseSeconds')) { $PulseSeconds } elseif ($existingConfig) { [int]$existingConfig.collector.pulseSeconds } else { 30 }
$effectiveRecoveryInterval = if ($PSBoundParameters.ContainsKey('RecoveryIntervalSeconds')) { $RecoveryIntervalSeconds } elseif ($existingConfig) { [int]$existingConfig.recovery.intervalSeconds } else { 180 }
$effectiveAfkEnabled = if ($PSBoundParameters.ContainsKey('AfkEnabled')) { [bool]$AfkEnabled } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'collectors' -and $existingConfig.collectors.PSObject.Properties.Name -contains 'afkEnabled') { [bool]$existingConfig.collectors.afkEnabled } else { $true }
$effectiveWindowEnabled = if ($PSBoundParameters.ContainsKey('WindowEnabled')) { [bool]$WindowEnabled } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'collectors' -and $existingConfig.collectors.PSObject.Properties.Name -contains 'windowEnabled') { [bool]$existingConfig.collectors.windowEnabled } else { $true }
$effectiveLocalAgentLogsEnabled = if ($PSBoundParameters.ContainsKey('LocalAgentLogsEnabled')) { [bool]$LocalAgentLogsEnabled } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'logging' -and $existingConfig.logging.PSObject.Properties.Name -contains 'localAgentLogsEnabled') { [bool]$existingConfig.logging.localAgentLogsEnabled } else { $false }
$effectiveIncidentCaptureEnabled = if ($PSBoundParameters.ContainsKey('IncidentCaptureEnabled')) { [bool]$IncidentCaptureEnabled } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $existingConfig.incidentCapture.PSObject.Properties.Name -contains 'enabled') { [bool]$existingConfig.incidentCapture.enabled } else { $true }
$effectiveIncidentScreenshotEnabled = if ($PSBoundParameters.ContainsKey('IncidentScreenshotEnabled')) { [bool]$IncidentScreenshotEnabled } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $existingConfig.incidentCapture.PSObject.Properties.Name -contains 'screenshotEnabled') { [bool]$existingConfig.incidentCapture.screenshotEnabled } else { $true }
$effectiveIncidentArtifactsRoot = if ($PSBoundParameters.ContainsKey('IncidentArtifactsRoot') -and $IncidentArtifactsRoot) { $IncidentArtifactsRoot } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $existingConfig.incidentCapture.PSObject.Properties.Name -contains 'artifactsRoot') { [string]$existingConfig.incidentCapture.artifactsRoot } else { Join-Path $effectiveStateRoot 'incident-artifacts' }
$effectiveLogonMarkerEnabled = if ($PSBoundParameters.ContainsKey('LogonMarkerEnabled')) { [bool]$LogonMarkerEnabled } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'sessionEvents' -and $existingConfig.sessionEvents.PSObject.Properties.Name -contains 'logonEnabled') { [bool]$existingConfig.sessionEvents.logonEnabled } else { $true }
$effectiveVersion = if ($Version) { $Version } elseif ($existingConfig) { [string]$existingConfig.package.version } else { 'v0.13.2' }
$effectiveUsers = if ($Users -or $UserListPath) {
Normalize-ActivityWatchUsers -Users $Users -UserListPath $UserListPath -Domain $Domain
}
elseif ($existingConfig) {
@($existingConfig.userTasks | ForEach-Object { [string]$_.userId })
}
else {
throw 'Target users are missing.'
}
New-ActivityWatchDirectory -Path $effectiveStateRoot
New-ActivityWatchDirectory -Path $effectiveLogsRoot
if ($RepairPackage) {
$workingRoot = Join-Path $env:TEMP 'activitywatch-windows-deploy'
$backupRoot = Join-Path $effectiveStateRoot 'backups'
$archivePath = Get-ActivityWatchArchive -PackageZipPath $PackageZipPath -PackageUrl $PackageUrl -Version $effectiveVersion -WorkingRoot $workingRoot
Install-ActivityWatchPackage -ArchivePath $archivePath -InstallRoot $effectiveInstallRoot -WorkingRoot $workingRoot -BackupRoot $backupRoot | Out-Null
}
Get-ActivityWatchExecutableMap -InstallRoot $effectiveInstallRoot | Out-Null
$assetResult = Copy-ActivityWatchCollectorAssets `
-CollectorScriptSource (Join-Path $PSScriptRoot 'browser-domains-native-collector.ps1') `
-EndpointCollectorScriptSource (Join-Path $PSScriptRoot 'dlp-endpoint-signals-collector.ps1') `
-ExampleRulesSource (Join-Path $PSScriptRoot 'web-category-rules.example.json') `
-ExamplePolicySource (Join-Path $PSScriptRoot 'dlp-policy.example.json') `
-StateRoot $effectiveStateRoot `
-CustomRulesSource $CustomRulesPath `
-CustomPolicySource $CustomPolicyPath
$taskDefinitions = New-ActivityWatchUserTaskDefinitions -Users $effectiveUsers
Write-ActivityWatchLaunchScript -Path $effectiveLaunchScript -ConfigPath $effectiveConfigPath
Write-ActivityWatchRecoveryScript -Path $effectiveRecoveryScript -ConfigPath $effectiveConfigPath
$config = New-ActivityWatchDeploymentConfig `
-ServerHost $effectiveServerHost `
-ServerPort $effectiveServerPort `
-ServerScheme $effectiveServerScheme `
-InstallRoot $effectiveInstallRoot `
-StateRoot $effectiveStateRoot `
-LogsRoot $effectiveLogsRoot `
-CollectorScript $effectiveCollector `
-EndpointCollectorScript $effectiveEndpointCollector `
-RulesPath $effectiveRules `
-PolicyPath $effectivePolicy `
-PollSeconds $effectivePollSeconds `
-PulseSeconds $effectivePulseSeconds `
-RecoveryIntervalSeconds $effectiveRecoveryInterval `
-AfkEnabled $effectiveAfkEnabled `
-WindowEnabled $effectiveWindowEnabled `
-LocalAgentLogsEnabled $effectiveLocalAgentLogsEnabled `
-IncidentCaptureEnabled $effectiveIncidentCaptureEnabled `
-IncidentScreenshotEnabled $effectiveIncidentScreenshotEnabled `
-IncidentArtifactsRoot $effectiveIncidentArtifactsRoot `
-LogonMarkerEnabled $effectiveLogonMarkerEnabled `
-LaunchScriptPath $effectiveLaunchScript `
-RecoveryScriptPath $effectiveRecoveryScript `
-UserTasks $taskDefinitions `
-PackageVersion $effectiveVersion
Write-ActivityWatchDeploymentConfig -Config $config -Path $effectiveConfigPath
Remove-LegacyActivityWatchEntries
Set-ActivityWatchAcl -InstallRoot $effectiveInstallRoot -StateRoot $effectiveStateRoot -LogsRoot $effectiveLogsRoot
Register-ActivityWatchUserTasks -TaskDefinitions $taskDefinitions -LaunchScriptPath $effectiveLaunchScript -ConfigPath $effectiveConfigPath
Register-ActivityWatchRecoveryTask -TaskName $config.recovery.taskName -RecoveryScriptPath $effectiveRecoveryScript -ConfigPath $effectiveConfigPath
Start-ActivityWatchTasks -TaskDefinitions $taskDefinitions -RecoveryTaskName $config.recovery.taskName
Write-Host 'ActivityWatch hardening/recovery completed.'
Write-Host "Config: $effectiveConfigPath"
Write-Host "Users repaired: $($effectiveUsers -join ', ')"
@@ -0,0 +1,90 @@
[CmdletBinding()]
param(
[string]$ConfigPath = 'C:\ProgramData\ActivityWatch\deployment-config.json'
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$modulePath = Join-Path $PSScriptRoot 'ActivityWatch.Windows.Common.psm1'
Import-Module $modulePath -Force
$config = Read-ActivityWatchDeploymentConfig -Path $ConfigPath
$installRoot = [string]$config.paths.installRoot
$stateRoot = [string]$config.paths.stateRoot
$collectorScript = [string]$config.paths.collectorScript
$endpointCollectorScript = if ($config.paths.PSObject.Properties.Name -contains 'endpointCollectorScript') { [string]$config.paths.endpointCollectorScript } else { Join-Path $stateRoot 'dlp-endpoint-signals-collector.ps1' }
$rulesPath = [string]$config.paths.rulesPath
$policyPath = if ($config.paths.PSObject.Properties.Name -contains 'policyPath') { [string]$config.paths.policyPath } else { Join-Path $stateRoot 'dlp-policy.json' }
$launchScript = [string]$config.paths.launchScript
$recoveryScript = [string]$config.paths.recoveryScript
$requiredFiles = @(
(Join-Path $installRoot 'aw-watcher-afk\aw-watcher-afk.exe'),
(Join-Path $installRoot 'aw-watcher-window\aw-watcher-window.exe'),
$collectorScript,
$endpointCollectorScript,
$rulesPath,
$policyPath,
$launchScript,
$recoveryScript,
$ConfigPath
)
$missingFiles = @(
$requiredFiles | Where-Object { -not (Test-Path -LiteralPath $_) }
)
$processNames = @('aw-watcher-afk', 'aw-watcher-window')
$runningProcesses = Get-Process -Name $processNames -ErrorAction SilentlyContinue | Select-Object Name, Id, SessionId
$taskNames = @()
if ($config.userTasks) {
$taskNames += @($config.userTasks | ForEach-Object { [string]$_.launchTaskName })
}
$taskNames += [string]$config.recovery.taskName
$taskNames = $taskNames | Sort-Object -Unique
$tasks = foreach ($taskName in $taskNames) {
$task = Get-ScheduledTask -ErrorAction SilentlyContinue | Where-Object { $_.TaskName -eq $taskName } | Select-Object -First 1
if ($task) {
[pscustomobject]@{
taskName = $task.TaskName
state = [string]$task.State
present = $true
}
}
else {
[pscustomobject]@{
taskName = $taskName
state = 'Missing'
present = $false
}
}
}
$serverUrl = '{0}://{1}:{2}' -f [string]$config.server.scheme, [string]$config.server.host, [int]$config.server.port
$result = [ordered]@{
generatedAtUtc = (Get-Date).ToUniversalTime().ToString('o')
configPath = $ConfigPath
serverUrl = $serverUrl
installRoot = $installRoot
stateRoot = $stateRoot
files = [ordered]@{
required = $requiredFiles
missing = $missingFiles
ok = ($missingFiles.Count -eq 0)
}
tasks = [ordered]@{
list = $tasks
ok = [bool]($tasks.Count -gt 0 -and -not ($tasks | Where-Object { -not $_.present }))
}
processes = [ordered]@{
list = @($runningProcesses)
ok = [bool](($runningProcesses | Select-Object -ExpandProperty Name -Unique).Count -ge 2)
}
}
$result.overallOk = [bool]($result.files.ok -and $result.tasks.ok -and $result.processes.ok)
$result
@@ -0,0 +1,37 @@
{
"version": 1,
"description": "Override or extend built-in ActivityWatch web categorization rules.",
"rules": [
{
"name": "work_crm",
"group": "work",
"domains": [
"crm.example.com",
"portal.example.org"
]
},
{
"name": "work_erp",
"group": "work",
"domains": [
"erp.example.com",
"bi.example.com"
]
},
{
"name": "neutral_training",
"group": "neutral",
"domains": [
"wiki.example.net",
"kb.example.net"
]
},
{
"name": "personal_social",
"group": "personal",
"domains": [
"social.example.net"
]
}
]
}
+6 -1
View File
@@ -25,11 +25,16 @@ for file_name in \
activitywatch-server.service \
aw-server.env.example \
aw-ru-patch.js \
aw-sw-cleanup.js
aw-sw-cleanup.js \
aw-host-groups.json
do
pct push "$CT_ID" "$PROJECT_ROOT/aw-server/$file_name" "/root/bootstrap/$file_name"
done
pct exec "$CT_ID" -- mkdir -p /root/bootstrap/settings
pct push "$CT_ID" "$PROJECT_ROOT/aw-server/settings/classes-worktime.json" "/root/bootstrap/settings/classes-worktime.json"
pct push "$CT_ID" "$PROJECT_ROOT/aw-server/settings/views-default.json" "/root/bootstrap/settings/views-default.json"
if [ -n "${AW_SERVER_VERSION:-}" ] &&
[ -n "${AW_SERVER_DOWNLOAD_URL:-}" ] &&
[ -n "${AW_SERVER_BIND_HOST:-}" ] &&
+375
View File
@@ -0,0 +1,375 @@
#!/usr/bin/env sh
set -eu
SERVER_HOST="10.10.10.13"
SERVER_PORT="5600"
POLL_INTERVAL="5"
INSTALL_ROOT="${HOME}/.local/opt/aw-console-ssh-logger"
BIN_DIR="${HOME}/.local/bin"
STATE_DIR="${HOME}/.local/state/aw-console-ssh-logger"
LOG_DIR="${HOME}/.local/state/aw-console-ssh-logger/logs"
SYSTEMD_USER_DIR="${HOME}/.config/systemd/user"
BASHRC="${HOME}/.bashrc"
BASH_PROFILE="${HOME}/.bash_profile"
usage() {
cat <<'EOF'
Usage: install_aw_console_ssh_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 for history/session tracking (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}" "${SYSTEMD_USER_DIR}"
write_file() {
target="$1"
mkdir -p "$(dirname "${target}")"
cat > "${target}"
}
write_file "${INSTALL_ROOT}/config.json" <<EOF
{
"server_host": "${SERVER_HOST}",
"server_port": ${SERVER_PORT},
"poll_interval_seconds": ${POLL_INTERVAL},
"history_file": "${HOME}/.bash_history",
"state_dir": "${STATE_DIR}",
"hostname": "$(hostname -s)",
"username": "$(id -un)"
}
EOF
write_file "${INSTALL_ROOT}/collector.py" <<'EOF'
#!/usr/bin/env python3
import datetime as dt
import json
import os
import pathlib
import shutil
import socket
import subprocess
import time
import urllib.error
import urllib.request
def to_iso(ts: float) -> str:
return dt.datetime.fromtimestamp(ts, 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.history_bucket = f"aw-console-commands_{self.host}"
self.ssh_bucket = f"aw-ssh-sessions_{self.host}"
self.history_path = pathlib.Path(cfg["history_file"]).expanduser()
self.state_dir = pathlib.Path(cfg["state_dir"]).expanduser()
self.state_dir.mkdir(parents=True, exist_ok=True)
self.history_state_path = self.state_dir / "history_state.json"
self.ssh_state_path = self.state_dir / "ssh_state.json"
self.history_state = self._read_json(self.history_state_path, {"inode": None, "offset": 0})
self.ssh_state = self._read_json(self.ssh_state_path, {"active": {}})
self.poll_interval = max(1, int(cfg.get("poll_interval_seconds", 5)))
self.ensured_buckets = set()
def _read_json(self, path, default):
if not path.exists():
return default
try:
return json.loads(path.read_text(encoding="utf-8"))
except Exception:
return default
def _write_json(self, path, payload):
path.write_text(json.dumps(payload, ensure_ascii=True), encoding="utf-8")
def _heartbeat(self, bucket_id, payload):
self._ensure_bucket(bucket_id)
url = f"{self.server}/buckets/{bucket_id}/heartbeat?pulsetime=60"
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
req = urllib.request.Request(url, data=body, headers={"Content-Type": "application/json"}, method="POST")
try:
with urllib.request.urlopen(req, timeout=10):
return True
except urllib.error.URLError:
return False
def _ensure_bucket(self, bucket_id):
if bucket_id in self.ensured_buckets:
return True
if bucket_id == self.history_bucket:
bucket_type = "app.console.command"
elif bucket_id == self.ssh_bucket:
bucket_type = "app.ssh.session"
else:
bucket_type = "app.custom"
payload = {
"client": "aw-console-ssh-logger",
"type": bucket_type,
"hostname": self.host
}
url = f"{self.server}/buckets/{bucket_id}"
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
req = urllib.request.Request(url, data=body, headers={"Content-Type": "application/json"}, method="POST")
try:
with urllib.request.urlopen(req, timeout=10):
self.ensured_buckets.add(bucket_id)
return True
except urllib.error.URLError:
return False
def process_history(self):
if not self.history_path.exists():
return
st = self.history_path.stat()
inode = int(st.st_ino)
size = int(st.st_size)
if self.history_state.get("inode") != inode or int(self.history_state.get("offset", 0)) > size:
self.history_state = {"inode": inode, "offset": 0}
offset = int(self.history_state.get("offset", 0))
with self.history_path.open("r", encoding="utf-8", errors="replace") as f:
f.seek(offset)
lines = f.readlines()
new_offset = f.tell()
pending_ts = None
now_iso = to_iso(time.time())
for raw in lines:
line = raw.rstrip("\n")
if not line:
continue
if line.startswith("#") and line[1:].isdigit():
pending_ts = int(line[1:])
continue
ts = pending_ts if pending_ts is not None else time.time()
pending_ts = None
event = {
"timestamp": to_iso(ts),
"duration": 0,
"data": {
"source": "bash_history",
"user": self.user,
"host": self.host,
"command": line
}
}
self._heartbeat(self.history_bucket, event)
self.history_state["inode"] = inode
self.history_state["offset"] = new_offset
self._write_json(self.history_state_path, self.history_state)
def get_sessions(self):
who_bin = shutil.which("who") or "/usr/bin/who"
try:
out = subprocess.check_output([who_bin, "-u"], text=True, stderr=subprocess.DEVNULL)
except Exception:
return {}
sessions = {}
for row in out.splitlines():
parts = row.split()
if len(parts) < 2:
continue
user = parts[0]
tty = next((p for p in parts[1:] if p.startswith("pts/") or p.startswith("tty")), "")
if not tty.startswith("pts/"):
continue
sessions[tty] = {
"user": user,
"tty": tty,
"raw": row
}
return sessions
def process_ssh_sessions(self):
active_prev = dict(self.ssh_state.get("active", {}))
active_now = self.get_sessions()
for tty, meta in active_now.items():
if tty in active_prev:
continue
event = {
"timestamp": to_iso(time.time()),
"duration": 0,
"data": {
"source": "who",
"event": "login",
"user": meta["user"],
"tty": tty,
"host": self.host,
"raw": meta["raw"]
}
}
self._heartbeat(self.ssh_bucket, event)
for tty, meta in active_prev.items():
if tty in active_now:
continue
event = {
"timestamp": to_iso(time.time()),
"duration": 0,
"data": {
"source": "who",
"event": "logout",
"user": meta.get("user", self.user),
"tty": tty,
"host": self.host,
"raw": meta.get("raw", "")
}
}
self._heartbeat(self.ssh_bucket, event)
self.ssh_state["active"] = active_now
self._write_json(self.ssh_state_path, self.ssh_state)
def run(self):
while True:
self.process_history()
self.process_ssh_sessions()
time.sleep(self.poll_interval)
def main():
config_path = pathlib.Path(os.environ.get("AW_CONSOLE_SSH_CONFIG", "~/.local/opt/aw-console-ssh-logger/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-console-ssh-logger-start" <<EOF
#!/usr/bin/env sh
set -eu
export AW_CONSOLE_SSH_CONFIG="${INSTALL_ROOT}/config.json"
mkdir -p "${LOG_DIR}"
if pgrep -u "$(id -u)" -f "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-console-ssh-logger-start"
write_file "${BIN_DIR}/aw-console-ssh-logger-stop" <<'EOF'
#!/usr/bin/env sh
set -eu
pkill -u "$(id -u)" -f "aw-console-ssh-logger/collector.py" || true
EOF
chmod 0755 "${BIN_DIR}/aw-console-ssh-logger-stop"
write_file "${BIN_DIR}/aw-console-ssh-logger-status" <<'EOF'
#!/usr/bin/env sh
set -eu
pgrep -a -u "$(id -u)" -f "aw-console-ssh-logger/collector.py" || true
EOF
chmod 0755 "${BIN_DIR}/aw-console-ssh-logger-status"
write_file "${SYSTEMD_USER_DIR}/aw-console-ssh-logger.service" <<EOF
[Unit]
Description=AW console and SSH logger (user space)
After=default.target
[Service]
Type=simple
Environment=AW_CONSOLE_SSH_CONFIG=${INSTALL_ROOT}/config.json
ExecStart=${INSTALL_ROOT}/collector.py
Restart=always
RestartSec=3
[Install]
WantedBy=default.target
EOF
HIST_BLOCK_START="# >>> AW_CONSOLE_SSH_HISTORY >>>"
HIST_BLOCK_END="# <<< AW_CONSOLE_SSH_HISTORY <<<"
if ! grep -qF "${HIST_BLOCK_START}" "${BASHRC}" 2>/dev/null; then
cat >> "${BASHRC}" <<EOF
${HIST_BLOCK_START}
export HISTSIZE=200000
export HISTFILESIZE=200000
export HISTTIMEFORMAT="%s "
shopt -s histappend
case "\${PROMPT_COMMAND-}" in
*"history -a; history -n;"*) ;;
"") PROMPT_COMMAND="history -a; history -n;" ;;
*) PROMPT_COMMAND="history -a; history -n; \${PROMPT_COMMAND}" ;;
esac
${HIST_BLOCK_END}
EOF
fi
AUTO_BLOCK_START="# >>> AW_CONSOLE_SSH_AUTOSTART >>>"
AUTO_BLOCK_END="# <<< AW_CONSOLE_SSH_AUTOSTART <<<"
if ! grep -qF "${AUTO_BLOCK_START}" "${BASH_PROFILE}" 2>/dev/null; then
cat >> "${BASH_PROFILE}" <<EOF
${AUTO_BLOCK_START}
if [ -x "${BIN_DIR}/aw-console-ssh-logger-start" ]; then
"${BIN_DIR}/aw-console-ssh-logger-start" || true
fi
${AUTO_BLOCK_END}
EOF
fi
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-console-ssh-logger.service >/dev/null 2>&1 || true
if systemctl --user is-active --quiet aw-console-ssh-logger.service >/dev/null 2>&1; then
started_with_systemd="1"
fi
fi
if [ "${started_with_systemd}" != "1" ]; then
"${BIN_DIR}/aw-console-ssh-logger-start"
fi
echo "Installed AW console/ssh logger"
echo "Server target: ${SERVER_HOST}:${SERVER_PORT}"
echo "Collector: ${INSTALL_ROOT}/collector.py"
echo "State dir: ${STATE_DIR}"
echo "Log file: ${LOG_DIR}/collector.log"
+174
View File
@@ -0,0 +1,174 @@
#!/usr/bin/env sh
set -eu
VERSION="0.13.2"
SERVER_HOST="10.10.10.13"
SERVER_PORT="5600"
INSTALL_BASE="${HOME}/.local/opt/activitywatch"
BIN_DIR="${HOME}/.local/bin"
AUTOSTART_DIR="${HOME}/.config/autostart"
CONFIG_ROOT="${XDG_CONFIG_HOME:-$HOME/.config}/activitywatch"
FORCE="0"
usage() {
cat <<'EOF'
Usage: install_aw_linux_client.sh [options]
Options:
--server-host HOST Remote AW server host (default: 10.10.10.13)
--server-port PORT Remote AW server port (default: 5600)
--version VERSION ActivityWatch version (default: 0.13.2)
--install-base PATH Install root (default: ~/.local/opt/activitywatch)
--force Reinstall selected version even if it exists
-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
;;
--version)
VERSION="$2"
shift 2
;;
--install-base)
INSTALL_BASE="$2"
shift 2
;;
--force)
FORCE="1"
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown option: $1" >&2
usage >&2
exit 1
;;
esac
done
ARCHIVE="activitywatch-v${VERSION}-linux-x86_64.zip"
DOWNLOAD_URL="https://github.com/ActivityWatch/activitywatch/releases/download/v${VERSION}/${ARCHIVE}"
VERSION_DIR="${INSTALL_BASE}/v${VERSION}"
CURRENT_LINK="${INSTALL_BASE}/current"
TMP_DIR="$(mktemp -d)"
ARCHIVE_PATH="${TMP_DIR}/${ARCHIVE}"
BUNDLE_DIR=""
cleanup() {
rm -rf "${TMP_DIR}"
}
trap cleanup EXIT INT TERM
download_archive() {
if command -v curl >/dev/null 2>&1; then
curl -fL "${DOWNLOAD_URL}" -o "${ARCHIVE_PATH}"
return
fi
if command -v wget >/dev/null 2>&1; then
wget -O "${ARCHIVE_PATH}" "${DOWNLOAD_URL}"
return
fi
echo "Neither curl nor wget is available" >&2
exit 1
}
extract_archive() {
mkdir -p "${VERSION_DIR}"
if command -v unzip >/dev/null 2>&1; then
unzip -q "${ARCHIVE_PATH}" -d "${VERSION_DIR}"
return
fi
python3 -m zipfile -e "${ARCHIVE_PATH}" "${VERSION_DIR}"
}
resolve_bundle_dir() {
if [ -x "${VERSION_DIR}/aw-qt" ]; then
BUNDLE_DIR="${VERSION_DIR}"
return
fi
if [ -x "${VERSION_DIR}/activitywatch/aw-qt" ]; then
BUNDLE_DIR="${VERSION_DIR}/activitywatch"
return
fi
echo "Cannot find aw-qt inside ${VERSION_DIR}" >&2
exit 1
}
write_file() {
target="$1"
mkdir -p "$(dirname "${target}")"
cat > "${target}"
}
if [ -d "${VERSION_DIR}" ] && [ "${FORCE}" != "1" ]; then
echo "Version directory already exists: ${VERSION_DIR}" >&2
echo "Use --force to reinstall the same version." >&2
exit 1
fi
rm -rf "${VERSION_DIR}"
mkdir -p "${INSTALL_BASE}" "${BIN_DIR}" "${AUTOSTART_DIR}" "${CONFIG_ROOT}"
download_archive
extract_archive
resolve_bundle_dir
rm -f "${CURRENT_LINK}"
ln -sfn "${BUNDLE_DIR}" "${CURRENT_LINK}"
write_file "${CONFIG_ROOT}/aw-client/aw-client.toml" <<EOF
[server]
hostname = "${SERVER_HOST}"
port = "${SERVER_PORT}"
EOF
write_file "${CONFIG_ROOT}/aw-qt/aw-qt.toml" <<'EOF'
[aw-qt]
autostart_modules = ["aw-watcher-afk", "aw-watcher-window"]
EOF
write_file "${BIN_DIR}/activitywatch-remote-aw" <<EOF
#!/usr/bin/env sh
set -eu
AW_HOME="${CURRENT_LINK}"
export NO_PROXY="\${NO_PROXY:+\${NO_PROXY},}127.0.0.1,localhost,${SERVER_HOST}"
exec "\${AW_HOME}/aw-qt"
EOF
chmod 0755 "${BIN_DIR}/activitywatch-remote-aw"
write_file "${AUTOSTART_DIR}/activitywatch-remote-aw.desktop" <<EOF
[Desktop Entry]
Type=Application
Version=1.0
Name=ActivityWatch Remote
Comment=Start ActivityWatch watchers and report to ${SERVER_HOST}:${SERVER_PORT}
Exec=${BIN_DIR}/activitywatch-remote-aw
Terminal=false
X-GNOME-Autostart-enabled=true
EOF
"${CURRENT_LINK}/aw-qt" --help >/dev/null 2>&1 || true
"${CURRENT_LINK}/aw-watcher-afk/aw-watcher-afk" --help >/dev/null 2>&1 || true
"${CURRENT_LINK}/aw-watcher-window/aw-watcher-window" --help >/dev/null 2>&1 || true
echo "Installed ActivityWatch ${VERSION} into ${VERSION_DIR}"
echo "Remote server target: ${SERVER_HOST}:${SERVER_PORT}"
echo "Launcher: ${BIN_DIR}/activitywatch-remote-aw"
echo "Autostart: ${AUTOSTART_DIR}/activitywatch-remote-aw.desktop"
+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"
+349
View File
@@ -0,0 +1,349 @@
#!/usr/bin/env sh
set -eu
SERVER_HOST="10.10.10.13"
SERVER_PORT="5600"
POLL_INTERVAL="5"
INSTALL_ROOT="/opt/aw-pve-webadmin-logger"
STATE_DIR="/var/lib/aw-pve-webadmin-logger"
LOG_DIR="/var/log/aw-pve-webadmin-logger"
CONFIG_PATH="/etc/aw-pve-webadmin-logger/config.json"
SERVICE_PATH="/etc/systemd/system/aw-pve-webadmin-logger.service"
usage() {
cat <<'EOF'
Usage: install_aw_pve_webadmin_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
if [ "$(id -u)" -ne 0 ]; then
echo "Run as root" >&2
exit 1
fi
mkdir -p "$INSTALL_ROOT" "$STATE_DIR" "$LOG_DIR" "$(dirname "$CONFIG_PATH")"
HOST_SHORT="$(hostname -s)"
cat > "$CONFIG_PATH" <<EOF
{
"server_host": "${SERVER_HOST}",
"server_port": ${SERVER_PORT},
"poll_interval_seconds": ${POLL_INTERVAL},
"host": "${HOST_SHORT}",
"state_dir": "${STATE_DIR}",
"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}",
"category_bucket": "aw-detmir-web-category_${HOST_SHORT}"
}
EOF
cat > "${INSTALL_ROOT}/collector.py" <<'PY'
#!/usr/bin/env python3
import datetime as dt
import json
import pathlib
import re
import socket
import time
import urllib.error
import urllib.request
ACCESS_RE = re.compile(
r'^(?P<ip>\S+)\s+-\s+(?P<user>\S+)\s+\[(?P<ts>[^\]]+)\]\s+"(?P<method>\S+)\s+(?P<path>\S+)\s+(?P<proto>[^"]+)"\s+(?P<status>\d{3})\s+(?P<size>\S+)'
)
NOISE_GET_PATHS = [
re.compile(r"^/api2/json/version$"),
re.compile(r"^/api2/json/cluster/resources$"),
re.compile(r"^/api2/json/cluster/tasks$"),
re.compile(r"^/api2/json/nodes/[^/]+/(qemu|lxc)/\d+/status/current$"),
re.compile(r"^/api2/json/nodes/[^/]+/(qemu|lxc)/\d+/interfaces$"),
re.compile(r"^/api2/json/nodes/[^/]+/(qemu|lxc)/\d+/rrddata(\?.*)?$"),
]
TASK_RE = re.compile(
r'^UPID:(?P<node>[^:]+):(?P<pid>[^:]+):(?P<pstart>[^:]+):(?P<start>[^:]+):(?P<action>[^:]*):(?P<target>[^:]*):(?P<user>[^:]*):\s*(?P<msg>.*)$'
)
def iso_now():
return dt.datetime.now(tz=dt.timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
def parse_access_ts(value: str) -> str:
try:
parsed = dt.datetime.strptime(value, "%d/%b/%Y:%H:%M:%S %z")
return parsed.astimezone(dt.timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
except Exception:
return iso_now()
class TailState:
def __init__(self, path: pathlib.Path):
self.path = path
self.data = {"inode": None, "offset": 0}
if path.exists():
try:
self.data = json.loads(path.read_text(encoding="utf-8"))
except Exception:
self.data = {"inode": None, "offset": 0}
def save(self):
self.path.write_text(json.dumps(self.data, ensure_ascii=True), encoding="utf-8")
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("host") or socket.gethostname().split(".")[0]
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"])
self.state_dir.mkdir(parents=True, exist_ok=True)
self.access_state = TailState(self.state_dir / "access_state.json")
self.tasks_state = TailState(self.state_dir / "tasks_state.json")
self.ensured = set()
self.recent = {}
def ensure_bucket(self, bucket_id: str, bucket_type: str):
if bucket_id in self.ensured:
return True
payload = {"client": "aw-pve-webadmin-logger", "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: str, payload: dict, bucket_type: str):
if not self.ensure_bucket(bucket_id, bucket_type):
return False
req = urllib.request.Request(
f"{self.server}/buckets/{bucket_id}/heartbeat?pulsetime=60",
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 read_new_lines(self, src: pathlib.Path, st: TailState):
if not src.exists():
return []
fs = src.stat()
inode = int(fs.st_ino)
size = int(fs.st_size)
prev_inode = st.data.get("inode")
prev_off = int(st.data.get("offset", 0))
if prev_inode != inode or prev_off > size:
prev_off = 0
with src.open("r", encoding="utf-8", errors="replace") as f:
f.seek(prev_off)
lines = f.readlines()
st.data = {"inode": inode, "offset": f.tell()}
st.save()
return [ln.rstrip("\n") for ln in lines if ln.strip()]
def process_access(self):
for line in self.read_new_lines(self.access_log, self.access_state):
m = ACCESS_RE.match(line)
if not m:
continue
user = m.group("user")
status = int(m.group("status"))
method = m.group("method")
path = m.group("path")
if path == "/api2/json/version":
continue
if user == "-" and status < 400:
continue
# Proxmox UI does high-frequency read polling. Keep real actions and auth failures.
if method == "GET" and status == 200 and any(rx.match(path) for rx in NOISE_GET_PATHS):
continue
event_kind = "auth_failed" if status in (401, 403) else "request"
dedup_key = f"{event_kind}|{user}|{m.group('ip')}|{method}|{path}|{status}"
now = time.time()
if now - float(self.recent.get(dedup_key, 0)) < 30:
continue
self.recent[dedup_key] = now
event = {
"timestamp": parse_access_ts(m.group("ts")),
"duration": 0,
"data": {
"source": "pveproxy_access",
"event_kind": event_kind,
"host": self.host,
"user": user,
"remote_ip": m.group("ip"),
"method": method,
"path": path,
"status": status,
"protocol": m.group("proto"),
"raw": line,
},
}
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):
m = TASK_RE.match(line)
if not m:
continue
msg = m.group("msg")
event = {
"timestamp": iso_now(),
"duration": 0,
"data": {
"source": "pve_tasks_index",
"host": self.host,
"node": m.group("node"),
"upid_pid": m.group("pid"),
"action": m.group("action"),
"target": m.group("target"),
"user": m.group("user"),
"message": msg,
"result": "ok" if " OK" in msg else ("error" if "error" in msg.lower() else "info"),
"raw": line,
},
}
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()
self.process_tasks()
time.sleep(self.poll)
def main():
cfg = json.loads(pathlib.Path("/etc/aw-pve-webadmin-logger/config.json").read_text(encoding="utf-8"))
Collector(cfg).run()
if __name__ == "__main__":
main()
PY
chmod 0755 "${INSTALL_ROOT}/collector.py"
cat > "$SERVICE_PATH" <<EOF
[Unit]
Description=AW PVE web-admin activity logger
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=${INSTALL_ROOT}/collector.py
Restart=always
RestartSec=3
WorkingDirectory=${INSTALL_ROOT}
StandardOutput=append:${LOG_DIR}/collector.log
StandardError=append:${LOG_DIR}/collector.log
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable --now aw-pve-webadmin-logger.service
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}, aw-detmir-web-category_${HOST_SHORT}"
+10
View File
@@ -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
@@ -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)
@@ -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
+3
View File
@@ -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 `
+3
View File
@@ -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 `
+90 -24
View File
@@ -402,6 +402,64 @@ function Test-LooksLikeMojibakeQuestionMarks {
return $Value -match '\?{2,}'
}
function Normalize-OwnerForMatch {
param([AllowNull()][string]$Value)
if ([string]::IsNullOrWhiteSpace($Value)) { return '' }
$normalized = $Value.Trim().ToLowerInvariant()
if ($normalized -match '[\\/]') {
$parts = $normalized -split '[\\/]'
if ($parts.Count -gt 0) {
$normalized = [string]$parts[$parts.Count - 1]
}
}
if ($normalized -match '@') {
$parts = $normalized -split '@'
if ($parts.Count -gt 0) {
$normalized = [string]$parts[0]
}
}
return $normalized
}
function Test-OwnerLooseMatch {
param(
[string]$Expected,
[string]$Actual
)
$expectedNorm = Normalize-OwnerForMatch -Value $Expected
$actualNorm = Normalize-OwnerForMatch -Value $Actual
if ([string]::IsNullOrWhiteSpace($expectedNorm) -or [string]::IsNullOrWhiteSpace($actualNorm)) {
return $false
}
return ($actualNorm -eq $expectedNorm) -or $actualNorm.Contains($expectedNorm) -or $expectedNorm.Contains($actualNorm)
}
function Normalize-PrinterForMatch {
param([AllowNull()][string]$Value)
if ([string]::IsNullOrWhiteSpace($Value)) { return '' }
$normalized = $Value.Trim().ToLowerInvariant()
if ($normalized.Contains(',')) {
$normalized = ($normalized -split ',', 2)[0].Trim()
}
if ($normalized -match '\son\s') {
$normalized = ($normalized -split '\son\s', 2)[0].Trim()
}
return $normalized
}
function Test-PrinterLooseMatch {
param(
[string]$Expected,
[string]$Actual
)
$expectedNorm = Normalize-PrinterForMatch -Value $Expected
$actualNorm = Normalize-PrinterForMatch -Value $Actual
if ([string]::IsNullOrWhiteSpace($expectedNorm) -or [string]::IsNullOrWhiteSpace($actualNorm)) {
return $false
}
return ($actualNorm -eq $expectedNorm) -or $actualNorm.Contains($expectedNorm) -or $expectedNorm.Contains($actualNorm)
}
function Get-PrintServiceEventSummary {
param([Parameter(Mandatory = $true)]$Event)
@@ -515,32 +573,40 @@ function Get-BetterDocumentNameFromPrintServiceEvents {
StartTime = $startTime
} -MaxEvents 200 -ErrorAction Stop
foreach ($event in @($events)) {
$summary = Get-PrintServiceEventSummary -Event $event
$matchReason = 'scan'
$resolvedDocument = Get-PrintServiceDocumentFallback -EventSummary $summary -Owner $Owner -PrinterName $PrinterName
foreach ($pass in @('strict', 'relaxed')) {
foreach ($event in @($events)) {
$summary = Get-PrintServiceEventSummary -Event $event
$resolvedDocument = Get-PrintServiceDocumentFallback -EventSummary $summary -Owner $Owner -PrinterName $PrinterName
if ($Owner -and $summary.Owner -and ($summary.Owner -notlike "*$Owner*")) {
$matchReason = 'owner-mismatch'
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason $matchReason -ResolvedDocument $resolvedDocument
continue
$ownerMatches = if ($Owner) { Test-OwnerLooseMatch -Expected $Owner -Actual $summary.Owner } else { $true }
$printerMatches = if ($PrinterName) { Test-PrinterLooseMatch -Expected $PrinterName -Actual $summary.PrinterName } else { $true }
if ($pass -eq 'strict') {
if ($Owner -and -not $ownerMatches) {
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'owner-mismatch-strict' -ResolvedDocument $resolvedDocument
continue
}
if ($PrinterName -and -not $printerMatches) {
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'printer-mismatch-strict' -ResolvedDocument $resolvedDocument
continue
}
}
else {
if ($Owner -and $PrinterName -and -not $ownerMatches -and -not $printerMatches) {
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason 'owner-and-printer-mismatch-relaxed' -ResolvedDocument $resolvedDocument
continue
}
}
if ([string]::IsNullOrWhiteSpace($resolvedDocument)) {
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason ('no-document-candidate-' + $pass) -ResolvedDocument ''
continue
}
$matchReasonBase = if (Test-LooksLikeMojibakeQuestionMarks -Value $summary.DocumentName) { 'fallback-used' } else { 'direct' }
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'selected' -MatchReason ($matchReasonBase + '-' + $pass) -ResolvedDocument $resolvedDocument
return $resolvedDocument
}
if ($PrinterName -and $summary.PrinterName -and ($summary.PrinterName -notlike "*$PrinterName*")) {
$matchReason = 'printer-mismatch'
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason $matchReason -ResolvedDocument $resolvedDocument
continue
}
if ([string]::IsNullOrWhiteSpace($resolvedDocument)) {
$matchReason = 'no-document-candidate'
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'scan' -MatchReason $matchReason -ResolvedDocument ''
continue
}
$matchReason = if (Test-LooksLikeMojibakeQuestionMarks -Value $summary.DocumentName) { 'fallback-used' } else { 'direct' }
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'selected' -MatchReason $matchReason -ResolvedDocument $resolvedDocument
return $resolvedDocument
}
}
catch {
+13 -1
View File
@@ -14,6 +14,7 @@ $installRoot = [string]$config.paths.installRoot
$stateRoot = [string]$config.paths.stateRoot
$collectorScript = [string]$config.paths.collectorScript
$endpointCollectorScript = if ($config.paths.PSObject.Properties.Name -contains 'endpointCollectorScript') { [string]$config.paths.endpointCollectorScript } else { Join-Path $stateRoot 'dlp-endpoint-signals-collector.ps1' }
$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
@@ -24,6 +25,7 @@ $requiredFiles = @(
(Join-Path $installRoot 'aw-watcher-window\aw-watcher-window.exe'),
$collectorScript,
$endpointCollectorScript,
$sessionCollectorScript,
$rulesPath,
$policyPath,
$launchScript,
@@ -37,6 +39,12 @@ $missingFiles = @(
$processNames = @('aw-watcher-afk', 'aw-watcher-window')
$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) {
@@ -81,7 +89,11 @@ $result = [ordered]@{
}
processes = [ordered]@{
list = @($runningProcesses)
ok = [bool](($runningProcesses | Select-Object -ExpandProperty Name -Unique).Count -ge 2)
sessionCollectors = @($sessionCollectorProcesses)
ok = [bool](
(($runningProcesses | Select-Object -ExpandProperty Name -Unique).Count -ge 2) -and
($sessionCollectorProcesses.Count -ge 1)
)
}
}
+145
View File
@@ -0,0 +1,145 @@
param(
[string]$ConfigPath = 'C:\ProgramData\ActivityWatch\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 "Config not found: $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
}
$sessionId = 0
if ($parts[2] -match '^\d+$') {
$sessionId = [int]$parts[2]
}
$records += [pscustomobject]@{
username = $parts[0]
sessionName = $parts[1]
sessionId = $sessionId
state = $parts[3]
}
}
}
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
}