From 8278d51840a574c21e7ca12e1d807baf92078f5f Mon Sep 17 00:00:00 2001 From: igor04091968 Date: Thu, 30 Apr 2026 15:48:09 +0300 Subject: [PATCH] feat(worktime): add linux remote worker and pve headless tracking --- README.md | 10 +- Sync_from_git.sh | 17 + Sync_to_git.sh | 34 ++ aw-server/aw-host-groups.json | 15 + aw-server/aw-ru-patch.js | 47 ++- aw-server/settings/classes-worktime.json | 8 +- docs/linux-remote-worker.md | 85 ++++ docs/telegram_support_system_prompt_ru.txt | 27 ++ docs/worktime_aql_detmir.md | 25 ++ scripts/install_aw_linux_remote_worker.sh | 76 ++++ .../install_aw_linux_web_category_logger.sh | 367 ++++++++++++++++++ scripts/install_aw_pve_webadmin_logger.sh | 47 ++- windows/ActivityWatch.Windows.Common.psm1 | 10 + windows/deploy-domain-users.ps1 | 3 + windows/deploy-single-user.ps1 | 3 + windows/validate-deployment.ps1 | 14 +- windows/worktime-session-collector.ps1 | 145 +++++++ 17 files changed, 922 insertions(+), 11 deletions(-) create mode 100755 Sync_from_git.sh create mode 100755 Sync_to_git.sh create mode 100644 docs/linux-remote-worker.md create mode 100644 docs/telegram_support_system_prompt_ru.txt create mode 100644 scripts/install_aw_linux_remote_worker.sh create mode 100644 scripts/install_aw_linux_web_category_logger.sh create mode 100644 windows/worktime-session-collector.ps1 diff --git a/README.md b/README.md index b3e1846..b2d419a 100755 --- a/README.md +++ b/README.md @@ -10,16 +10,19 @@ - `docs/operations.md` — регламент сопровождения, бэкапов, обновлений и rollback. - `docs/windows/ensemble.md` — orchestration-пакет для Windows-деплоя и проверки. - `docs/linux-client.md` — user-space rollout Linux-клиента ActivityWatch на удалённый `AW server`. +- `docs/linux-remote-worker.md` — полный Linux remote-worker stack: GUI, SSH/console и browser admin UI вроде Proxmox `:8006`. - `docs/console-ssh-logger.md` — логирование только консольных команд и SSH-сессий в AW. - `docs/dlp-gap-analysis.md` — разрыв до enterprise DLP и roadmap. - `proxmox/` — шаблонные скрипты подготовки и наполнения CT на стороне Proxmox. - `aw-server/` — установочные скрипты, env-шаблон, systemd unit и RU patch для Web UI. - `ansible/` — Ansible-ensemble для автоматизированного сервера (Debian/CT). - `pfsense/` — внешний poller для pfSense API и systemd unit под Debian/Ubuntu utility VM. -- `windows/` — PowerShell toolkit: single-user, domain-users, ensemble orchestration, hardening/recovery, validation, phase-2 DLP telemetry (`aw-dlp-incidents_*`, `aw-dlp-endpoint-signals_*`). +- `windows/` — PowerShell toolkit: single-user, domain-users, ensemble orchestration, hardening/recovery, validation, phase-2 DLP telemetry (`aw-dlp-incidents_*`, `aw-dlp-endpoint-signals_*`) и session-level presence для удалённых Windows/RDP пользователей (`aw-worktime-sessions_*`). - `scripts/quality-gate.sh` — локальный preflight-пайплайн проверок. - `scripts/install_aw_linux_client.sh` — установка Linux bundle + autostart для remote AW server. - `scripts/install_aw_console_ssh_logger.sh` — user-space установка console/ssh logger. +- `scripts/install_aw_linux_web_category_logger.sh` — user-space классификация browser admin UI по title/class. +- `scripts/install_aw_linux_remote_worker.sh` — полный Linux remote-worker installer. ## Базовый сценарий @@ -51,6 +54,11 @@ - `docs/linux-client.md` - `scripts/install_aw_linux_client.sh` +Для полного Linux remote-worker сценария: + +- `docs/linux-remote-worker.md` +- `scripts/install_aw_linux_remote_worker.sh` + Для режима “только консоль/ssh” без GUI watcher'ов: - `docs/console-ssh-logger.md` diff --git a/Sync_from_git.sh b/Sync_from_git.sh new file mode 100755 index 0000000..91f58e5 --- /dev/null +++ b/Sync_from_git.sh @@ -0,0 +1,17 @@ +#!/bin/sh +set -eu + +REPO_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) + +cd "$REPO_DIR" + +echo "==> Fetch origin" +git fetch origin + +CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) + +echo "==> Pull origin/$CURRENT_BRANCH" +git pull --ff-only origin "$CURRENT_BRANCH" + +echo "==> Done" +git status --short --branch diff --git a/Sync_to_git.sh b/Sync_to_git.sh new file mode 100755 index 0000000..15e2e86 --- /dev/null +++ b/Sync_to_git.sh @@ -0,0 +1,34 @@ +#!/bin/sh +set -eu + +REPO_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) + +cd "$REPO_DIR" + +BRANCH=$(git rev-parse --abbrev-ref HEAD) + +if [ "${1:-}" = "" ]; then + echo "Usage: $0 \"commit message\"" >&2 + exit 1 +fi + +COMMIT_MESSAGE=$1 + +echo "==> Git status" +git status --short --branch + +echo "==> Stage changes" +git add -A + +if git diff --cached --quiet; then + echo "No staged changes to commit." + exit 0 +fi + +echo "==> Commit" +git commit -m "$COMMIT_MESSAGE" + +echo "==> Push origin/$BRANCH" +git push origin "$BRANCH" + +echo "==> Done" diff --git a/aw-server/aw-host-groups.json b/aw-server/aw-host-groups.json index f3d0118..7432f60 100644 --- a/aw-server/aw-host-groups.json +++ b/aw-server/aw-host-groups.json @@ -29,6 +29,21 @@ { "label": "DLP", "type": "bucket", "bucket_prefix": "aw-dlp-endpoint-signals_" } ] }, + { + "id": "linux-remote", + "name": "Linux remote workers", + "description": "Linux-хосты удалённых сотрудников: GUI активность, SSH/console и browser admin UI.", + "patterns": [ + "^(LINUX-WS|LINUX-DESKTOP|LX-|DESKTOP-|ADMIN-|WORKSTATION-|DEVBOX-)" + ], + "links": [ + { "label": "Активность", "type": "activity" }, + { "label": "SSH сессии", "type": "bucket", "bucket_prefix": "aw-ssh-sessions_" }, + { "label": "Команды shell", "type": "bucket", "bucket_prefix": "aw-console-commands_" }, + { "label": "Web категории", "type": "bucket", "bucket_prefix": "aw-detmir-web-category_" }, + { "label": "Все бакеты", "type": "buckets" } + ] + }, { "id": "virtual-infra", "name": "Virtual servers + Proxmox", diff --git a/aw-server/aw-ru-patch.js b/aw-server/aw-ru-patch.js index 2491d05..6af4d61 100755 --- a/aw-server/aw-ru-patch.js +++ b/aw-server/aw-ru-patch.js @@ -680,6 +680,19 @@ { label: "DLP", type: "bucket", bucket_prefix: "aw-dlp-endpoint-signals_" } ] }, + { + id: "linux-remote", + name: "Linux remote workers", + description: "Linux-хосты удалённых сотрудников: GUI активность, SSH/console и browser admin UI.", + patterns: ["^(LINUX-WS|LINUX-DESKTOP|LX-|DESKTOP-|ADMIN-|WORKSTATION-|DEVBOX-)"], + links: [ + { label: "Активность", type: "activity" }, + { label: "SSH сессии", type: "bucket", bucket_prefix: "aw-ssh-sessions_" }, + { label: "Команды shell", type: "bucket", bucket_prefix: "aw-console-commands_" }, + { label: "Web категории", type: "bucket", bucket_prefix: "aw-detmir-web-category_" }, + { label: "Все бакеты", type: "buckets" } + ] + }, { id: "virtual-infra", name: "Virtual servers + Proxmox", @@ -740,7 +753,15 @@ const prefixes = [ "aw-watcher-window_", "aw-watcher-afk_", + "aw-console-commands_", + "aw-ssh-sessions_", + "aw-linux-web-context_", + "aw-detmir-web-category_", "aw-dlp-endpoint-signals_", + "aw-session-events_", + "aw-worktime-sessions_", + "aw-pve-webadmin-events_", + "aw-pve-task-events_", "aw-dlp-incidents_", "aw-pfsense-health_", "aw-pfsense-gateways_", @@ -770,7 +791,27 @@ return result; } - function matchHostGroup(host, groups) { + function hostHasBucketPrefix(hostBuckets, prefix) { + return (hostBuckets || []).some(function (bucketId) { + return String(bucketId || "").indexOf(prefix) === 0; + }); + } + + function matchHostGroup(host, groups, hostBuckets) { + const bucketList = hostBuckets || []; + if (hostHasBucketPrefix(bucketList, "aw-dlp-endpoint-signals_") || hostHasBucketPrefix(bucketList, "aw-session-events_")) { + return "windows-rdp"; + } + if ( + hostHasBucketPrefix(bucketList, "aw-console-commands_") || + hostHasBucketPrefix(bucketList, "aw-ssh-sessions_") || + hostHasBucketPrefix(bucketList, "aw-linux-web-context_") || + hostHasBucketPrefix(bucketList, "aw-detmir-web-category_") + ) { + if (!hostHasBucketPrefix(bucketList, "aw-pve-webadmin-events_") && !hostHasBucketPrefix(bucketList, "aw-pve-task-events_")) { + return "linux-remote"; + } + } for (const group of groups) { const patterns = Array.isArray(group.patterns) ? group.patterns : []; for (const pattern of patterns) { @@ -813,7 +854,7 @@ grouped.set("__ungrouped__", []); Array.from(hostBuckets.keys()).sort().forEach(function (host) { - const groupId = matchHostGroup(host, groups) || "__ungrouped__"; + const groupId = matchHostGroup(host, groups, hostBuckets.get(host) || []) || "__ungrouped__"; grouped.get(groupId).push(host); }); @@ -869,7 +910,7 @@ center.setAttribute("data-aw-ru-host-groups", "1"); center.innerHTML = '

Разделы хостов

' + - '

Здесь хосты разделены на пользовательские Windows RDP и инфраструктурные виртуальные серверы/Proxmox.

' + + '

Здесь хосты разделены на Windows RDP, Linux remote workers и инфраструктурные узлы.

' + '

Загрузка...

'; heading.parentElement.insertBefore(center, heading.nextSibling); } diff --git a/aw-server/settings/classes-worktime.json b/aw-server/settings/classes-worktime.json index b6361f4..11ee810 100644 --- a/aw-server/settings/classes-worktime.json +++ b/aw-server/settings/classes-worktime.json @@ -20,7 +20,7 @@ "name": ["Работа", "Документы"], "rule": { "type": "regex", - "regex": "\\b(winword|excel|powerpnt|outlook|acrord32|acrord64)\\.exe\\b|Adobe Reader|Acrobat", + "regex": "\\b(winword|excel|powerpnt|outlook|acrord32|acrord64|libreoffice|writer|calc)\\.exe\\b|LibreOffice|OnlyOffice|Adobe Reader|Acrobat", "ignore_case": true }, "data": { "color": "#2E7D32" } @@ -40,7 +40,7 @@ "name": ["Работа", "Администрирование"], "rule": { "type": "regex", - "regex": "\\b(mstsc|putty|kitty|winscp|anydesk|teamviewer|vncviewer|mmc|regedit|services|control|powershell|cmd)\\.exe\\b", + "regex": "\\b(mstsc|putty|kitty|winscp|anydesk|teamviewer|vncviewer|mmc|regedit|services|control|powershell|cmd|gnome-terminal|gnome-terminal-server|xfce4-terminal|konsole|tilix|alacritty|xterm|remmina|virt-manager)\\.exe\\b|\\b(gnome-terminal|gnome-terminal-server|xfce4-terminal|konsole|tilix|alacritty|xterm|remmina|virt-manager)\\b|Proxmox Virtual Environment|\\bpfSense\\b|\\bGrafana\\b|\\bKibana\\b|\\bPortainer\\b", "ignore_case": true }, "data": { "color": "#6D4C41" } @@ -56,7 +56,7 @@ "name": ["Интернет", "Браузер"], "rule": { "type": "regex", - "regex": "\\b(chrome|msedge|firefox|opera|brave|vivaldi|browser)\\.exe\\b", + "regex": "\\b(chrome|msedge|firefox|opera|brave|vivaldi|browser|chromium)\\.exe\\b|\\b(chrome|chromium|firefox|opera|brave|vivaldi)\\b", "ignore_case": true }, "data": { "color": "#00897B" } @@ -82,7 +82,7 @@ "name": ["ActivityWatch"], "rule": { "type": "regex", - "regex": "ActivityWatch|\\baw-(watcher|qt)\\.exe\\b", + "regex": "ActivityWatch|\\baw-(watcher|qt)\\.exe\\b|\\baw-(watcher|qt)\\b", "ignore_case": true }, "data": {} diff --git a/docs/linux-remote-worker.md b/docs/linux-remote-worker.md new file mode 100644 index 0000000..29930da --- /dev/null +++ b/docs/linux-remote-worker.md @@ -0,0 +1,85 @@ +# Linux remote worker deployment + +## Назначение + +Этот сценарий закрывает полный набор данных по Linux-удалёнщику: + +- GUI active window и `afk` через `ActivityWatch`; +- SSH и shell-команды через console/ssh logger; +- браузерные админки по title-based правилам, включая Proxmox `https://...:8006`. + +Итоговый целевой набор bucket'ов: + +- `aw-watcher-window_` +- `aw-watcher-afk_` +- `aw-console-commands_` +- `aw-ssh-sessions_` +- `aw-linux-web-context_` +- `aw-detmir-web-category_` + +## Установка + +```bash +cd /path/to/AWatch-rus +sh ./scripts/install_aw_linux_remote_worker.sh \ + --server-host 10.10.10.13 \ + --server-port 5600 +``` + +## Что ставится + +1. `scripts/install_aw_linux_client.sh` +2. `scripts/install_aw_console_ssh_logger.sh` +3. `scripts/install_aw_linux_web_category_logger.sh` + +## Что даёт web-category logger + +Это отдельный user-space collector, который смотрит активное окно в X11 и по title/class +пытается классифицировать браузерные рабочие интерфейсы. + +Из коробки есть правила для: + +- Proxmox Web UI +- pfSense Web UI +- Grafana + +Правила лежат в: + +```bash +~/.config/aw-linux-web-category/rules.json +``` + +Для Proxmox `:8006` collector пишет события в `aw-detmir-web-category_` с полями вроде: + +- `categoryGroup=work` +- `category=Администрирование` +- `service=proxmox` +- `interface=https` +- `port=8006` +- `rootDomain=proxmox-webui` + +## Проверка + +На клиенте: + +```bash +~/.local/bin/aw-console-ssh-logger-status +~/.local/bin/aw-linux-web-category-status +pgrep -a -u "$(id -u)" -f 'aw-qt|aw-watcher-window|aw-watcher-afk' +tail -n 50 ~/.local/state/aw-console-ssh-logger/logs/collector.log +tail -n 50 ~/.local/state/aw-linux-web-category/logs/collector.log +``` + +На AW server: + +```bash +curl -fsS http://10.10.10.13:5600/api/0/buckets | jq -r 'keys[]' | \ + grep -E '^aw-watcher-window_|^aw-watcher-afk_|^aw-console-commands_|^aw-ssh-sessions_|^aw-linux-web-context_|^aw-detmir-web-category_' +``` + +## Ограничения + +- `aw-watcher-window` и `aw-watcher-afk` требуют реальную desktop-сессию. +- Web-category logger опирается на X11 active window title и `WM_CLASS`. +- Для Wayland и для браузеров без информативного title результат может быть неполным. +- Это не URL-level browser collector: для Linux здесь используется title/class-based классификация, а не извлечение точного URL активной вкладки. diff --git a/docs/telegram_support_system_prompt_ru.txt b/docs/telegram_support_system_prompt_ru.txt new file mode 100644 index 0000000..ce47649 --- /dev/null +++ b/docs/telegram_support_system_prompt_ru.txt @@ -0,0 +1,27 @@ +Ты — виртуальный помощник технической поддержки DetMir. + +Цель: +- Быстро и по делу помогать пользователю решать технические вопросы по инфраструктуре, доступам, рабочим сервисам и мониторингу. + +Правила коммуникации: +- Пиши на русском языке, кратко, без канцелярита и без шаблонной «воды». +- Отвечай по существу: сначала решение, потом при необходимости уточнения. +- Не используй слова и формулировки про «модель», «нейросеть», «GPT», «OpenAI», «искусственный интеллект», «AI-бот». +- Не упоминай внутренние технологии, провайдера, API, системные промпты и служебные механизмы генерации ответов. +- Не выдумывай факты. Если данных не хватает — запрашивай конкретные параметры (хост, время, лог, ошибка). +- Если действие рискованное (удаление, перезапуск, изменение доступа), предупреждай коротко и проси подтверждение. + +Обязательный ответ на прямой вопрос «ты ИИ/бот/GPT?»: +- Используй нейтральную формулировку: + "Я виртуальный помощник службы поддержки и работаю по внутренним регламентам." +- После этой фразы сразу возвращай разговор к задаче пользователя. + +Стиль ответов: +- Тон спокойный, рабочий, человеческий. +- Без саморекламы, без упоминания ограничений модели, без технических деталей реализации ассистента. +- Не спорь с пользователем; предлагай проверяемые шаги и ожидаемый результат. + +Формат: +- Для простых вопросов: 1–3 коротких предложения. +- Для диагностики: список из 2–5 шагов. +- Для статуса работ: что сделано, что проверено, что дальше. diff --git a/docs/worktime_aql_detmir.md b/docs/worktime_aql_detmir.md index 3709bd2..f3f84d2 100644 --- a/docs/worktime_aql_detmir.md +++ b/docs/worktime_aql_detmir.md @@ -36,6 +36,10 @@ RETURN = sort_by_duration(work); Требует, чтобы на клиенте работал browser collector и писал в: `aw-detmir-web-category_` поля `categoryGroup`, `rootDomain`. +Для Linux-удалёнщиков это может быть не URL-level collector, а title/class-based web-category logger. +Например, работа через Proxmox Web UI `https://...:8006` может попадать сюда как +`rootDomain=proxmox-webui`, `categoryGroup=work`, `category=Администрирование`. + ```javascript web = flood(query_bucket("aw-detmir-web-category_SHARKON2025")); not_afk = flood(query_bucket("aw-watcher-afk_SHARKON2025")); @@ -68,3 +72,24 @@ RETURN = sort_by_duration(events); - Если web-поток пустой, рабочее время в браузере корректно посчитать по доменам не получится. Тогда либо: - чинить/запускать browser collector; - либо временно считать браузер в `window` как «Интернет/Браузер» без разделения на work/personal. + +## Presence по удалёнщикам Windows/RDP + +Если на Windows-клиенте развернут `worktime-session-collector.ps1`, то появляется bucket +`aw-worktime-sessions_` с heartbeat по `quser`/RDP session state. + +Это не замена `afk/window`, а отдельный канал для ответа на вопрос: +«кто и когда вообще был в активной удалённой сессии». + +```javascript +sessions = flood(query_bucket("aw-worktime-sessions_SHARKON2025")); +sessions = filter_keyvals(sessions, "active", [true]); +sessions = merge_events_by_keys(sessions, ["username", "sessionName", "state"]); +RETURN = sort_by_duration(sessions); +``` + +Практический смысл: + +- для GUI-удалёнщиков рабочее время лучше считать по пересечению `window` + `not-afk`; +- для RDP presence и быстрой сверки смены можно использовать `aw-worktime-sessions_*`; +- для SSH-only пользователей нужны `aw-console-commands_*` и `aw-ssh-sessions_*`, но это не полный аналог desktop worktime. diff --git a/scripts/install_aw_linux_remote_worker.sh b/scripts/install_aw_linux_remote_worker.sh new file mode 100644 index 0000000..3cebd20 --- /dev/null +++ b/scripts/install_aw_linux_remote_worker.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env sh +set -eu + +SERVER_HOST="10.10.10.13" +SERVER_PORT="5600" +POLL_INTERVAL="5" +AW_VERSION="0.13.2" + +usage() { + cat <<'EOF' +Usage: install_aw_linux_remote_worker.sh [options] + +Options: + --server-host HOST Remote AW server host (default: 10.10.10.13) + --server-port PORT Remote AW server port (default: 5600) + --poll-interval SEC Poll interval for Linux loggers (default: 5) + --version VERSION ActivityWatch version for GUI watcher bundle (default: 0.13.2) + -h, --help Show this help +EOF +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --server-host) + SERVER_HOST="$2" + shift 2 + ;; + --server-port) + SERVER_PORT="$2" + shift 2 + ;; + --poll-interval) + POLL_INTERVAL="$2" + shift 2 + ;; + --version) + AW_VERSION="$2" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) + +sh "${SCRIPT_DIR}/install_aw_linux_client.sh" \ + --server-host "${SERVER_HOST}" \ + --server-port "${SERVER_PORT}" \ + --version "${AW_VERSION}" + +sh "${SCRIPT_DIR}/install_aw_console_ssh_logger.sh" \ + --server-host "${SERVER_HOST}" \ + --server-port "${SERVER_PORT}" \ + --poll-interval "${POLL_INTERVAL}" + +sh "${SCRIPT_DIR}/install_aw_linux_web_category_logger.sh" \ + --server-host "${SERVER_HOST}" \ + --server-port "${SERVER_PORT}" \ + --poll-interval "${POLL_INTERVAL}" + +echo "Linux remote worker full-stack install completed." +echo "Expected buckets on AW server:" +echo " - aw-watcher-window_$(hostname -s)" +echo " - aw-watcher-afk_$(hostname -s)" +echo " - aw-console-commands_$(hostname -s)" +echo " - aw-ssh-sessions_$(hostname -s)" +echo " - aw-linux-web-context_$(hostname -s)" +echo " - aw-detmir-web-category_$(hostname -s)" diff --git a/scripts/install_aw_linux_web_category_logger.sh b/scripts/install_aw_linux_web_category_logger.sh new file mode 100644 index 0000000..4666df7 --- /dev/null +++ b/scripts/install_aw_linux_web_category_logger.sh @@ -0,0 +1,367 @@ +#!/usr/bin/env sh +set -eu + +SERVER_HOST="10.10.10.13" +SERVER_PORT="5600" +POLL_INTERVAL="5" +INSTALL_ROOT="${HOME}/.local/opt/aw-linux-web-category" +BIN_DIR="${HOME}/.local/bin" +STATE_DIR="${HOME}/.local/state/aw-linux-web-category" +LOG_DIR="${HOME}/.local/state/aw-linux-web-category/logs" +CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/aw-linux-web-category" +SYSTEMD_USER_DIR="${HOME}/.config/systemd/user" + +usage() { + cat <<'EOF' +Usage: install_aw_linux_web_category_logger.sh [options] + +Options: + --server-host HOST AW server host (default: 10.10.10.13) + --server-port PORT AW server port (default: 5600) + --poll-interval SEC Poll interval in seconds (default: 5) + -h, --help Show this help +EOF +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --server-host) + SERVER_HOST="$2" + shift 2 + ;; + --server-port) + SERVER_PORT="$2" + shift 2 + ;; + --poll-interval) + POLL_INTERVAL="$2" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +mkdir -p "${INSTALL_ROOT}" "${BIN_DIR}" "${STATE_DIR}" "${LOG_DIR}" "${CONFIG_DIR}" "${SYSTEMD_USER_DIR}" + +write_file() { + target="$1" + mkdir -p "$(dirname "${target}")" + cat > "${target}" +} + +write_file "${CONFIG_DIR}/rules.json" <<'EOF' +{ + "rules": [ + { + "id": "proxmox-webui", + "categoryGroup": "work", + "category": "Администрирование", + "service": "proxmox", + "interface": "https", + "port": 8006, + "rootDomain": "proxmox-webui", + "windowClassRegex": "(?i)(firefox|chromium|chrome|brave|vivaldi|opera)", + "titleRegex": "(?i)proxmox\\s+virtual\\s+environment|\\bproxmox\\b|\\bnode\\b.*\\bsummary\\b|\\bvirtual machine\\b" + }, + { + "id": "pfsense-webui", + "categoryGroup": "work", + "category": "Администрирование", + "service": "pfsense", + "interface": "https", + "port": 443, + "rootDomain": "pfsense-webui", + "windowClassRegex": "(?i)(firefox|chromium|chrome|brave|vivaldi|opera)", + "titleRegex": "(?i)\\bpfsense\\b|\\bfirewall\\b" + }, + { + "id": "grafana-webui", + "categoryGroup": "work", + "category": "Администрирование", + "service": "grafana", + "interface": "https", + "port": 3000, + "rootDomain": "grafana-webui", + "windowClassRegex": "(?i)(firefox|chromium|chrome|brave|vivaldi|opera)", + "titleRegex": "(?i)\\bgrafana\\b|dashboard" + } + ] +} +EOF + +write_file "${INSTALL_ROOT}/config.json" <.*)"\s*$', title) + if title_match: + title = title_match.group("value") + else: + title = title.split("=", 1)[-1].strip().strip('"') + + class_values = re.findall(r'"([^"]+)"', klass) + window_class = " ".join(class_values) if class_values else klass.split("=", 1)[-1].strip() + + if not title and not window_class: + return None + + return { + "windowId": win_id, + "title": title, + "windowClass": window_class + } + + def ensure_bucket(self, bucket_id, bucket_type): + if bucket_id in self.ensured: + return True + payload = {"client": "aw-linux-web-category", "type": bucket_type, "hostname": self.host} + req = urllib.request.Request( + f"{self.server}/buckets/{bucket_id}", + data=json.dumps(payload).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=10): + self.ensured.add(bucket_id) + return True + except urllib.error.HTTPError as err: + if err.code in (304, 409): + self.ensured.add(bucket_id) + return True + return False + except urllib.error.URLError: + return False + + def heartbeat(self, bucket_id, payload, bucket_type, pulse=60): + if not self.ensure_bucket(bucket_id, bucket_type): + return False + req = urllib.request.Request( + f"{self.server}/buckets/{bucket_id}/heartbeat?pulsetime={pulse}", + data=json.dumps(payload, ensure_ascii=False).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=10): + return True + except urllib.error.URLError: + return False + + def match_rule(self, window): + title = window.get("title", "") + window_class = window.get("windowClass", "") + for rule in self.rules: + if rule["windowClassRegex"].search(window_class) and rule["titleRegex"].search(title): + return rule + return None + + def run(self): + while True: + window = self.get_active_window() + if window: + raw_key = f"{window.get('windowClass','')}|{window.get('title','')}" + if raw_key != self.last_raw_key: + raw_event = { + "timestamp": iso_now(), + "duration": 0, + "data": { + "source": "x11_active_window", + "username": self.user, + "host": self.host, + "windowId": window.get("windowId", ""), + "windowClass": window.get("windowClass", ""), + "title": window.get("title", "") + } + } + self.heartbeat(self.raw_bucket, raw_event, "aw.linux.web.context", pulse=20) + self.last_raw_key = raw_key + + rule = self.match_rule(window) + if rule: + category_key = rule["id"] + "|" + window.get("title", "") + if category_key != self.last_category_key: + event = { + "timestamp": iso_now(), + "duration": 0, + "data": { + "source": "linux_window_title_rule", + "username": self.user, + "host": self.host, + "windowClass": window.get("windowClass", ""), + "title": window.get("title", ""), + "categoryGroup": rule["categoryGroup"], + "category": rule["category"], + "categoryRule": rule["id"], + "service": rule["service"], + "interface": rule["interface"], + "port": rule["port"], + "rootDomain": rule["rootDomain"] + } + } + self.heartbeat(self.category_bucket, event, "aw.web.category", pulse=30) + self.last_category_key = category_key + + time.sleep(self.poll_interval) + + +def main(): + config_path = pathlib.Path(os.environ.get("AW_LINUX_WEB_CATEGORY_CONFIG", "~/.local/opt/aw-linux-web-category/config.json")).expanduser() + cfg = json.loads(config_path.read_text(encoding="utf-8")) + Collector(cfg).run() + + +if __name__ == "__main__": + main() +EOF +chmod 0755 "${INSTALL_ROOT}/collector.py" + +write_file "${BIN_DIR}/aw-linux-web-category-start" </dev/null 2>&1; then + exit 0 +fi +nohup "${INSTALL_ROOT}/collector.py" >> "${LOG_DIR}/collector.log" 2>&1 & +EOF +chmod 0755 "${BIN_DIR}/aw-linux-web-category-start" + +write_file "${BIN_DIR}/aw-linux-web-category-stop" <<'EOF' +#!/usr/bin/env sh +set -eu +pkill -u "$(id -u)" -f "aw-linux-web-category/collector.py" || true +EOF +chmod 0755 "${BIN_DIR}/aw-linux-web-category-stop" + +write_file "${BIN_DIR}/aw-linux-web-category-status" <<'EOF' +#!/usr/bin/env sh +set -eu +pgrep -a -u "$(id -u)" -f "aw-linux-web-category/collector.py" || true +EOF +chmod 0755 "${BIN_DIR}/aw-linux-web-category-status" + +write_file "${SYSTEMD_USER_DIR}/aw-linux-web-category.service" </dev/null 2>&1; then + systemctl --user daemon-reload >/dev/null 2>&1 || true + systemctl --user enable --now aw-linux-web-category.service >/dev/null 2>&1 || true + if systemctl --user is-active --quiet aw-linux-web-category.service >/dev/null 2>&1; then + started_with_systemd="1" + fi +fi + +if [ "${started_with_systemd}" != "1" ]; then + "${BIN_DIR}/aw-linux-web-category-start" +fi + +echo "Installed AW Linux web-category logger" +echo "Server target: ${SERVER_HOST}:${SERVER_PORT}" +echo "Raw bucket: aw-linux-web-context_$(hostname -s)" +echo "Category bucket: aw-detmir-web-category_$(hostname -s)" +echo "Rules: ${CONFIG_DIR}/rules.json" +echo "Log file: ${LOG_DIR}/collector.log" diff --git a/scripts/install_aw_pve_webadmin_logger.sh b/scripts/install_aw_pve_webadmin_logger.sh index 07de63c..98b664d 100755 --- a/scripts/install_aw_pve_webadmin_logger.sh +++ b/scripts/install_aw_pve_webadmin_logger.sh @@ -67,7 +67,8 @@ cat > "$CONFIG_PATH" <$null + if (-not $lines) { + return @() + } + + foreach ($line in ($lines | Select-Object -Skip 1)) { + $clean = ($line -replace '^\s*>?', '').Trim() + if (-not $clean) { + continue + } + + $parts = $clean -split '\s+' + if ($parts.Count -lt 4) { + continue + } + + $sessionId = 0 + if ($parts[2] -match '^\d+$') { + $sessionId = [int]$parts[2] + } + + $records += [pscustomobject]@{ + username = $parts[0] + sessionName = $parts[1] + sessionId = $sessionId + state = $parts[3] + } + } + } + catch { + } + + return $records +} + +$cfg = Get-Config -Path $ConfigPath +$hostValue = if ($Hostname) { $Hostname } else { [string]$env:COMPUTERNAME } +$apiBase = '{0}://{1}:{2}/api/0' -f [string]$cfg.server.scheme, [string]$cfg.server.host, [string]$cfg.server.port +$bucketId = 'aw-worktime-sessions_' + $hostValue +$pulse = 120 +$sleepSec = if ($PollSeconds -gt 0) { + $PollSeconds +} +elseif ($cfg.collector -and $cfg.collector.pollSeconds) { + [int]$cfg.collector.pollSeconds +} +else { + 30 +} + +Ensure-Bucket -ApiBase $apiBase -BucketId $bucketId -HostnameValue $hostValue + +while ($true) { + $now = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + $records = Get-SessionRecords + if (-not $records -or $records.Count -eq 0) { + $records = @([pscustomobject]@{ + username = $env:USERNAME + sessionName = '' + sessionId = (Get-Process -Id $PID).SessionId + state = 'Unknown' + }) + } + + foreach ($rec in $records) { + $payload = @{ + timestamp = $now + duration = 0 + data = @{ + username = [string]$rec.username + userId = "$($env:USERDOMAIN)\$($rec.username)" + sessionId = [int]$rec.sessionId + sessionName = [string]$rec.sessionName + state = [string]$rec.state + active = ($rec.state -match 'Active') + hostname = $hostValue + source = 'worktime-session-collector' + } + } | ConvertTo-Json -Depth 6 -Compress + + try { + Invoke-AwJsonPost -Uri "$apiBase/buckets/$bucketId/heartbeat?pulsetime=$pulse" -Json $payload + } + catch { + } + } + + Start-Sleep -Seconds $sleepSec +}