Compare commits

..
48 changed files with 4310 additions and 49 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
+23
View File
@@ -14,6 +14,7 @@
- `/home/igor/tmp/AWatch-rus/ansible/provision_proxmox_ct_matrix_and_deploy_aw.yml` — массовый full-stack playbook (несколько CT).
- `/home/igor/tmp/AWatch-rus/ansible/deploy_aw_windows_phase2.yml` — WinRM playbook для развёртывания phase-2 Windows collector'ов.
- `/home/igor/tmp/AWatch-rus/ansible/deploy_aw_pfsense_poller.yml` — deployment pfSense poller'а.
- `/home/igor/tmp/AWatch-rus/ansible/install_full_stack.yml` — полный установочный playbook (оркестратор всех этапов).
- `/home/igor/tmp/AWatch-rus/ansible/inventory.example.ini` — шаблон inventory.
- `/home/igor/tmp/AWatch-rus/ansible/group_vars/all.example.yml` — шаблон переменных.
- `/home/igor/tmp/AWatch-rus/ansible/group_vars/proxmox.example.yml` — шаблон переменных CT в Proxmox.
@@ -34,6 +35,24 @@ cd /home/igor/tmp/AWatch-rus/ansible
ansible-playbook -i inventory.ini deploy_aw_server.yml
```
## Полный установочный playbook (всё за один запуск)
Если нужно прогнать полный цикл одной командой:
```bash
cd /home/igor/tmp/AWatch-rus/ansible
ansible-playbook -i inventory.ini install_full_stack.yml
```
Что делает:
- `provision_proxmox_ct_and_deploy_aw.yml` (если есть хосты в группе `[proxmox]`);
- `deploy_aw_server.yml` (группа `[aw_server]`);
- `deploy_aw_windows_phase2.yml` (группа `[aw_windows]`);
- `deploy_aw_pfsense_poller.yml` (группа `[aw_pfsense_pollers]`).
Пустые группы в `inventory.ini` безопасны: соответствующий play будет пропущен.
## Полный запуск с нуля в Proxmox
1. Подготовьте inventory и vars:
@@ -66,6 +85,8 @@ ansible-playbook -i inventory.ini provision_proxmox_ct_matrix_and_deploy_aw.yml
- `cp /home/igor/tmp/AWatch-rus/ansible/inventory.example.ini /home/igor/tmp/AWatch-rus/ansible/inventory.ini`
- `cp /home/igor/tmp/AWatch-rus/ansible/group_vars/windows.example.yml /home/igor/tmp/AWatch-rus/ansible/group_vars/windows.yml`
2. Заполните `inventory.ini` (секция `[aw_windows]`) и `group_vars/windows.yml`.
- Для русской локализации Windows часто нужен `ansible_user=Администратор` (а не `Administrator`).
- Если WinRM закрыт, playbook не сможет стартовать и нужно сначала открыть `5985/5986` и `wsman`.
3. Запустите:
```bash
@@ -77,6 +98,8 @@ Playbook:
- выгружает `windows/*` toolkit на целевой хост в `C:\Deploy\AWatch-rus\windows`;
- выполняет `deploy-ensemble.ps1` (deploy + hardening/recovery) с phase-2 policy/rules;
- после deploy принудительно запускает `ActivityWatch Recovery` и все `ActivityWatch Launch *` задачи;
- выполняет API smoke-check bucket `aw-watcher-afk_SHARKON2025` и ожидает свежие `not-afk` события;
- запускает `validate-deployment.ps1`;
- забирает JSON-отчёт в локальную директорию (`/tmp/aw-rus-validation` по умолчанию).
+36
View File
@@ -31,6 +31,12 @@
aw_windows_policy_path: "{{ aw_windows_deploy_root }}\\windows\\dlp-policy.example.json"
aw_windows_validation_remote_path: "C:\\Windows\\Temp\\aw_validate_phase2_ansible.json"
aw_windows_validation_local_dir: "/tmp/aw-rus-validation"
aw_windows_launch_task_pattern: "ActivityWatch Launch *"
aw_windows_recovery_task_name: "ActivityWatch Recovery"
aw_windows_force_task_restart: true
aw_windows_api_smoke_check_enabled: true
aw_windows_api_smoke_check_bucket: "aw-watcher-afk_SHARKON2025"
aw_windows_api_smoke_check_limit: 10
tasks:
- name: Validate required variables
@@ -105,6 +111,36 @@
{% endif %}
& "{{ aw_windows_deploy_root }}\windows\deploy-ensemble.ps1" @params
- name: Force start ActivityWatch recovery and launch tasks
when: aw_windows_force_task_restart | bool
ansible.windows.win_powershell:
script: |
$ErrorActionPreference = 'Stop'
Start-ScheduledTask -TaskName "{{ aw_windows_recovery_task_name }}"
Get-ScheduledTask |
Where-Object TaskName -like "{{ aw_windows_launch_task_pattern }}" |
ForEach-Object { Start-ScheduledTask -TaskName $_.TaskName }
- name: Wait for fresh AFK events to appear on AW server
when: aw_windows_api_smoke_check_enabled | bool
delegate_to: localhost
ansible.builtin.uri:
url: "http://{{ aw_windows_server_host }}:{{ aw_windows_server_port }}/api/0/buckets/{{ aw_windows_api_smoke_check_bucket }}/events?limit={{ aw_windows_api_smoke_check_limit }}"
method: GET
return_content: true
register: aw_windows_api_smoke
until: >
aw_windows_api_smoke.status == 200 and
(aw_windows_api_smoke.json | length) > 0 and
(
aw_windows_api_smoke.json
| selectattr('data.status', 'equalto', 'not-afk')
| list
| length
) > 0
retries: 10
delay: 6
- name: Run validation and store report on target
ansible.windows.win_powershell:
script: |
+16
View File
@@ -0,0 +1,16 @@
---
# Full-stack installer for AWatch-rus.
# Runs end-to-end rollout in one command:
# 1) Proxmox CT provision + AW bootstrap (if [proxmox] exists in inventory)
# 2) AW server deploy on [aw_server] hosts
# 3) Windows phase2 rollout on [aw_windows] hosts
# 4) pfSense poller deploy on [aw_pfsense_pollers] hosts
#
# Notes:
# - Keep only relevant inventory groups filled for your environment.
# - Plays with unmatched host groups are skipped automatically by Ansible.
- import_playbook: provision_proxmox_ct_and_deploy_aw.yml
- import_playbook: deploy_aw_server.yml
- import_playbook: deploy_aw_windows_phase2.yml
- import_playbook: deploy_aw_pfsense_poller.yml
+2 -1
View File
@@ -5,4 +5,5 @@ pve-main ansible_host=192.168.10.2 ansible_user=root ansible_port=22
aw-ct ansible_host=10.20.30.13 ansible_user=root ansible_port=22
[aw_windows]
win-node1 ansible_host=192.168.100.21 ansible_user=Administrator ansible_password=CHANGE_ME ansible_connection=winrm ansible_winrm_transport=ntlm ansible_port=5985 ansible_winrm_server_cert_validation=ignore
# NOTE: in RU-localized installs this account is often "Администратор" instead of "Administrator".
win-node1 ansible_host=192.168.100.21 ansible_user=Администратор ansible_password=CHANGE_ME ansible_connection=winrm ansible_winrm_transport=ntlm ansible_port=5985 ansible_winrm_server_cert_validation=ignore
@@ -12,6 +12,9 @@
- aw-server.env.example
- aw-ru-patch.js
- aw-sw-cleanup.js
- aw-host-groups.json
- settings/classes-worktime.json
- settings/views-default.json
tasks:
- name: Execute single-CT provisioning workflow
@@ -12,6 +12,9 @@
- aw-server.env.example
- aw-ru-patch.js
- aw-sw-cleanup.js
- aw-host-groups.json
- settings/classes-worktime.json
- settings/views-default.json
tasks:
- name: Validate CT matrix is provided
+37 -8
View File
@@ -9,17 +9,22 @@ fi
source "$ENV_FILE"
WEBUI_DIR="${AW_SERVER_WEBUI_DIR:-/opt/activitywatch/webui-ru}"
WEBUI_DIR="${AW_SERVER_WEBUI_DIR:-${AW_WEBUI_DIR:-/opt/activitywatch/webui-ru}}"
PATCH_JS_SRC="/root/bootstrap/aw-ru-patch.js"
SW_CLEANUP_SRC="/root/bootstrap/aw-sw-cleanup.js"
HOST_GROUPS_SRC="/root/bootstrap/aw-host-groups.json"
INDEX_HTML="$WEBUI_DIR/index.html"
SERVICE_WORKER="$WEBUI_DIR/service-worker.js"
TS=$(date +%Y%m%d%H%M%S)
PATCH_TARGET="$WEBUI_DIR/js/ru-patch-v5.js"
SW_TARGET="$WEBUI_DIR/js/sw-cleanup.js"
HOST_GROUPS_TARGET="$WEBUI_DIR/js/aw-host-groups.json"
TRENDS_NEEDLE='this.activityStore.query_category_time_by_period(r)'
TRENDS_REPLACEMENT='this.activityStore.ensure_loaded(r)'
TIMESPIRAL_NEEDLE='start:new Date("2022-08-08")'
TIMESPIRAL_REPLACEMENT='start:new Date(Date.now()-12*36e5)'
CATEGORY_HELPER_NEEDLE='hostname:t.hostnameChoices[0]'
CATEGORY_HELPER_REPLACEMENT='hostname:t.hostnameChoices.filter((function(t){return"unknown"!==t}))[0]||t.hostnameChoices[0]'
[[ -f "$PATCH_JS_SRC" ]] || { echo "missing $PATCH_JS_SRC" >&2; exit 1; }
[[ -f "$SW_CLEANUP_SRC" ]] || { echo "missing $SW_CLEANUP_SRC" >&2; exit 1; }
@@ -27,14 +32,17 @@ TIMESPIRAL_REPLACEMENT='start:new Date(Date.now()-12*36e5)'
[[ -f "$INDEX_HTML" ]] || { echo "missing $INDEX_HTML" >&2; exit 1; }
install -d "$WEBUI_DIR/js"
install -m 0644 "$PATCH_JS_SRC" "$WEBUI_DIR/js/aw-ru-patch.js"
install -m 0644 "$SW_CLEANUP_SRC" "$WEBUI_DIR/js/aw-sw-cleanup.js"
install -m 0644 "$HOST_GROUPS_SRC" "$WEBUI_DIR/js/aw-host-groups.json"
install -m 0644 "$PATCH_JS_SRC" "$PATCH_TARGET"
install -m 0644 "$SW_CLEANUP_SRC" "$SW_TARGET"
install -m 0644 "$HOST_GROUPS_SRC" "$HOST_GROUPS_TARGET"
cp "$INDEX_HTML" "$INDEX_HTML.bak.$TS"
sed -i '/aw-ru-patch.js/d;/aw-sw-cleanup.js/d' "$INDEX_HTML"
sed -i 's#</head>#<script src="/js/aw-sw-cleanup.js"></script></head>#' "$INDEX_HTML"
sed -i 's#</body>#<script defer="defer" src="/js/aw-ru-patch.js"></script></body>#' "$INDEX_HTML"
patch_hash="$(sha1sum "$PATCH_TARGET" | awk '{print substr($1,1,12)}')"
sw_hash="$(sha1sum "$SW_TARGET" | awk '{print substr($1,1,12)}')"
sed -i '/ru-patch-v5.js/d;/sw-cleanup.js/d;/aw-ru-patch.js/d;/aw-sw-cleanup.js/d' "$INDEX_HTML"
sed -i "s#</head>#<script src=\"/js/sw-cleanup.js?v=$sw_hash\"></script></head>#" "$INDEX_HTML"
sed -i "s#</body>#<script defer=\"defer\" src=\"/js/ru-patch-v5.js?v=$patch_hash\"></script></body>#" "$INDEX_HTML"
cp "$SW_CLEANUP_SRC" "$SERVICE_WORKER"
trends_chunk="$(grep -Rsl "$TRENDS_NEEDLE" "$WEBUI_DIR/js"/*.js 2>/dev/null | head -n 1 || true)"
@@ -79,4 +87,25 @@ else
echo "Timespiral hotfix skipped: chunk not found"
fi
echo "RU patch applied to $WEBUI_DIR"
category_helper_chunk="$(grep -Rsl "$CATEGORY_HELPER_NEEDLE" "$WEBUI_DIR/js"/*.js 2>/dev/null | head -n 1 || true)"
if [[ -n "$category_helper_chunk" ]]; then
cp "$category_helper_chunk" "$category_helper_chunk.bak.$TS"
python3 - "$category_helper_chunk" "$CATEGORY_HELPER_NEEDLE" "$CATEGORY_HELPER_REPLACEMENT" <<'PY'
from pathlib import Path
import sys
path = Path(sys.argv[1])
old = sys.argv[2]
new = sys.argv[3]
content = path.read_text()
if old in content:
path.write_text(content.replace(old, new, 1))
print(f"Category helper host hotfix applied to {path}")
else:
print(f"Category helper host hotfix already present in {path}")
PY
else
echo "Category helper host hotfix skipped: chunk not found"
fi
echo "RU patch applied to $WEBUI_DIR (ru-patch-v5.js?v=$patch_hash)"
+5 -1
View File
@@ -8,7 +8,11 @@
"^pve-detmir$"
],
"links": [
{ "label": "Активность", "type": "activity" },
{ "label": "Активность", "type": "activity", "view": "pve_audit" },
{ "label": "Web-admin аудит", "type": "bucket", "bucket_prefix": "aw-pve-webadmin-events_" },
{ "label": "PVE tasks", "type": "bucket", "bucket_prefix": "aw-pve-task-events_" },
{ "label": "SSH сессии", "type": "bucket", "bucket_prefix": "aw-ssh-sessions_" },
{ "label": "Команды shell", "type": "bucket", "bucket_prefix": "aw-console-commands_" },
{ "label": "Web категории", "type": "bucket", "bucket_prefix": "aw-detmir-web-category_" },
{ "label": "Все бакеты", "type": "buckets" }
]
+394 -7
View File
@@ -1,6 +1,6 @@
(function () {
window.__awRuPatchVersion = "template-v6-pve-detmir-client";
document.documentElement.setAttribute("data-aw-ru-patch", "template-v6-pve-detmir-client");
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", "АктивВотч"],
@@ -332,7 +332,20 @@
'.aw-ru-host-item { border: 1px solid rgba(120,120,120,.2); border-radius: 6px; padding: 8px; }',
'.aw-ru-host-item-title { font-weight: 600; margin-bottom: 6px; }',
'.aw-ru-host-links { display: flex; flex-wrap: wrap; gap: 6px; }',
'.aw-ru-host-links a { display: inline-block; padding: 4px 8px; border-radius: 999px; background: rgba(90,140,255,.15); text-decoration: none; }'
'.aw-ru-host-links a { display: inline-block; padding: 4px 8px; border-radius: 999px; background: rgba(90,140,255,.15); text-decoration: none; }',
'.aw-ru-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); }',
'.aw-ru-pve-audit-card h5 { margin: 0 0 6px; font-size: 13px; opacity: .8; }',
'.aw-ru-pve-audit-value { font-size: 24px; font-weight: 700; }',
'.aw-ru-pve-audit-table { width: 100%; border-collapse: collapse; margin-top: 8px; }',
'.aw-ru-pve-audit-table th, .aw-ru-pve-audit-table td { padding: 6px 8px; border-bottom: 1px solid rgba(120,120,120,.18); vertical-align: top; text-align: left; font-size: 13px; }',
'.aw-ru-pve-audit-muted { opacity: .72; font-size: 13px; }'
].join("\n");
document.head.appendChild(style);
}
@@ -358,6 +371,24 @@
return "";
}
function isPveLikeHost(host) {
return /^pve[-_]/i.test(String(host || ""));
}
function enforceSafeActivityViewForPveHost() {
const hash = window.location.hash || "";
const match = hash.match(/^#\/activity\/([^/]+)\/day\/([^/]+)\/view\/([^/?#]+)/i);
if (!match) return;
const host = decodeURIComponent(match[1] || "");
const day = decodeURIComponent(match[2] || "");
const viewId = decodeURIComponent(match[3] || "");
if (!isPveLikeHost(host)) return;
const safeHash = "#/activity/" + encodeURIComponent(host) + "/day/" + encodeURIComponent(day) + "/view/" + encodeURIComponent("pve_audit");
if (safeHash !== hash && !/^pve_audit$/i.test(viewId)) {
window.location.replace(safeHash);
}
}
function getDlpHostFromSettings(settings) {
const routeHost = getCurrentHostFromHash();
if (routeHost) return routeHost;
@@ -605,7 +636,14 @@
}
function injectDlpNavigation(root) {
const href = getDlpHref(window.__awRuPatchSettingsHost || getCurrentHostFromHash());
const hostForDlp = window.__awRuPatchSettingsHost || getCurrentHostFromHash();
if (hostForDlp && isPveLikeHost(hostForDlp)) {
removeBadDlpLinks(root);
const ownItem = root.querySelector("[data-aw-ru-dlp-item='1']");
if (ownItem) ownItem.remove();
return;
}
const href = getDlpHref(hostForDlp);
removeBadDlpLinks(root);
updateDlpLinks(root, href);
if (root.querySelector("[data-aw-ru-dlp-item='1']")) return;
@@ -696,6 +734,29 @@
return state;
}
function isPveActivityRoute() {
const hash = window.location.hash || "";
const match = hash.match(/^#\/activity\/([^/]+)/i);
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 = [
@@ -749,7 +810,8 @@
function buildHostLink(host, hostBuckets, linkDef) {
if (!linkDef || !linkDef.type) return "";
if (linkDef.type === "activity") {
return '#/activity/' + encodeURIComponent(host) + '/day/' + encodeURIComponent(new Date().toISOString().slice(0, 10)) + '/view/summary';
const viewId = linkDef.view ? String(linkDef.view) : "summary";
return '#/activity/' + encodeURIComponent(host) + '/day/' + encodeURIComponent(new Date().toISOString().slice(0, 10)) + '/view/' + encodeURIComponent(viewId);
}
if (linkDef.type === "buckets") {
return "#/buckets";
@@ -1254,6 +1316,90 @@
}
}
async function refreshPveAuditCenter(center, host) {
const message = center.querySelector("[data-aw-ru-pve-audit-message]");
const recentBody = center.querySelector("[data-aw-ru-pve-audit-events]");
message.textContent = "Загрузка audit-событий...";
try {
const [webEvents, taskEvents, sshEvents, cmdEvents] = await Promise.all([
loadBucketEvents("aw-pve-webadmin-events_" + host, 50).catch(function () { return []; }),
loadBucketEvents("aw-pve-task-events_" + host, 50).catch(function () { return []; }),
loadBucketEvents("aw-ssh-sessions_" + host, 50).catch(function () { return []; }),
loadBucketEvents("aw-console-commands_" + host, 50).catch(function () { return []; })
]);
const data = {
web: 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);
center.querySelector("[data-aw-ru-pve-ssh-count]").textContent = String(data.ssh.length);
center.querySelector("[data-aw-ru-pve-cmd-count]").textContent = String(data.cmd.length);
const recent = []
.concat(data.web.map(function (event) { return { kind: "Web-admin", event: event, text: (event.data && (event.data.method || "") + " " + (event.data.path || "")) || "" }; }))
.concat(data.tasks.map(function (event) { return { kind: "PVE task", event: event, text: (event.data && ((event.data.action || "") + " " + (event.data.target || ""))) || "" }; }))
.concat(data.ssh.map(function (event) { return { kind: "SSH", event: event, text: (event.data && ((event.data.event || "") + " " + (event.data.tty || ""))) || "" }; }))
.concat(data.cmd.slice(0, 25).map(function (event) { return { kind: "Shell", event: event, text: (event.data && (event.data.command || "")) || "" }; }))
.sort(function (a, b) { return String(b.event && b.event.timestamp || "").localeCompare(String(a.event && a.event.timestamp || "")); })
.slice(0, 25);
recentBody.innerHTML = recent.length ? recent.map(function (item) {
const ev = item.event || {};
const d = ev.data || {};
return "<tr>" +
"<td>" + escapeHtml(new Date(ev.timestamp).toLocaleString()) + "</td>" +
"<td>" + escapeHtml(item.kind) + "</td>" +
"<td>" + escapeHtml(d.user || d.username || "-") + "</td>" +
"<td>" + escapeHtml(d.remote_ip || d.tty || d.host || "-") + "</td>" +
"<td>" + escapeHtml(item.text) + "</td>" +
"</tr>";
}).join("") : '<tr><td colspan="5">Пока нет audit-событий.</td></tr>';
message.textContent = "Audit-панель обновлена.";
} catch (error) {
recentBody.innerHTML = '<tr><td colspan="5">Не удалось загрузить audit-события.</td></tr>';
message.textContent = "Ошибка загрузки audit-событий: " + error.message;
}
}
function injectPveAuditCenter(root) {
if (!isPveActivityRoute()) return;
const host = getCurrentHostFromHash();
if (!host) return;
const heading = root.querySelector("h3");
if (!heading || !heading.parentElement) return;
let center = root.querySelector("[data-aw-ru-pve-audit='1']");
if (!center) {
center = document.createElement("section");
center.className = "aw-ru-pve-audit";
center.setAttribute("data-aw-ru-pve-audit", "1");
center.innerHTML =
"<h4>PVE Audit</h4>" +
'<p class="aw-ru-pve-audit-muted">Для Proxmox-хоста показывается audit-панель вместо desktop-виджетов ActivityWatch, так как у этого хоста нет window/afk watcher данных.</p>' +
'<div class="aw-ru-pve-audit-grid">' +
'<div class="aw-ru-pve-audit-card"><h5>Web-admin</h5><div class="aw-ru-pve-audit-value" data-aw-ru-pve-web-count>0</div></div>' +
'<div class="aw-ru-pve-audit-card"><h5>PVE tasks</h5><div class="aw-ru-pve-audit-value" data-aw-ru-pve-task-count>0</div></div>' +
'<div class="aw-ru-pve-audit-card"><h5>SSH events</h5><div class="aw-ru-pve-audit-value" data-aw-ru-pve-ssh-count>0</div></div>' +
'<div class="aw-ru-pve-audit-card"><h5>Shell commands</h5><div class="aw-ru-pve-audit-value" data-aw-ru-pve-cmd-count>0</div></div>' +
"</div>" +
'<table class="aw-ru-pve-audit-table">' +
"<thead><tr><th>Время</th><th>Тип</th><th>Пользователь</th><th>Источник</th><th>Детали</th></tr></thead>" +
'<tbody data-aw-ru-pve-audit-events><tr><td colspan="5">Загрузка...</td></tr></tbody>' +
"</table>" +
'<div class="aw-ru-dlp-message" data-aw-ru-pve-audit-message></div>';
heading.parentElement.insertBefore(center, heading.nextSibling);
}
Array.from(heading.parentElement.children).forEach(function (child) {
if (child === heading || child === center) return;
child.style.display = "none";
});
const routeKey = host + "|" + (window.location.hash || "");
if (center.getAttribute("data-aw-ru-pve-route") !== routeKey) {
center.setAttribute("data-aw-ru-pve-route", routeKey);
refreshPveAuditCenter(center, host);
}
}
function injectDlpAlertsCenter(root) {
if (!isAlertsRoute()) return;
const host = window.__awRuPatchSettingsHost || getCurrentHostFromHash();
@@ -1294,6 +1440,8 @@
let trendsRedirectInFlight = false;
let settingsHostFetchInFlight = false;
let applyPatchScheduled = false;
let networkPatchesInstalled = false;
function getTrendsHostFromSettings(settings) {
if (!settings || typeof settings !== "object") return "";
@@ -1354,28 +1502,267 @@
});
}
function getPreferredWindowHostFromBuckets() {
const state = getHostGroupsState();
const rawBuckets = state && state.buckets ? state.buckets : {};
const settingsHost = normalizeText(window.__awRuPatchSettingsHost || "");
const bucketIds = Array.isArray(rawBuckets)
? rawBuckets.map(function (item) { return item && item.id ? String(item.id) : ""; })
: Object.keys(rawBuckets || {});
const hosts = bucketIds
.filter(function (bucketId) { return /^aw-watcher-window_/i.test(bucketId); })
.map(function (bucketId) { return bucketId.replace(/^aw-watcher-window_/i, ""); })
.filter(Boolean)
.filter(function (host) { return !/^unknown$/i.test(host); });
if (settingsHost && hosts.indexOf(settingsHost) >= 0) return settingsHost;
if (settingsHost) return settingsHost;
hosts.sort();
return hosts[0] || "";
}
function rewriteUnknownCategoryBuilderQueryBody(body) {
if (typeof body !== "string") return body;
if (body.indexOf("aw-watcher-window_unknown") === -1 && body.indexOf("aw-watcher-afk_unknown") === -1) {
return body;
}
const preferredHost = getPreferredWindowHostFromBuckets();
if (!preferredHost) return body;
return body
.replace(/aw-watcher-window_unknown/g, "aw-watcher-window_" + preferredHost)
.replace(/aw-watcher-afk_unknown/g, "aw-watcher-afk_" + preferredHost);
}
function installCategoryBuilderNetworkPatch() {
if (networkPatchesInstalled) return;
networkPatchesInstalled = true;
const originalFetch = window.fetch ? window.fetch.bind(window) : null;
if (originalFetch) {
window.fetch = function (input, init) {
try {
const url = typeof input === "string" ? input : String(input && input.url || "");
if (/\/api\/0\/query\/?$/i.test(url) && init && typeof init.body === "string") {
init = Object.assign({}, init, {
body: rewriteUnknownCategoryBuilderQueryBody(init.body)
});
}
} catch (error) {
}
return originalFetch(input, init);
};
}
if (window.XMLHttpRequest && window.XMLHttpRequest.prototype) {
const proto = window.XMLHttpRequest.prototype;
if (!proto.__awRuCategoryBuilderPatched) {
const originalOpen = proto.open;
const originalSend = proto.send;
proto.open = function (method, url) {
this.__awRuMethod = method;
this.__awRuUrl = url;
return originalOpen.apply(this, arguments);
};
proto.send = function (body) {
try {
const url = String(this.__awRuUrl || "");
if (/\/api\/0\/query\/?$/i.test(url) && typeof body === "string") {
body = rewriteUnknownCategoryBuilderQueryBody(body);
}
} catch (error) {
}
return originalSend.call(this, body);
};
proto.__awRuCategoryBuilderPatched = true;
}
}
}
function patchCategoryBuilderHostLabel(root) {
if (!/^#\/settings\/category-builder(?:[/?#]|$)/i.test(window.location.hash || "")) return;
const preferredHost = getPreferredWindowHostFromBuckets();
if (!preferredHost) return;
Array.from(root.querySelectorAll("*")).forEach(function (element) {
if (element.children.length) return;
const text = element.textContent || "";
if (!/Имя хоста:\s*(unknown|неизвестно)\b|Hostname:\s*unknown\b/i.test(text)) return;
const next = text
.replace(/Имя хоста:\s*(unknown|неизвестно)\b/i, "Имя хоста: " + preferredHost)
.replace(/Hostname:\s*unknown\b/i, "Hostname: " + preferredHost);
if (next !== text) {
element.textContent = next;
}
});
}
function patchActivityHeading(root) {
const heading = root.querySelector("h3");
if (!heading) return;
const inlineParts = heading.querySelectorAll("span");
inlineParts.forEach(function (element) {
const text = (element.textContent || "").trim();
if (text === "for") {
element.textContent = "за ";
}
});
}
function 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();
ensureHostGroupsData().catch(function () {});
installCategoryBuilderNetworkPatch();
injectStyles();
walk(document.body);
translateAttributes(document.body);
hideNoiseNavigation(document.body);
patchActivityHeading(document.body);
patchCategoryBuilderHostLabel(document.body);
injectPveAuditCenter(document.body);
injectDlpNavigation(document.body);
injectDlpReviewCenter(document.body);
injectDlpAlertsCenter(document.body);
injectPersonalWorktimeCenter(document.body).catch(function () {});
injectHostGroupsCenter(document.body).catch(function () {});
redirectBareTrendsRoute();
}
function scheduleApplyPatch() {
if (applyPatchScheduled) return;
applyPatchScheduled = true;
window.setTimeout(function () {
applyPatchScheduled = false;
applyPatch();
}, 50);
}
const observer = new MutationObserver(function () {
applyPatch();
scheduleApplyPatch();
});
window.addEventListener("load", function () {
applyPatch();
observer.observe(document.body, { childList: true, subtree: true, characterData: true });
observer.observe(document.body, { childList: true, subtree: true });
});
window.addEventListener("hashchange", function () {
redirectBareTrendsRoute();
scheduleApplyPatch();
});
})();
+31
View File
@@ -21,6 +21,10 @@ required_vars=(
AW_SERVER_GROUP
)
BOOTSTRAP_DIR="/root/bootstrap"
VIEWS_JSON="$BOOTSTRAP_DIR/settings/views-default.json"
CLASSES_JSON="$BOOTSTRAP_DIR/settings/classes-worktime.json"
for var_name in "${required_vars[@]}"; do
if [[ -z "${!var_name:-}" ]]; then
echo "missing required variable: $var_name" >&2
@@ -84,3 +88,30 @@ systemctl daemon-reload
systemctl enable activitywatch-server.service
systemctl restart activitywatch-server.service
systemctl --no-pager --full status activitywatch-server.service || true
for _ in $(seq 1 20); do
if curl -fsS "http://127.0.0.1:${AW_SERVER_PORT}/api/0/info" >/dev/null 2>&1; then
break
fi
sleep 2
done
if [[ -f "$CLASSES_JSON" ]]; then
curl -fsS -X POST \
-H 'Content-Type: application/json' \
--data-binary @"$CLASSES_JSON" \
"http://127.0.0.1:${AW_SERVER_PORT}/api/0/settings/classes" >/dev/null
echo "Applied worktime classes from $CLASSES_JSON"
else
echo "Worktime classes bootstrap not found, skipped: $CLASSES_JSON"
fi
if [[ -f "$VIEWS_JSON" ]]; then
curl -fsS -X POST \
-H 'Content-Type: application/json' \
--data-binary @"$VIEWS_JSON" \
"http://127.0.0.1:${AW_SERVER_PORT}/api/0/settings/views" >/dev/null
echo "Applied baseline views from $VIEWS_JSON"
else
echo "Views bootstrap not found, skipped: $VIEWS_JSON"
fi
+5
View File
@@ -30,5 +30,10 @@
{ "type": "category_tree", "size": 3, "props": {} },
{ "type": "top_apps", "size": 3, "props": {} }
]
},
{
"id": "pve_audit",
"name": "PVE Audit",
"elements": []
}
]
+54
View File
@@ -101,6 +101,60 @@ Get-CimInstance Win32_Process |
1. Поставить `incidentCapture.screenshotEnabled = false` в `deployment-config.json` (для каждого StateRoot).
2. Запустить `Start-ScheduledTask -TaskName 'ActivityWatch Recovery'`.
### SHARKON2025: `Активное время = 0s`, хотя `window`-события есть
Симптом:
- в Activity view за день видно `Worktime = 0s`;
- `Top Window Titles / Top Categories / Category Tree` пустые;
- при этом bucket `aw-watcher-window_SHARKON2025` содержит свежие события.
Подтвержденная причина:
- watcher `afk` "залип" в `status=afk` без `not-afk`;
- из-за этого дневная сводка не считает подтвержденную активность.
Быстрый recovery (с Linux admin host):
1. Проверить учетку входа. Для этого кейса рабочая учетная запись: `SHARKON2025\Администратор` (не `Administrator`).
2. Поднять remote execution через `wmiexec.py` с auth-file:
```sh
cat > /tmp/sharkon_ru.auth << 'EOF'
username = Администратор
password = <PASSWORD>
domain = SHARKON2025
EOF
chmod 600 /tmp/sharkon_ru.auth
```
3. Запустить recovery task:
```sh
wmiexec.py -nooutput -A /tmp/sharkon_ru.auth 192.168.100.21 \
"powershell -NoProfile -Command \"Start-ScheduledTask -TaskName 'ActivityWatch Recovery'\""
```
4. Запустить все launch tasks:
```sh
wmiexec.py -nooutput -A /tmp/sharkon_ru.auth 192.168.100.21 \
"powershell -NoProfile -Command \"Get-ScheduledTask | Where-Object TaskName -like 'ActivityWatch Launch *' | ForEach-Object { Start-ScheduledTask -TaskName \$_.TaskName }\""
```
5. Подождать 10-20 секунд и проверить API на AW server (`10.10.10.13:5600`):
```sh
curl -fsS 'http://10.10.10.13:5600/api/0/buckets/aw-watcher-afk_SHARKON2025/events?limit=30' \
| jq '{latest:.[0].timestamp, statuses:(group_by(.data.status)|map({status:.[0].data.status,count:length}))}'
```
Ожидаемо после фикса:
- в свежих AFK-событиях появляется `status=not-afk`;
- `aw-watcher-window_SHARKON2025` продолжает обновляться;
- после обновления страницы UI дневная сводка перестает быть `0s`.
### Сервис не стартует
```sh
Binary file not shown.
Binary file not shown.
@@ -1,28 +1,37 @@
KIT_DIR=install-kit-awindows-20260427-211240
CREATED_AT=2026-04-27T21:14:11+03:00
FILES:
/home/igor/tmp/AWatch-rus/install-kit-awindows-20260427-211240/MANIFEST.txt
/home/igor/tmp/AWatch-rus/install-kit-awindows-20260427-211240/README-INSTALL-KIT.txt
/home/igor/tmp/AWatch-rus/install-kit-awindows-20260427-211240/ansible/README.md
/home/igor/tmp/AWatch-rus/install-kit-awindows-20260427-211240/ansible/deploy_aw_windows_phase2.yml
/home/igor/tmp/AWatch-rus/install-kit-awindows-20260427-211240/ansible/windows.example.yml
/home/igor/tmp/AWatch-rus/install-kit-awindows-20260427-211240/server-configs-192.168.100.21/phase2-admin.deployment-config.json
/home/igor/tmp/AWatch-rus/install-kit-awindows-20260427-211240/server-configs-192.168.100.21/phase2-u2u5.deployment-config.json
/home/igor/tmp/AWatch-rus/install-kit-awindows-20260427-211240/server-configs-192.168.100.21/phase2-user1.deployment-config.json
/home/igor/tmp/AWatch-rus/install-kit-awindows-20260427-211240/windows/ActivityWatch.Windows.Common.psd1
/home/igor/tmp/AWatch-rus/install-kit-awindows-20260427-211240/windows/ActivityWatch.Windows.Common.psm1
/home/igor/tmp/AWatch-rus/install-kit-awindows-20260427-211240/windows/browser-domains-native-collector.ps1
/home/igor/tmp/AWatch-rus/install-kit-awindows-20260427-211240/windows/deploy-domain-users.ps1
/home/igor/tmp/AWatch-rus/install-kit-awindows-20260427-211240/windows/deploy-ensemble.ps1
/home/igor/tmp/AWatch-rus/install-kit-awindows-20260427-211240/windows/deploy-single-user.ps1
/home/igor/tmp/AWatch-rus/install-kit-awindows-20260427-211240/windows/dlp-endpoint-signals-collector.ps1
/home/igor/tmp/AWatch-rus/install-kit-awindows-20260427-211240/windows/dlp-policy.example.json
/home/igor/tmp/AWatch-rus/install-kit-awindows-20260427-211240/windows/hardening-recovery.ps1
/home/igor/tmp/AWatch-rus/install-kit-awindows-20260427-211240/windows/validate-deployment.ps1
/home/igor/tmp/AWatch-rus/install-kit-awindows-20260427-211240/windows/web-category-rules.example.json
SHA256:
40bdf651a876f1f545c06c9191ff5d9ec3ca1ce75d3edf1d647e3e2fbafece52 /home/igor/tmp/AWatch-rus/install-kit-awindows-20260427-211240.tar.gz
e7e020bff342070d55d2489d9a5e40822543a518863a97cd46865ccf00f64399 /home/igor/tmp/AWatch-rus/install-kit-awindows-20260427-211240.zip
6e1f304f468d77f12df67face6afaaca5bbcfb1496f43df4e4fa0557cf847829 install-kit-awindows-20260427-211240/README-INSTALL-KIT.txt
a11f41827769be915f73d0de2c5503b05f61ccf56f70a50845771bcb79c5ebb7 install-kit-awindows-20260427-211240/ansible/README.md
02ca96f5ecc6abf89ab3271bd08add5795dbba2281168f158d96166f557e33f0 install-kit-awindows-20260427-211240/ansible/deploy_aw_pfsense_poller.yml
7c1ad9363412e802f4272f2e91a1d9f26be722eaf22e654c0a2512b0cebbbfd0 install-kit-awindows-20260427-211240/ansible/deploy_aw_server.yml
d5c42e6fe49c14a0769517ff28184139e467d40f22d4a632630c42ed1ff34ce5 install-kit-awindows-20260427-211240/ansible/deploy_aw_windows_phase2.yml
bc791462b9c00adc8c68ed81e2a7697c560bdbc46f156b4840c7bca1dee2157d install-kit-awindows-20260427-211240/ansible/group_vars/all.example.yml
95696c243ab331f06e77a40a9800c4b6668de77675ebbdf2ef54ae49e1b18874 install-kit-awindows-20260427-211240/ansible/group_vars/pfsense-poller.example.yml
c5cab36645065815571c99f6d360f910dcccbb54b780c8bfd526a6cdc3684e19 install-kit-awindows-20260427-211240/ansible/group_vars/proxmox-matrix.example.yml
35a33c8a1c75ded5e85c6b79e0b3efde07959ff61ee5f66d83b7e0c2abe87fc5 install-kit-awindows-20260427-211240/ansible/group_vars/proxmox.example.yml
69368b7adb7711fa81304866373e61ed464bcc23a08e4509fa54655b05f95790 install-kit-awindows-20260427-211240/ansible/group_vars/windows.example.yml
00064ce5187569fb3221ddd45c2ad7eb37cd50b2a0a832ddf7843e4ff461849a install-kit-awindows-20260427-211240/ansible/inventory.example.ini
bbef175cb77dd53aa07452dbb2fe8797f38b58f42372005c3404c8dc9d6f8e13 install-kit-awindows-20260427-211240/ansible/provision_proxmox_ct_and_deploy_aw.yml
b8f8b6bc504a51cd87db3f46c35a27295b395ea516533068f96af35f8b720434 install-kit-awindows-20260427-211240/ansible/provision_proxmox_ct_matrix_and_deploy_aw.yml
d35bc97b6de18f0006cbbad4adf8a8a5db8ed912997d8fc47c7f11fa9247e907 install-kit-awindows-20260427-211240/ansible/tasks/provision_ct_and_deploy_aw.yml
a50dbadbf619342c2178e255b68f69a36503756daf80eedd9170311c63964f2e install-kit-awindows-20260427-211240/aw-server/activitywatch-server.service
2dbf55d4a8f204ebdc97af926d90435932c0aad7a2431e2b9987e47c5abf71a9 install-kit-awindows-20260427-211240/aw-server/apply_webui_ru_patch.sh
07d4e583f6e9757a11f01558e1f15cfd73c4d82695f1768204f2f50621712168 install-kit-awindows-20260427-211240/aw-server/aw-host-groups.json
09605da1754abb0dc0446825580b57ebad6e646dc670f9f072fce1489e88dd43 install-kit-awindows-20260427-211240/aw-server/aw-ru-patch.js
7c5952f8f0a8590e849ea8381bfcd7059b138250bca8551bd5625f395eb66cd8 install-kit-awindows-20260427-211240/aw-server/aw-server.env.example
98c0bed353bbda0fa7a69df23f3b008cb0e8e70cdff6cc63330d4caf79fd3280 install-kit-awindows-20260427-211240/aw-server/aw-sw-cleanup.js
dce731fdfdcfd773c154d12dbd6b9e621a0bff17ced5a05a65f0fdcb1adcb70f install-kit-awindows-20260427-211240/aw-server/install_aw_server.sh
1856e9f44636030b0cb9ece37ba2a0618eb5187fa82c7969976c1bb5f10fc622 install-kit-awindows-20260427-211240/aw-server/settings/classes-worktime.json
ff07b90cb6a7f09b27d522307cf55b0359e136a2e695190b8564e859f14f9204 install-kit-awindows-20260427-211240/aw-server/settings/views-default.json
59307d284caa74eb3dc129765f9db93b6e8dfd5b1d960b98f347a332b23f82dc install-kit-awindows-20260427-211240/server-configs-192.168.100.21/phase2-admin.deployment-config.json
f2cee1872bf274f15dcfb8fb595fb20a11d228a4bae0930dc6cb918a6f800756 install-kit-awindows-20260427-211240/server-configs-192.168.100.21/phase2-u2u5.deployment-config.json
6aefedcdac8c1d3823c9f4065b051a67a221a3e9424c913a673667b2a23ea1e7 install-kit-awindows-20260427-211240/server-configs-192.168.100.21/phase2-user1.deployment-config.json
33aa34b89246d6c079ef9afe2f5cd153bd9d5946b69a175ff6fd678c77f61da5 install-kit-awindows-20260427-211240/windows/ActivityWatch.Windows.Common.psd1
d506614168227fa01fa481289079b432b6d8846d5ee8961cff8c21fd0bf7ea8f install-kit-awindows-20260427-211240/windows/ActivityWatch.Windows.Common.psm1
2a0b94ddad43a6bc684037243e636a54c168d8d4ad25b29778c4d78180da2532 install-kit-awindows-20260427-211240/windows/browser-domains-native-collector.ps1
98bfcf5dca972f1ba1845bbca546133c192824ec40dd2e55f4e6d59c24a834d1 install-kit-awindows-20260427-211240/windows/deploy-domain-users.ps1
a19c2a98e6483eb921457f472d1cf62a76e344f2ef3621f04fafa3d7dc2df353 install-kit-awindows-20260427-211240/windows/deploy-ensemble.ps1
2542425e02acd8a8b02ed701ca2382cf7ae612be28441d1a10a40e48c6a13ad0 install-kit-awindows-20260427-211240/windows/deploy-single-user.ps1
a2f963927c8b263a21aaffc0a926a058dcec65a04a5fa57c271d3dbe59f9347c install-kit-awindows-20260427-211240/windows/dlp-endpoint-signals-collector.ps1
aef0032edd9b1e0c54f7b575664ed511dfc6cb53364e7496cbc95e137678e11a install-kit-awindows-20260427-211240/windows/dlp-policy.example.json
ade74a55ce00d9295f2efa0fd72f987688154c93f1142ce0eb6e982f07271be7 install-kit-awindows-20260427-211240/windows/hardening-recovery.ps1
88ffe06093ef5f7247bd2b990b0c8f801c706a26dc87725bb1194504fab7e306 install-kit-awindows-20260427-211240/windows/validate-deployment.ps1
731098681d89b9af6f3872abd586ac3b1faba2d7f9340211e503f52ad0243b3f install-kit-awindows-20260427-211240/windows/web-category-rules.example.json
@@ -2,9 +2,9 @@ ActivityWatch DetMir Windows Install Kit
Includes:
- windows/* (deploy scripts, collectors, common module, configs/examples)
- ansible/deploy_aw_windows_phase2.yml
- ansible/group_vars/windows.example.yml
- ansible/README.md
- ansible/* (Windows and AW server playbooks, examples, inventory, tasks)
- aw-server/* (server installer, RU patch loader, host groups, default settings)
- server-configs-192.168.100.21/* (working Windows Phase2 config snapshots)
Source:
- Local project snapshot at build time.
@@ -0,0 +1,66 @@
---
- name: Deploy pfSense ActivityWatch poller
hosts: aw_pfsense_pollers
become: true
gather_facts: true
vars:
aw_pfsense_install_root: "/opt/aw-pfsense"
aw_pfsense_config_dir: "/etc/aw-pfsense"
aw_pfsense_service_name: "aw-pfsense-poller.service"
tasks:
- name: Install required packages
ansible.builtin.apt:
name:
- python3
state: present
update_cache: true
- name: Ensure directories exist
ansible.builtin.file:
path: "{{ item }}"
state: directory
mode: "0755"
loop:
- "{{ aw_pfsense_install_root }}"
- "{{ aw_pfsense_config_dir }}"
- name: Install pfSense poller script
ansible.builtin.copy:
src: "{{ aw_repo_root }}/pfsense/pfsense-aw-poller.py"
dest: "{{ aw_pfsense_install_root }}/pfsense-aw-poller.py"
mode: "0755"
- name: Install systemd service
ansible.builtin.copy:
src: "{{ aw_repo_root }}/pfsense/pfsense-aw-poller.service"
dest: "/etc/systemd/system/{{ aw_pfsense_service_name }}"
mode: "0644"
notify:
- Reload systemd
- name: Write pfSense poller config
ansible.builtin.copy:
dest: "{{ aw_pfsense_config_dir }}/poller.json"
mode: "0600"
content: "{{ aw_pfsense_poller_config | to_nice_json }}"
notify:
- Restart pfSense poller
- name: Enable and start pfSense poller
ansible.builtin.systemd:
name: "{{ aw_pfsense_service_name }}"
enabled: true
state: restarted
daemon_reload: true
handlers:
- name: Reload systemd
ansible.builtin.systemd:
daemon_reload: true
- name: Restart pfSense poller
ansible.builtin.systemd:
name: "{{ aw_pfsense_service_name }}"
state: restarted
@@ -0,0 +1,229 @@
---
- name: Deploy AWatch-rus server
hosts: aw_server
become: true
gather_facts: true
vars:
aw_release_root: "/opt/activitywatch/releases"
aw_release_dir: "{{ aw_release_root }}/{{ aw_server_version }}"
aw_archive_path: "/tmp/activitywatch-{{ aw_server_version }}.zip"
aw_bootstrap_dir: "/tmp/aw-rus-bootstrap"
aw_ru_patch_cache_bust: "{{ lookup('file', aw_repo_root + '/aw-server/aw-ru-patch.js') | hash('sha1') | truncate(12, true, '') }}"
aw_sw_cleanup_cache_bust: "{{ lookup('file', aw_repo_root + '/aw-server/aw-sw-cleanup.js') | hash('sha1') | truncate(12, true, '') }}"
aw_host_groups_cache_bust: "{{ lookup('file', aw_repo_root + '/aw-server/aw-host-groups.json') | hash('sha1') | truncate(12, true, '') }}"
aw_worktime_classes: "{{ lookup('file', aw_repo_root + '/aw-server/settings/classes-worktime.json') | from_json }}"
aw_default_views: "{{ lookup('file', aw_repo_root + '/aw-server/settings/views-default.json') | from_json }}"
tasks:
- name: Install base packages
ansible.builtin.apt:
name:
- curl
- unzip
state: present
update_cache: true
- name: Ensure service account exists
ansible.builtin.user:
name: "{{ aw_server_user }}"
group: "{{ aw_server_group }}"
home: "{{ aw_server_data_dir }}"
shell: /usr/sbin/nologin
system: true
create_home: false
- name: Ensure required directories
ansible.builtin.file:
path: "{{ item }}"
state: directory
owner: "{{ aw_server_user }}"
group: "{{ aw_server_group }}"
mode: "0755"
loop:
- "{{ aw_release_root }}"
- "{{ aw_release_dir }}"
- "{{ aw_server_webui_dir }}"
- "{{ aw_server_data_dir }}"
- "{{ aw_server_log_dir }}"
- /etc/activitywatch
- "{{ aw_bootstrap_dir }}"
- name: Download ActivityWatch release archive
ansible.builtin.get_url:
url: "{{ aw_server_download_url }}"
dest: "{{ aw_archive_path }}"
mode: "0644"
- name: Unpack ActivityWatch release
ansible.builtin.unarchive:
src: "{{ aw_archive_path }}"
dest: "{{ aw_release_dir }}"
remote_src: true
extra_opts: ["-o"]
- name: Discover extracted AW directory
ansible.builtin.find:
paths: "{{ aw_release_dir }}"
file_type: directory
patterns: "activitywatch*"
register: aw_release_find
- name: Set release extracted path
ansible.builtin.set_fact:
aw_release_extracted: "{{ (aw_release_find.files | sort(attribute='path') | map(attribute='path') | list | first) }}"
- name: Verify extracted directory exists
ansible.builtin.assert:
that:
- aw_release_extracted is defined
- aw_release_extracted | length > 0
fail_msg: "Cannot locate extracted ActivityWatch release directory."
- name: Sync release content to /opt/activitywatch
ansible.builtin.command:
cmd: "rsync -a --delete {{ aw_release_extracted }}/ /opt/activitywatch/"
- name: Copy bootstrap files from repository
ansible.builtin.copy:
src: "{{ item.src }}"
dest: "{{ item.dest }}"
mode: "{{ item.mode }}"
loop:
- { src: "{{ aw_repo_root }}/aw-server/activitywatch-server.service", dest: "/etc/systemd/system/activitywatch-server.service", mode: "0644" }
- { src: "{{ aw_repo_root }}/aw-server/aw-ru-patch.js", dest: "{{ aw_server_webui_dir }}/js/ru-patch-v5.js", mode: "0644" }
- { src: "{{ aw_repo_root }}/aw-server/aw-sw-cleanup.js", dest: "{{ aw_server_webui_dir }}/js/sw-cleanup.js", mode: "0644" }
- { src: "{{ aw_repo_root }}/aw-server/aw-host-groups.json", dest: "{{ aw_server_webui_dir }}/js/aw-host-groups.json", mode: "0644" }
notify:
- Reload systemd
- Restart activitywatch
- name: Copy WebUI index template from installed distribution
ansible.builtin.copy:
remote_src: true
src: "/opt/activitywatch/aw-webui/index.html"
dest: "{{ aw_server_webui_dir }}/index.html"
mode: "0644"
- name: Insert RU patch scripts into index.html
ansible.builtin.replace:
path: "{{ aw_server_webui_dir }}/index.html"
regexp: '</head>'
replace: '<script src="/js/sw-cleanup.js?v={{ aw_sw_cleanup_cache_bust }}"></script></head>'
- name: Insert RU patch loader before body end
ansible.builtin.replace:
path: "{{ aw_server_webui_dir }}/index.html"
regexp: '</body>'
replace: '<script defer="defer" src="/js/ru-patch-v5.js?v={{ aw_ru_patch_cache_bust }}"></script></body>'
- name: Write /etc/activitywatch/aw-server.env
ansible.builtin.copy:
dest: /etc/activitywatch/aw-server.env
mode: "0640"
content: |
AW_SERVER_HOST={{ aw_server_bind_host }}
AW_SERVER_PORT={{ aw_server_port }}
AW_DATA_DIR={{ aw_server_data_dir }}
AW_LOG_DIR={{ aw_server_log_dir }}
AW_WEBUI_DIR={{ aw_server_webui_dir }}
AW_SERVER_USER={{ aw_server_user }}
AW_SERVER_GROUP={{ aw_server_group }}
- name: Enable and start service
ansible.builtin.systemd:
name: activitywatch-server.service
enabled: true
state: restarted
daemon_reload: true
- name: Wait for API
ansible.builtin.uri:
url: "http://127.0.0.1:{{ aw_server_port }}/api/0/info"
method: GET
status_code: 200
register: aw_api
retries: 10
delay: 3
until: aw_api.status == 200
- name: Apply baseline worktime settings (classes)
ansible.builtin.uri:
url: "http://127.0.0.1:{{ aw_server_port }}/api/0/settings/classes"
method: POST
body: "{{ aw_worktime_classes }}"
body_format: json
status_code: 201
when: aw_apply_worktime_settings | default(false) | bool
- name: Apply baseline views (include DLP and worktime)
ansible.builtin.uri:
url: "http://127.0.0.1:{{ aw_server_port }}/api/0/settings/views"
method: POST
body: "{{ aw_default_views }}"
body_format: json
status_code: 201
when: aw_apply_worktime_settings | default(false) | bool
- name: Derive worktime durationDefault from aw_worktime_from/to
ansible.builtin.set_fact:
aw_worktime_from_h: "{{ (aw_worktime_from | default('08:00')).split(':')[0] | int }}"
aw_worktime_from_m: "{{ (aw_worktime_from | default('08:00')).split(':')[1] | int }}"
aw_worktime_to_h: "{{ (aw_worktime_to | default('17:00')).split(':')[0] | int }}"
aw_worktime_to_m: "{{ (aw_worktime_to | default('17:00')).split(':')[1] | int }}"
aw_worktime_duration_default_derived: >-
{{
(
(
((aw_worktime_to_h | int) * 60 + (aw_worktime_to_m | int)) -
((aw_worktime_from_h | int) * 60 + (aw_worktime_from_m | int))
) * 60
)
}}
when: aw_apply_worktime_settings | default(false) | bool
- name: Normalize derived durationDefault for overnight shifts
ansible.builtin.set_fact:
aw_worktime_duration_default_effective: >-
{{
(aw_worktime_duration_default_derived | int)
if (aw_worktime_duration_default_derived | int) > 0
else ((aw_worktime_duration_default_derived | int) + 86400)
}}
when: aw_apply_worktime_settings | default(false) | bool
- name: Validate derived durationDefault is sane
ansible.builtin.assert:
that:
- aw_worktime_duration_default_effective | int > 0
- aw_worktime_duration_default_effective | int <= 86400
fail_msg: "Invalid worktime window: {{ aw_worktime_from }}..{{ aw_worktime_to }}"
when: aw_apply_worktime_settings | default(false) | bool
- name: Apply baseline worktime period (startOfDay)
ansible.builtin.uri:
url: "http://127.0.0.1:{{ aw_server_port }}/api/0/settings/startOfDay"
method: POST
body: "{{ aw_worktime_start_of_day }}"
body_format: json
status_code: 200
when: aw_apply_worktime_settings | default(false) | bool
- name: Apply baseline worktime period (durationDefault seconds)
ansible.builtin.uri:
url: "http://127.0.0.1:{{ aw_server_port }}/api/0/settings/durationDefault"
method: POST
body: "{{ aw_worktime_duration_default_effective }}"
body_format: json
status_code: 200
when: aw_apply_worktime_settings | default(false) | bool
handlers:
- name: Reload systemd
ansible.builtin.systemd:
daemon_reload: true
- name: Restart activitywatch
ansible.builtin.systemd:
name: activitywatch-server.service
state: restarted
@@ -0,0 +1,24 @@
aw_server_version: "v0.13.2"
aw_server_download_url: "https://github.com/ActivityWatch/activitywatch/releases/download/v0.13.2/activitywatch-v0.13.2-linux-x86_64.zip"
aw_server_bind_host: "0.0.0.0"
aw_server_port: 5600
aw_server_webui_dir: "/opt/activitywatch/webui-ru"
aw_server_data_dir: "/var/lib/activitywatch"
aw_server_log_dir: "/var/log/activitywatch"
aw_server_user: "activitywatch"
aw_server_group: "activitywatch"
aw_repo_root: "/home/igor/tmp/AWatch-rus"
# Optional: apply a baseline worktime-focused categorization and views via AW settings API.
# WARNING: this overwrites existing server-side settings/classes/views.
aw_apply_worktime_settings: false
# Optional defaults for the worktime period in Web UI.
# startOfDay controls day-boundary and default report window start.
# durationDefault controls default time range (seconds) shown in UI.
#
# Recommended: set worktime window explicitly and let the playbook derive duration.
aw_worktime_from: "08:00"
aw_worktime_to: "17:00"
aw_worktime_start_of_day: "{{ aw_worktime_from }}"
@@ -0,0 +1,30 @@
aw_pfsense_poller_config:
poll_interval_seconds: 60
aw:
server_host: "10.10.10.13"
server_port: 5600
hostname: "PFSENSE-EDGE01"
pulse_time_seconds: 120
timeout_seconds: 15
pfsense:
name: "pfSense Edge 01"
host: "10.10.10.1"
scheme: "https"
verify_tls: false
timeout_seconds: 15
headers:
X-API-Key: "replace-me"
X-API-Secret: "replace-me"
endpoints:
- name: "system-status"
path: "/api/v2/status/system"
bucket_prefix: "aw-pfsense-health"
bucket_type: "aw.pfsense.health"
- name: "interfaces"
path: "/api/v2/interface"
bucket_prefix: "aw-pfsense-interfaces"
bucket_type: "aw.pfsense.interfaces"
- name: "gateways"
path: "/api/v2/status/gateways"
bucket_prefix: "aw-pfsense-gateways"
bucket_type: "aw.pfsense.gateways"
@@ -0,0 +1,38 @@
proxmox_ct_matrix:
- id: "203"
hostname: "activitywatch-user1"
storage: "local-lvm"
template: "local:vztmpl/debian-12-standard_12.7-1_amd64.tar.zst"
rootfs_size: "8G"
cores: "2"
memory: "2048"
swap: "512"
bridge: "vmbr10"
ip: "10.20.30.13/24"
gw: "10.20.30.1"
vlan: ""
nameserver: "1.1.1.1 8.8.8.8"
searchdomain: "example.internal"
password: "CHANGE_ME"
unprivileged: "1"
onboot: "1"
features: "nesting=1,keyctl=1"
- id: "204"
hostname: "activitywatch-user2"
storage: "local-lvm"
template: "local:vztmpl/debian-12-standard_12.7-1_amd64.tar.zst"
rootfs_size: "8G"
cores: "2"
memory: "2048"
swap: "512"
bridge: "vmbr10"
ip: "10.20.30.14/24"
gw: "10.20.30.1"
vlan: ""
nameserver: "1.1.1.1 8.8.8.8"
searchdomain: "example.internal"
password: "CHANGE_ME"
unprivileged: "1"
onboot: "1"
features: "nesting=1,keyctl=1"
@@ -0,0 +1,18 @@
proxmox_ct_id: "203"
proxmox_ct_hostname: "activitywatch-server"
proxmox_ct_storage: "local-lvm"
proxmox_ct_template: "local:vztmpl/debian-12-standard_12.7-1_amd64.tar.zst"
proxmox_ct_rootfs_size: "8G"
proxmox_ct_cores: "2"
proxmox_ct_memory: "2048"
proxmox_ct_swap: "512"
proxmox_ct_bridge: "vmbr10"
proxmox_ct_ip: "10.20.30.13/24"
proxmox_ct_gw: "10.20.30.1"
proxmox_ct_vlan: ""
proxmox_ct_nameserver: "1.1.1.1 8.8.8.8"
proxmox_ct_searchdomain: "example.internal"
proxmox_ct_password: "CHANGE_ME"
proxmox_ct_unprivileged: "1"
proxmox_ct_onboot: "1"
proxmox_ct_features: "nesting=1,keyctl=1"
@@ -0,0 +1,8 @@
[proxmox]
pve-main ansible_host=192.168.10.2 ansible_user=root ansible_port=22
[aw_server]
aw-ct ansible_host=10.20.30.13 ansible_user=root ansible_port=22
[aw_windows]
win-node1 ansible_host=192.168.100.21 ansible_user=Administrator ansible_password=CHANGE_ME ansible_connection=winrm ansible_winrm_transport=ntlm ansible_port=5985 ansible_winrm_server_cert_validation=ignore
@@ -0,0 +1,40 @@
---
- name: Provision single Proxmox CT and deploy AWatch-rus
hosts: proxmox
gather_facts: false
vars:
proxmox_bootstrap_dir: "/tmp/aw-rus-bootstrap"
aw_bootstrap_files:
- install_aw_server.sh
- apply_webui_ru_patch.sh
- activitywatch-server.service
- aw-server.env.example
- aw-ru-patch.js
- aw-sw-cleanup.js
- aw-host-groups.json
- settings/classes-worktime.json
- settings/views-default.json
tasks:
- name: Execute single-CT provisioning workflow
ansible.builtin.include_tasks: tasks/provision_ct_and_deploy_aw.yml
vars:
ct_id: "{{ proxmox_ct_id }}"
ct_hostname: "{{ proxmox_ct_hostname }}"
ct_storage: "{{ proxmox_ct_storage }}"
ct_template: "{{ proxmox_ct_template }}"
ct_rootfs_size: "{{ proxmox_ct_rootfs_size }}"
ct_cores: "{{ proxmox_ct_cores }}"
ct_memory: "{{ proxmox_ct_memory }}"
ct_swap: "{{ proxmox_ct_swap }}"
ct_bridge: "{{ proxmox_ct_bridge }}"
ct_ip: "{{ proxmox_ct_ip }}"
ct_gw: "{{ proxmox_ct_gw }}"
ct_vlan: "{{ proxmox_ct_vlan | default('') }}"
ct_nameserver: "{{ proxmox_ct_nameserver | default('') }}"
ct_searchdomain: "{{ proxmox_ct_searchdomain | default('') }}"
ct_password: "{{ proxmox_ct_password }}"
ct_unprivileged: "{{ proxmox_ct_unprivileged }}"
ct_onboot: "{{ proxmox_ct_onboot }}"
ct_features: "{{ proxmox_ct_features }}"
@@ -0,0 +1,50 @@
---
- name: Provision Proxmox CT matrix and deploy AWatch-rus with RU patch
hosts: proxmox
gather_facts: false
vars:
proxmox_bootstrap_dir: "/tmp/aw-rus-bootstrap"
aw_bootstrap_files:
- install_aw_server.sh
- apply_webui_ru_patch.sh
- activitywatch-server.service
- aw-server.env.example
- aw-ru-patch.js
- aw-sw-cleanup.js
- aw-host-groups.json
- settings/classes-worktime.json
- settings/views-default.json
tasks:
- name: Validate CT matrix is provided
ansible.builtin.assert:
that:
- proxmox_ct_matrix is defined
- proxmox_ct_matrix | length > 0
fail_msg: "Define proxmox_ct_matrix in group_vars/proxmox-matrix.yml"
- name: Execute provisioning workflow for each CT
ansible.builtin.include_tasks: tasks/provision_ct_and_deploy_aw.yml
vars:
ct_id: "{{ item.id }}"
ct_hostname: "{{ item.hostname }}"
ct_storage: "{{ item.storage }}"
ct_template: "{{ item.template }}"
ct_rootfs_size: "{{ item.rootfs_size }}"
ct_cores: "{{ item.cores }}"
ct_memory: "{{ item.memory }}"
ct_swap: "{{ item.swap }}"
ct_bridge: "{{ item.bridge }}"
ct_ip: "{{ item.ip }}"
ct_gw: "{{ item.gw }}"
ct_vlan: "{{ item.vlan | default('') }}"
ct_nameserver: "{{ item.nameserver | default('') }}"
ct_searchdomain: "{{ item.searchdomain | default('') }}"
ct_password: "{{ item.password }}"
ct_unprivileged: "{{ item.unprivileged }}"
ct_onboot: "{{ item.onboot }}"
ct_features: "{{ item.features }}"
loop: "{{ proxmox_ct_matrix }}"
loop_control:
label: "ct={{ item.id }} host={{ item.hostname }} ip={{ item.ip }}"
@@ -0,0 +1,217 @@
---
- name: Validate required per-CT variables
ansible.builtin.assert:
that:
- ct_id is defined
- ct_hostname is defined
- ct_storage is defined
- ct_template is defined
- ct_rootfs_size is defined
- ct_cores is defined
- ct_memory is defined
- ct_swap is defined
- ct_bridge is defined
- ct_ip is defined
- ct_gw is defined
- ct_password is defined
- ct_unprivileged is defined
- ct_onboot is defined
- ct_features is defined
- aw_repo_root is defined
- aw_server_version is defined
- aw_server_download_url is defined
- aw_server_bind_host is defined
- aw_server_port is defined
- aw_server_webui_dir is defined
- aw_server_data_dir is defined
- aw_server_log_dir is defined
- aw_server_user is defined
- aw_server_group is defined
fail_msg: "Missing required variables for CT provisioning/deploy."
- name: Build CT network string
ansible.builtin.set_fact:
ct_net0: >-
name=eth0,bridge={{ ct_bridge }},ip={{ ct_ip }},gw={{ ct_gw }}{% if (ct_vlan | default('') | string | length) > 0 %},tag={{ ct_vlan }}{% endif %}
- name: Check whether CT already exists
ansible.builtin.command:
argv:
- pct
- status
- "{{ ct_id }}"
register: ct_status_check
failed_when: false
changed_when: false
- name: Create CT when absent
ansible.builtin.command:
argv:
- pct
- create
- "{{ ct_id }}"
- "{{ ct_template }}"
- --hostname
- "{{ ct_hostname }}"
- --cores
- "{{ ct_cores }}"
- --memory
- "{{ ct_memory }}"
- --swap
- "{{ ct_swap }}"
- --rootfs
- "{{ ct_storage }}:{{ ct_rootfs_size }}"
- --password
- "{{ ct_password }}"
- --unprivileged
- "{{ ct_unprivileged }}"
- --onboot
- "{{ ct_onboot }}"
- --features
- "{{ ct_features }}"
- --net0
- "{{ ct_net0 }}"
- --nameserver
- "{{ ct_nameserver | default('') }}"
- --searchdomain
- "{{ ct_searchdomain | default('') }}"
- --ostype
- debian
when: ct_status_check.rc != 0
- name: Check current CT runtime state
ansible.builtin.command:
argv:
- pct
- status
- "{{ ct_id }}"
register: ct_runtime_status
changed_when: false
- name: Start CT when stopped
ansible.builtin.command:
argv:
- pct
- start
- "{{ ct_id }}"
when: "'stopped' in ct_runtime_status.stdout"
- name: Ensure bootstrap directory on Proxmox host
ansible.builtin.file:
path: "{{ proxmox_bootstrap_dir }}"
state: directory
mode: "0700"
- name: Copy AW bootstrap files to Proxmox host temp
ansible.builtin.copy:
src: "{{ aw_repo_root }}/aw-server/{{ item }}"
dest: "{{ proxmox_bootstrap_dir }}/{{ item }}"
mode: "0644"
loop: "{{ aw_bootstrap_files }}"
- name: Bootstrap CT OS dependencies
ansible.builtin.command:
argv:
- pct
- exec
- "{{ ct_id }}"
- --
- bash
- -lc
- |
set -euo pipefail
export DEBIAN_FRONTEND=noninteractive
apt-get update
apt-get install -y curl ca-certificates bash unzip xz-utils jq rsync openssh-server
mkdir -p /root/bootstrap /etc/activitywatch
systemctl enable ssh || true
systemctl restart ssh || true
- name: Push bootstrap files into CT
ansible.builtin.command:
argv:
- pct
- push
- "{{ ct_id }}"
- "{{ proxmox_bootstrap_dir }}/{{ item }}"
- "/root/bootstrap/{{ item }}"
loop: "{{ aw_bootstrap_files }}"
- name: Write AW server env file on Proxmox host temp
ansible.builtin.copy:
dest: "{{ proxmox_bootstrap_dir }}/aw-server.env"
mode: "0600"
content: |
AW_SERVER_VERSION={{ aw_server_version }}
AW_SERVER_DOWNLOAD_URL={{ aw_server_download_url }}
AW_SERVER_BIND_HOST={{ aw_server_bind_host }}
AW_SERVER_PORT={{ aw_server_port }}
AW_SERVER_WEBUI_DIR={{ aw_server_webui_dir }}
AW_SERVER_DATA_DIR={{ aw_server_data_dir }}
AW_SERVER_LOG_DIR={{ aw_server_log_dir }}
AW_SERVER_USER={{ aw_server_user }}
AW_SERVER_GROUP={{ aw_server_group }}
- name: Push AW server env into CT
ansible.builtin.command:
argv:
- pct
- push
- "{{ ct_id }}"
- "{{ proxmox_bootstrap_dir }}/aw-server.env"
- /etc/activitywatch/aw-server.env
- name: Set mode for env inside CT
ansible.builtin.command:
argv:
- pct
- exec
- "{{ ct_id }}"
- --
- chmod
- "0600"
- /etc/activitywatch/aw-server.env
- name: Install server and apply RU patch inside CT
ansible.builtin.command:
argv:
- pct
- exec
- "{{ ct_id }}"
- --
- bash
- -lc
- |
set -euo pipefail
chmod +x /root/bootstrap/install_aw_server.sh /root/bootstrap/apply_webui_ru_patch.sh
bash /root/bootstrap/install_aw_server.sh
bash /root/bootstrap/apply_webui_ru_patch.sh
systemctl restart activitywatch-server.service
- name: Validate AW API from inside CT
ansible.builtin.command:
argv:
- pct
- exec
- "{{ ct_id }}"
- --
- bash
- -lc
- "curl -fsS http://127.0.0.1:{{ aw_server_port }}/api/0/info >/dev/null"
- name: Validate RU patch hooks in index
ansible.builtin.command:
argv:
- pct
- exec
- "{{ ct_id }}"
- --
- bash
- -lc
- "grep -q 'ru-patch-v5.js' {{ aw_server_webui_dir }}/index.html && grep -q 'sw-cleanup.js' {{ aw_server_webui_dir }}/index.html"
- name: Show final endpoint
ansible.builtin.debug:
msg:
- "CT {{ ct_id }} is provisioned and configured."
- "ActivityWatch endpoint: http://{{ ct_ip | regex_replace('/[0-9]+$', '') }}:{{ aw_server_port }}"
@@ -0,0 +1,24 @@
[Unit]
Description=ActivityWatch Server (Rust)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
EnvironmentFile=/etc/activitywatch/aw-server.env
User=__AW_SERVER_USER__
Group=__AW_SERVER_GROUP__
WorkingDirectory=__AW_SERVER_DATA_DIR__
ExecStart=/bin/sh -lc 'exec /opt/activitywatch/bin/aw-server-rust --host "$AW_SERVER_BIND_HOST" --port "$AW_SERVER_PORT"'
Restart=on-failure
RestartSec=5s
StateDirectory=activitywatch
LogsDirectory=activitywatch
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ProtectHome=true
LimitNOFILE=65535
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,111 @@
#!/bin/bash
set -euo pipefail
ENV_FILE="/etc/activitywatch/aw-server.env"
if [[ ! -f "$ENV_FILE" ]]; then
echo "missing env file: $ENV_FILE" >&2
exit 1
fi
source "$ENV_FILE"
WEBUI_DIR="${AW_SERVER_WEBUI_DIR:-${AW_WEBUI_DIR:-/opt/activitywatch/webui-ru}}"
PATCH_JS_SRC="/root/bootstrap/aw-ru-patch.js"
SW_CLEANUP_SRC="/root/bootstrap/aw-sw-cleanup.js"
HOST_GROUPS_SRC="/root/bootstrap/aw-host-groups.json"
INDEX_HTML="$WEBUI_DIR/index.html"
SERVICE_WORKER="$WEBUI_DIR/service-worker.js"
TS=$(date +%Y%m%d%H%M%S)
PATCH_TARGET="$WEBUI_DIR/js/ru-patch-v5.js"
SW_TARGET="$WEBUI_DIR/js/sw-cleanup.js"
HOST_GROUPS_TARGET="$WEBUI_DIR/js/aw-host-groups.json"
TRENDS_NEEDLE='this.activityStore.query_category_time_by_period(r)'
TRENDS_REPLACEMENT='this.activityStore.ensure_loaded(r)'
TIMESPIRAL_NEEDLE='start:new Date("2022-08-08")'
TIMESPIRAL_REPLACEMENT='start:new Date(Date.now()-12*36e5)'
CATEGORY_HELPER_NEEDLE='hostname:t.hostnameChoices[0]'
CATEGORY_HELPER_REPLACEMENT='hostname:t.hostnameChoices.filter((function(t){return"unknown"!==t}))[0]||t.hostnameChoices[0]'
[[ -f "$PATCH_JS_SRC" ]] || { echo "missing $PATCH_JS_SRC" >&2; exit 1; }
[[ -f "$SW_CLEANUP_SRC" ]] || { echo "missing $SW_CLEANUP_SRC" >&2; exit 1; }
[[ -f "$HOST_GROUPS_SRC" ]] || { echo "missing $HOST_GROUPS_SRC" >&2; exit 1; }
[[ -f "$INDEX_HTML" ]] || { echo "missing $INDEX_HTML" >&2; exit 1; }
install -d "$WEBUI_DIR/js"
install -m 0644 "$PATCH_JS_SRC" "$PATCH_TARGET"
install -m 0644 "$SW_CLEANUP_SRC" "$SW_TARGET"
install -m 0644 "$HOST_GROUPS_SRC" "$HOST_GROUPS_TARGET"
cp "$INDEX_HTML" "$INDEX_HTML.bak.$TS"
patch_hash="$(sha1sum "$PATCH_TARGET" | awk '{print substr($1,1,12)}')"
sw_hash="$(sha1sum "$SW_TARGET" | awk '{print substr($1,1,12)}')"
sed -i '/ru-patch-v5.js/d;/sw-cleanup.js/d;/aw-ru-patch.js/d;/aw-sw-cleanup.js/d' "$INDEX_HTML"
sed -i "s#</head>#<script src=\"/js/sw-cleanup.js?v=$sw_hash\"></script></head>#" "$INDEX_HTML"
sed -i "s#</body>#<script defer=\"defer\" src=\"/js/ru-patch-v5.js?v=$patch_hash\"></script></body>#" "$INDEX_HTML"
cp "$SW_CLEANUP_SRC" "$SERVICE_WORKER"
trends_chunk="$(grep -Rsl "$TRENDS_NEEDLE" "$WEBUI_DIR/js"/*.js 2>/dev/null | head -n 1 || true)"
if [[ -n "$trends_chunk" ]]; then
cp "$trends_chunk" "$trends_chunk.bak.$TS"
python3 - "$trends_chunk" "$TRENDS_NEEDLE" "$TRENDS_REPLACEMENT" <<'PY'
from pathlib import Path
import sys
path = Path(sys.argv[1])
old = sys.argv[2]
new = sys.argv[3]
content = path.read_text()
if old in content:
path.write_text(content.replace(old, new, 1))
print(f"Trends hotfix applied to {path}")
else:
print(f"Trends hotfix already present in {path}")
PY
else
echo "Trends hotfix skipped: chunk not found"
fi
timespiral_chunk="$(grep -Rsl "$TIMESPIRAL_NEEDLE" "$WEBUI_DIR/js"/*.js 2>/dev/null | head -n 1 || true)"
if [[ -n "$timespiral_chunk" ]]; then
cp "$timespiral_chunk" "$timespiral_chunk.bak.$TS"
python3 - "$timespiral_chunk" "$TIMESPIRAL_NEEDLE" "$TIMESPIRAL_REPLACEMENT" <<'PY'
from pathlib import Path
import sys
path = Path(sys.argv[1])
old = sys.argv[2]
new = sys.argv[3]
content = path.read_text()
if old in content:
path.write_text(content.replace(old, new, 1))
print(f"Timespiral hotfix applied to {path}")
else:
print(f"Timespiral hotfix already present in {path}")
PY
else
echo "Timespiral hotfix skipped: chunk not found"
fi
category_helper_chunk="$(grep -Rsl "$CATEGORY_HELPER_NEEDLE" "$WEBUI_DIR/js"/*.js 2>/dev/null | head -n 1 || true)"
if [[ -n "$category_helper_chunk" ]]; then
cp "$category_helper_chunk" "$category_helper_chunk.bak.$TS"
python3 - "$category_helper_chunk" "$CATEGORY_HELPER_NEEDLE" "$CATEGORY_HELPER_REPLACEMENT" <<'PY'
from pathlib import Path
import sys
path = Path(sys.argv[1])
old = sys.argv[2]
new = sys.argv[3]
content = path.read_text()
if old in content:
path.write_text(content.replace(old, new, 1))
print(f"Category helper host hotfix applied to {path}")
else:
print(f"Category helper host hotfix already present in {path}")
PY
else
echo "Category helper host hotfix skipped: chunk not found"
fi
echo "RU patch applied to $WEBUI_DIR (ru-patch-v5.js?v=$patch_hash)"
@@ -0,0 +1,47 @@
{
"groups": [
{
"id": "pve-detmir",
"name": "pve-detmir",
"description": "Выделенный клиент DetMir в разделе Активность.",
"patterns": [
"^pve-detmir$"
],
"links": [
{ "label": "Активность", "type": "activity", "view": "pve_audit" },
{ "label": "Web-admin аудит", "type": "bucket", "bucket_prefix": "aw-pve-webadmin-events_" },
{ "label": "PVE tasks", "type": "bucket", "bucket_prefix": "aw-pve-task-events_" },
{ "label": "SSH сессии", "type": "bucket", "bucket_prefix": "aw-ssh-sessions_" },
{ "label": "Команды shell", "type": "bucket", "bucket_prefix": "aw-console-commands_" },
{ "label": "Web категории", "type": "bucket", "bucket_prefix": "aw-detmir-web-category_" },
{ "label": "Все бакеты", "type": "buckets" }
]
},
{
"id": "windows-rdp",
"name": "Windows RDP",
"description": "Пользовательские Windows/RDP хосты с активностью, DLP и рабочим временем.",
"patterns": [
"^(SHARKON|WIN|RDP|TERM|TS-|WS-)"
],
"links": [
{ "label": "Активность", "type": "activity" },
{ "label": "DLP", "type": "bucket", "bucket_prefix": "aw-dlp-endpoint-signals_" }
]
},
{
"id": "virtual-infra",
"name": "Virtual servers + Proxmox",
"description": "Инфраструктурные VM и сетевые узлы. Здесь должны лежать Proxmox, pfSense, Debian и Ubuntu серверы.",
"patterns": [
"^(PFSENSE|PVE|PROXMOX|DEBIAN|UBUNTU|LINUX|VM-|SRV-|INFRA-)"
],
"links": [
{ "label": "pfSense health", "type": "bucket", "bucket_prefix": "aw-pfsense-health_" },
{ "label": "pfSense gateways", "type": "bucket", "bucket_prefix": "aw-pfsense-gateways_" },
{ "label": "Все бакеты", "type": "buckets" }
]
}
],
"ungrouped_name": "Прочие хосты"
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,11 @@
# Copy to /etc/activitywatch/aw-server.env and fill with real values.
AW_SERVER_VERSION=0.13.2
AW_SERVER_DOWNLOAD_URL=https://github.com/ActivityWatch/aw-server-rust/releases/download/v0.13.2/aw-server-rust-linux-x86_64.zip
AW_SERVER_BIND_HOST=0.0.0.0
AW_SERVER_PORT=5600
AW_SERVER_WEBUI_DIR=/opt/activitywatch/webui-ru
AW_SERVER_DATA_DIR=/var/lib/activitywatch
AW_SERVER_LOG_DIR=/var/log/activitywatch
AW_SERVER_USER=activitywatch
AW_SERVER_GROUP=activitywatch
@@ -0,0 +1,18 @@
self.addEventListener("install", function (event) {
self.skipWaiting();
event.waitUntil((async function () {
const keys = await caches.keys();
await Promise.all(keys.map(function (key) { return caches.delete(key); }));
})());
});
self.addEventListener("activate", function (event) {
event.waitUntil((async function () {
const keys = await caches.keys();
await Promise.all(keys.map(function (key) { return caches.delete(key); }));
await self.clients.claim();
await self.registration.unregister();
})());
});
self.addEventListener("fetch", function () {});
@@ -0,0 +1,117 @@
#!/bin/bash
set -euo pipefail
ENV_FILE="/etc/activitywatch/aw-server.env"
if [[ ! -f "$ENV_FILE" ]]; then
echo "missing env file: $ENV_FILE" >&2
exit 1
fi
source "$ENV_FILE"
required_vars=(
AW_SERVER_VERSION
AW_SERVER_DOWNLOAD_URL
AW_SERVER_BIND_HOST
AW_SERVER_PORT
AW_SERVER_WEBUI_DIR
AW_SERVER_DATA_DIR
AW_SERVER_LOG_DIR
AW_SERVER_USER
AW_SERVER_GROUP
)
BOOTSTRAP_DIR="/root/bootstrap"
VIEWS_JSON="$BOOTSTRAP_DIR/settings/views-default.json"
CLASSES_JSON="$BOOTSTRAP_DIR/settings/classes-worktime.json"
for var_name in "${required_vars[@]}"; do
if [[ -z "${!var_name:-}" ]]; then
echo "missing required variable: $var_name" >&2
exit 1
fi
done
export DEBIAN_FRONTEND=noninteractive
apt-get update
apt-get install -y curl ca-certificates unzip jq
if ! getent group "$AW_SERVER_GROUP" >/dev/null; then
groupadd --system "$AW_SERVER_GROUP"
fi
if ! id "$AW_SERVER_USER" >/dev/null 2>&1; then
useradd --system --gid "$AW_SERVER_GROUP" --home-dir "$AW_SERVER_DATA_DIR" --shell /usr/sbin/nologin "$AW_SERVER_USER"
fi
install -d -o "$AW_SERVER_USER" -g "$AW_SERVER_GROUP" /opt/activitywatch/bin
install -d -o "$AW_SERVER_USER" -g "$AW_SERVER_GROUP" /opt/activitywatch/releases
install -d -o "$AW_SERVER_USER" -g "$AW_SERVER_GROUP" "$AW_SERVER_WEBUI_DIR"
install -d -o "$AW_SERVER_USER" -g "$AW_SERVER_GROUP" "$AW_SERVER_DATA_DIR"
install -d -o "$AW_SERVER_USER" -g "$AW_SERVER_GROUP" "$AW_SERVER_LOG_DIR"
tmp_dir=$(mktemp -d)
trap 'rm -rf "$tmp_dir"' EXIT
curl -fL "$AW_SERVER_DOWNLOAD_URL" -o "$tmp_dir/aw-server.zip"
unzip -q "$tmp_dir/aw-server.zip" -d "$tmp_dir/unpacked"
server_bin=$(find "$tmp_dir/unpacked" -type f \( -name 'aw-server-rust' -o -name 'aw-server' \) | head -n 1)
webui_dir=$(find "$tmp_dir/unpacked" -type d \( -name 'webui' -o -name 'aw-webui' \) | head -n 1 || true)
if [[ -z "$server_bin" || ! -f "$server_bin" ]]; then
echo "aw-server binary not found in archive" >&2
exit 1
fi
release_dir="/opt/activitywatch/releases/aw-server-rust-v${AW_SERVER_VERSION}"
rm -rf "$release_dir"
install -d -o "$AW_SERVER_USER" -g "$AW_SERVER_GROUP" "$release_dir"
install -m 0755 -o "$AW_SERVER_USER" -g "$AW_SERVER_GROUP" "$server_bin" "$release_dir/aw-server-rust"
ln -sfn "$release_dir/aw-server-rust" /opt/activitywatch/bin/aw-server-rust
if [[ -n "$webui_dir" && -d "$webui_dir" ]]; then
rm -rf "$AW_SERVER_WEBUI_DIR"
mkdir -p "$AW_SERVER_WEBUI_DIR"
cp -a "$webui_dir"/. "$AW_SERVER_WEBUI_DIR"/
chown -R "$AW_SERVER_USER:$AW_SERVER_GROUP" "$AW_SERVER_WEBUI_DIR"
fi
sed \
-e "s#__AW_SERVER_USER__#$AW_SERVER_USER#g" \
-e "s#__AW_SERVER_GROUP__#$AW_SERVER_GROUP#g" \
-e "s#__AW_SERVER_DATA_DIR__#$AW_SERVER_DATA_DIR#g" \
/root/bootstrap/activitywatch-server.service > /etc/systemd/system/activitywatch-server.service
chmod 0644 /etc/systemd/system/activitywatch-server.service
systemctl daemon-reload
systemctl enable activitywatch-server.service
systemctl restart activitywatch-server.service
systemctl --no-pager --full status activitywatch-server.service || true
for _ in $(seq 1 20); do
if curl -fsS "http://127.0.0.1:${AW_SERVER_PORT}/api/0/info" >/dev/null 2>&1; then
break
fi
sleep 2
done
if [[ -f "$CLASSES_JSON" ]]; then
curl -fsS -X POST \
-H 'Content-Type: application/json' \
--data-binary @"$CLASSES_JSON" \
"http://127.0.0.1:${AW_SERVER_PORT}/api/0/settings/classes" >/dev/null
echo "Applied worktime classes from $CLASSES_JSON"
else
echo "Worktime classes bootstrap not found, skipped: $CLASSES_JSON"
fi
if [[ -f "$VIEWS_JSON" ]]; then
curl -fsS -X POST \
-H 'Content-Type: application/json' \
--data-binary @"$VIEWS_JSON" \
"http://127.0.0.1:${AW_SERVER_PORT}/api/0/settings/views" >/dev/null
echo "Applied baseline views from $VIEWS_JSON"
else
echo "Views bootstrap not found, skipped: $VIEWS_JSON"
fi
@@ -0,0 +1,90 @@
[
{
"id": 0,
"name": ["Работа"],
"rule": { "type": "none" },
"data": {}
},
{
"id": 1,
"name": ["Работа", "1С"],
"rule": {
"type": "regex",
"regex": "\\b(1cv8s?|1cv8c|1cestart)\\.exe\\b|1С:Предприятие|Запуск 1С:Предприятия|Загрузка конфигурационной информации|Доступ к информационной базе",
"ignore_case": true
},
"data": { "color": "#194D33" }
},
{
"id": 2,
"name": ["Работа", "Документы"],
"rule": {
"type": "regex",
"regex": "\\b(winword|excel|powerpnt|outlook|acrord32|acrord64)\\.exe\\b|Adobe Reader|Acrobat",
"ignore_case": true
},
"data": { "color": "#2E7D32" }
},
{
"id": 3,
"name": ["Работа", "Коммуникации"],
"rule": {
"type": "regex",
"regex": "\\b(teams|telegram|slack|thunderbird|zoom|skype|whatsapp|viber|discord)\\.exe\\b|Mattermost|Element|Riot",
"ignore_case": true
},
"data": { "color": "#1E88E5" }
},
{
"id": 4,
"name": ["Работа", "Администрирование"],
"rule": {
"type": "regex",
"regex": "\\b(mstsc|putty|kitty|winscp|anydesk|teamviewer|vncviewer|mmc|regedit|services|control|powershell|cmd)\\.exe\\b",
"ignore_case": true
},
"data": { "color": "#6D4C41" }
},
{
"id": 5,
"name": ["Интернет"],
"rule": { "type": "none" },
"data": {}
},
{
"id": 6,
"name": ["Интернет", "Браузер"],
"rule": {
"type": "regex",
"regex": "\\b(chrome|msedge|firefox|opera|brave|vivaldi|browser)\\.exe\\b",
"ignore_case": true
},
"data": { "color": "#00897B" }
},
{
"id": 7,
"name": ["Система"],
"rule": { "type": "none" },
"data": {}
},
{
"id": 8,
"name": ["Система", "Windows"],
"rule": {
"type": "regex",
"regex": "\\b(SearchHost|explorer|ShellExperienceHost|ApplicationFrameHost|RuntimeBroker|sihost|dwm|svchost|fontdrvhost|userinit)\\.exe\\b|\\\\Windows\\\\System32",
"ignore_case": true
},
"data": { "color": "#607D8B" }
},
{
"id": 9,
"name": ["ActivityWatch"],
"rule": {
"type": "regex",
"regex": "ActivityWatch|\\baw-(watcher|qt)\\.exe\\b",
"ignore_case": true
},
"data": {}
}
]
@@ -0,0 +1,39 @@
[
{
"id": "summary",
"name": "Summary",
"elements": [
{ "type": "top_titles", "size": 3 },
{ "type": "timeline_barchart", "size": 3 },
{ "type": "top_categories", "size": 3 },
{ "type": "category_tree", "size": 3 }
]
},
{
"id": "window",
"name": "Window",
"elements": [
{ "type": "top_apps", "size": 3, "props": {} }
]
},
{
"id": "DLP",
"name": "DLP",
"elements": []
},
{
"id": "worktime",
"name": "Worktime",
"elements": [
{ "type": "top_categories", "size": 3, "props": {} },
{ "type": "timeline_barchart", "size": 3, "props": {} },
{ "type": "category_tree", "size": 3, "props": {} },
{ "type": "top_apps", "size": 3, "props": {} }
]
},
{
"id": "pve_audit",
"name": "PVE Audit",
"elements": []
}
]
+6 -1
View File
@@ -25,11 +25,16 @@ for file_name in \
activitywatch-server.service \
aw-server.env.example \
aw-ru-patch.js \
aw-sw-cleanup.js
aw-sw-cleanup.js \
aw-host-groups.json
do
pct push "$CT_ID" "$PROJECT_ROOT/aw-server/$file_name" "/root/bootstrap/$file_name"
done
pct exec "$CT_ID" -- mkdir -p /root/bootstrap/settings
pct push "$CT_ID" "$PROJECT_ROOT/aw-server/settings/classes-worktime.json" "/root/bootstrap/settings/classes-worktime.json"
pct push "$CT_ID" "$PROJECT_ROOT/aw-server/settings/views-default.json" "/root/bootstrap/settings/views-default.json"
if [ -n "${AW_SERVER_VERSION:-}" ] &&
[ -n "${AW_SERVER_DOWNLOAD_URL:-}" ] &&
[ -n "${AW_SERVER_BIND_HOST:-}" ] &&
+306
View File
@@ -0,0 +1,306 @@
#!/usr/bin/env sh
set -eu
SERVER_HOST="10.10.10.13"
SERVER_PORT="5600"
POLL_INTERVAL="5"
INSTALL_ROOT="/opt/aw-pve-webadmin-logger"
STATE_DIR="/var/lib/aw-pve-webadmin-logger"
LOG_DIR="/var/log/aw-pve-webadmin-logger"
CONFIG_PATH="/etc/aw-pve-webadmin-logger/config.json"
SERVICE_PATH="/etc/systemd/system/aw-pve-webadmin-logger.service"
usage() {
cat <<'EOF'
Usage: install_aw_pve_webadmin_logger.sh [options]
Options:
--server-host HOST AW server host (default: 10.10.10.13)
--server-port PORT AW server port (default: 5600)
--poll-interval SEC Poll interval in seconds (default: 5)
-h, --help Show this help
EOF
}
while [ "$#" -gt 0 ]; do
case "$1" in
--server-host)
SERVER_HOST="$2"
shift 2
;;
--server-port)
SERVER_PORT="$2"
shift 2
;;
--poll-interval)
POLL_INTERVAL="$2"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown option: $1" >&2
usage >&2
exit 1
;;
esac
done
if [ "$(id -u)" -ne 0 ]; then
echo "Run as root" >&2
exit 1
fi
mkdir -p "$INSTALL_ROOT" "$STATE_DIR" "$LOG_DIR" "$(dirname "$CONFIG_PATH")"
HOST_SHORT="$(hostname -s)"
cat > "$CONFIG_PATH" <<EOF
{
"server_host": "${SERVER_HOST}",
"server_port": ${SERVER_PORT},
"poll_interval_seconds": ${POLL_INTERVAL},
"host": "${HOST_SHORT}",
"state_dir": "${STATE_DIR}",
"access_log": "/var/log/pveproxy/access.log",
"tasks_index": "/var/log/pve/tasks/index",
"web_bucket": "aw-pve-webadmin-events_${HOST_SHORT}",
"task_bucket": "aw-pve-task-events_${HOST_SHORT}"
}
EOF
cat > "${INSTALL_ROOT}/collector.py" <<'PY'
#!/usr/bin/env python3
import datetime as dt
import json
import pathlib
import re
import socket
import time
import urllib.error
import urllib.request
ACCESS_RE = re.compile(
r'^(?P<ip>\S+)\s+-\s+(?P<user>\S+)\s+\[(?P<ts>[^\]]+)\]\s+"(?P<method>\S+)\s+(?P<path>\S+)\s+(?P<proto>[^"]+)"\s+(?P<status>\d{3})\s+(?P<size>\S+)'
)
NOISE_GET_PATHS = [
re.compile(r"^/api2/json/version$"),
re.compile(r"^/api2/json/cluster/resources$"),
re.compile(r"^/api2/json/cluster/tasks$"),
re.compile(r"^/api2/json/nodes/[^/]+/(qemu|lxc)/\d+/status/current$"),
re.compile(r"^/api2/json/nodes/[^/]+/(qemu|lxc)/\d+/interfaces$"),
re.compile(r"^/api2/json/nodes/[^/]+/(qemu|lxc)/\d+/rrddata(\?.*)?$"),
]
TASK_RE = re.compile(
r'^UPID:(?P<node>[^:]+):(?P<pid>[^:]+):(?P<pstart>[^:]+):(?P<start>[^:]+):(?P<action>[^:]*):(?P<target>[^:]*):(?P<user>[^:]*):\s*(?P<msg>.*)$'
)
def iso_now():
return dt.datetime.now(tz=dt.timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
def parse_access_ts(value: str) -> str:
try:
parsed = dt.datetime.strptime(value, "%d/%b/%Y:%H:%M:%S %z")
return parsed.astimezone(dt.timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
except Exception:
return iso_now()
class TailState:
def __init__(self, path: pathlib.Path):
self.path = path
self.data = {"inode": None, "offset": 0}
if path.exists():
try:
self.data = json.loads(path.read_text(encoding="utf-8"))
except Exception:
self.data = {"inode": None, "offset": 0}
def save(self):
self.path.write_text(json.dumps(self.data, ensure_ascii=True), encoding="utf-8")
class Collector:
def __init__(self, cfg):
self.cfg = cfg
self.server = f"http://{cfg['server_host']}:{cfg['server_port']}/api/0"
self.host = cfg.get("host") or socket.gethostname().split(".")[0]
self.poll = max(1, int(cfg.get("poll_interval_seconds", 5)))
self.web_bucket = cfg["web_bucket"]
self.task_bucket = cfg["task_bucket"]
self.access_log = pathlib.Path(cfg["access_log"])
self.tasks_index = pathlib.Path(cfg["tasks_index"])
self.state_dir = pathlib.Path(cfg["state_dir"])
self.state_dir.mkdir(parents=True, exist_ok=True)
self.access_state = TailState(self.state_dir / "access_state.json")
self.tasks_state = TailState(self.state_dir / "tasks_state.json")
self.ensured = set()
self.recent = {}
def ensure_bucket(self, bucket_id: str, bucket_type: str):
if bucket_id in self.ensured:
return True
payload = {"client": "aw-pve-webadmin-logger", "type": bucket_type, "hostname": self.host}
req = urllib.request.Request(
f"{self.server}/buckets/{bucket_id}",
data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=10):
self.ensured.add(bucket_id)
return True
except urllib.error.HTTPError as err:
if err.code in (304, 409):
self.ensured.add(bucket_id)
return True
return False
except urllib.error.URLError:
return False
def heartbeat(self, bucket_id: str, payload: dict, bucket_type: str):
if not self.ensure_bucket(bucket_id, bucket_type):
return False
req = urllib.request.Request(
f"{self.server}/buckets/{bucket_id}/heartbeat?pulsetime=60",
data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=10):
return True
except urllib.error.URLError:
return False
def read_new_lines(self, src: pathlib.Path, st: TailState):
if not src.exists():
return []
fs = src.stat()
inode = int(fs.st_ino)
size = int(fs.st_size)
prev_inode = st.data.get("inode")
prev_off = int(st.data.get("offset", 0))
if prev_inode != inode or prev_off > size:
prev_off = 0
with src.open("r", encoding="utf-8", errors="replace") as f:
f.seek(prev_off)
lines = f.readlines()
st.data = {"inode": inode, "offset": f.tell()}
st.save()
return [ln.rstrip("\n") for ln in lines if ln.strip()]
def process_access(self):
for line in self.read_new_lines(self.access_log, self.access_state):
m = ACCESS_RE.match(line)
if not m:
continue
user = m.group("user")
status = int(m.group("status"))
method = m.group("method")
path = m.group("path")
if path == "/api2/json/version":
continue
if user == "-" and status < 400:
continue
# Proxmox UI does high-frequency read polling. Keep real actions and auth failures.
if method == "GET" and status == 200 and any(rx.match(path) for rx in NOISE_GET_PATHS):
continue
event_kind = "auth_failed" if status in (401, 403) else "request"
dedup_key = f"{event_kind}|{user}|{m.group('ip')}|{method}|{path}|{status}"
now = time.time()
if now - float(self.recent.get(dedup_key, 0)) < 30:
continue
self.recent[dedup_key] = now
event = {
"timestamp": parse_access_ts(m.group("ts")),
"duration": 0,
"data": {
"source": "pveproxy_access",
"event_kind": event_kind,
"host": self.host,
"user": user,
"remote_ip": m.group("ip"),
"method": method,
"path": path,
"status": status,
"protocol": m.group("proto"),
"raw": line,
},
}
self.heartbeat(self.web_bucket, event, "app.pve.webadmin.event")
def process_tasks(self):
for line in self.read_new_lines(self.tasks_index, self.tasks_state):
m = TASK_RE.match(line)
if not m:
continue
msg = m.group("msg")
event = {
"timestamp": iso_now(),
"duration": 0,
"data": {
"source": "pve_tasks_index",
"host": self.host,
"node": m.group("node"),
"upid_pid": m.group("pid"),
"action": m.group("action"),
"target": m.group("target"),
"user": m.group("user"),
"message": msg,
"result": "ok" if " OK" in msg else ("error" if "error" in msg.lower() else "info"),
"raw": line,
},
}
self.heartbeat(self.task_bucket, event, "app.pve.task.event")
def run(self):
while True:
self.process_access()
self.process_tasks()
time.sleep(self.poll)
def main():
cfg = json.loads(pathlib.Path("/etc/aw-pve-webadmin-logger/config.json").read_text(encoding="utf-8"))
Collector(cfg).run()
if __name__ == "__main__":
main()
PY
chmod 0755 "${INSTALL_ROOT}/collector.py"
cat > "$SERVICE_PATH" <<EOF
[Unit]
Description=AW PVE web-admin activity logger
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=${INSTALL_ROOT}/collector.py
Restart=always
RestartSec=3
WorkingDirectory=${INSTALL_ROOT}
StandardOutput=append:${LOG_DIR}/collector.log
StandardError=append:${LOG_DIR}/collector.log
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable --now aw-pve-webadmin-logger.service
systemctl --no-pager --full status aw-pve-webadmin-logger.service || true
echo "Installed aw-pve-webadmin-logger"
echo "Config: ${CONFIG_PATH}"
echo "Buckets: aw-pve-webadmin-events_${HOST_SHORT}, aw-pve-task-events_${HOST_SHORT}"
+10
View File
@@ -243,6 +243,8 @@ function Copy-ActivityWatchCollectorAssets {
[Parameter(Mandatory = $true)]
[string]$EndpointCollectorScriptSource,
[Parameter(Mandatory = $true)]
[string]$SessionCollectorScriptSource,
[Parameter(Mandatory = $true)]
[string]$ExampleRulesSource,
[Parameter(Mandatory = $true)]
[string]$ExamplePolicySource,
@@ -256,6 +258,7 @@ function Copy-ActivityWatchCollectorAssets {
$collectorTarget = Join-Path $StateRoot 'browser-domains-native-collector.ps1'
$endpointCollectorTarget = Join-Path $StateRoot 'dlp-endpoint-signals-collector.ps1'
$sessionCollectorTarget = Join-Path $StateRoot 'worktime-session-collector.ps1'
$exampleRulesTarget = Join-Path $StateRoot 'web-category-rules.example.json'
$rulesTarget = Join-Path $StateRoot 'web-category-rules.json'
$examplePolicyTarget = Join-Path $StateRoot 'dlp-policy.example.json'
@@ -263,6 +266,7 @@ function Copy-ActivityWatchCollectorAssets {
Copy-Item -LiteralPath $CollectorScriptSource -Destination $collectorTarget -Force
Copy-Item -LiteralPath $EndpointCollectorScriptSource -Destination $endpointCollectorTarget -Force
Copy-Item -LiteralPath $SessionCollectorScriptSource -Destination $sessionCollectorTarget -Force
Copy-Item -LiteralPath $ExampleRulesSource -Destination $exampleRulesTarget -Force
Copy-Item -LiteralPath $ExamplePolicySource -Destination $examplePolicyTarget -Force
@@ -282,6 +286,7 @@ function Copy-ActivityWatchCollectorAssets {
return [pscustomobject]@{
CollectorScript = $collectorTarget
EndpointCollectorScript = $endpointCollectorTarget
SessionCollectorScript = $sessionCollectorTarget
ExampleRules = $exampleRulesTarget
ActiveRules = $rulesTarget
ExamplePolicy = $examplePolicyTarget
@@ -308,6 +313,8 @@ function New-ActivityWatchDeploymentConfig {
[Parameter(Mandatory = $true)]
[string]$EndpointCollectorScript,
[Parameter(Mandatory = $true)]
[string]$SessionCollectorScript,
[Parameter(Mandatory = $true)]
[string]$RulesPath,
[Parameter(Mandatory = $true)]
[string]$PolicyPath,
@@ -349,6 +356,7 @@ function New-ActivityWatchDeploymentConfig {
logsRoot = $LogsRoot
collectorScript = $CollectorScript
endpointCollectorScript = $EndpointCollectorScript
sessionCollectorScript = $SessionCollectorScript
rulesPath = $RulesPath
policyPath = $PolicyPath
launchScript = $LaunchScriptPath
@@ -631,6 +639,7 @@ function Start-CollectorScriptIfNeeded {
`$script:KnownBuckets = @{}
`$collectorScript = [string]`$config.paths.collectorScript
`$endpointCollectorScript = if (`$config.paths.PSObject.Properties.Name -contains 'endpointCollectorScript') { [string]`$config.paths.endpointCollectorScript } else { '' }
`$sessionCollectorScript = if (`$config.paths.PSObject.Properties.Name -contains 'sessionCollectorScript') { [string]`$config.paths.sessionCollectorScript } else { '' }
`$afkExe = Join-Path `$installRoot 'aw-watcher-afk\aw-watcher-afk.exe'
`$windowExe = Join-Path `$installRoot 'aw-watcher-window\aw-watcher-window.exe'
`$serverArgs = @('--host', [string]`$config.server.host, '--port', [string]`$config.server.port)
@@ -661,6 +670,7 @@ catch {
}
Start-CollectorScriptIfNeeded -ScriptPath `$collectorScript -ConfigPath `$ConfigPath -PowerShellExe `$powershellExe -SessionId `$sessionId
Start-CollectorScriptIfNeeded -ScriptPath `$endpointCollectorScript -ConfigPath `$ConfigPath -PowerShellExe `$powershellExe -SessionId `$sessionId
Start-CollectorScriptIfNeeded -ScriptPath `$sessionCollectorScript -ConfigPath `$ConfigPath -PowerShellExe `$powershellExe -SessionId `$sessionId
"@
Set-Content -LiteralPath $Path -Value $content -Encoding UTF8
+3
View File
@@ -44,6 +44,7 @@ $launchScriptPath = Join-Path $StateRoot 'launch-watchers.ps1'
$recoveryScriptPath = Join-Path $StateRoot 'recovery-loop.ps1'
$collectorSource = Join-Path $PSScriptRoot 'browser-domains-native-collector.ps1'
$endpointCollectorSource = Join-Path $PSScriptRoot 'dlp-endpoint-signals-collector.ps1'
$sessionCollectorSource = Join-Path $PSScriptRoot 'worktime-session-collector.ps1'
$exampleRulesSource = Join-Path $PSScriptRoot 'web-category-rules.example.json'
$examplePolicySource = Join-Path $PSScriptRoot 'dlp-policy.example.json'
@@ -57,6 +58,7 @@ Get-ActivityWatchExecutableMap -InstallRoot $InstallRoot | Out-Null
$assetResult = Copy-ActivityWatchCollectorAssets `
-CollectorScriptSource $collectorSource `
-EndpointCollectorScriptSource $endpointCollectorSource `
-SessionCollectorScriptSource $sessionCollectorSource `
-ExampleRulesSource $exampleRulesSource `
-ExamplePolicySource $examplePolicySource `
-StateRoot $StateRoot `
@@ -76,6 +78,7 @@ $config = New-ActivityWatchDeploymentConfig `
-LogsRoot $logsRoot `
-CollectorScript $assetResult.CollectorScript `
-EndpointCollectorScript $assetResult.EndpointCollectorScript `
-SessionCollectorScript $assetResult.SessionCollectorScript `
-RulesPath $assetResult.ActiveRules `
-PolicyPath $assetResult.ActivePolicy `
-PollSeconds $PollSeconds `
+3
View File
@@ -42,6 +42,7 @@ $launchScriptPath = Join-Path $StateRoot 'launch-watchers.ps1'
$recoveryScriptPath = Join-Path $StateRoot 'recovery-loop.ps1'
$collectorSource = Join-Path $PSScriptRoot 'browser-domains-native-collector.ps1'
$endpointCollectorSource = Join-Path $PSScriptRoot 'dlp-endpoint-signals-collector.ps1'
$sessionCollectorSource = Join-Path $PSScriptRoot 'worktime-session-collector.ps1'
$exampleRulesSource = Join-Path $PSScriptRoot 'web-category-rules.example.json'
$examplePolicySource = Join-Path $PSScriptRoot 'dlp-policy.example.json'
@@ -55,6 +56,7 @@ Get-ActivityWatchExecutableMap -InstallRoot $InstallRoot | Out-Null
$assetResult = Copy-ActivityWatchCollectorAssets `
-CollectorScriptSource $collectorSource `
-EndpointCollectorScriptSource $endpointCollectorSource `
-SessionCollectorScriptSource $sessionCollectorSource `
-ExampleRulesSource $exampleRulesSource `
-ExamplePolicySource $examplePolicySource `
-StateRoot $StateRoot `
@@ -74,6 +76,7 @@ $config = New-ActivityWatchDeploymentConfig `
-LogsRoot $logsRoot `
-CollectorScript $assetResult.CollectorScript `
-EndpointCollectorScript $assetResult.EndpointCollectorScript `
-SessionCollectorScript $assetResult.SessionCollectorScript `
-RulesPath $assetResult.ActiveRules `
-PolicyPath $assetResult.ActivePolicy `
-PollSeconds $PollSeconds `
+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)"
+120
View File
@@ -0,0 +1,120 @@
param(
[string]$ConfigPath = 'C:\ProgramData\ActivityWatch\deployment-config.json',
[string]$Hostname,
[int]$PollSeconds = 30
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
function Get-Config {
param([string]$Path)
if (-not (Test-Path -LiteralPath $Path)) { throw "Config not found: $Path" }
return Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json
}
function Invoke-AwJsonPost {
param(
[Parameter(Mandatory = $true)][string]$Uri,
[Parameter(Mandatory = $true)][string]$Json
)
$bytes = [Text.Encoding]::UTF8.GetBytes($Json)
Invoke-RestMethod -Method Post -Uri $Uri -ContentType 'application/json; charset=utf-8' -Body $bytes | Out-Null
}
function Ensure-Bucket {
param(
[Parameter(Mandatory = $true)][string]$ApiBase,
[Parameter(Mandatory = $true)][string]$BucketId,
[Parameter(Mandatory = $true)][string]$HostnameValue
)
try {
Invoke-RestMethod -Method Get -Uri "$ApiBase/buckets/$BucketId" | Out-Null
return
}
catch {
}
$body = @{
client = 'aw-worktime-session-collector'
type = 'aw.worktime.session'
hostname = $HostnameValue
} | ConvertTo-Json -Compress
Invoke-AwJsonPost -Uri "$ApiBase/buckets/$BucketId" -Json $body
}
function Get-SessionRecords {
$records = @()
try {
$lines = (quser 2>$null)
if (-not $lines) { return @() }
foreach ($line in $lines | Select-Object -Skip 1) {
$clean = ($line -replace '^\s*>?', '').Trim()
if (-not $clean) { continue }
$parts = $clean -split '\s+'
if ($parts.Count -lt 4) { continue }
$user = $parts[0]
$sessionName = $parts[1]
$sessionId = 0
if ($parts[2] -match '^\d+$') {
$sessionId = [int]$parts[2]
}
$state = $parts[3]
$records += [pscustomobject]@{
username = $user
sessionName = $sessionName
sessionId = $sessionId
state = $state
}
}
}
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
}