commit 0821de09644cbe4be8e2b5815521a8c3c9649bd9 Author: igor04091968 Date: Sat Apr 25 15:24:04 2026 +0300 chore: initial private ActivityWatch Russian deployment project diff --git a/README.md b/README.md new file mode 100755 index 0000000..3b8e312 --- /dev/null +++ b/README.md @@ -0,0 +1,56 @@ +# ActivityWatch-Russian + +Практический каркас проекта для повторного развёртывания ActivityWatch Server в новом окружении с LXC-контейнером на Proxmox, русифицированным Web UI, systemd-юнитами, шаблонными скриптами деплоя и эксплуатационной документацией. + +## Что входит + +- `docs/preparation.md` — подготовка инфраструктуры и входных параметров. +- `docs/deployment.md` — пошаговый деплой LXC и ActivityWatch Server. +- `docs/runbook.md` — быстрый runbook для оператора. +- `docs/operations.md` — регламент сопровождения, бэкапов, обновлений и rollback. +- `proxmox/` — шаблонные скрипты подготовки и наполнения CT на стороне Proxmox. +- `aw-server/` — установочные скрипты, env-шаблон, systemd unit и RU patch для Web UI. + +## Базовый сценарий + +1. Подготовить параметры окружения по `docs/preparation.md`. +2. Заполнить единый файл секретов `secrets/deploy.secrets.env` (автоподключение). +3. На узле Proxmox создать контейнер через `proxmox/create-ct.sh`. +4. Загрузить артефакты и серверный env в CT через `proxmox/push-aw-artifacts.sh`. +5. Внутри контейнера выполнить `aw-server/install_aw_server.sh`. +6. Применить русификацию Web UI через `aw-server/apply_webui_ru_patch.sh`. +7. Проверить API, Web UI и состояние systemd по `docs/runbook.md`. + +Скрипты `proxmox/create-ct.sh` и `proxmox/push-aw-artifacts.sh` по умолчанию читают: + +- `secrets/deploy.secrets.env` + +## Принципы + +- Никаких реальных секретов, токенов и боевых IP в репозитории. +- Все переменные вынесены в `.example` / `.env` шаблоны. +- Документация ориентирована на повторяемое развёртывание, а не на одноразовую ручную установку. +- Rollback и backup описаны как обязательная часть каждой операции. + +## Минимальная структура + +- CT/LXC на Debian 12 +- ActivityWatch Server Rust release +- Web UI override в `/opt/activitywatch/webui-ru` +- systemd unit `activitywatch-server.service` +- bind/listen через переменные окружения + +## Ограничения + +- Windows-клиенты и watcher-автоматизация в этой части каркаса не описываются. +- Интеграции с InfluxDB/Grafana/LDAP оставлены как следующий слой, не как обязательная база. + +## Быстрые ссылки + +- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/docs/FULL_DEPLOYMENT_MANUAL_RU.md` +- `docs/preparation.md` +- `docs/deployment.md` +- `docs/runbook.md` +- `docs/operations.md` +- `proxmox/create-ct.sh` +- `aw-server/install_aw_server.sh` diff --git a/aw-server/activitywatch-server.service b/aw-server/activitywatch-server.service new file mode 100755 index 0000000..e8f26e3 --- /dev/null +++ b/aw-server/activitywatch-server.service @@ -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 diff --git a/aw-server/apply_webui_ru_patch.sh b/aw-server/apply_webui_ru_patch.sh new file mode 100755 index 0000000..fdc781c --- /dev/null +++ b/aw-server/apply_webui_ru_patch.sh @@ -0,0 +1,33 @@ +#!/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:-/opt/activitywatch/webui-ru}" +PATCH_JS_SRC="/root/bootstrap/aw-ru-patch.js" +SW_CLEANUP_SRC="/root/bootstrap/aw-sw-cleanup.js" +INDEX_HTML="$WEBUI_DIR/index.html" +SERVICE_WORKER="$WEBUI_DIR/service-worker.js" +TS=$(date +%Y%m%d%H%M%S) + +[[ -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 "$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" +cp "$INDEX_HTML" "$INDEX_HTML.bak.$TS" + +sed -i '/aw-ru-patch.js/d;/aw-sw-cleanup.js/d' "$INDEX_HTML" +sed -i 's###' "$INDEX_HTML" +sed -i 's###' "$INDEX_HTML" +cp "$SW_CLEANUP_SRC" "$SERVICE_WORKER" + +echo "RU patch applied to $WEBUI_DIR" diff --git a/aw-server/aw-ru-patch.js b/aw-server/aw-ru-patch.js new file mode 100755 index 0000000..5486994 --- /dev/null +++ b/aw-server/aw-ru-patch.js @@ -0,0 +1,122 @@ +(function () { + window.__awRuPatchVersion = "template-v1"; + document.documentElement.setAttribute("data-aw-ru-patch", "template-v1"); + + const exact = new Map([ + ["ActivityWatch", "АктивВотч"], + ["Home", "Главная"], + ["Activity", "Активность"], + ["Timeline", "Таймлайн"], + ["Trends", "Тренды"], + ["Report", "Отчеты"], + ["Settings", "Настройки"], + ["Search", "Поиск"], + ["Buckets", "Бакеты"], + ["Stopwatch", "Секундомер"], + ["Tools", "Инструменты"], + ["Raw Data", "Сырые данные"], + ["Summary", "Сводка"], + ["All", "Все"], + ["None", "Нет"], + ["Date", "Дата"], + ["Time", "Время"], + ["Start", "Начало"], + ["End", "Конец"], + ["Download", "Скачать"], + ["Upload", "Загрузить"], + ["Show", "Показать"], + ["Hide", "Скрыть"], + ["Close", "Закрыть"], + ["Week", "Неделя"], + ["Month", "Месяц"], + ["Year", "Год"], + ["Today", "Сегодня"], + ["Yesterday", "Вчера"], + ["No data", "Нет данных"], + ["Category", "Категория"], + ["Categories", "Категории"], + ["Duration", "Длительность"], + ["Applications", "Приложения"], + ["Hosts", "Хосты"], + ["Date Range", "Диапазон дат"], + ["Generate", "Сформировать"], + ["Loading", "Загрузка"], + ["Loading...", "Загрузка..."], + ["Dark", "Темная"], + ["Light", "Светлая"], + ["Save", "Сохранить"], + ["Cancel", "Отмена"], + ["Delete", "Удалить"], + ["API Browser", "API-браузер"], + ["Documentation", "Документация"] + ]); + + const partial = [ + ["Top apps", "Топ приложений"], + ["Top titles", "Топ заголовков"], + ["Top URLs", "Топ URL"], + ["Top domains", "Топ доменов"], + ["Top Browser Domains", "Топ доменов браузера"], + ["Top Browser URLs", "Топ URL браузера"], + ["Top Browser Titles", "Топ заголовков браузера"], + ["Top Categories", "Топ категорий"], + ["Category Tree", "Дерево категорий"], + ["Timeline (barchart)", "Таймлайн (гистограмма)"], + ["Calculate Work Time", "Рассчитать рабочее время"], + ["Export CSV", "Экспорт CSV"], + ["Export JSON", "Экспорт JSON"], + ["No duplicate events found.", "Дубликаты событий не найдены."], + ["No overlapping events found.", "Пересекающиеся события не найдены."], + ["No zero-duration events found.", "События нулевой длительности не найдены."] + ]; + + function replaceText(text) { + if (!text) return text; + if (exact.has(text.trim())) { + return text.replace(text.trim(), exact.get(text.trim())); + } + let result = text; + for (const [en, ru] of partial) { + result = result.split(en).join(ru); + } + return result; + } + + function walk(root) { + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null); + const nodes = []; + while (walker.nextNode()) nodes.push(walker.currentNode); + for (const node of nodes) { + const nextValue = replaceText(node.nodeValue); + if (nextValue !== node.nodeValue) { + node.nodeValue = nextValue; + } + } + } + + function translateAttributes(root) { + const elements = root.querySelectorAll("[title],[placeholder],[aria-label]"); + for (const element of elements) { + ["title", "placeholder", "aria-label"].forEach(function (attr) { + const value = element.getAttribute(attr); + if (value) { + element.setAttribute(attr, replaceText(value)); + } + }); + } + } + + function applyPatch() { + walk(document.body); + translateAttributes(document.body); + } + + const observer = new MutationObserver(function () { + applyPatch(); + }); + + window.addEventListener("load", function () { + applyPatch(); + observer.observe(document.body, { childList: true, subtree: true, characterData: true }); + }); +})(); diff --git a/aw-server/aw-server.env.example b/aw-server/aw-server.env.example new file mode 100755 index 0000000..93ef939 --- /dev/null +++ b/aw-server/aw-server.env.example @@ -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 diff --git a/aw-server/aw-sw-cleanup.js b/aw-server/aw-sw-cleanup.js new file mode 100755 index 0000000..b7aa742 --- /dev/null +++ b/aw-server/aw-sw-cleanup.js @@ -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 () {}); diff --git a/aw-server/install_aw_server.sh b/aw-server/install_aw_server.sh new file mode 100755 index 0000000..fa2dc58 --- /dev/null +++ b/aw-server/install_aw_server.sh @@ -0,0 +1,86 @@ +#!/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 +) + +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 diff --git a/docs/FULL_DEPLOYMENT_MANUAL_RU.md b/docs/FULL_DEPLOYMENT_MANUAL_RU.md new file mode 100755 index 0000000..6263467 --- /dev/null +++ b/docs/FULL_DEPLOYMENT_MANUAL_RU.md @@ -0,0 +1,308 @@ +# Полная инструкция по развёртыванию и поддержке ActivityWatch-Russian + +Документ описывает полный цикл: Proxmox/LXC сервер, установка ActivityWatch Server, RU Web UI patch, развёртывание Windows-клиентов в другом AD-домене, валидация, сопровождение и rollback. + +--- + +## 0) Структура проекта (полные пути) + +- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/secrets/deploy.secrets.env` +- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/proxmox/create-ct.sh` +- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/proxmox/push-aw-artifacts.sh` +- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/aw-server/install_aw_server.sh` +- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/aw-server/apply_webui_ru_patch.sh` +- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/windows/deploy-single-user.ps1` +- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/windows/deploy-domain-users.ps1` +- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/windows/hardening-recovery.ps1` +- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/windows/browser-domains-native-collector.ps1` + +--- + +## 1) Подготовка + +### 1.1 Требования + +- Proxmox VE 8/9, доступ root (или sudo с правами на `pct`). +- Шаблон Debian 12 LXC на хосте Proxmox. +- Windows хост(ы) с PowerShell 5.1+ и правами локального администратора. +- Сетевой доступ Windows-клиентов до ActivityWatch Server (`5600/tcp`). + +### 1.2 Подготовка единого файла секретов + +Скопируйте шаблон: + +```bash +cp /mnt/usb_hdd2/Projects/ActivityWatch-Russian/secrets/deploy.secrets.env.example \ + /mnt/usb_hdd2/Projects/ActivityWatch-Russian/secrets/deploy.secrets.env +``` + +Заполните в файле `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/secrets/deploy.secrets.env`: + +- все `CT_*` параметры контейнера; +- все `AW_SERVER_*` параметры сервера; +- `CT_PASSWORD` (реальный пароль). + +Важно: этот файл подхватывается автоматически скриптами Proxmox. + +--- + +## 2) Развёртывание сервера в Proxmox + +### 2.1 Создать LXC контейнер + +На узле Proxmox: + +```bash +cd /mnt/usb_hdd2/Projects/ActivityWatch-Russian +/mnt/usb_hdd2/Projects/ActivityWatch-Russian/proxmox/create-ct.sh +``` + +По умолчанию читается: + +- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/secrets/deploy.secrets.env` + +При необходимости можно передать другой путь: + +```bash +/mnt/usb_hdd2/Projects/ActivityWatch-Russian/proxmox/create-ct.sh /absolute/path/to/deploy.secrets.env +``` + +### 2.2 Загрузить bootstrap-артефакты и env внутрь CT + +```bash +cd /mnt/usb_hdd2/Projects/ActivityWatch-Russian +/mnt/usb_hdd2/Projects/ActivityWatch-Russian/proxmox/push-aw-artifacts.sh +``` + +Скрипт загружает в CT: + +- `/root/bootstrap/install_aw_server.sh` +- `/root/bootstrap/apply_webui_ru_patch.sh` +- `/root/bootstrap/activitywatch-server.service` +- `/root/bootstrap/aw-ru-patch.js` +- `/root/bootstrap/aw-sw-cleanup.js` +- `/etc/activitywatch/aw-server.env` (из `AW_SERVER_*`) + +### 2.3 Установить ActivityWatch Server внутри CT + +```bash +pct enter +bash /root/bootstrap/install_aw_server.sh +``` + +### 2.4 Применить RU patch Web UI + +```bash +bash /root/bootstrap/apply_webui_ru_patch.sh +systemctl restart activitywatch-server.service +``` + +### 2.5 Проверка сервера + +В CT: + +```bash +systemctl status activitywatch-server.service --no-pager +curl -fsS http://127.0.0.1:5600/api/0/info +ss -ltnp | grep 5600 +grep -n 'aw-ru-patch\|aw-sw-cleanup' /opt/activitywatch/webui-ru/index.html +``` + +Ожидается: + +- сервис `active (running)`; +- API отвечает JSON; +- порт 5600 слушается; +- в `index.html` присутствуют оба скрипта. + +--- + +## 3) Развёртывание Windows-клиентов (другой AD-домен) + +### 3.1 Подготовка на Windows-хосте + +Скопируйте каталог: + +- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/windows` + +например в: + +- `C:\Deploy\ActivityWatch-Russian\windows` + +Откройте **elevated PowerShell**: + +```powershell +Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope Process +``` + +### 3.2 Массовое доменное развёртывание (рекомендуется) + +Пример со списком пользователей: + +```powershell +C:\Deploy\ActivityWatch-Russian\windows\deploy-domain-users.ps1 ` + -ServerHost aw.example.local ` + -ServerPort 5600 ` + -Domain CONTOSO ` + -UserListPath C:\Deploy\aw-users.txt ` + -CustomRulesPath C:\Deploy\ActivityWatch-Russian\windows\web-category-rules.example.json +``` + +Поддерживаемые варианты: + +- `-Users user01,user02` +- `-Users 'CONTOSO\user01','CONTOSO\user02'` +- `-UserListPath ` + +### 3.3 Single-user развёртывание + +```powershell +C:\Deploy\ActivityWatch-Russian\windows\deploy-single-user.ps1 ` + -ServerHost aw.example.local ` + -ServerPort 5600 ` + -TargetUser 'CONTOSO\user01' ` + -CustomRulesPath C:\Deploy\ActivityWatch-Russian\windows\web-category-rules.example.json +``` + +### 3.4 Recovery / hardening + +```powershell +C:\Deploy\ActivityWatch-Russian\windows\hardening-recovery.ps1 ` + -ConfigPath C:\ProgramData\ActivityWatch\deployment-config.json +``` + +--- + +## 4) Что должно появиться на Windows после установки + +- `C:\Program Files\ActivityWatch` +- `C:\ProgramData\ActivityWatch\deployment-config.json` +- `C:\ProgramData\ActivityWatch\launch-watchers.ps1` +- `C:\ProgramData\ActivityWatch\recovery-loop.ps1` +- `C:\ProgramData\ActivityWatch\browser-domains-native-collector.ps1` +- `C:\ProgramData\ActivityWatch\web-category-rules.json` +- `C:\ProgramData\ActivityWatch\logs\` + +Задачи планировщика: + +- `ActivityWatch Launch []` (per-user, при логоне) +- `ActivityWatch Recovery` (system-level recovery) + +--- + +## 5) Полная валидация потока данных + +### 5.1 На Windows-хосте + +Проверить процессы: + +```powershell +Get-Process aw-watcher-afk,aw-watcher-window -ErrorAction SilentlyContinue +Get-CimInstance Win32_Process | ? { $_.CommandLine -like '*browser-domains-native-collector.ps1*' } | select ProcessId,SessionId,CommandLine +``` + +Проверить задачи: + +```powershell +Get-ScheduledTask | ? { $_.TaskName -like 'ActivityWatch*' } | select TaskName,State +``` + +### 5.2 На сервере ActivityWatch API + +```bash +curl -sS http://127.0.0.1:5600/api/0/buckets | jq 'keys' +``` + +Ожидаемые bucket'ы: + +- `aw-watcher-afk_` +- `aw-watcher-window_` +- `aw-watcher-web-_` +- `aw-detmir-web-category_` (категоризованный поток) + +Проверка событий браузера: + +```bash +curl -sS "http://127.0.0.1:5600/api/0/buckets/aw-watcher-web-edge_/events?limit=5" | jq +``` + +Проверка категоризации: + +```bash +curl -sS "http://127.0.0.1:5600/api/0/buckets/aw-detmir-web-category_/events?limit=5" | jq +``` + +--- + +## 6) Сопровождение (обязательно) + +### 6.1 Backup перед любыми изменениями + +На Proxmox: + +```bash +vzdump --mode snapshot --compress zstd --storage +``` + +Конфиги внутри CT: + +```bash +pct exec -- tar -C / -czf /root/activitywatch-config-backup.tgz \ + etc/activitywatch \ + etc/systemd/system/activitywatch-server.service \ + opt/activitywatch/webui-ru \ + opt/activitywatch/releases +``` + +### 6.2 Обновление сервера + +1. Обновить `AW_SERVER_VERSION` и `AW_SERVER_DOWNLOAD_URL` в + `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/secrets/deploy.secrets.env` +2. Выполнить: + +```bash +/mnt/usb_hdd2/Projects/ActivityWatch-Russian/proxmox/push-aw-artifacts.sh +pct enter +bash /root/bootstrap/install_aw_server.sh +bash /root/bootstrap/apply_webui_ru_patch.sh +systemctl restart activitywatch-server.service +``` + +3. Повторить валидацию API/UI. + +### 6.3 Rollback + +RU patch rollback: + +```bash +cp /opt/activitywatch/webui-ru/index.html.bak. /opt/activitywatch/webui-ru/index.html +systemctl restart activitywatch-server.service +``` + +Полный rollback: + +- восстановить CT из snapshot/backup; +- проверить API и Web UI; +- проверить доступность для Windows-клиентов. + +--- + +## 7) Безопасность + +- Не хранить реальные секреты вне `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/secrets/deploy.secrets.env`. +- Не открывать `5600/tcp` в интернет напрямую. +- Публиковать через VPN или reverse proxy с ограничением доступа. +- Перед изменениями всегда делать backup. + +--- + +## 8) Короткий чек-лист ввода в эксплуатацию + +1. Заполнен `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/secrets/deploy.secrets.env`. +2. Выполнен `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/proxmox/create-ct.sh`. +3. Выполнен `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/proxmox/push-aw-artifacts.sh`. +4. В CT выполнены `/root/bootstrap/install_aw_server.sh` и `/root/bootstrap/apply_webui_ru_patch.sh`. +5. Сервер API/порт/UI проверены. +6. На Windows выполнен `deploy-domain-users.ps1`. +7. Проверены процессы, задачи и bucket'ы. +8. Зафиксированы параметры и дата ввода. diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100755 index 0000000..b4baf68 --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,116 @@ +# Deployment + +## 1. Подготовить env-файлы + +На рабочей машине оператора: + +```sh +cp proxmox/ct-vars.example.env /root/activitywatch-ct.env +cp aw-server/aw-server.env.example /root/activitywatch-aw.env +``` + +Заполнить оба файла реальными значениями вне git. + +## 2. Создать CT на Proxmox + +На узле Proxmox: + +```sh +cd /path/to/ActivityWatch-Russian +./proxmox/create-ct.sh /root/activitywatch-ct.env +``` + +Скрипт: + +- валидирует обязательные переменные; +- создаёт Debian 12 CT; +- запускает контейнер; +- выполняет минимальный bootstrap пакетов; +- готовит `/root/bootstrap` для дальнейшей загрузки артефактов. + +## 3. Загрузить артефакты в CT + +На узле Proxmox: + +```sh +./proxmox/push-aw-artifacts.sh /root/activitywatch-ct.env +pct push /root/activitywatch-aw.env /etc/activitywatch/aw-server.env +``` + +В CT будут загружены: + +- `install_aw_server.sh` +- `apply_webui_ru_patch.sh` +- `activitywatch-server.service` +- `aw-ru-patch.js` +- `aw-sw-cleanup.js` + +## 4. Установить ActivityWatch Server + +Внутри CT: + +```sh +pct enter +bash /root/bootstrap/install_aw_server.sh +``` + +Скрипт установки: + +- ставит `curl`, `unzip`, `jq`, `ca-certificates`; +- создаёт пользователя `activitywatch`; +- скачивает release `aw-server-rust`; +- раскладывает бинарник по версиям; +- создаёт активный symlink; +- копирует systemd unit; +- включает и запускает сервис. + +## 5. Применить RU patch для Web UI + +Внутри CT: + +```sh +bash /root/bootstrap/apply_webui_ru_patch.sh +systemctl restart activitywatch-server.service +``` + +Патч: + +- копирует `aw-ru-patch.js` и `aw-sw-cleanup.js`; +- делает backup `index.html`; +- добавляет include в `index.html`; +- заменяет `service-worker.js` cleanup-версией для сброса старого cache. + +## 6. Проверить сервис + +```sh +systemctl status activitywatch-server.service --no-pager +curl -fsS http://127.0.0.1:5600/api/0/info +ss -ltnp | grep 5600 +grep -n 'aw-ru-patch\|aw-sw-cleanup' /opt/activitywatch/webui-ru/index.html +``` + +Ожидаемо: + +- сервис `active (running)`; +- API отвечает; +- порт слушается; +- в `index.html` есть оба include. + +## 7. После публикации + +Проверить извне: + +- открывается `/`; +- API доступен по ожидаемому URL; +- после hard refresh видна русификация; +- reverse proxy не кэширует старую статику. + +## 8. Что зафиксировать после ввода + +- дата ввода; +- узел Proxmox; +- `CT_ID`; +- IP/FQDN; +- версия `aw-server-rust`; +- место хранения backup; +- фактическая схема публикации. diff --git a/docs/operations.md b/docs/operations.md new file mode 100755 index 0000000..38ec951 --- /dev/null +++ b/docs/operations.md @@ -0,0 +1,113 @@ +# Operations + +## Эксплуатационная модель + +Система состоит из: + +- узла Proxmox VE; +- LXC-контейнера с Debian 12; +- `ActivityWatch Server` на Rust; +- Web UI override с RU patch; +- systemd unit `activitywatch-server.service`. + +Базовые правила: + +- все изменения только через backup-first workflow; +- секреты не хранить в git; +- каждое изменение фиксировать в ticket/run log; +- публичную публикацию делать через отдельный proxy/security layer. + +## Регулярные проверки + +### Ежедневно или перед работами + +- `pct status `; +- `systemctl is-active activitywatch-server.service`; +- локальный `curl /api/0/info`; +- `df -h`; +- `journalctl -p err -b`. + +### Еженедельно + +- проверить свежесть backup/snapshot; +- проверить publish URL извне; +- проверить срок TLS, если есть reverse proxy; +- перепроверить firewall/ACL для `5600/tcp`. + +### Ежемесячно + +- проверить новые релизы ActivityWatch; +- сделать dry-run rollback; +- обновить runbook при изменениях инфраструктуры. + +## Backup + +Минимум сохранять: + +- backup CT через `vzdump`; +- `/etc/activitywatch/aw-server.env`; +- `/etc/systemd/system/activitywatch-server.service`; +- `/opt/activitywatch/webui-ru/`; +- `/opt/activitywatch/releases/`; +- конфиг reverse proxy, если он есть. + +Пример: + +```sh +vzdump --mode snapshot --compress zstd --storage +pct exec -- tar -C / -czf /root/activitywatch-config-backup.tgz \ + etc/activitywatch etc/systemd/system/activitywatch-server.service opt/activitywatch/webui-ru +``` + +## Rollback + +### Быстрый rollback RU patch + +```sh +cp /opt/activitywatch/webui-ru/index.html.bak. /opt/activitywatch/webui-ru/index.html +systemctl restart activitywatch-server.service +``` + +### Rollback server release + +1. Остановить сервис. +2. Переключить symlink на предыдущий release. +3. Проверить права. +4. Запустить сервис. +5. Проверить API/UI. + +### Полный rollback CT + +- остановить CT; +- восстановить snapshot или `vzdump`; +- поднять CT; +- проверить API и publish path. + +## Обновление + +Порядок: + +1. Сделать backup. +2. Скачать новый release в отдельную директорию. +3. Не затирать прошлую версию до успешной проверки. +4. Проверить совместимость RU patch. +5. Перезапустить сервис. +6. Проверить API/UI. +7. Зафиксировать результат. + +## Эскалация + +Эскалировать сразу, если: + +- потерян доступ к Proxmox или CT; +- backup chain повреждён; +- UI/API не вернулись после rollback; +- изменились маршруты, bridge, VLAN или firewall policy; +- нужен новый публичный endpoint. + +## Что не делать + +- не вшивать реальные IP, пароли и токены; +- не обновлять поверх рабочего бинарника без backup; +- не открывать `5600/tcp` наружу без отдельной защиты; +- не править `index.html` вручную без backup. diff --git a/docs/preparation.md b/docs/preparation.md new file mode 100755 index 0000000..17425cc --- /dev/null +++ b/docs/preparation.md @@ -0,0 +1,92 @@ +# Preparation + +## Цель + +Подготовить чистое окружение для повторяемого развёртывания отдельного `ActivityWatch Server` в LXC-контейнере на Proxmox без зависимости от старой инсталляции. + +## Что определить заранее + +### Инфраструктурные параметры + +- имя узла Proxmox; +- `CT_ID`; +- `CT_HOSTNAME`; +- `CT_BRIDGE`; +- `CT_VLAN`, если нужен; +- статический IP/маска/шлюз; +- список DNS; +- storage для `rootfs`; +- размер диска; +- лимиты CPU/RAM/SWAP; +- timezone; +- внутренний или внешний FQDN для публикации. + +### Доступы + +Хранить вне репозитория: + +- SSH/API-доступ к Proxmox; +- пароль или ключ для CT; +- TLS-сертификаты reverse proxy; +- токены внешнего мониторинга или backup target. + +## Рекомендуемая базовая схема + +- Proxmox VE 8/9 +- Debian 12 LXC +- `unprivileged=1` +- `nesting=1,keyctl=1` +- внутренний listen ActivityWatch: `0.0.0.0:5600` +- внешняя публикация только через VPN или reverse proxy + +## Проверки на узле Proxmox + +```sh +pveversion +pveam update +pveam available | grep debian-12 +pvesm status +pct list +``` + +Проверить: + +- есть место под CT и backup; +- `CT_ID` не занят; +- выбранный bridge/VLAN реально маршрутизируется; +- Debian template доступен; +- есть маршрут до будущего адреса контейнера. + +## Что подготовить до деплоя + +- заполненный `secrets/deploy.secrets.env` (единый файл для CT + AW server); +- согласованный URL релиза `aw-server-rust`; +- решение по публикации: VPN или reverse proxy; +- решение по backup: `vzdump`, snapshot, rsync, NAS или object storage. + +## Единый файл секретов + +Файл `secrets/deploy.secrets.env` автоматически подхватывается: + +- `proxmox/create-ct.sh` +- `proxmox/push-aw-artifacts.sh` + +В нём хранятся: + +- `CT_*` параметры контейнера; +- `AW_SERVER_*` параметры сервера. + +## Базовые каталоги внутри CT + +- `/opt/activitywatch/bin` +- `/opt/activitywatch/releases` +- `/opt/activitywatch/webui-ru` +- `/etc/activitywatch` +- `/var/lib/activitywatch` +- `/var/log/activitywatch` + +## Security-заметки + +- не публиковать `5600/tcp` напрямую в интернет; +- не хранить реальные значения в `.example` файлах; +- перед вводом в эксплуатацию зафиксировать firewall, DNS и publish path в `docs/operations.md`. diff --git a/docs/runbook.md b/docs/runbook.md new file mode 100755 index 0000000..ee83ca2 --- /dev/null +++ b/docs/runbook.md @@ -0,0 +1,91 @@ +# Runbook + +## Быстрый health-check + +### На Proxmox + +```sh +pct status +pct config +pct exec -- systemctl is-active activitywatch-server.service +pct exec -- curl -fsS http://127.0.0.1:5600/api/0/info +``` + +### Внутри CT + +```sh +systemctl status activitywatch-server.service --no-pager +journalctl -u activitywatch-server.service -n 100 --no-pager +curl -fsS http://127.0.0.1:5600/api/0/info +ss -ltnp | grep 5600 +``` + +## Проверка RU patch + +```sh +grep -n 'aw-ru-patch\|aw-sw-cleanup' /opt/activitywatch/webui-ru/index.html +ls -l /opt/activitywatch/webui-ru/js/ +``` + +Проверить: + +- есть `aw-ru-patch.js`; +- есть `aw-sw-cleanup.js`; +- `index.html` содержит оба include; +- `service-worker.js` заменён cleanup-версией. + +## Типовые инциденты + +### Сервис не стартует + +```sh +systemctl cat activitywatch-server.service +cat /etc/activitywatch/aw-server.env +journalctl -xeu activitywatch-server.service --no-pager +``` + +Частые причины: + +- битый URL релиза; +- неполная распаковка архива; +- занят порт; +- не созданы каталоги или пользователь; +- ошибка в env-файле. + +### API отвечает, но UI без русификации + +Проверить: + +- патч реально вставлен в `index.html`; +- browser cache/service worker очищен; +- reverse proxy не отдаёт старую статику; +- сервис был перезапущен после правок. + +Повторное применение: + +```sh +bash /root/bootstrap/apply_webui_ru_patch.sh +systemctl restart activitywatch-server.service +``` + +### После обновления UI сломался патч + +- сравнить `index.html` с backup; +- заново применить patch script; +- проверить словарь в `aw-ru-patch.js`; +- при необходимости откатить только Web UI override. + +## Перед любыми изменениями + +1. Сделать snapshot или `vzdump`. +2. Сохранить текущий `/etc/activitywatch/aw-server.env`. +3. Сохранить текущий `index.html`. +4. Зафиксировать текущую версию `aw-server-rust`. + +## Критерии готовности + +- systemd unit стартует без ручного вмешательства; +- API `/api/0/info` отвечает локально; +- UI открывается; +- русификация присутствует; +- rollback-путь понятен оператору. diff --git a/docs/windows/deployment.md b/docs/windows/deployment.md new file mode 100755 index 0000000..ddcc87b --- /dev/null +++ b/docs/windows/deployment.md @@ -0,0 +1,111 @@ +# Windows deployment + +## Состав пакета + +- `windows/deploy-single-user.ps1` — развёртывание для одного пользователя. +- `windows/deploy-domain-users.ps1` — массовое развёртывание по списку пользователей. +- `windows/hardening-recovery.ps1` — повторная регистрация задач, ACL и recovery-loop. +- `windows/browser-domains-native-collector.ps1` — native collector доменов браузера с категоризацией. +- `windows/web-category-rules.example.json` — пример кастомных правил категоризации. + +## Что делает пакет + +- Ставит `aw-watcher-afk` и `aw-watcher-window` из официального Windows ZIP ActivityWatch. +- Копирует browser-domain collector в `C:\ProgramData\ActivityWatch`. +- Создаёт per-user задачи `ActivityWatch Launch [...]` с запуском при логоне. +- Создаёт системную задачу `ActivityWatch Recovery`, которая циклически перезапускает per-user launch tasks. +- Применяет ACL к `C:\Program Files\ActivityWatch`, `C:\ProgramData\ActivityWatch` и каталогу логов. +- Не содержит хардкодов инфраструктуры: сервер, домен, список пользователей и правила передаются параметрами. + +## Предпосылки + +- Windows 10/11 или Windows Server с PowerShell 5.1+. +- Запуск из elevated PowerShell. +- Доступ до ActivityWatch Server по `host:port`. +- Пользователи домена должны реально входить на хост интерактивно. +- Если интернет недоступен, заранее скачайте ActivityWatch ZIP и передайте `-PackageZipPath`. + +## Single-user deploy + +```powershell +Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope Process + +.\windows\deploy-single-user.ps1 ` + -ServerHost aw.example.local ` + -ServerPort 5600 ` + -TargetUser 'CONTOSO\svc.activity.user01' ` + -CustomRulesPath .\windows\web-category-rules.example.json +``` + +Локальный пользователь: + +```powershell +.\windows\deploy-single-user.ps1 ` + -ServerHost aw-gateway.internal ` + -ServerPort 5600 ` + -TargetUser '.\operator01' +``` + +Оффлайн-установка из локального ZIP: + +```powershell +.\windows\deploy-single-user.ps1 ` + -ServerHost aw.example.local ` + -TargetUser 'CONTOSO\user01' ` + -PackageZipPath C:\Temp\activitywatch-v0.13.2-windows-x86_64.zip +``` + +## Multi-user / domain deploy + +Поддерживаются: + +- `-Users user1,user2` +- `-Users 'CONTOSO\user1','CONTOSO\user2'` +- `-UserListPath .\users.txt` +- `-UserListPath .\users.csv` + +TXT-формат: + +```text +# comments are ignored +user01 +user02 +user03 +``` + +CSV-формат: колонка `User`, `Username`, `SamAccountName` или `Login`. + +Пример: + +```powershell +.\windows\deploy-domain-users.ps1 ` + -ServerHost aw.example.local ` + -ServerPort 5600 ` + -Domain CONTOSO ` + -UserListPath C:\Temp\aw-users.txt ` + -CustomRulesPath C:\Temp\web-category-rules.json +``` + +Если список уже содержит `DOMAIN\user`, параметр `-Domain` не нужен. + +## Категоризация доменов + +- Встроенные категории покрывают базовые рабочие, нейтральные и личные домены. +- Для кастомизации скопируйте `windows/web-category-rules.example.json` и отредактируйте домены. +- Передайте файл через `-CustomRulesPath`; он будет сохранён как `C:\ProgramData\ActivityWatch\web-category-rules.json`. +- Пользовательские правила имеют приоритет над встроенными. + +## Структура после установки + +- `C:\Program Files\ActivityWatch` — бинарники watcher'ов. +- `C:\ProgramData\ActivityWatch\deployment-config.json` — итоговая конфигурация. +- `C:\ProgramData\ActivityWatch\launch-watchers.ps1` — per-user launcher. +- `C:\ProgramData\ActivityWatch\recovery-loop.ps1` — system recovery loop. +- `C:\ProgramData\ActivityWatch\browser-domains-native-collector.ps1` — runtime collector. +- `C:\ProgramData\ActivityWatch\logs\` — логи collector'а. + +## Повторный прогон + +- Скрипты идемпотентны: переустанавливают задачи и обновляют runtime-файлы. +- Предыдущая установка ActivityWatch бэкапится в `C:\ProgramData\ActivityWatch\backups\install-YYYYMMDD-HHMMSS`. +- Для жёсткого восстановления запускайте `windows/hardening-recovery.ps1`. diff --git a/docs/windows/troubleshooting.md b/docs/windows/troubleshooting.md new file mode 100755 index 0000000..0b3e487 --- /dev/null +++ b/docs/windows/troubleshooting.md @@ -0,0 +1,102 @@ +# Windows troubleshooting + +## Частые проблемы + +### `Run this script from an elevated PowerShell session` + +- Откройте PowerShell через `Run as administrator`. +- Проверьте, что UAC не понизил токен. + +### Задачи созданы, но watcher'ы не стартуют + +- Убедитесь, что пользователь реально вошёл в интерактивную сессию. +- Проверьте задачу `ActivityWatch Launch [...]` в Task Scheduler. +- Запустите вручную: + +```powershell +Start-ScheduledTask -TaskName 'ActivityWatch Launch [CONTOSO_user01]' +``` + +- Если доменный логон ещё ни разу не происходил на хосте, сначала выполните вход этим пользователем. + +### Collector не видит URL + +- Скрипт работает через UI Automation и foreground window. +- Некоторые браузеры/страницы могут скрывать адресную строку или блокировать UIA. +- Проверьте лог `C:\ProgramData\ActivityWatch\logs\browser-domains-.log`. +- Убедитесь, что активное окно — поддерживаемый браузер: Edge, Chrome, Brave, Vivaldi, Opera, Firefox. + +### Сервер недоступен + +- Проверьте TCP-доступ: + +```powershell +Test-NetConnection aw.example.local -Port 5600 +``` + +- Проверьте локально API: + +```powershell +Invoke-WebRequest http://aw.example.local:5600/api/0/info +``` + +- Если нужен HTTPS reverse proxy, задайте `-ServerScheme https`. + +### Неправильная категоризация домена + +- Проверьте содержимое `C:\ProgramData\ActivityWatch\web-category-rules.json`. +- Пользовательские правила должны быть валидным JSON. +- Один и тот же домен лучше определять только в одной категории. +- После изменения правил достаточно перезапустить collector или задачу пользователя: + +```powershell +Stop-Process -Name powershell -ErrorAction SilentlyContinue +Start-ScheduledTask -TaskName 'ActivityWatch Launch [CONTOSO_user01]' +``` + +### ACL сломаны или пользователи удалили runtime-файлы + +- Запустите: + +```powershell +.\windows\hardening-recovery.ps1 -RepairPackage +``` + +- Если сервер и пользователи уже есть в `deployment-config.json`, дополнительные параметры не нужны. + +## Диагностика + +Проверить задачи: + +```powershell +Get-ScheduledTask -TaskName 'ActivityWatch*' | Select-Object TaskName, State +``` + +Проверить процессы в пользовательской сессии: + +```powershell +Get-Process aw-watcher-afk, aw-watcher-window -ErrorAction SilentlyContinue | + Select-Object ProcessName, Id, SessionId, StartTime +``` + +Проверить collector: + +```powershell +Get-CimInstance Win32_Process | + Where-Object { $_.Name -in 'powershell.exe', 'pwsh.exe' -and $_.CommandLine -match 'browser-domains-native-collector.ps1' } | + Select-Object ProcessId, SessionId, CommandLine +``` + +Проверить конфиг: + +```powershell +Get-Content C:\ProgramData\ActivityWatch\deployment-config.json -Raw +``` + +## Когда запускать hardening/recovery + +- После ручной чистки задач. +- После неудачного обновления ActivityWatch. +- После переноса сервера на другой host/port. +- После обновления списка пользователей. +- После изменения кастомных правил, если нужен централизованный repair pass. diff --git a/docs/windows/validation.md b/docs/windows/validation.md new file mode 100755 index 0000000..bbb9970 --- /dev/null +++ b/docs/windows/validation.md @@ -0,0 +1,109 @@ +# Windows validation + +## Базовая проверка после развёртывания + +### 1. Проверить установленные файлы + +```powershell +Test-Path 'C:\Program Files\ActivityWatch\aw-watcher-afk\aw-watcher-afk.exe' +Test-Path 'C:\Program Files\ActivityWatch\aw-watcher-window\aw-watcher-window.exe' +Test-Path 'C:\ProgramData\ActivityWatch\browser-domains-native-collector.ps1' +Test-Path 'C:\ProgramData\ActivityWatch\deployment-config.json' +``` + +Ожидаемый результат — везде `True`. + +### 2. Проверить задачи + +```powershell +Get-ScheduledTask -TaskName 'ActivityWatch*' | + Select-Object TaskName, Author, State +``` + +Ожидаемо: + +- по одной задаче `ActivityWatch Launch [...]` на пользователя; +- одна задача `ActivityWatch Recovery`. + +### 3. Проверить процессы после логина пользователя + +```powershell +Get-Process aw-watcher-afk, aw-watcher-window -ErrorAction SilentlyContinue | + Select-Object ProcessName, SessionId, StartTime +``` + +И collector: + +```powershell +Get-CimInstance Win32_Process | + Where-Object { $_.Name -in 'powershell.exe', 'pwsh.exe' -and $_.CommandLine -match 'browser-domains-native-collector.ps1' } | + Select-Object ProcessId, SessionId, CommandLine +``` + +### 4. Проверить сетевую связность + +```powershell +Test-NetConnection aw.example.local -Port 5600 +Invoke-WebRequest http://aw.example.local:5600/api/0/info +``` + +Если используется HTTPS: + +```powershell +Invoke-WebRequest https://aw.example.local/api/0/info +``` + +### 5. Проверить buckets на сервере + +После открытия нескольких сайтов в браузере на сервере должны появиться: + +- `aw-watcher-window_` +- `aw-watcher-web-edge_` или другой browser bucket +- `aw-watcher-web-category_` + +Проверка через API: + +```powershell +Invoke-WebRequest http://aw.example.local:5600/api/0/buckets | Select-Object -ExpandProperty Content +``` + +## Проверка категоризации + +1. Откройте сайт из кастомного правила. +2. Подождите `PollSeconds + PulseSeconds`. +3. Проверьте category bucket на сервере. +4. Убедитесь, что поля `domain`, `rootDomain`, `category`, `categoryGroup`, `categoryRule` заполнены. + +## Проверка восстановления + +1. Завершите `aw-watcher-afk.exe` и `aw-watcher-window.exe` у тестового пользователя. +2. Подождите до `RecoveryIntervalSeconds`. +3. Убедитесь, что per-user launch task стартовала процессы заново. + +Ручной запуск recovery: + +```powershell +Start-ScheduledTask -TaskName 'ActivityWatch Recovery' +``` + +## Проверка ACL + +```powershell +icacls 'C:\Program Files\ActivityWatch' +icacls 'C:\ProgramData\ActivityWatch' +icacls 'C:\ProgramData\ActivityWatch\logs' +``` + +Ожидаемо: + +- `SYSTEM` и `Administrators` имеют `F`; +- `Users` имеет `RX` на install/state; +- `Users` имеет `M` на `logs`. + +## Критерий готовности к массовому развёртыванию + +- Установка проходит без ручного редактирования скриптов. +- Все параметры инфраструктуры передаются снаружи. +- Повторный запуск не ломает текущую установку. +- Recovery восстанавливает запуск watcher'ов после остановки. +- Collector пишет domain/category события без расширений браузера. diff --git a/proxmox/create-ct.sh b/proxmox/create-ct.sh new file mode 100755 index 0000000..c92ef25 --- /dev/null +++ b/proxmox/create-ct.sh @@ -0,0 +1,66 @@ +#!/bin/sh +set -eu + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +DEFAULT_ENV_FILE="$SCRIPT_DIR/../secrets/deploy.secrets.env" + +if [ "$#" -gt 1 ]; then + echo "usage: $0 [ /path/to/deploy.secrets.env ]" >&2 + exit 1 +fi + +ENV_FILE="${1:-$DEFAULT_ENV_FILE}" +if [ ! -f "$ENV_FILE" ]; then + echo "env file not found: $ENV_FILE" >&2 + exit 1 +fi + +# shellcheck disable=SC1090 +. "$ENV_FILE" + +required_vars="CT_ID CT_HOSTNAME CT_STORAGE CT_TEMPLATE CT_ROOTFS_SIZE CT_CORES CT_MEMORY CT_SWAP CT_BRIDGE CT_IP CT_GW CT_PASSWORD CT_UNPRIVILEGED CT_ONBOOT CT_FEATURES" +for var_name in $required_vars; do + eval "var_value=\${$var_name:-}" + if [ -z "$var_value" ]; then + echo "missing required variable: $var_name" >&2 + exit 1 + fi +done + +if pct status "$CT_ID" >/dev/null 2>&1; then + echo "CT $CT_ID already exists" >&2 + exit 1 +fi + +NET0="name=eth0,bridge=$CT_BRIDGE,ip=$CT_IP,gw=$CT_GW" +if [ -n "${CT_VLAN:-}" ]; then + NET0="$NET0,tag=$CT_VLAN" +fi + +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 "$NET0" \ + --nameserver "${CT_NAMESERVER:-}" \ + --searchdomain "${CT_SEARCHDOMAIN:-}" \ + --ostype debian + +pct start "$CT_ID" +sleep 5 + +pct exec "$CT_ID" -- bash -lc ' + set -eu + export DEBIAN_FRONTEND=noninteractive + apt-get update + apt-get install -y curl ca-certificates bash unzip xz-utils jq + mkdir -p /root/bootstrap /etc/activitywatch +' + +echo "CT $CT_ID created and bootstrapped" diff --git a/proxmox/ct-vars.example.env b/proxmox/ct-vars.example.env new file mode 100755 index 0000000..90318d6 --- /dev/null +++ b/proxmox/ct-vars.example.env @@ -0,0 +1,21 @@ +# Copy this file outside git and fill with real values. + +PVE_NODE=pve-example +CT_ID=203 +CT_HOSTNAME=activitywatch-server +CT_STORAGE=local-lvm +CT_TEMPLATE=local:vztmpl/debian-12-standard_12.7-1_amd64.tar.zst +CT_ROOTFS_SIZE=8G +CT_CORES=2 +CT_MEMORY=2048 +CT_SWAP=512 +CT_BRIDGE=vmbr10 +CT_IP=10.20.30.13/24 +CT_GW=10.20.30.1 +CT_VLAN= +CT_NAMESERVER=1.1.1.1 8.8.8.8 +CT_SEARCHDOMAIN=example.internal +CT_PASSWORD=CHANGE_ME +CT_UNPRIVILEGED=1 +CT_ONBOOT=1 +CT_FEATURES=nesting=1,keyctl=1 diff --git a/proxmox/push-aw-artifacts.sh b/proxmox/push-aw-artifacts.sh new file mode 100755 index 0000000..da46de2 --- /dev/null +++ b/proxmox/push-aw-artifacts.sh @@ -0,0 +1,62 @@ +#!/bin/sh +set -eu + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +PROJECT_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd) +DEFAULT_ENV_FILE="$PROJECT_ROOT/secrets/deploy.secrets.env" + +if [ "$#" -gt 1 ]; then + echo "usage: $0 [ /path/to/deploy.secrets.env ]" >&2 + exit 1 +fi + +ENV_FILE="${1:-$DEFAULT_ENV_FILE}" + +# shellcheck disable=SC1090 +. "$ENV_FILE" + +: "${CT_ID:?missing CT_ID}" + +pct exec "$CT_ID" -- mkdir -p /root/bootstrap + +for file_name in \ + install_aw_server.sh \ + apply_webui_ru_patch.sh \ + activitywatch-server.service \ + aw-server.env.example \ + aw-ru-patch.js \ + aw-sw-cleanup.js +do + pct push "$CT_ID" "$PROJECT_ROOT/aw-server/$file_name" "/root/bootstrap/$file_name" +done + +if [ -n "${AW_SERVER_VERSION:-}" ] && + [ -n "${AW_SERVER_DOWNLOAD_URL:-}" ] && + [ -n "${AW_SERVER_BIND_HOST:-}" ] && + [ -n "${AW_SERVER_PORT:-}" ] && + [ -n "${AW_SERVER_WEBUI_DIR:-}" ] && + [ -n "${AW_SERVER_DATA_DIR:-}" ] && + [ -n "${AW_SERVER_LOG_DIR:-}" ] && + [ -n "${AW_SERVER_USER:-}" ] && + [ -n "${AW_SERVER_GROUP:-}" ]; then + tmp_env=$(mktemp) + trap 'rm -f "$tmp_env"' EXIT + { + echo "AW_SERVER_VERSION=$AW_SERVER_VERSION" + echo "AW_SERVER_DOWNLOAD_URL=$AW_SERVER_DOWNLOAD_URL" + echo "AW_SERVER_BIND_HOST=$AW_SERVER_BIND_HOST" + echo "AW_SERVER_PORT=$AW_SERVER_PORT" + echo "AW_SERVER_WEBUI_DIR=$AW_SERVER_WEBUI_DIR" + echo "AW_SERVER_DATA_DIR=$AW_SERVER_DATA_DIR" + echo "AW_SERVER_LOG_DIR=$AW_SERVER_LOG_DIR" + echo "AW_SERVER_USER=$AW_SERVER_USER" + echo "AW_SERVER_GROUP=$AW_SERVER_GROUP" + } > "$tmp_env" + pct push "$CT_ID" "$tmp_env" "/etc/activitywatch/aw-server.env" + pct exec "$CT_ID" -- chmod 0600 /etc/activitywatch/aw-server.env + echo "Server env pushed to CT $CT_ID:/etc/activitywatch/aw-server.env" +else + echo "WARN: AW_SERVER_* variables are incomplete in $ENV_FILE; /etc/activitywatch/aw-server.env was not updated" >&2 +fi + +echo "Bootstrap artifacts pushed to CT $CT_ID:/root/bootstrap" diff --git a/secrets/.gitignore b/secrets/.gitignore new file mode 100755 index 0000000..e83eee3 --- /dev/null +++ b/secrets/.gitignore @@ -0,0 +1 @@ +deploy.secrets.env diff --git a/secrets/deploy.secrets.env.example b/secrets/deploy.secrets.env.example new file mode 100755 index 0000000..4dec88a --- /dev/null +++ b/secrets/deploy.secrets.env.example @@ -0,0 +1,31 @@ +# Copy to deploy.secrets.env and fill real values. + +PVE_NODE=pve-example +CT_ID=203 +CT_HOSTNAME=activitywatch-server +CT_STORAGE=local-lvm +CT_TEMPLATE=local:vztmpl/debian-12-standard_12.7-1_amd64.tar.zst +CT_ROOTFS_SIZE=8G +CT_CORES=2 +CT_MEMORY=2048 +CT_SWAP=512 +CT_BRIDGE=vmbr10 +CT_IP=10.20.30.13/24 +CT_GW=10.20.30.1 +CT_VLAN= +CT_NAMESERVER=1.1.1.1 8.8.8.8 +CT_SEARCHDOMAIN=example.internal +CT_PASSWORD=CHANGE_ME +CT_UNPRIVILEGED=1 +CT_ONBOOT=1 +CT_FEATURES=nesting=1,keyctl=1 + +AW_SERVER_VERSION=0.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 diff --git a/windows/ActivityWatch.Windows.Common.psm1 b/windows/ActivityWatch.Windows.Common.psm1 new file mode 100755 index 0000000..d0c4cf6 --- /dev/null +++ b/windows/ActivityWatch.Windows.Common.psm1 @@ -0,0 +1,597 @@ +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]$ExampleRulesSource, + [Parameter(Mandatory = $true)] + [string]$StateRoot, + [string]$CustomRulesSource + ) + + New-ActivityWatchDirectory -Path $StateRoot + + $collectorTarget = Join-Path $StateRoot 'browser-domains-native-collector.ps1' + $exampleRulesTarget = Join-Path $StateRoot 'web-category-rules.example.json' + $rulesTarget = Join-Path $StateRoot 'web-category-rules.json' + + Copy-Item -LiteralPath $CollectorScriptSource -Destination $collectorTarget -Force + Copy-Item -LiteralPath $ExampleRulesSource -Destination $exampleRulesTarget -Force + + if ($CustomRulesSource) { + $resolvedRules = Resolve-Path -LiteralPath $CustomRulesSource -ErrorAction Stop + Copy-Item -LiteralPath $resolvedRules.Path -Destination $rulesTarget -Force + } + + return [pscustomobject]@{ + CollectorScript = $collectorTarget + ExampleRules = $exampleRulesTarget + ActiveRules = $rulesTarget + } +} + +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]$RulesPath, + [Parameter(Mandatory = $true)] + [int]$PollSeconds, + [Parameter(Mandatory = $true)] + [int]$PulseSeconds, + [Parameter(Mandatory = $true)] + [int]$RecoveryIntervalSeconds, + [Parameter(Mandatory = $true)] + [string]$LaunchScriptPath, + [Parameter(Mandatory = $true)] + [string]$RecoveryScriptPath, + [Parameter(Mandatory = $true)] + [pscustomobject[]]$UserTasks, + [string]$PackageVersion = 'v0.13.2' + ) + + 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 + rulesPath = $RulesPath + launchScript = $LaunchScriptPath + recoveryScript = $RecoveryScriptPath + } + collector = [pscustomobject]@{ + pollSeconds = $PollSeconds + pulseSeconds = $PulseSeconds + } + recovery = [pscustomobject]@{ + intervalSeconds = $RecoveryIntervalSeconds + taskName = 'ActivityWatch Recovery' + } + 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]`$CollectorScript, + [int]`$SessionId + ) + + `$escapedCollector = [Regex]::Escape(`$CollectorScript) + `$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) +} + +`$config = Get-DeploymentConfig -Path `$ConfigPath +`$sessionId = (Get-Process -Id `$PID).SessionId +`$installRoot = [string]`$config.paths.installRoot +`$collectorScript = [string]`$config.paths.collectorScript +`$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' + +if (-not (Test-Path -LiteralPath `$afkExe)) { + throw "Missing aw-watcher-afk.exe: `$afkExe" +} + +if (-not (Test-Path -LiteralPath `$windowExe)) { + throw "Missing aw-watcher-window.exe: `$windowExe" +} + +if (-not (Test-ProcessInSession -Name 'aw-watcher-afk' -SessionId `$sessionId)) { + Start-Process -FilePath `$afkExe -ArgumentList `$serverArgs -WindowStyle Hidden +} + +if (-not (Test-ProcessInSession -Name 'aw-watcher-window' -SessionId `$sessionId)) { + Start-Process -FilePath `$windowExe -ArgumentList `$serverArgs -WindowStyle Hidden +} + +if ((Test-Path -LiteralPath `$collectorScript) -and -not (Test-CollectorRunning -CollectorScript `$collectorScript -SessionId `$sessionId)) { + Start-Process -FilePath `$powershellExe -ArgumentList @( + '-NoProfile', + '-WindowStyle', 'Hidden', + '-ExecutionPolicy', 'Bypass', + '-File', `$collectorScript, + '-ConfigPath', `$ConfigPath + ) -WindowStyle Hidden +} +"@ + + 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 +} + +while (`$true) { + try { + `$config = Get-DeploymentConfig -Path `$ConfigPath + foreach (`$task in @(`$config.userTasks)) { + Start-ScheduledTask -TaskName ([string]`$task.launchTaskName) -ErrorAction SilentlyContinue + } + } + catch { + } + + `$config = Get-DeploymentConfig -Path `$ConfigPath + Start-Sleep -Seconds ([int]`$config.recovery.intervalSeconds) +} +"@ + + Set-Content -LiteralPath $Path -Value $content -Encoding UTF8 +} + +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 Register-ActivityWatchUserTasks { + param( + [Parameter(Mandatory = $true)] + [pscustomobject[]]$TaskDefinitions, + [Parameter(Mandatory = $true)] + [string]$LaunchScriptPath, + [Parameter(Mandatory = $true)] + [string]$ConfigPath + ) + + $powershellExe = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' + + foreach ($definition in $TaskDefinitions) { + Unregister-ScheduledTask -TaskName $definition.LaunchTaskName -Confirm:$false -ErrorAction SilentlyContinue + + $action = New-ScheduledTaskAction -Execute $powershellExe -Argument "-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File `"$LaunchScriptPath`" -ConfigPath `"$ConfigPath`"" + $trigger = New-ScheduledTaskTrigger -AtLogOn -User $definition.UserId + $principal = New-ScheduledTaskPrincipal -UserId $definition.UserId -LogonType InteractiveToken -RunLevel Highest + $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -StartWhenAvailable -MultipleInstances IgnoreNew -ExecutionTimeLimit (New-TimeSpan -Hours 0) + + 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 + ) + + Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue + + $powershellExe = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' + $action = New-ScheduledTaskAction -Execute $powershellExe -Argument "-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File `"$RecoveryScriptPath`" -ConfigPath `"$ConfigPath`"" + $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 diff --git a/windows/browser-domains-native-collector.ps1 b/windows/browser-domains-native-collector.ps1 new file mode 100755 index 0000000..e231f05 --- /dev/null +++ b/windows/browser-domains-native-collector.ps1 @@ -0,0 +1,434 @@ +[CmdletBinding()] +param( + [string]$ConfigPath = 'C:\ProgramData\ActivityWatch\deployment-config.json', + [string]$ServerHost, + [int]$ServerPort, + [ValidateSet('http', 'https')] + [string]$ServerScheme, + [string]$RulesPath, + [string]$LogPath, + [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' } +$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) } + +if (-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:LogPath = $resolvedLogPath +$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) + + try { + Add-Content -LiteralPath $script:LogPath -Value ('{0} {1}' -f (Get-Date -Format s), $Message) + } + catch { + } +} + +function Test-DomainMatch { + param( + [string]$Host, + [string]$RuleDomain + ) + + if ([string]::IsNullOrWhiteSpace($Host) -or [string]::IsNullOrWhiteSpace($RuleDomain)) { + return $false + } + + $left = $Host.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]$Host) + + if ([string]::IsNullOrWhiteSpace($Host)) { + return $null + } + + $parts = $Host.Split('.') + if ($parts.Count -le 2) { + return $Host + } + + $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]$Host) + + foreach ($rule in $script:CategoryRules) { + foreach ($domain in $rule.Domains) { + if (Test-DomainMatch -Host $Host -RuleDomain $domain) { + return [pscustomobject]@{ + Name = [string]$rule.Name + Group = [string]$rule.Group + Rule = [string]$domain + } + } + } + } + + return [pscustomobject]@{ + Name = 'uncategorized' + Group = 'neutral' + Rule = 'none' + } +} + +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-watcher-web-category_' + $script:Hostname + Ensure-Bucket -BucketId $bucketId -ClientName 'aw-watcher-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 +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 -Host $domain + if (-not $rootDomain) { + $rootDomain = $domain + } + + $category = Get-WebCategory -Host $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 + } + } + } + catch { + Write-CollectorLog ("collector error: {0}" -f $_.Exception.Message) + } + + Start-Sleep -Seconds $resolvedPollSeconds +} diff --git a/windows/deploy-domain-users.ps1 b/windows/deploy-domain-users.ps1 new file mode 100755 index 0000000..3580ca2 --- /dev/null +++ b/windows/deploy-domain-users.ps1 @@ -0,0 +1,80 @@ +[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, + [string]$CustomRulesPath +) + +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' +$exampleRulesSource = Join-Path $PSScriptRoot 'web-category-rules.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 -ExampleRulesSource $exampleRulesSource -StateRoot $StateRoot -CustomRulesSource $CustomRulesPath +$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 ` + -RulesPath $assetResult.ActiveRules ` + -PollSeconds $PollSeconds ` + -PulseSeconds $PulseSeconds ` + -RecoveryIntervalSeconds $RecoveryIntervalSeconds ` + -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" diff --git a/windows/deploy-single-user.ps1 b/windows/deploy-single-user.ps1 new file mode 100755 index 0000000..df4301e --- /dev/null +++ b/windows/deploy-single-user.ps1 @@ -0,0 +1,79 @@ +[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, + [string]$CustomRulesPath +) + +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' +$exampleRulesSource = Join-Path $PSScriptRoot 'web-category-rules.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 -ExampleRulesSource $exampleRulesSource -StateRoot $StateRoot -CustomRulesSource $CustomRulesPath +$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 ` + -RulesPath $assetResult.ActiveRules ` + -PollSeconds $PollSeconds ` + -PulseSeconds $PulseSeconds ` + -RecoveryIntervalSeconds $RecoveryIntervalSeconds ` + -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)" diff --git a/windows/hardening-recovery.ps1 b/windows/hardening-recovery.ps1 new file mode 100755 index 0000000..c44d475 --- /dev/null +++ b/windows/hardening-recovery.ps1 @@ -0,0 +1,115 @@ +[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, + [string]$CustomRulesPath, + [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' +$effectiveRules = Join-Path $effectiveStateRoot 'web-category-rules.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 } +$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') ` + -ExampleRulesSource (Join-Path $PSScriptRoot 'web-category-rules.example.json') ` + -StateRoot $effectiveStateRoot ` + -CustomRulesSource $CustomRulesPath + +$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 ` + -RulesPath $effectiveRules ` + -PollSeconds $effectivePollSeconds ` + -PulseSeconds $effectivePulseSeconds ` + -RecoveryIntervalSeconds $effectiveRecoveryInterval ` + -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 ', ')" diff --git a/windows/web-category-rules.example.json b/windows/web-category-rules.example.json new file mode 100755 index 0000000..ab51f5a --- /dev/null +++ b/windows/web-category-rules.example.json @@ -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" + ] + } + ] +}