Compare commits

..
25 changed files with 591 additions and 1110 deletions
+34
View File
@@ -0,0 +1,34 @@
name: Build Windows installer
on:
workflow_dispatch:
push:
paths:
- 'windows/**'
- '.github/workflows/build-installer.yml'
jobs:
build:
runs-on: windows-latest
env:
SIGNTOOL_CMD: ${{ secrets.SIGNTOOL_CMD }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Inno
shell: pwsh
run: |
choco install innosetup --no-progress -y
- name: Build installer
shell: pwsh
run: |
./windows/installer/build-installer.ps1
- name: Upload Setup.exe artifact
uses: actions/upload-artifact@v4
with:
name: awatch-rus-setup
path: windows/installer/output/*.exe
if-no-files-found: error
+1 -9
View File
@@ -10,19 +10,16 @@
- `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_*`) и session-level presence для удалённых Windows/RDP пользователей (`aw-worktime-sessions_*`).
- `windows/` — PowerShell toolkit: single-user, domain-users, ensemble orchestration, hardening/recovery, validation, phase-2 DLP telemetry (`aw-dlp-incidents_*`, `aw-dlp-endpoint-signals_*`).
- `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.
## Базовый сценарий
@@ -54,11 +51,6 @@
- `docs/linux-client.md`
- `scripts/install_aw_linux_client.sh`
Для полного Linux remote-worker сценария:
- `docs/linux-remote-worker.md`
- `scripts/install_aw_linux_remote_worker.sh`
Для режима “только консоль/ssh” без GUI watcher'ов:
- `docs/console-ssh-logger.md`
-17
View File
@@ -1,17 +0,0 @@
#!/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
-34
View File
@@ -1,34 +0,0 @@
#!/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"
-15
View File
@@ -29,21 +29,6 @@
{ "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",
+150 -50
View File
@@ -1,6 +1,6 @@
(function () {
window.__awRuPatchVersion = "template-v12-activity-heading-ru";
document.documentElement.setAttribute("data-aw-ru-patch", "template-v12-activity-heading-ru");
window.__awRuPatchVersion = "template-v13-pve-audit-day-filter";
document.documentElement.setAttribute("data-aw-ru-patch", "template-v13-pve-audit-day-filter");
const exact = new Map([
["ActivityWatch", "АктивВотч"],
@@ -333,6 +333,11 @@
'.aw-ru-host-item-title { font-weight: 600; margin-bottom: 6px; }',
'.aw-ru-host-links { display: flex; flex-wrap: wrap; gap: 6px; }',
'.aw-ru-host-links a { display: inline-block; padding: 4px 8px; border-radius: 999px; background: rgba(90,140,255,.15); text-decoration: none; }',
'.aw-ru-worktime-center { margin: 16px 0; padding: 16px; border: 1px solid rgba(120,120,120,.35); border-radius: 8px; background: rgba(20,20,20,.03); }',
'.aw-ru-worktime-controls { display: flex; flex-wrap: wrap; gap: 8px; align-items: end; margin-bottom: 10px; }',
'.aw-ru-worktime-controls label { font-size: 12px; display:flex; flex-direction:column; gap:4px; }',
'.aw-ru-worktime-table { width: 100%; border-collapse: collapse; font-size: 13px; }',
'.aw-ru-worktime-table th, .aw-ru-worktime-table td { border: 1px solid rgba(120,120,120,.25); padding: 6px 8px; text-align: left; }',
'.aw-ru-pve-audit { margin: 16px 0; padding: 16px; border: 1px solid rgba(120,120,120,.35); border-radius: 8px; background: rgba(10,20,40,.04); }',
'.aw-ru-pve-audit-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; margin: 12px 0 16px; }',
'.aw-ru-pve-audit-card { border: 1px solid rgba(120,120,120,.22); border-radius: 8px; padding: 12px; background: rgba(255,255,255,.02); }',
@@ -680,19 +685,6 @@
{ 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",
@@ -748,20 +740,29 @@
return !!(match && isPveLikeHost(decodeURIComponent(match[1] || "")));
}
function getSelectedDayFromHash() {
const hash = window.location.hash || "";
const match = hash.match(/^#\/activity\/[^/]+\/day\/([^/?#]+)/i);
return match && match[1] ? decodeURIComponent(match[1]) : "";
}
function filterEventsBySelectedDay(events) {
const day = getSelectedDayFromHash();
if (!day) return Array.isArray(events) ? events : [];
const start = new Date(day + "T00:00:00");
const end = new Date(day + "T23:59:59.999");
return (Array.isArray(events) ? events : []).filter(function (event) {
const timestamp = event && event.timestamp ? new Date(event.timestamp) : null;
return timestamp && !isNaN(timestamp.getTime()) && timestamp >= start && timestamp <= end;
});
}
function extractHostFromBucket(bucketId, bucketMeta) {
if (bucketMeta && bucketMeta.hostname) return String(bucketMeta.hostname);
const prefixes = [
"aw-watcher-window_",
"aw-watcher-afk_",
"aw-console-commands_",
"aw-ssh-sessions_",
"aw-linux-web-context_",
"aw-detmir-web-category_",
"aw-dlp-endpoint-signals_",
"aw-session-events_",
"aw-worktime-sessions_",
"aw-pve-webadmin-events_",
"aw-pve-task-events_",
"aw-dlp-incidents_",
"aw-pfsense-health_",
"aw-pfsense-gateways_",
@@ -791,27 +792,7 @@
return result;
}
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";
}
}
function matchHostGroup(host, groups) {
for (const group of groups) {
const patterns = Array.isArray(group.patterns) ? group.patterns : [];
for (const pattern of patterns) {
@@ -854,7 +835,7 @@
grouped.set("__ungrouped__", []);
Array.from(hostBuckets.keys()).sort().forEach(function (host) {
const groupId = matchHostGroup(host, groups, hostBuckets.get(host) || []) || "__ungrouped__";
const groupId = matchHostGroup(host, groups) || "__ungrouped__";
grouped.get(groupId).push(host);
});
@@ -910,7 +891,7 @@
center.setAttribute("data-aw-ru-host-groups", "1");
center.innerHTML =
'<h4>Разделы хостов</h4>' +
'<p>Здесь хосты разделены на Windows RDP, Linux remote workers и инфраструктурные узлы.</p>' +
'<p>Здесь хосты разделены на пользовательские Windows RDP и инфраструктурные виртуальные серверы/Proxmox.</p>' +
'<div class="aw-ru-host-groups-grid" data-aw-ru-host-groups-grid><section class="aw-ru-host-group-card"><p>Загрузка...</p></section></div>';
heading.parentElement.insertBefore(center, heading.nextSibling);
}
@@ -1347,10 +1328,10 @@
loadBucketEvents("aw-console-commands_" + host, 50).catch(function () { return []; })
]);
const data = {
web: webEvents || [],
tasks: taskEvents || [],
ssh: sshEvents || [],
cmd: cmdEvents || []
web: filterEventsBySelectedDay(webEvents || []),
tasks: filterEventsBySelectedDay(taskEvents || []),
ssh: filterEventsBySelectedDay(sshEvents || []),
cmd: filterEventsBySelectedDay(cmdEvents || [])
};
center.querySelector("[data-aw-ru-pve-web-count]").textContent = String(data.web.length);
center.querySelector("[data-aw-ru-pve-task-count]").textContent = String(data.tasks.length);
@@ -1625,6 +1606,124 @@
});
}
function ms(v) {
return new Date(v).getTime();
}
function fmtHours(sec) {
return (Math.round((sec / 3600) * 100) / 100).toFixed(2);
}
function rangesIntersect(a0, a1, b0, b1) {
return Math.max(a0, b0) < Math.min(a1, b1);
}
function intersectDurationSeconds(a0, a1, b0, b1) {
return Math.max(0, (Math.min(a1, b1) - Math.max(a0, b0)) / 1000);
}
async function renderPersonalWorktime(host, fromIso, toIso, target) {
const sessionBucket = "aw-worktime-sessions_" + host;
const windowBucket = "aw-watcher-window_" + host;
const afkBucket = "aw-watcher-afk_" + host;
const [sessionEvents, windowEvents, afkEvents] = await Promise.all([
loadBucketEvents(sessionBucket, 5000).catch(function () { return []; }),
loadBucketEvents(windowBucket, 10000).catch(function () { return []; }),
loadBucketEvents(afkBucket, 10000).catch(function () { return []; })
]);
const fromMs = ms(fromIso);
const toMs = ms(toIso);
const userRanges = {};
(sessionEvents || []).forEach(function (e) {
const d = e && e.data ? e.data : {};
const u = String(d.username || "").trim();
if (!u) return;
const t0 = ms(e.timestamp);
const t1 = t0 + Math.max(1, Number(e.duration || 0)) * 1000;
if (!rangesIntersect(t0, t1, fromMs, toMs)) return;
userRanges[u] = userRanges[u] || [];
userRanges[u].push([t0, t1]);
});
const activeRanges = [];
const afkByTs = new Map();
(afkEvents || []).forEach(function (e) { afkByTs.set(String(e.timestamp || ""), e); });
(windowEvents || []).forEach(function (e) {
const t0 = ms(e.timestamp);
const t1 = t0 + Math.max(1, Number(e.duration || 0)) * 1000;
if (!rangesIntersect(t0, t1, fromMs, toMs)) return;
const afk = afkByTs.get(String(e.timestamp || ""));
const status = String(afk && afk.data && afk.data.status || "");
if (status && status.toLowerCase() === "afk") return;
activeRanges.push([t0, t1]);
});
const rows = Object.keys(userRanges).sort().map(function (u) {
let presentSec = 0;
let activeSec = 0;
userRanges[u].forEach(function (r) {
presentSec += intersectDurationSeconds(r[0], r[1], fromMs, toMs);
activeRanges.forEach(function (a) {
activeSec += intersectDurationSeconds(r[0], r[1], a[0], a[1]);
});
});
const idleSec = Math.max(0, presentSec - activeSec);
return { user: u, presentSec: presentSec, activeSec: activeSec, idleSec: idleSec };
});
if (!rows.length) {
target.innerHTML = '<div class="aw-ru-pve-audit-muted">Нет данных в aw-worktime-sessions_' + host + ' за выбранный интервал.</div>';
return;
}
target.innerHTML = '<table class="aw-ru-worktime-table"><thead><tr><th>Пользователь</th><th>Активно, ч</th><th>В сессии, ч</th><th>Простой, ч</th></tr></thead><tbody>' +
rows.map(function (r) {
return '<tr><td>' + r.user + '</td><td>' + fmtHours(r.activeSec) + '</td><td>' + fmtHours(r.presentSec) + '</td><td>' + fmtHours(r.idleSec) + '</td></tr>';
}).join("") +
'</tbody></table>';
}
async function injectPersonalWorktimeCenter(root) {
const isActivity = /^#\/activity\//i.test(window.location.hash || "");
if (!isActivity) return;
if (root.querySelector(".aw-ru-worktime-center")) return;
const host = getCurrentHostFromHash();
if (!host) return;
const container = document.createElement("div");
container.className = "aw-ru-worktime-center";
const now = new Date();
const day = now.toISOString().slice(0, 10);
container.innerHTML =
'<h3 style="margin-top:0">Персональный учёт рабочего времени</h3>' +
'<div class="aw-ru-worktime-controls">' +
'<label>Дата <input type="date" data-aw-wt-date value="' + day + '"></label>' +
'<label>С <input type="time" data-aw-wt-from value="09:00"></label>' +
'<label>По <input type="time" data-aw-wt-to value="18:00"></label>' +
'<button type="button" data-aw-wt-run>Рассчитать</button>' +
'</div>' +
'<div data-aw-wt-result class="aw-ru-pve-audit-muted">Нажмите "Рассчитать".</div>';
root.insertBefore(container, root.firstChild);
const run = async function () {
const date = container.querySelector("[data-aw-wt-date]").value;
const tFrom = container.querySelector("[data-aw-wt-from]").value || "09:00";
const tTo = container.querySelector("[data-aw-wt-to]").value || "18:00";
const result = container.querySelector("[data-aw-wt-result]");
result.textContent = "Расчёт...";
const fromIso = new Date(date + "T" + tFrom + ":00").toISOString();
const toIso = new Date(date + "T" + tTo + ":00").toISOString();
try {
await renderPersonalWorktime(host, fromIso, toIso, result);
} catch (e) {
result.textContent = "Ошибка расчёта: " + String(e.message || e);
}
};
container.querySelector("[data-aw-wt-run]").addEventListener("click", function () { run(); });
}
function applyPatch() {
enforceSafeActivityViewForPveHost();
ensureSettingsHost();
@@ -1640,6 +1739,7 @@
injectDlpNavigation(document.body);
injectDlpReviewCenter(document.body);
injectDlpAlertsCenter(document.body);
injectPersonalWorktimeCenter(document.body).catch(function () {});
injectHostGroupsCenter(document.body).catch(function () {});
redirectBareTrendsRoute();
}
+4 -4
View File
@@ -20,7 +20,7 @@
"name": ["Работа", "Документы"],
"rule": {
"type": "regex",
"regex": "\\b(winword|excel|powerpnt|outlook|acrord32|acrord64|libreoffice|writer|calc)\\.exe\\b|LibreOffice|OnlyOffice|Adobe Reader|Acrobat",
"regex": "\\b(winword|excel|powerpnt|outlook|acrord32|acrord64)\\.exe\\b|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|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",
"regex": "\\b(mstsc|putty|kitty|winscp|anydesk|teamviewer|vncviewer|mmc|regedit|services|control|powershell|cmd)\\.exe\\b",
"ignore_case": true
},
"data": { "color": "#6D4C41" }
@@ -56,7 +56,7 @@
"name": ["Интернет", "Браузер"],
"rule": {
"type": "regex",
"regex": "\\b(chrome|msedge|firefox|opera|brave|vivaldi|browser|chromium)\\.exe\\b|\\b(chrome|chromium|firefox|opera|brave|vivaldi)\\b",
"regex": "\\b(chrome|msedge|firefox|opera|brave|vivaldi|browser)\\.exe\\b",
"ignore_case": true
},
"data": { "color": "#00897B" }
@@ -82,7 +82,7 @@
"name": ["ActivityWatch"],
"rule": {
"type": "regex",
"regex": "ActivityWatch|\\baw-(watcher|qt)\\.exe\\b|\\baw-(watcher|qt)\\b",
"regex": "ActivityWatch|\\baw-(watcher|qt)\\.exe\\b",
"ignore_case": true
},
"data": {}
-85
View File
@@ -1,85 +0,0 @@
# Linux remote worker deployment
## Назначение
Этот сценарий закрывает полный набор данных по Linux-удалёнщику:
- GUI active window и `afk` через `ActivityWatch`;
- SSH и shell-команды через console/ssh logger;
- браузерные админки по title-based правилам, включая Proxmox `https://...:8006`.
Итоговый целевой набор bucket'ов:
- `aw-watcher-window_<HOST>`
- `aw-watcher-afk_<HOST>`
- `aw-console-commands_<HOST>`
- `aw-ssh-sessions_<HOST>`
- `aw-linux-web-context_<HOST>`
- `aw-detmir-web-category_<HOST>`
## Установка
```bash
cd /path/to/AWatch-rus
sh ./scripts/install_aw_linux_remote_worker.sh \
--server-host 10.10.10.13 \
--server-port 5600
```
## Что ставится
1. `scripts/install_aw_linux_client.sh`
2. `scripts/install_aw_console_ssh_logger.sh`
3. `scripts/install_aw_linux_web_category_logger.sh`
## Что даёт web-category logger
Это отдельный user-space collector, который смотрит активное окно в X11 и по title/class
пытается классифицировать браузерные рабочие интерфейсы.
Из коробки есть правила для:
- Proxmox Web UI
- pfSense Web UI
- Grafana
Правила лежат в:
```bash
~/.config/aw-linux-web-category/rules.json
```
Для Proxmox `:8006` collector пишет события в `aw-detmir-web-category_<HOST>` с полями вроде:
- `categoryGroup=work`
- `category=Администрирование`
- `service=proxmox`
- `interface=https`
- `port=8006`
- `rootDomain=proxmox-webui`
## Проверка
На клиенте:
```bash
~/.local/bin/aw-console-ssh-logger-status
~/.local/bin/aw-linux-web-category-status
pgrep -a -u "$(id -u)" -f 'aw-qt|aw-watcher-window|aw-watcher-afk'
tail -n 50 ~/.local/state/aw-console-ssh-logger/logs/collector.log
tail -n 50 ~/.local/state/aw-linux-web-category/logs/collector.log
```
На AW server:
```bash
curl -fsS http://10.10.10.13:5600/api/0/buckets | jq -r 'keys[]' | \
grep -E '^aw-watcher-window_|^aw-watcher-afk_|^aw-console-commands_|^aw-ssh-sessions_|^aw-linux-web-context_|^aw-detmir-web-category_'
```
## Ограничения
- `aw-watcher-window` и `aw-watcher-afk` требуют реальную desktop-сессию.
- Web-category logger опирается на X11 active window title и `WM_CLASS`.
- Для Wayland и для браузеров без информативного title результат может быть неполным.
- Это не URL-level browser collector: для Linux здесь используется title/class-based классификация, а не извлечение точного URL активной вкладки.
@@ -1,27 +0,0 @@
Ты — виртуальный помощник технической поддержки DetMir.
Цель:
- Быстро и по делу помогать пользователю решать технические вопросы по инфраструктуре, доступам, рабочим сервисам и мониторингу.
Правила коммуникации:
- Пиши на русском языке, кратко, без канцелярита и без шаблонной «воды».
- Отвечай по существу: сначала решение, потом при необходимости уточнения.
- Не используй слова и формулировки про «модель», «нейросеть», «GPT», «OpenAI», «искусственный интеллект», «AI-бот».
- Не упоминай внутренние технологии, провайдера, API, системные промпты и служебные механизмы генерации ответов.
- Не выдумывай факты. Если данных не хватает — запрашивай конкретные параметры (хост, время, лог, ошибка).
- Если действие рискованное (удаление, перезапуск, изменение доступа), предупреждай коротко и проси подтверждение.
Обязательный ответ на прямой вопрос «ты ИИ/бот/GPT?»:
- Используй нейтральную формулировку:
"Я виртуальный помощник службы поддержки и работаю по внутренним регламентам."
- После этой фразы сразу возвращай разговор к задаче пользователя.
Стиль ответов:
- Тон спокойный, рабочий, человеческий.
- Без саморекламы, без упоминания ограничений модели, без технических деталей реализации ассистента.
- Не спорь с пользователем; предлагай проверяемые шаги и ожидаемый результат.
Формат:
- Для простых вопросов: 1–3 коротких предложения.
- Для диагностики: список из 2–5 шагов.
- Для статуса работ: что сделано, что проверено, что дальше.
@@ -1,7 +0,0 @@
# Moved
Документ перенесён в новую структуру install-kit:
- `windows/installkit/innosetup/innosetup-rdp-package-filelist.md`
Этот файл оставлен как совместимый указатель, чтобы не ломать существующие ссылки в документации/автоматизации.
-25
View File
@@ -36,10 +36,6 @@ RETURN = sort_by_duration(work);
Требует, чтобы на клиенте работал browser collector и писал в:
`aw-detmir-web-category_<HOST>` поля `categoryGroup`, `rootDomain`.
Для Linux-удалёнщиков это может быть не URL-level collector, а title/class-based web-category logger.
Например, работа через Proxmox Web UI `https://...:8006` может попадать сюда как
`rootDomain=proxmox-webui`, `categoryGroup=work`, `category=Администрирование`.
```javascript
web = flood(query_bucket("aw-detmir-web-category_SHARKON2025"));
not_afk = flood(query_bucket("aw-watcher-afk_SHARKON2025"));
@@ -72,24 +68,3 @@ RETURN = sort_by_duration(events);
- Если web-поток пустой, рабочее время в браузере корректно посчитать по доменам не получится. Тогда либо:
- чинить/запускать browser collector;
- либо временно считать браузер в `window` как «Интернет/Браузер» без разделения на work/personal.
## Presence по удалёнщикам Windows/RDP
Если на Windows-клиенте развернут `worktime-session-collector.ps1`, то появляется bucket
`aw-worktime-sessions_<HOST>` с heartbeat по `quser`/RDP session state.
Это не замена `afk/window`, а отдельный канал для ответа на вопрос:
«кто и когда вообще был в активной удалённой сессии».
```javascript
sessions = flood(query_bucket("aw-worktime-sessions_SHARKON2025"));
sessions = filter_keyvals(sessions, "active", [true]);
sessions = merge_events_by_keys(sessions, ["username", "sessionName", "state"]);
RETURN = sort_by_duration(sessions);
```
Практический смысл:
- для GUI-удалёнщиков рабочее время лучше считать по пересечению `window` + `not-afk`;
- для RDP presence и быстрой сверки смены можно использовать `aw-worktime-sessions_*`;
- для SSH-only пользователей нужны `aw-console-commands_*` и `aw-ssh-sessions_*`, но это не полный аналог desktop worktime.
-157
View File
@@ -1,157 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
TARGET_DIR="windows/installkit/innosetup"
TARGET_FILE="$TARGET_DIR/innosetup-rdp-package-filelist.md"
POINTER_FILE="docs/windows/innosetup-rdp-package-filelist.md"
ISS_FILE="$TARGET_DIR/AWatch-rus-InnoSetup.iss"
PAYLOAD_DIR="$TARGET_DIR/payload"
mkdir -p "$TARGET_DIR" "$PAYLOAD_DIR" "docs/windows"
cat > "$TARGET_FILE" <<'DOC'
# Inno Setup: файл-лист для Windows RDP deployment
Дата актуализации: 2026-05-02 (UTC).
## Что это за документ
Этот файл — **чеклист упаковки** для Inno Setup.
- Он описывает, **что класть** в инсталлятор.
- Он описывает, **что не класть** (генерируется уже на целевом хосте).
- Он **не меняет** текущие deploy-скрипты и логику проекта.
## Важное уточнение по `phase2`
Чтобы исключить путаницу:
1. В каталоге `windows/` нет файлов с именами `phase2-*`.
2. `phase2` в проекте — это обозначение этапа/набора телеметрии (DLP + endpoint signals).
3. Файлы вида `phase2-*.deployment-config.json` — это **примерные конфиги install-kit**, они лежат в `install-kit-*/server-configs-*`.
## 1) Обязательные файлы для Inno Setup пакета
### 1.1 PowerShell-модуль
- `windows/ActivityWatch.Windows.Common.psd1`
- `windows/ActivityWatch.Windows.Common.psm1`
### 1.2 Скрипты деплоя и сопровождения
- `windows/deploy-single-user.ps1`
- `windows/deploy-domain-users.ps1`
- `windows/deploy-ensemble.ps1`
- `windows/hardening-recovery.ps1`
- `windows/validate-deployment.ps1`
### 1.3 Коллекторы
- `windows/worktime-session-collector.ps1` (RDP/session presence)
- `windows/browser-domains-native-collector.ps1`
- `windows/dlp-endpoint-signals-collector.ps1`
### 1.4 Шаблоны конфигурации
- `windows/web-category-rules.example.json`
- `windows/dlp-policy.example.json`
## 2) Бинарный payload ActivityWatch
Для закрытой среды используется **offline-режим по умолчанию**:
- В комплект Inno Setup сразу включается `payload\activitywatch-v0.13.2-windows-x86_64.zip`.
- Deploy запускается с параметром `-PackageZipPath` на локальный ZIP из `{app}\payload`.
- Online-загрузка из GitHub Releases в закрытой среде не требуется.
## 3) Что НЕ включать в installer как статические файлы
Эти файлы/папки появляются на целевом Windows-хосте во время/после деплоя:
- `C:\ProgramData\ActivityWatch\deployment-config.json`
- `C:\ProgramData\ActivityWatch\web-category-rules.json`
- `C:\ProgramData\ActivityWatch\dlp-policy.json`
- `C:\ProgramData\ActivityWatch\logs\*`
- `%LOCALAPPDATA%\ActivityWatch-Phase2\incident-artifacts\*`
## 4) Опционально приложить в операторский install-kit
- `docs/windows/deployment.md`
- `docs/windows/validation.md`
- `docs/windows/troubleshooting.md`
- `docs/windows/ensemble.md`
## 5) Рекомендуемая структура внутри пакета
- `windows\ActivityWatch.Windows.Common.psd1`
- `windows\ActivityWatch.Windows.Common.psm1`
- `windows\deploy-single-user.ps1`
- `windows\deploy-domain-users.ps1`
- `windows\deploy-ensemble.ps1`
- `windows\hardening-recovery.ps1`
- `windows\validate-deployment.ps1`
- `windows\worktime-session-collector.ps1`
- `windows\browser-domains-native-collector.ps1`
- `windows\dlp-endpoint-signals-collector.ps1`
- `windows\web-category-rules.example.json`
- `windows\dlp-policy.example.json`
- `payload\activitywatch-v0.13.2-windows-x86_64.zip`
## 6) Контроль перед сборкой .iss
1. Все файлы из раздела 1 присутствуют.
2. ZIP `activitywatch-v0.13.2-windows-x86_64.zip` лежит в `windows/installkit/innosetup/payload/`.
3. В .iss добавлен `Source: "payload\activitywatch-v0.13.2-windows-x86_64.zip"`.
4. Deploy в .iss запускается с `-PackageZipPath` на локальный ZIP.
5. После установки запускается `validate-deployment.ps1` с сохранением JSON-отчёта.
DOC
cat > "$POINTER_FILE" <<'DOC'
windows/installkit/innosetup/innosetup-rdp-package-filelist.md
DOC
cat > "$ISS_FILE" <<'DOC'
#define MyAppName "AWatch-rus InstallKit"
#define MyAppVersion "1.0.0"
#define MyAppPublisher "AWatch-rus"
[Setup]
AppId={{6D6A1F74-0F4F-4A57-B5E3-1C2C2F56C0E9}
AppName={#MyAppName}
AppVersion={#MyAppVersion}
AppPublisher={#MyAppPublisher}
DefaultDirName={autopf}\AWatch-rus
DefaultGroupName=AWatch-rus
OutputDir=.
OutputBaseFilename=AWatch-rus-InstallKit-offline
Compression=lzma
SolidCompression=yes
ArchitecturesInstallIn64BitMode=x64
PrivilegesRequired=admin
[Languages]
Name: "russian"; MessagesFile: "compiler:Languages\Russian.isl"
[Files]
Source: "..\..\ActivityWatch.Windows.Common.psd1"; DestDir: "{app}\windows"; Flags: ignoreversion
Source: "..\..\ActivityWatch.Windows.Common.psm1"; DestDir: "{app}\windows"; Flags: ignoreversion
Source: "..\..\deploy-single-user.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion
Source: "..\..\deploy-domain-users.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion
Source: "..\..\deploy-ensemble.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion
Source: "..\..\hardening-recovery.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion
Source: "..\..\validate-deployment.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion
Source: "..\..\worktime-session-collector.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion
Source: "..\..\browser-domains-native-collector.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion
Source: "..\..\dlp-endpoint-signals-collector.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion
Source: "..\..\web-category-rules.example.json"; DestDir: "{app}\windows"; Flags: ignoreversion
Source: "..\..\dlp-policy.example.json"; DestDir: "{app}\windows"; Flags: ignoreversion
Source: "payload\activitywatch-v0.13.2-windows-x86_64.zip"; DestDir: "{app}\payload"; Flags: ignoreversion
Source: "innosetup-rdp-package-filelist.md"; DestDir: "{app}\windows\installkit\innosetup"; Flags: ignoreversion
[Run]
Filename: "powershell.exe"; Parameters: "-NoProfile -ExecutionPolicy Bypass -File \"{app}\windows\deploy-ensemble.ps1\" -PackageZipPath \"{app}\payload\activitywatch-v0.13.2-windows-x86_64.zip\""; Flags: runhidden
Filename: "powershell.exe"; Parameters: "-NoProfile -ExecutionPolicy Bypass -File \"{app}\windows\validate-deployment.ps1\""; Flags: runhidden
DOC
touch "$PAYLOAD_DIR/.gitkeep"
echo "$TARGET_FILE"
echo "$POINTER_FILE"
echo "$ISS_FILE"
echo "$PAYLOAD_DIR/.gitkeep"
-76
View File
@@ -1,76 +0,0 @@
#!/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)"
@@ -1,367 +0,0 @@
#!/usr/bin/env sh
set -eu
SERVER_HOST="10.10.10.13"
SERVER_PORT="5600"
POLL_INTERVAL="5"
INSTALL_ROOT="${HOME}/.local/opt/aw-linux-web-category"
BIN_DIR="${HOME}/.local/bin"
STATE_DIR="${HOME}/.local/state/aw-linux-web-category"
LOG_DIR="${HOME}/.local/state/aw-linux-web-category/logs"
CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/aw-linux-web-category"
SYSTEMD_USER_DIR="${HOME}/.config/systemd/user"
usage() {
cat <<'EOF'
Usage: install_aw_linux_web_category_logger.sh [options]
Options:
--server-host HOST AW server host (default: 10.10.10.13)
--server-port PORT AW server port (default: 5600)
--poll-interval SEC Poll interval in seconds (default: 5)
-h, --help Show this help
EOF
}
while [ "$#" -gt 0 ]; do
case "$1" in
--server-host)
SERVER_HOST="$2"
shift 2
;;
--server-port)
SERVER_PORT="$2"
shift 2
;;
--poll-interval)
POLL_INTERVAL="$2"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown option: $1" >&2
usage >&2
exit 1
;;
esac
done
mkdir -p "${INSTALL_ROOT}" "${BIN_DIR}" "${STATE_DIR}" "${LOG_DIR}" "${CONFIG_DIR}" "${SYSTEMD_USER_DIR}"
write_file() {
target="$1"
mkdir -p "$(dirname "${target}")"
cat > "${target}"
}
write_file "${CONFIG_DIR}/rules.json" <<'EOF'
{
"rules": [
{
"id": "proxmox-webui",
"categoryGroup": "work",
"category": "Администрирование",
"service": "proxmox",
"interface": "https",
"port": 8006,
"rootDomain": "proxmox-webui",
"windowClassRegex": "(?i)(firefox|chromium|chrome|brave|vivaldi|opera)",
"titleRegex": "(?i)proxmox\\s+virtual\\s+environment|\\bproxmox\\b|\\bnode\\b.*\\bsummary\\b|\\bvirtual machine\\b"
},
{
"id": "pfsense-webui",
"categoryGroup": "work",
"category": "Администрирование",
"service": "pfsense",
"interface": "https",
"port": 443,
"rootDomain": "pfsense-webui",
"windowClassRegex": "(?i)(firefox|chromium|chrome|brave|vivaldi|opera)",
"titleRegex": "(?i)\\bpfsense\\b|\\bfirewall\\b"
},
{
"id": "grafana-webui",
"categoryGroup": "work",
"category": "Администрирование",
"service": "grafana",
"interface": "https",
"port": 3000,
"rootDomain": "grafana-webui",
"windowClassRegex": "(?i)(firefox|chromium|chrome|brave|vivaldi|opera)",
"titleRegex": "(?i)\\bgrafana\\b|dashboard"
}
]
}
EOF
write_file "${INSTALL_ROOT}/config.json" <<EOF
{
"server_host": "${SERVER_HOST}",
"server_port": ${SERVER_PORT},
"poll_interval_seconds": ${POLL_INTERVAL},
"hostname": "$(hostname -s)",
"username": "$(id -un)",
"state_dir": "${STATE_DIR}",
"rules_path": "${CONFIG_DIR}/rules.json",
"raw_bucket": "aw-linux-web-context_$(hostname -s)",
"category_bucket": "aw-detmir-web-category_$(hostname -s)"
}
EOF
write_file "${INSTALL_ROOT}/collector.py" <<'EOF'
#!/usr/bin/env python3
import datetime as dt
import json
import os
import pathlib
import re
import socket
import subprocess
import time
import urllib.error
import urllib.request
def iso_now():
return dt.datetime.now(tz=dt.timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
class Collector:
def __init__(self, cfg):
self.cfg = cfg
self.server = f"http://{cfg['server_host']}:{cfg['server_port']}/api/0"
self.host = cfg.get("hostname") or socket.gethostname().split(".")[0]
self.user = cfg.get("username") or os.environ.get("USER", "unknown")
self.poll_interval = max(1, int(cfg.get("poll_interval_seconds", 5)))
self.state_dir = pathlib.Path(cfg["state_dir"]).expanduser()
self.state_dir.mkdir(parents=True, exist_ok=True)
self.raw_bucket = cfg.get("raw_bucket", f"aw-linux-web-context_{self.host}")
self.category_bucket = cfg.get("category_bucket", f"aw-detmir-web-category_{self.host}")
self.rules = self._load_rules(pathlib.Path(cfg["rules_path"]).expanduser())
self.ensured = set()
self.last_raw_key = None
self.last_category_key = None
def _load_rules(self, path):
if not path.exists():
return []
payload = json.loads(path.read_text(encoding="utf-8"))
rules = []
for item in payload.get("rules", []):
rules.append({
"id": item.get("id", "rule"),
"categoryGroup": item.get("categoryGroup", "work"),
"category": item.get("category", "Работа"),
"service": item.get("service", ""),
"interface": item.get("interface", "https"),
"port": item.get("port"),
"rootDomain": item.get("rootDomain", item.get("service", "web-ui")),
"windowClassRegex": re.compile(item.get("windowClassRegex", ".*")),
"titleRegex": re.compile(item.get("titleRegex", ".*"))
})
return rules
def _run(self, *cmd):
try:
return subprocess.check_output(cmd, text=True, stderr=subprocess.DEVNULL).strip()
except Exception:
return ""
def get_active_window(self):
win_id_line = self._run("xprop", "-root", "_NET_ACTIVE_WINDOW")
match = re.search(r"0x[0-9a-fA-F]+", win_id_line)
if not match:
return None
win_id = match.group(0)
title = self._run("xprop", "-id", win_id, "_NET_WM_NAME")
if not title:
title = self._run("xprop", "-id", win_id, "WM_NAME")
klass = self._run("xprop", "-id", win_id, "WM_CLASS")
title_match = re.search(r'=\s*"(?P<value>.*)"\s*$', title)
if title_match:
title = title_match.group("value")
else:
title = title.split("=", 1)[-1].strip().strip('"')
class_values = re.findall(r'"([^"]+)"', klass)
window_class = " ".join(class_values) if class_values else klass.split("=", 1)[-1].strip()
if not title and not window_class:
return None
return {
"windowId": win_id,
"title": title,
"windowClass": window_class
}
def ensure_bucket(self, bucket_id, bucket_type):
if bucket_id in self.ensured:
return True
payload = {"client": "aw-linux-web-category", "type": bucket_type, "hostname": self.host}
req = urllib.request.Request(
f"{self.server}/buckets/{bucket_id}",
data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=10):
self.ensured.add(bucket_id)
return True
except urllib.error.HTTPError as err:
if err.code in (304, 409):
self.ensured.add(bucket_id)
return True
return False
except urllib.error.URLError:
return False
def heartbeat(self, bucket_id, payload, bucket_type, pulse=60):
if not self.ensure_bucket(bucket_id, bucket_type):
return False
req = urllib.request.Request(
f"{self.server}/buckets/{bucket_id}/heartbeat?pulsetime={pulse}",
data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=10):
return True
except urllib.error.URLError:
return False
def match_rule(self, window):
title = window.get("title", "")
window_class = window.get("windowClass", "")
for rule in self.rules:
if rule["windowClassRegex"].search(window_class) and rule["titleRegex"].search(title):
return rule
return None
def run(self):
while True:
window = self.get_active_window()
if window:
raw_key = f"{window.get('windowClass','')}|{window.get('title','')}"
if raw_key != self.last_raw_key:
raw_event = {
"timestamp": iso_now(),
"duration": 0,
"data": {
"source": "x11_active_window",
"username": self.user,
"host": self.host,
"windowId": window.get("windowId", ""),
"windowClass": window.get("windowClass", ""),
"title": window.get("title", "")
}
}
self.heartbeat(self.raw_bucket, raw_event, "aw.linux.web.context", pulse=20)
self.last_raw_key = raw_key
rule = self.match_rule(window)
if rule:
category_key = rule["id"] + "|" + window.get("title", "")
if category_key != self.last_category_key:
event = {
"timestamp": iso_now(),
"duration": 0,
"data": {
"source": "linux_window_title_rule",
"username": self.user,
"host": self.host,
"windowClass": window.get("windowClass", ""),
"title": window.get("title", ""),
"categoryGroup": rule["categoryGroup"],
"category": rule["category"],
"categoryRule": rule["id"],
"service": rule["service"],
"interface": rule["interface"],
"port": rule["port"],
"rootDomain": rule["rootDomain"]
}
}
self.heartbeat(self.category_bucket, event, "aw.web.category", pulse=30)
self.last_category_key = category_key
time.sleep(self.poll_interval)
def main():
config_path = pathlib.Path(os.environ.get("AW_LINUX_WEB_CATEGORY_CONFIG", "~/.local/opt/aw-linux-web-category/config.json")).expanduser()
cfg = json.loads(config_path.read_text(encoding="utf-8"))
Collector(cfg).run()
if __name__ == "__main__":
main()
EOF
chmod 0755 "${INSTALL_ROOT}/collector.py"
write_file "${BIN_DIR}/aw-linux-web-category-start" <<EOF
#!/usr/bin/env sh
set -eu
export AW_LINUX_WEB_CATEGORY_CONFIG="${INSTALL_ROOT}/config.json"
mkdir -p "${LOG_DIR}"
if pgrep -u "$(id -u)" -f "aw-linux-web-category/collector.py" >/dev/null 2>&1; then
exit 0
fi
nohup "${INSTALL_ROOT}/collector.py" >> "${LOG_DIR}/collector.log" 2>&1 &
EOF
chmod 0755 "${BIN_DIR}/aw-linux-web-category-start"
write_file "${BIN_DIR}/aw-linux-web-category-stop" <<'EOF'
#!/usr/bin/env sh
set -eu
pkill -u "$(id -u)" -f "aw-linux-web-category/collector.py" || true
EOF
chmod 0755 "${BIN_DIR}/aw-linux-web-category-stop"
write_file "${BIN_DIR}/aw-linux-web-category-status" <<'EOF'
#!/usr/bin/env sh
set -eu
pgrep -a -u "$(id -u)" -f "aw-linux-web-category/collector.py" || true
EOF
chmod 0755 "${BIN_DIR}/aw-linux-web-category-status"
write_file "${SYSTEMD_USER_DIR}/aw-linux-web-category.service" <<EOF
[Unit]
Description=AW Linux web-category logger (user space)
After=default.target
[Service]
Type=simple
Environment=AW_LINUX_WEB_CATEGORY_CONFIG=${INSTALL_ROOT}/config.json
ExecStart=${INSTALL_ROOT}/collector.py
Restart=always
RestartSec=3
[Install]
WantedBy=default.target
EOF
started_with_systemd="0"
if command -v systemctl >/dev/null 2>&1; then
systemctl --user daemon-reload >/dev/null 2>&1 || true
systemctl --user enable --now aw-linux-web-category.service >/dev/null 2>&1 || true
if systemctl --user is-active --quiet aw-linux-web-category.service >/dev/null 2>&1; then
started_with_systemd="1"
fi
fi
if [ "${started_with_systemd}" != "1" ]; then
"${BIN_DIR}/aw-linux-web-category-start"
fi
echo "Installed AW Linux web-category logger"
echo "Server target: ${SERVER_HOST}:${SERVER_PORT}"
echo "Raw bucket: aw-linux-web-context_$(hostname -s)"
echo "Category bucket: aw-detmir-web-category_$(hostname -s)"
echo "Rules: ${CONFIG_DIR}/rules.json"
echo "Log file: ${LOG_DIR}/collector.log"
+2 -45
View File
@@ -67,8 +67,7 @@ cat > "$CONFIG_PATH" <<EOF
"access_log": "/var/log/pveproxy/access.log",
"tasks_index": "/var/log/pve/tasks/index",
"web_bucket": "aw-pve-webadmin-events_${HOST_SHORT}",
"task_bucket": "aw-pve-task-events_${HOST_SHORT}",
"category_bucket": "aw-detmir-web-category_${HOST_SHORT}"
"task_bucket": "aw-pve-task-events_${HOST_SHORT}"
}
EOF
@@ -134,7 +133,6 @@ class Collector:
self.poll = max(1, int(cfg.get("poll_interval_seconds", 5)))
self.web_bucket = cfg["web_bucket"]
self.task_bucket = cfg["task_bucket"]
self.category_bucket = cfg.get("category_bucket", f"aw-detmir-web-category_{self.host}")
self.access_log = pathlib.Path(cfg["access_log"])
self.tasks_index = pathlib.Path(cfg["tasks_index"])
self.state_dir = pathlib.Path(cfg["state_dir"])
@@ -237,9 +235,6 @@ class Collector:
},
}
self.heartbeat(self.web_bucket, event, "app.pve.webadmin.event")
category_event = self.classify_access_event(event)
if category_event:
self.heartbeat(self.category_bucket, category_event, "web.tab.current")
def process_tasks(self):
for line in self.read_new_lines(self.tasks_index, self.tasks_state):
@@ -265,44 +260,6 @@ class Collector:
}
self.heartbeat(self.task_bucket, event, "app.pve.task.event")
def classify_access_event(self, source_event: dict):
data = source_event.get("data", {})
path = str(data.get("path") or "")
user = str(data.get("user") or "")
remote_ip = str(data.get("remote_ip") or "")
method = str(data.get("method") or "")
status = int(data.get("status") or 0)
# Keep only meaningful API/UI actions for worktime.
if not path.startswith("/api2/"):
return None
if method == "GET" and ("/cluster/resources" in path or "/status/current" in path):
return None
category = "Администрирование"
category_group = "work"
if status in (401, 403):
category = "Безопасность"
title = f"Proxmox API {method} {path}"
return {
"timestamp": source_event.get("timestamp") or iso_now(),
"duration": 0,
"data": {
"source": "pve-webadmin-bridge",
"service": "proxmox",
"host": self.host,
"app": "proxmox-webui",
"title": title[:512],
"url": f"https://{self.host}:8006{path}",
"domain": self.host,
"rootDomain": "proxmox-webui",
"categoryGroup": category_group,
"category": category,
"user": user,
"remote_ip": remote_ip,
"method": method,
"status": status,
},
}
def run(self):
while True:
self.process_access()
@@ -346,4 +303,4 @@ systemctl --no-pager --full status aw-pve-webadmin-logger.service || true
echo "Installed aw-pve-webadmin-logger"
echo "Config: ${CONFIG_PATH}"
echo "Buckets: aw-pve-webadmin-events_${HOST_SHORT}, aw-pve-task-events_${HOST_SHORT}, aw-detmir-web-category_${HOST_SHORT}"
echo "Buckets: aw-pve-webadmin-events_${HOST_SHORT}, aw-pve-task-events_${HOST_SHORT}"
+3
View File
@@ -53,6 +53,7 @@ $effectiveLaunchScript = Join-Path $effectiveStateRoot 'launch-watchers.ps1'
$effectiveRecoveryScript = Join-Path $effectiveStateRoot 'recovery-loop.ps1'
$effectiveCollector = Join-Path $effectiveStateRoot 'browser-domains-native-collector.ps1'
$effectiveEndpointCollector = if ($existingConfig -and $existingConfig.paths.PSObject.Properties.Name -contains 'endpointCollectorScript') { [string]$existingConfig.paths.endpointCollectorScript } else { Join-Path $effectiveStateRoot 'dlp-endpoint-signals-collector.ps1' }
$effectiveSessionCollector = if ($existingConfig -and $existingConfig.paths.PSObject.Properties.Name -contains 'sessionCollectorScript') { [string]$existingConfig.paths.sessionCollectorScript } else { Join-Path $effectiveStateRoot 'worktime-session-collector.ps1' }
$effectiveRules = Join-Path $effectiveStateRoot 'web-category-rules.json'
$effectivePolicy = if ($existingConfig -and $existingConfig.paths.PSObject.Properties.Name -contains 'policyPath') { [string]$existingConfig.paths.policyPath } else { Join-Path $effectiveStateRoot 'dlp-policy.json' }
@@ -96,6 +97,7 @@ Get-ActivityWatchExecutableMap -InstallRoot $effectiveInstallRoot | Out-Null
$assetResult = Copy-ActivityWatchCollectorAssets `
-CollectorScriptSource (Join-Path $PSScriptRoot 'browser-domains-native-collector.ps1') `
-EndpointCollectorScriptSource (Join-Path $PSScriptRoot 'dlp-endpoint-signals-collector.ps1') `
-SessionCollectorScriptSource (Join-Path $PSScriptRoot 'worktime-session-collector.ps1') `
-ExampleRulesSource (Join-Path $PSScriptRoot 'web-category-rules.example.json') `
-ExamplePolicySource (Join-Path $PSScriptRoot 'dlp-policy.example.json') `
-StateRoot $effectiveStateRoot `
@@ -115,6 +117,7 @@ $config = New-ActivityWatchDeploymentConfig `
-LogsRoot $effectiveLogsRoot `
-CollectorScript $effectiveCollector `
-EndpointCollectorScript $effectiveEndpointCollector `
-SessionCollectorScript $effectiveSessionCollector `
-RulesPath $effectiveRules `
-PolicyPath $effectivePolicy `
-PollSeconds $effectivePollSeconds `
+241
View File
@@ -0,0 +1,241 @@
#define MyAppName "AWatch-rus Windows Agent"
#define MyAppVersion "1.0.0"
#define MyAppPublisher "AWatch-rus"
#define MyAppURL "https://example.local/awatch-rus"
#define MyAppExeName "AWatch-rus-Setup.exe"
[Setup]
AppId={{7F2C4B63-2B8A-4F1C-BA12-66A7E2C0A0A1}
AppName={#MyAppName}
AppVersion={#MyAppVersion}
AppPublisher={#MyAppPublisher}
AppPublisherURL={#MyAppURL}
AppSupportURL={#MyAppURL}
AppUpdatesURL={#MyAppURL}
DefaultDirName={autopf}\AWatch-rus
DefaultGroupName=AWatch-rus
DisableProgramGroupPage=yes
PrivilegesRequired=admin
ArchitecturesInstallIn64BitMode=x64compatible
OutputDir=output
OutputBaseFilename={#MyAppExeName}
Compression=lzma2
SolidCompression=yes
WizardStyle=modern
SetupLogging=yes
; Optional production code signing. Configure SIGNTOOL_CMD in CI/local env
SignTool=byparam $q$zSIGNTOOL_CMD $f$q
DisableWelcomePage=no
AllowNoIcons=yes
UninstallDisplayIcon={app}\tools\validate-deployment.ps1
[Languages]
Name: "russian"; MessagesFile: "compiler:Languages\Russian.isl"
Name: "english"; MessagesFile: "compiler:Default.isl"
[Tasks]
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
[Files]
Source: "..\ActivityWatch.Windows.Common.psd1"; DestDir: "{app}\tools"; Flags: ignoreversion
Source: "..\ActivityWatch.Windows.Common.psm1"; DestDir: "{app}\tools"; Flags: ignoreversion
Source: "..\browser-domains-native-collector.ps1"; DestDir: "{app}\tools"; Flags: ignoreversion
Source: "..\dlp-endpoint-signals-collector.ps1"; DestDir: "{app}\tools"; Flags: ignoreversion
Source: "..\deploy-single-user.ps1"; DestDir: "{app}\tools"; Flags: ignoreversion
Source: "..\deploy-domain-users.ps1"; DestDir: "{app}\tools"; Flags: ignoreversion
Source: "..\deploy-ensemble.ps1"; DestDir: "{app}\tools"; Flags: ignoreversion
Source: "..\hardening-recovery.ps1"; DestDir: "{app}\tools"; Flags: ignoreversion
Source: "..\validate-deployment.ps1"; DestDir: "{app}\tools"; Flags: ignoreversion
Source: "..\web-category-rules.example.json"; DestDir: "{app}\config"; Flags: ignoreversion
Source: "..\dlp-policy.example.json"; DestDir: "{app}\config"; Flags: ignoreversion
Source: "README-INSTALLER.md"; DestDir: "{app}"; Flags: ignoreversion
Source: "Invoke-AWatchRusInstall.ps1"; DestDir: "{app}\tools"; Flags: ignoreversion
[Icons]
Name: "{autoprograms}\AWatch-rus\Run validation"; Filename: "powershell.exe"; Parameters: "-NoProfile -ExecutionPolicy Bypass -File ""{app}\tools\validate-deployment.ps1"" -ConfigPath ""C:\ProgramData\ActivityWatch\deployment-config.json"""; WorkingDir: "{app}\tools"
Name: "{autodesktop}\AWatch-rus Validation"; Filename: "powershell.exe"; Parameters: "-NoProfile -ExecutionPolicy Bypass -File ""{app}\tools\validate-deployment.ps1"" -ConfigPath ""C:\ProgramData\ActivityWatch\deployment-config.json"""; Tasks: desktopicon
[Run]
Filename: "powershell.exe"; Parameters: "-NoProfile -ExecutionPolicy Bypass -File ""{app}\tools\Invoke-AWatchRusInstall.ps1"" -ServerHost ""{code:GetServerHost}"" -ServerPort {code:GetServerPort} -Domain ""{code:GetDomain}"" -Users ""{code:GetUsers}"" -InstallRoot ""{code:GetInstallRoot}"" -StateRoot ""{code:GetStateRoot}"" -CustomRulesPath ""{app}\config\web-category-rules.example.json"" -CustomPolicyPath ""{app}\config\dlp-policy.example.json"" -AfkEnabled:{code:GetAfkEnabled} -WindowEnabled:{code:GetWindowEnabled}"; Flags: runhidden waituntilterminated
Filename: "powershell.exe"; Parameters: "-NoProfile -ExecutionPolicy Bypass -File ""{app}\tools\validate-deployment.ps1"" -ConfigPath ""{code:GetStateRoot}\deployment-config.json"""; Flags: postinstall shellexec skipifsilent
[UninstallRun]
Filename: "powershell.exe"; Parameters: "-NoProfile -ExecutionPolicy Bypass -Command ""Get-ScheduledTask -TaskName 'ActivityWatch Launch *' -ErrorAction SilentlyContinue | ForEach-Object { Unregister-ScheduledTask -TaskName $_.TaskName -Confirm:$false }; Unregister-ScheduledTask -TaskName 'ActivityWatch Recovery' -Confirm:$false -ErrorAction SilentlyContinue"""; Flags: runhidden waituntilterminated
[Code]
var
ConfigPage: TWizardPage;
ServerHostEdit: TEdit;
ServerPortEdit: TEdit;
DomainEdit: TEdit;
UsersEdit: TEdit;
InstallRootEdit: TEdit;
StateRootEdit: TEdit;
AfkCheck: TNewCheckBox;
WindowCheck: TNewCheckBox;
procedure ApplyCmdParamIfPresent(const ParamName: string; Edit: TEdit);
forward;
procedure ApplyCmdBoolIfPresent(const ParamName: string; Check: TNewCheckBox);
forward;
procedure InitializeWizard;
begin
ConfigPage := CreateCustomPage(wpSelectTasks,
'AWatch-rus configuration',
'Specify deployment settings passed to deploy-ensemble.ps1');
ServerHostEdit := TEdit.Create(ConfigPage);
ServerHostEdit.Parent := ConfigPage.Surface;
ServerHostEdit.Left := ScaleX(0);
ServerHostEdit.Top := ScaleY(8);
ServerHostEdit.Width := ScaleX(420);
ServerHostEdit.Text := 'aw.example.local';
ServerPortEdit := TEdit.Create(ConfigPage);
ServerPortEdit.Parent := ConfigPage.Surface;
ServerPortEdit.Left := ScaleX(0);
ServerPortEdit.Top := ScaleY(40);
ServerPortEdit.Width := ScaleX(120);
ServerPortEdit.Text := '5600';
DomainEdit := TEdit.Create(ConfigPage);
DomainEdit.Parent := ConfigPage.Surface;
DomainEdit.Left := ScaleX(0);
DomainEdit.Top := ScaleY(72);
DomainEdit.Width := ScaleX(260);
DomainEdit.Text := 'CONTOSO';
UsersEdit := TEdit.Create(ConfigPage);
UsersEdit.Parent := ConfigPage.Surface;
UsersEdit.Left := ScaleX(0);
UsersEdit.Top := ScaleY(104);
UsersEdit.Width := ScaleX(420);
UsersEdit.Text := 'user1,user2';
InstallRootEdit := TEdit.Create(ConfigPage);
InstallRootEdit.Parent := ConfigPage.Surface;
InstallRootEdit.Left := ScaleX(0);
InstallRootEdit.Top := ScaleY(136);
InstallRootEdit.Width := ScaleX(420);
InstallRootEdit.Text := 'C:\Program Files\ActivityWatch';
StateRootEdit := TEdit.Create(ConfigPage);
StateRootEdit.Parent := ConfigPage.Surface;
StateRootEdit.Left := ScaleX(0);
StateRootEdit.Top := ScaleY(168);
StateRootEdit.Width := ScaleX(420);
StateRootEdit.Text := 'C:\ProgramData\ActivityWatch';
AfkCheck := TNewCheckBox.Create(ConfigPage);
AfkCheck.Parent := ConfigPage.Surface;
AfkCheck.Top := ScaleY(200);
AfkCheck.Caption := 'Enable AFK watcher';
AfkCheck.Checked := True;
WindowCheck := TNewCheckBox.Create(ConfigPage);
WindowCheck.Parent := ConfigPage.Surface;
WindowCheck.Top := ScaleY(224);
WindowCheck.Caption := 'Enable Window watcher';
WindowCheck.Checked := True;
ApplyCmdParamIfPresent('SERVERHOST', ServerHostEdit);
ApplyCmdParamIfPresent('SERVERPORT', ServerPortEdit);
ApplyCmdParamIfPresent('DOMAIN', DomainEdit);
ApplyCmdParamIfPresent('USERS', UsersEdit);
ApplyCmdParamIfPresent('INSTALLROOT', InstallRootEdit);
ApplyCmdParamIfPresent('STATEROOT', StateRootEdit);
ApplyCmdBoolIfPresent('AFKENABLED', AfkCheck);
ApplyCmdBoolIfPresent('WINDOWENABLED', WindowCheck);
end;
procedure ApplyCmdParamIfPresent(const ParamName: string; Edit: TEdit);
var
V: string;
begin
V := ExpandConstant('{param:' + ParamName + '|}');
if Trim(V) <> '' then
Edit.Text := V;
end;
procedure ApplyCmdBoolIfPresent(const ParamName: string; Check: TNewCheckBox);
var
V: string;
begin
V := Lowercase(Trim(ExpandConstant('{param:' + ParamName + '|}')));
if (V = '1') or (V = 'true') or (V = 'yes') then
Check.Checked := True
else if (V = '0') or (V = 'false') or (V = 'no') then
Check.Checked := False;
end;
function NextButtonClick(CurPageID: Integer): Boolean;
var
PortNum: Integer;
begin
Result := True;
if CurPageID = ConfigPage.ID then
begin
if Trim(ServerHostEdit.Text) = '' then
begin
MsgBox('ServerHost is required.', mbError, MB_OK);
Result := False;
exit;
end;
PortNum := StrToIntDef(Trim(ServerPortEdit.Text), 0);
if (PortNum < 1) or (PortNum > 65535) then
begin
MsgBox('ServerPort must be in range 1..65535.', mbError, MB_OK);
Result := False;
exit;
end;
if Trim(UsersEdit.Text) = '' then
begin
MsgBox('Users list is required (comma-separated).', mbError, MB_OK);
Result := False;
exit;
end;
end;
end;
function GetServerHost(Param: string): string;
begin
Result := Trim(ServerHostEdit.Text);
end;
function GetServerPort(Param: string): string;
begin
Result := Trim(ServerPortEdit.Text);
end;
function GetDomain(Param: string): string;
begin
Result := Trim(DomainEdit.Text);
end;
function GetUsers(Param: string): string;
begin
Result := Trim(UsersEdit.Text);
end;
function GetInstallRoot(Param: string): string;
begin
Result := Trim(InstallRootEdit.Text);
end;
function GetStateRoot(Param: string): string;
begin
Result := Trim(StateRootEdit.Text);
end;
function GetAfkEnabled(Param: string): string;
begin
if AfkCheck.Checked then Result := '$true' else Result := '$false';
end;
function GetWindowEnabled(Param: string): string;
begin
if WindowCheck.Checked then Result := '$true' else Result := '$false';
end;
@@ -0,0 +1,47 @@
[CmdletBinding()]
param(
[Parameter(Mandatory=$true)][string]$ServerHost,
[int]$ServerPort = 5600,
[Parameter(Mandatory=$true)][string]$Domain,
[Parameter(Mandatory=$true)][string]$Users,
[string]$InstallRoot = 'C:\Program Files\ActivityWatch',
[string]$StateRoot = 'C:\ProgramData\ActivityWatch',
[string]$CustomRulesPath,
[string]$CustomPolicyPath,
[bool]$AfkEnabled = $true,
[bool]$WindowEnabled = $true
)
$ErrorActionPreference = 'Stop'
$scriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
$ensembleScript = Join-Path $scriptRoot 'deploy-ensemble.ps1'
if (-not (Test-Path -LiteralPath $ensembleScript)) {
throw "deploy-ensemble.ps1 not found at $ensembleScript"
}
$usersList = $Users.Split(',') | ForEach-Object { $_.Trim() } | Where-Object { $_ }
if (-not $usersList -or $usersList.Count -eq 0) {
throw 'Users must contain at least one username.'
}
$params = @{
ServerHost = $ServerHost
ServerPort = $ServerPort
Domain = $Domain
Users = $usersList
InstallRoot = $InstallRoot
StateRoot = $StateRoot
AfkEnabled = $AfkEnabled
WindowEnabled = $WindowEnabled
}
if ($CustomRulesPath -and (Test-Path -LiteralPath $CustomRulesPath)) {
$params['CustomRulesPath'] = $CustomRulesPath
}
if ($CustomPolicyPath -and (Test-Path -LiteralPath $CustomPolicyPath)) {
$params['CustomPolicyPath'] = $CustomPolicyPath
}
& $ensembleScript @params
+51
View File
@@ -0,0 +1,51 @@
# AWatch-rus Inno Setup installer skeleton
This directory contains a ready-to-build scaffold for a classic Setup.exe installer.
## Files
- `AWatch-rus-Setup.iss` — Inno Setup script.
- `Invoke-AWatchRusInstall.ps1` — wrapper that invokes `deploy-ensemble.ps1` with installer parameters.
- `build-installer.ps1` — one-click local build script for Inno Setup.
## Local build (one-click)
```powershell
cd .\windows\installer
.\build-installer.ps1
```
Optional custom path to compiler:
```powershell
.\build-installer.ps1 -IsccPath 'C:\Program Files (x86)\Inno Setup 6\ISCC.exe'
```
## Silent deployment parameters (SCCM/Intune/GPO)
Installer supports command-line parameters:
- `/SERVERHOST=aw.example.local`
- `/SERVERPORT=5600`
- `/DOMAIN=CONTOSO`
- `/USERS=user1,user2`
- `/INSTALLROOT="C:\Program Files\ActivityWatch"`
- `/STATEROOT="C:\ProgramData\ActivityWatch"`
- `/AFKENABLED=true|false`
- `/WINDOWENABLED=true|false`
Example:
```powershell
AWatch-rus-Setup.exe /VERYSILENT /SUPPRESSMSGBOXES /NORESTART `
/SERVERHOST=aw.example.local /SERVERPORT=5600 /DOMAIN=CONTOSO `
/USERS=user1,user2 /AFKENABLED=true /WINDOWENABLED=true
```
## CI build and optional code signing
A GitHub Actions workflow builds the installer on `windows-latest` and uploads `Setup.exe` as an artifact.
Optional production signing is enabled via `SignTool=byparam ...` in `.iss` and expects `SIGNTOOL_CMD`.
Set repository secret `SIGNTOOL_CMD` with your full sign command template.
## Behavior
- Requires Administrator privileges.
- Copies toolkit scripts into `{app}\tools`.
- Collects server/domain/users parameters via wizard page (or command-line silent params).
- Runs `Invoke-AWatchRusInstall.ps1` -> `deploy-ensemble.ps1`.
- Runs validation at the end.
- Uninstall step removes `ActivityWatch` scheduled tasks.
+33
View File
@@ -0,0 +1,33 @@
[CmdletBinding()]
param(
[string]$IsccPath = "C:\Program Files (x86)\Inno Setup 6\ISCC.exe",
[string]$IssPath = "$PSScriptRoot\AWatch-rus-Setup.iss",
[switch]$SkipClean
)
$ErrorActionPreference = 'Stop'
if (-not (Test-Path -LiteralPath $IssPath)) {
throw "ISS file not found: $IssPath"
}
if (-not (Test-Path -LiteralPath $IsccPath)) {
throw "ISCC.exe not found: $IsccPath"
}
$outputDir = Join-Path $PSScriptRoot 'output'
if ((-not $SkipClean) -and (Test-Path -LiteralPath $outputDir)) {
Remove-Item -LiteralPath $outputDir -Recurse -Force
}
Write-Host "Building installer from: $IssPath"
& $IsccPath $IssPath
if ($LASTEXITCODE -ne 0) {
throw "Inno Setup build failed with code: $LASTEXITCODE"
}
$artifact = Get-ChildItem -Path $outputDir -Filter '*.exe' -File | Sort-Object LastWriteTime -Descending | Select-Object -First 1
if (-not $artifact) {
throw "Build finished but no .exe artifact found in: $outputDir"
}
Write-Host "Installer artifact: $($artifact.FullName)"
@@ -1,40 +0,0 @@
#define MyAppName "AWatch-rus InstallKit"
#define MyAppVersion "1.0.0"
#define MyAppPublisher "AWatch-rus"
[Setup]
AppId={{6D6A1F74-0F4F-4A57-B5E3-1C2C2F56C0E9}
AppName={#MyAppName}
AppVersion={#MyAppVersion}
AppPublisher={#MyAppPublisher}
DefaultDirName={autopf}\AWatch-rus
DefaultGroupName=AWatch-rus
OutputDir=.
OutputBaseFilename=AWatch-rus-InstallKit-offline
Compression=lzma
SolidCompression=yes
ArchitecturesInstallIn64BitMode=x64
PrivilegesRequired=admin
[Languages]
Name: "russian"; MessagesFile: "compiler:Languages\Russian.isl"
[Files]
Source: "..\..\ActivityWatch.Windows.Common.psd1"; DestDir: "{app}\windows"; Flags: ignoreversion
Source: "..\..\ActivityWatch.Windows.Common.psm1"; DestDir: "{app}\windows"; Flags: ignoreversion
Source: "..\..\deploy-single-user.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion
Source: "..\..\deploy-domain-users.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion
Source: "..\..\deploy-ensemble.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion
Source: "..\..\hardening-recovery.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion
Source: "..\..\validate-deployment.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion
Source: "..\..\worktime-session-collector.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion
Source: "..\..\browser-domains-native-collector.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion
Source: "..\..\dlp-endpoint-signals-collector.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion
Source: "..\..\web-category-rules.example.json"; DestDir: "{app}\windows"; Flags: ignoreversion
Source: "..\..\dlp-policy.example.json"; DestDir: "{app}\windows"; Flags: ignoreversion
Source: "payload\activitywatch-v0.13.2-windows-x86_64.zip"; DestDir: "{app}\payload"; Flags: ignoreversion
Source: "innosetup-rdp-package-filelist.md"; DestDir: "{app}\windows\installkit\innosetup"; Flags: ignoreversion
[Run]
Filename: "powershell.exe"; Parameters: "-NoProfile -ExecutionPolicy Bypass -File \"{app}\windows\deploy-ensemble.ps1\" -PackageZipPath \"{app}\payload\activitywatch-v0.13.2-windows-x86_64.zip\""; Flags: runhidden
Filename: "powershell.exe"; Parameters: "-NoProfile -ExecutionPolicy Bypass -File \"{app}\windows\validate-deployment.ps1\""; Flags: runhidden
@@ -1,90 +0,0 @@
# Inno Setup: файл-лист для Windows RDP deployment
Дата актуализации: 2026-05-02 (UTC).
## Что это за документ
Этот файл — **чеклист упаковки** для Inno Setup.
- Он описывает, **что класть** в инсталлятор.
- Он описывает, **что не класть** (генерируется уже на целевом хосте).
- Он **не меняет** текущие deploy-скрипты и логику проекта.
## Важное уточнение по `phase2`
Чтобы исключить путаницу:
1. В каталоге `windows/` нет файлов с именами `phase2-*`.
2. `phase2` в проекте — это обозначение этапа/набора телеметрии (DLP + endpoint signals).
3. Файлы вида `phase2-*.deployment-config.json` — это **примерные конфиги install-kit**, они лежат в `install-kit-*/server-configs-*`.
## 1) Обязательные файлы для Inno Setup пакета
### 1.1 PowerShell-модуль
- `windows/ActivityWatch.Windows.Common.psd1`
- `windows/ActivityWatch.Windows.Common.psm1`
### 1.2 Скрипты деплоя и сопровождения
- `windows/deploy-single-user.ps1`
- `windows/deploy-domain-users.ps1`
- `windows/deploy-ensemble.ps1`
- `windows/hardening-recovery.ps1`
- `windows/validate-deployment.ps1`
### 1.3 Коллекторы
- `windows/worktime-session-collector.ps1` (RDP/session presence)
- `windows/browser-domains-native-collector.ps1`
- `windows/dlp-endpoint-signals-collector.ps1`
### 1.4 Шаблоны конфигурации
- `windows/web-category-rules.example.json`
- `windows/dlp-policy.example.json`
## 2) Бинарный payload ActivityWatch
Для закрытой среды используется **offline-режим по умолчанию**:
- В комплект Inno Setup сразу включается `payload\activitywatch-v0.13.2-windows-x86_64.zip`.
- Deploy запускается с параметром `-PackageZipPath` на локальный ZIP из `{app}\payload`.
- Online-загрузка из GitHub Releases в закрытой среде не требуется.
## 3) Что НЕ включать в installer как статические файлы
Эти файлы/папки появляются на целевом Windows-хосте во время/после деплоя:
- `C:\ProgramData\ActivityWatch\deployment-config.json`
- `C:\ProgramData\ActivityWatch\web-category-rules.json`
- `C:\ProgramData\ActivityWatch\dlp-policy.json`
- `C:\ProgramData\ActivityWatch\logs\*`
- `%LOCALAPPDATA%\ActivityWatch-Phase2\incident-artifacts\*`
## 4) Опционально приложить в операторский install-kit
- `docs/windows/deployment.md`
- `docs/windows/validation.md`
- `docs/windows/troubleshooting.md`
- `docs/windows/ensemble.md`
## 5) Рекомендуемая структура внутри пакета
- `windows\ActivityWatch.Windows.Common.psd1`
- `windows\ActivityWatch.Windows.Common.psm1`
- `windows\deploy-single-user.ps1`
- `windows\deploy-domain-users.ps1`
- `windows\deploy-ensemble.ps1`
- `windows\hardening-recovery.ps1`
- `windows\validate-deployment.ps1`
- `windows\worktime-session-collector.ps1`
- `windows\browser-domains-native-collector.ps1`
- `windows\dlp-endpoint-signals-collector.ps1`
- `windows\web-category-rules.example.json`
- `windows\dlp-policy.example.json`
- `payload\activitywatch-v0.13.2-windows-x86_64.zip` (только для offline-режима)
## 6) Контроль перед сборкой .iss
1. Все файлы из раздела 1 присутствуют.
2. ZIP `activitywatch-v0.13.2-windows-x86_64.zip` лежит в `windows/installkit/innosetup/payload/`.
3. В .iss добавлен `Source: "payload\activitywatch-v0.13.2-windows-x86_64.zip"`.
4. Deploy в .iss запускается с `-PackageZipPath` на локальный ZIP.
5. После установки запускается `validate-deployment.ps1` с сохранением JSON-отчёта.
+1 -13
View File
@@ -14,7 +14,6 @@ $installRoot = [string]$config.paths.installRoot
$stateRoot = [string]$config.paths.stateRoot
$collectorScript = [string]$config.paths.collectorScript
$endpointCollectorScript = if ($config.paths.PSObject.Properties.Name -contains 'endpointCollectorScript') { [string]$config.paths.endpointCollectorScript } else { Join-Path $stateRoot 'dlp-endpoint-signals-collector.ps1' }
$sessionCollectorScript = if ($config.paths.PSObject.Properties.Name -contains 'sessionCollectorScript') { [string]$config.paths.sessionCollectorScript } else { Join-Path $stateRoot 'worktime-session-collector.ps1' }
$rulesPath = [string]$config.paths.rulesPath
$policyPath = if ($config.paths.PSObject.Properties.Name -contains 'policyPath') { [string]$config.paths.policyPath } else { Join-Path $stateRoot 'dlp-policy.json' }
$launchScript = [string]$config.paths.launchScript
@@ -25,7 +24,6 @@ $requiredFiles = @(
(Join-Path $installRoot 'aw-watcher-window\aw-watcher-window.exe'),
$collectorScript,
$endpointCollectorScript,
$sessionCollectorScript,
$rulesPath,
$policyPath,
$launchScript,
@@ -39,12 +37,6 @@ $missingFiles = @(
$processNames = @('aw-watcher-afk', 'aw-watcher-window')
$runningProcesses = Get-Process -Name $processNames -ErrorAction SilentlyContinue | Select-Object Name, Id, SessionId
$sessionCollectorProcesses = Get-CimInstance Win32_Process -ErrorAction SilentlyContinue |
Where-Object {
($_.Name -ieq 'powershell.exe' -or $_.Name -ieq 'pwsh.exe') -and
$_.CommandLine -match [Regex]::Escape($sessionCollectorScript)
} |
Select-Object Name, ProcessId, SessionId, CommandLine
$taskNames = @()
if ($config.userTasks) {
@@ -89,11 +81,7 @@ $result = [ordered]@{
}
processes = [ordered]@{
list = @($runningProcesses)
sessionCollectors = @($sessionCollectorProcesses)
ok = [bool](
(($runningProcesses | Select-Object -ExpandProperty Name -Unique).Count -ge 2) -and
($sessionCollectorProcesses.Count -ge 1)
)
ok = [bool](($runningProcesses | Select-Object -ExpandProperty Name -Unique).Count -ge 2)
}
}
+24 -49
View File
@@ -9,12 +9,8 @@ $ErrorActionPreference = 'Stop'
function Get-Config {
param([string]$Path)
if (-not (Test-Path -LiteralPath $Path)) {
throw "Config not found: $Path"
}
Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json
if (-not (Test-Path -LiteralPath $Path)) { throw "Config not found: $Path" }
return Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json
}
function Invoke-AwJsonPost {
@@ -22,7 +18,6 @@ function Invoke-AwJsonPost {
[Parameter(Mandatory = $true)][string]$Uri,
[Parameter(Mandatory = $true)][string]$Json
)
$bytes = [Text.Encoding]::UTF8.GetBytes($Json)
Invoke-RestMethod -Method Post -Uri $Uri -ContentType 'application/json; charset=utf-8' -Body $bytes | Out-Null
}
@@ -33,7 +28,6 @@ function Ensure-Bucket {
[Parameter(Mandatory = $true)][string]$BucketId,
[Parameter(Mandatory = $true)][string]$HostnameValue
)
try {
Invoke-RestMethod -Method Get -Uri "$ApiBase/buckets/$BucketId" | Out-Null
return
@@ -46,46 +40,36 @@ function Ensure-Bucket {
type = 'aw.worktime.session'
hostname = $HostnameValue
} | ConvertTo-Json -Compress
Invoke-AwJsonPost -Uri "$ApiBase/buckets/$BucketId" -Json $body
}
function Get-SessionRecords {
$records = @()
try {
$lines = quser 2>$null
if (-not $lines) {
return @()
}
foreach ($line in ($lines | Select-Object -Skip 1)) {
$lines = (quser 2>$null)
if (-not $lines) { return @() }
foreach ($line in $lines | Select-Object -Skip 1) {
$clean = ($line -replace '^\s*>?', '').Trim()
if (-not $clean) {
continue
}
if (-not $clean) { continue }
$parts = $clean -split '\s+'
if ($parts.Count -lt 4) {
continue
}
if ($parts.Count -lt 4) { continue }
$user = $parts[0]
$sessionName = $parts[1]
$sessionId = 0
if ($parts[2] -match '^\d+$') {
$sessionId = [int]$parts[2]
}
$state = $parts[3]
$records += [pscustomobject]@{
username = $parts[0]
sessionName = $parts[1]
username = $user
sessionName = $sessionName
sessionId = $sessionId
state = $parts[3]
state = $state
}
}
}
catch {
}
return $records
}
@@ -94,15 +78,7 @@ $hostValue = if ($Hostname) { $Hostname } else { [string]$env:COMPUTERNAME }
$apiBase = '{0}://{1}:{2}/api/0' -f [string]$cfg.server.scheme, [string]$cfg.server.host, [string]$cfg.server.port
$bucketId = 'aw-worktime-sessions_' + $hostValue
$pulse = 120
$sleepSec = if ($PollSeconds -gt 0) {
$PollSeconds
}
elseif ($cfg.collector -and $cfg.collector.pollSeconds) {
[int]$cfg.collector.pollSeconds
}
else {
30
}
$sleepSec = if ($PollSeconds -gt 0) { $PollSeconds } elseif ($cfg.collector -and $cfg.collector.pollSeconds) { [int]$cfg.collector.pollSeconds } else { 30 }
Ensure-Bucket -ApiBase $apiBase -BucketId $bucketId -HostnameValue $hostValue
@@ -111,10 +87,10 @@ while ($true) {
$records = Get-SessionRecords
if (-not $records -or $records.Count -eq 0) {
$records = @([pscustomobject]@{
username = $env:USERNAME
username = $env:USERNAME
sessionName = ''
sessionId = (Get-Process -Id $PID).SessionId
state = 'Unknown'
sessionId = (Get-Process -Id $PID).SessionId
state = 'Unknown'
})
}
@@ -123,17 +99,16 @@ while ($true) {
timestamp = $now
duration = 0
data = @{
username = [string]$rec.username
userId = "$($env:USERDOMAIN)\$($rec.username)"
sessionId = [int]$rec.sessionId
username = [string]$rec.username
userId = "$($env:USERDOMAIN)\$($rec.username)"
sessionId = [int]$rec.sessionId
sessionName = [string]$rec.sessionName
state = [string]$rec.state
active = ($rec.state -match 'Active')
hostname = $hostValue
source = 'worktime-session-collector'
state = [string]$rec.state
active = ($rec.state -match 'Active')
hostname = $hostValue
source = 'worktime-session-collector'
}
} | ConvertTo-Json -Depth 6 -Compress
try {
Invoke-AwJsonPost -Uri "$apiBase/buckets/$bucketId/heartbeat?pulsetime=$pulse" -Json $payload
}