Compare commits

...
Author SHA1 Message Date
IgorRachkov dc8988e5f7 Harden Grafana Prometheus e2e readiness 2026-05-09 03:58:52 +03:00
igor04091968 230a9c6936 fix(windows): harden worktime session collector encoding and query parsing 2026-05-08 10:54:18 +03:00
igor04091968 6a36febfeb docs: capture todo - deploy standalone agent on SHARKON2025 2026-05-08 08:15:53 +03:00
igor04091968 e80272a55f docs(installer): document standalone service mode and minimal host/port setup 2026-05-08 00:44:48 +03:00
igor04091968 1c3d7896cd feat(installer): add standalone Windows service deployment for DLP agent 2026-05-08 00:41:00 +03:00
igor04091968 211a6a6eac fix(windows-deploy): enforce awHostname override and safe single-instance collector restart 2026-05-08 00:41:00 +03:00
igor04091968 6f5e5eb751 Revert "merge: apply windows standalone service installer and awHostname hardening"
This reverts commit e643576aa9, reversing
changes made to 669501f20a.
2026-05-08 00:41:00 +03:00
igor04091968 e643576aa9 merge: apply windows standalone service installer and awHostname hardening 2026-05-08 00:40:19 +03:00
igor04091968 f5f4f84bef feat(installer): add standalone Windows service deployment for DLP agent 2026-05-08 00:39:28 +03:00
igor04091968 693e6832a6 fix(windows-deploy): enforce awHostname override and safe single-instance collector restart 2026-05-08 00:34:52 +03:00
igor04091968 538ff74611 feat(dlp): add health status and graceful shutdown for browser collector 2026-05-07 23:54:52 +03:00
igor04091968 b992ad2234 feat(dlp): graceful shutdown and COM cleanup for email collector 2026-05-07 23:52:03 +03:00
igor04091968 ac59d44719 feat(dlp): add WAL buffering and health snapshots for collectors 2026-05-07 23:47:03 +03:00
igor04091968 f3c5e9ea53 feat(dlp-hardening): secure inventory and extend aggregator sources 2026-05-07 23:38:20 +03:00
igor04091968 38107b3c84 ansible: remove per-host passwords from inventory\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> 2026-05-07 23:37:47 +03:00
igor04091968 8ad02ae194 docs(gsd): bootstrap planning and phase-1 discussion artifacts 2026-05-07 21:39:32 +03:00
igor04091968 2fd0ca8eda docs: add copilot instructions 2026-05-07 21:37:42 +03:00
igor04091968 9c59f74928 chore(install-kit): sync package payload with current repo baseline 2026-05-07 19:50:07 +03:00
igor04091968 0c5069c255 chore(ops): harden rollout gates and sanitize generated artifacts 2026-05-07 19:50:02 +03:00
igor04091968andCopilot f0db2a227b revert(pssa): restore validate-deployment.ps1 from commit 63904d2 (stable)
Restored clean version to fix corruption introduced earlier.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-07 19:22:05 +03:00
igor04091968andCopilot e37c3886ba revert(pssa): restore files corrupted by automatic fixes (ActivityWatch.Windows.Common.psm1, email-outbound-collector.ps1, file-operations-collector.ps1)
Restored previous versions for files that were corrupted by the automated PSScriptAnalyzer fixes. Please review and reapply safe fixes manually.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-07 18:10:12 +03:00
igor04091968andCopilot 429501d4fe chore(pssa): apply safe PSScriptAnalyzer fixes (BOM, empty catch -> Write-Error, Write-Host -> Write-Output)
Applied automatic, low-risk fixes for PSScriptAnalyzer warnings. Please review changes for behavior-sensitive code.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-07 12:50:35 +03:00
igor04091968 3e8a6981f5 fix(shell): silence remaining ShellCheck warnings for validate_install_kit and install_aw_linux_remote_worker\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> 2026-05-07 12:39:32 +03:00
36 changed files with 942 additions and 524 deletions
@@ -0,0 +1,20 @@
---
created: 2026-05-07T21:46:00Z
title: Deploy standalone agent on SHARKON2025 and validate live flow
area: tooling
files:
- windows/installkit/innosetup/AWatch-rus-InnoSetup.iss
- windows/install-standalone-service.ps1
- windows/aw-standalone-service.ps1
- windows/installkit/innosetup/BUILD.md
- docs/windows/deployment.md
- docs/windows/troubleshooting.md
---
## Problem
Standalone InnoSetup mode and service wrapper are implemented and pushed, and AW API write path was verified by manual heartbeat posts. But production value still depends on real endpoint rollout: installer must be deployed on SHARKON2025 (192.168.100.21), service must be running persistently, and UI/API must show continuously fresh events without manual seeding.
## Solution
Deploy the newly built installer `AWatch-rus-InstallKit.exe` to SHARKON2025, run installation with target `10.10.10.13:5600`, verify `AWatchRusStandaloneAgent` state, inspect `standalone-agent-service.log`, and confirm fresh `metadata.end` progression for `aw-dlp-endpoint-signals_SHARKON2025`, `aw-file-operations_SHARKON2025`, and `aw-worktime-sessions_SHARKON2025` over time.
-1
View File
@@ -1,5 +1,4 @@
#!/bin/sh #!/bin/sh
# shellcheck disable=SC1007
set -eu set -eu
REPO_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) REPO_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
-1
View File
@@ -1,5 +1,4 @@
#!/bin/sh #!/bin/sh
# shellcheck disable=SC1007
set -eu set -eu
REPO_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) REPO_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
+41
View File
@@ -160,6 +160,9 @@
{% if (aw_windows_package_zip_path | default('') | string | length) > 0 %} {% if (aw_windows_package_zip_path | default('') | string | length) > 0 %}
$params.PackageZipPath = "{{ aw_windows_package_zip_path }}" $params.PackageZipPath = "{{ aw_windows_package_zip_path }}"
{% endif %} {% endif %}
{% if (aw_windows_hostname_override | default('') | string | length) > 0 %}
$params.AwHostname = "{{ aw_windows_hostname_override }}"
{% endif %}
{% if aw_windows_skip_hardening | bool %} {% if aw_windows_skip_hardening | bool %}
$params.SkipHardening = $true $params.SkipHardening = $true
{% endif %} {% endif %}
@@ -183,6 +186,44 @@
ansible.windows.win_powershell: ansible.windows.win_powershell:
script: | script: |
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
function Get-CollectorKey {
param([string]$CommandLine)
if (-not $CommandLine) { return $null }
$cl = $CommandLine.ToLowerInvariant()
if ($cl -like '*browser-domains-native-collector.ps1*') { return 'browser' }
if ($cl -like '*file-operations-collector.ps1*') { return 'fileops' }
if ($cl -like '*dlp-endpoint-signals-collector.ps1*') { return 'endpoint' }
if ($cl -like '*email-outbound-collector.ps1*') { return 'email' }
if ($cl -like '*worktime-session-collector.ps1*') { return 'worktime' }
return $null
}
$collectorProcs = Get-CimInstance Win32_Process |
Where-Object { $_.Name -eq 'powershell.exe' -and $_.CommandLine } |
ForEach-Object {
$key = Get-CollectorKey -CommandLine $_.CommandLine
if ($key) {
[pscustomobject]@{
ProcessId = [int]$_.ProcessId
SessionId = [int]$_.SessionId
CreationDate = $_.CreationDate
CollectorKey = $key
}
}
} |
Where-Object { $_ -ne $null }
# Keep only one process per (collector, session): newest survives, older duplicates are stopped.
foreach ($group in ($collectorProcs | Group-Object CollectorKey, SessionId)) {
$ordered = @($group.Group | Sort-Object CreationDate -Descending)
if ($ordered.Count -le 1) { continue }
foreach ($dup in $ordered | Select-Object -Skip 1) {
Stop-Process -Id $dup.ProcessId -Force -ErrorAction SilentlyContinue
}
}
Start-Sleep -Seconds 2
Start-ScheduledTask -TaskName "{{ aw_windows_recovery_task_name }}" Start-ScheduledTask -TaskName "{{ aw_windows_recovery_task_name }}"
Get-ScheduledTask | Get-ScheduledTask |
Where-Object TaskName -like "{{ aw_windows_launch_task_pattern }}" | Where-Object TaskName -like "{{ aw_windows_launch_task_pattern }}" |
+1
View File
@@ -24,6 +24,7 @@ aw_windows_extra_users: []
aw_windows_install_root: "C:\\Program Files\\AWatch-rus\\bin" aw_windows_install_root: "C:\\Program Files\\AWatch-rus\\bin"
aw_windows_state_root: "C:\\ProgramData\\AWatch-rus" aw_windows_state_root: "C:\\ProgramData\\AWatch-rus"
aw_windows_hostname_override: ""
aw_windows_afk_enabled: true aw_windows_afk_enabled: true
aw_windows_window_enabled: true aw_windows_window_enabled: true
+1
View File
@@ -21,6 +21,7 @@ aw_windows_extra_users: []
# Единые Windows/RDP пути: те же, что использует InnoSetup. # Единые Windows/RDP пути: те же, что использует InnoSetup.
aw_windows_install_root: "C:\\Program Files\\AWatch-rus\\bin" aw_windows_install_root: "C:\\Program Files\\AWatch-rus\\bin"
aw_windows_state_root: "C:\\ProgramData\\AWatch-rus" aw_windows_state_root: "C:\\ProgramData\\AWatch-rus"
aw_windows_hostname_override: "" # Например: SHARKON2025
aw_windows_afk_enabled: true aw_windows_afk_enabled: true
aw_windows_window_enabled: true aw_windows_window_enabled: true
aw_windows_file_ops_enabled: true aw_windows_file_ops_enabled: true
+1 -1
View File
@@ -18,7 +18,7 @@ echo "=== ActivityWatch Data Check: $HOSTNAME_FILTER ==="
echo "" echo ""
echo -n "Server connectivity... " echo -n "Server connectivity... "
RESP=$(no_proxy=10.10.10.13 curl -s --connect-timeout 10 --max-time 15 "$SERVER/api/0/info" 2>&1) RESP=$(no_proxy=10.10.10.13 curl -s --connect-timeout 10 --max-time 15 "$SERVER/api/0/info" 2>&1)
if echo "$RESP" | jq -e '.version' > /dev/null 2>&1; then if [ $? -eq 0 ] && echo "$RESP" | jq -e '.version' > /dev/null 2>&1; then
VERSION=$(echo "$RESP" | jq -r '.version') VERSION=$(echo "$RESP" | jq -r '.version')
echo -e "${GREEN}OK${NC} (aw-server v$VERSION)" echo -e "${GREEN}OK${NC} (aw-server v$VERSION)"
else else
+1 -1
View File
@@ -22,7 +22,7 @@ echo ""
echo -e "${CYAN}--- 1. AW Server ($SERVER) ---${NC}" echo -e "${CYAN}--- 1. AW Server ($SERVER) ---${NC}"
echo -n " Connectivity... " echo -n " Connectivity... "
RESP=$(no_proxy=10.10.10.13 curl -s --connect-timeout 10 --max-time 15 "$SERVER/api/0/info" 2>&1) RESP=$(no_proxy=10.10.10.13 curl -s --connect-timeout 10 --max-time 15 "$SERVER/api/0/info" 2>&1)
if echo "$RESP" | jq -e '.version' > /dev/null 2>&1; then if [ $? -eq 0 ] && echo "$RESP" | jq -e '.version' > /dev/null 2>&1; then
VERSION=$(echo "$RESP" | jq -r '.version') VERSION=$(echo "$RESP" | jq -r '.version')
echo -e " ${GREEN}OK${NC} (aw-server $VERSION)" echo -e " ${GREEN}OK${NC} (aw-server $VERSION)"
else else
+12
View File
@@ -9,6 +9,10 @@
- `windows/validate-deployment.ps1` — машинная проверка состояния и JSON-отчёт. - `windows/validate-deployment.ps1` — машинная проверка состояния и JSON-отчёт.
- `windows/browser-domains-native-collector.ps1` — native collector доменов браузера с категоризацией. - `windows/browser-domains-native-collector.ps1` — native collector доменов браузера с категоризацией.
- `windows/dlp-endpoint-signals-collector.ps1` — Windows/RDP collector (clipboard/USB/print signals). - `windows/dlp-endpoint-signals-collector.ps1` — Windows/RDP collector (clipboard/USB/print signals).
- `windows/file-operations-collector.ps1` — collector файловых операций (create/delete/rename/archive hints).
- `windows/worktime-session-collector.ps1` — collector RDP-сессий и активности.
- `windows/install-standalone-service.ps1` — standalone установка агента как Windows Service (без Task Scheduler).
- `windows/aw-standalone-service.ps1` — service wrapper для поддержания collector-процессов.
- `windows/web-category-rules.example.json` — пример кастомных правил категоризации. - `windows/web-category-rules.example.json` — пример кастомных правил категоризации.
- `windows/dlp-policy.example.json` — пример DLP-политики (phase-1: alerting incidents). - `windows/dlp-policy.example.json` — пример DLP-политики (phase-1: alerting incidents).
@@ -27,6 +31,14 @@
- Корректно регистрирует задачи через `-LogonType Interactive` (совместимо с Windows Server, где `InteractiveToken` не поддерживается). - Корректно регистрирует задачи через `-LogonType Interactive` (совместимо с Windows Server, где `InteractiveToken` не поддерживается).
- Поддерживает отключение шумных watcher'ов через `-AfkEnabled:$false` и `-WindowEnabled:$false`. - Поддерживает отключение шумных watcher'ов через `-AfkEnabled:$false` и `-WindowEnabled:$false`.
### Standalone InnoSetup (без Ansible, без Task Scheduler)
- InnoSetup запускает `install-standalone-service.ps1`.
- Мастер спрашивает только `ServerHost` и `ServerPort`.
- Создаётся сервис `AWatchRusStandaloneAgent` (auto-start, restart-on-failure).
- Сервис управляет collector-скриптами и держит по одной рабочей копии каждого коллектора.
- `deployment-config.json` формируется в `C:\ProgramData\AWatch-rus\deployment-config.json`.
Важно: Важно:
- Скриншот делается только при DLP-инциденте (`Send-DlpIncidentHeartbeat`), не по таймеру и не на обычной активности. - Скриншот делается только при DLP-инциденте (`Send-DlpIncidentHeartbeat`), не по таймеру и не на обычной активности.
+29
View File
@@ -88,6 +88,35 @@ Start-ScheduledTask -TaskName 'ActivityWatch Launch [CONTOSO_user01]'
## Диагностика ## Диагностика
### Standalone service не работает
Проверить сервис:
```powershell
Get-Service AWatchRusStandaloneAgent
sc.exe query AWatchRusStandaloneAgent
```
Перезапуск:
```powershell
Restart-Service AWatchRusStandaloneAgent
```
Лог service wrapper:
```powershell
Get-Content C:\ProgramData\AWatch-rus\logs\standalone-agent-service.log -Tail 200
```
Проверить дочерние collector-процессы:
```powershell
Get-CimInstance Win32_Process |
Where-Object { $_.Name -eq 'powershell.exe' -and $_.CommandLine -like '*AWatch-rus*collector*.ps1*' } |
Select-Object ProcessId, SessionId, CommandLine
```
Проверить задачи: Проверить задачи:
```powershell ```powershell
+5
View File
@@ -4,4 +4,9 @@ GRAFANA_ADMIN_PASSWORD=change_me_now
GRAFANA_PORT=3000 GRAFANA_PORT=3000
PROMETHEUS_PORT=9090 PROMETHEUS_PORT=9090
SQL_EXPORTER_PORT=9399 SQL_EXPORTER_PORT=9399
AW_EXPORTER_PORT=9398
AW_SERVER_HOST=10.10.10.13
AW_SERVER_PORT=5600
AW_SERVER_SCHEME=http
AW_SCRAPE_INTERVAL_SECONDS=30
ONEC_DSN=postgres://onec_reader:change_me@10.10.10.20:5432/onec_db?sslmode=disable ONEC_DSN=postgres://onec_reader:change_me@10.10.10.20:5432/onec_db?sslmode=disable
+29 -21
View File
@@ -3,44 +3,46 @@
Готовый каркас для непрерывного сбора KPI из 1С и анализа в Grafana: Готовый каркас для непрерывного сбора KPI из 1С и анализа в Grafana:
- `sql-exporter` читает SQL-представления KPI из БД 1С; - `sql-exporter` читает SQL-представления KPI из БД 1С;
- `aw-exporter` собирает метрики ActivityWatch и отдает их Prometheus;
- `prometheus` собирает метрики и применяет alert-rules; - `prometheus` собирает метрики и применяет alert-rules;
- `grafana` поднимает datasource и дашборд автоматически. - `grafana` поднимает datasource и дашборд автоматически.
## Полные пути ## Полные пути
- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/.env.example` - `./.env.example`
- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/docker-compose.yml` - `./docker-compose.yml`
- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/sql-exporter/sql_exporter.yml` - `./sql-exporter/sql_exporter.yml`
- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/sql-exporter/collectors/onec_accounting_kpi.collector.yml` - `./sql-exporter/collectors/onec_accounting_kpi.collector.yml`
- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/prometheus/prometheus.yml` - `./prometheus/prometheus.yml`
- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/prometheus/alerts.yml` - `./prometheus/alerts.yml`
- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/prometheus/recording_rules.yml` - `./prometheus/recording_rules.yml`
- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/grafana/dashboards/1c-accounting-overview.json` - `./grafana/dashboards/1c-accounting-overview.json`
- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/grafana/dashboards/1c-accounting-sre.json` - `./grafana/dashboards/1c-accounting-sre.json`
- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/sql/postgres_views_template.sql` - `./sql/postgres_views_template.sql`
- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/sql/mssql_views_template.sql` - `./sql/mssql_views_template.sql`
- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/tools/discover_postgres_1c.sh` - `./tools/discover_postgres_1c.sh`
- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/tools/validate_kpi_views.sh` - `./tools/validate_kpi_views.sh`
- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/tools/check_pipeline.sh` - `./tools/check_pipeline.sh`
## Быстрый запуск ## Быстрый запуск
1. Подготовьте env: 1. Подготовьте env:
```bash ```bash
cd /mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c cd grafana-1c
cp .env.example .env cp .env.example .env
``` ```
2. В `.env` задайте: 2. В `.env` задайте:
- `GRAFANA_ADMIN_USER`, `GRAFANA_ADMIN_PASSWORD`; - `GRAFANA_ADMIN_USER`, `GRAFANA_ADMIN_PASSWORD`;
- `ONEC_DSN` (DSN read-only пользователя в БД 1С). - `ONEC_DSN` (DSN read-only пользователя в БД 1С);
- при необходимости `AW_SERVER_HOST`, `AW_SERVER_PORT`, `AW_SERVER_SCHEME`, `AW_EXPORTER_PORT` и `AW_SCRAPE_INTERVAL_SECONDS` для ActivityWatch exporter.
3. В БД 1С создайте KPI-представления: 3. В БД 1С создайте KPI-представления:
- для PostgreSQL возьмите `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/sql/postgres_views_template.sql`; - для PostgreSQL возьмите `./sql/postgres_views_template.sql`;
- для MS SQL возьмите `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/sql/mssql_views_template.sql`. - для MS SQL возьмите `./sql/mssql_views_template.sql`.
4. Поднимите стек: 4. Поднимите стек:
@@ -52,7 +54,9 @@ docker compose up -d
```bash ```bash
curl -fsS http://127.0.0.1:9399/metrics | head curl -fsS http://127.0.0.1:9399/metrics | head
curl -fsS http://127.0.0.1:9398/metrics | head
curl -fsS http://127.0.0.1:9090/-/healthy curl -fsS http://127.0.0.1:9090/-/healthy
curl -fsS http://127.0.0.1:3000/api/health
``` ```
Откройте Grafana: `http://<host>:3000`. Откройте Grafana: `http://<host>:3000`.
@@ -62,23 +66,25 @@ curl -fsS http://127.0.0.1:9090/-/healthy
Профилирование структуры 1С (PostgreSQL): Профилирование структуры 1С (PostgreSQL):
```bash ```bash
sh /mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/tools/discover_postgres_1c.sh \ sh ./tools/discover_postgres_1c.sh \
"postgres://user:pass@db-host:5432/db?sslmode=disable" "postgres://user:pass@db-host:5432/db?sslmode=disable"
``` ```
Проверка KPI views: Проверка KPI views:
```bash ```bash
sh /mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/tools/validate_kpi_views.sh \ sh ./tools/validate_kpi_views.sh \
"postgres://user:pass@db-host:5432/db?sslmode=disable" "postgres://user:pass@db-host:5432/db?sslmode=disable"
``` ```
Проверка end-to-end пайплайна: Проверка end-to-end пайплайна:
```bash ```bash
sh /mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/tools/check_pipeline.sh sh ./tools/check_pipeline.sh
``` ```
Скрипт проверяет полный путь сбора данных: `sql-exporter` и `aw-exporter` отдают обязательные метрики, Prometheus успешно выполняет запросы по scrape-targets, а Grafana отвечает на health/API, видит datasource `prometheus` и provisioned dashboards. Если стек запущен не из каталога репозитория, передайте путь к каталогу `grafana-1c` первым аргументом.
## Что контролируется ## Что контролируется
- Непроведенные документы (`onec_unposted_documents_total`) - Непроведенные документы (`onec_unposted_documents_total`)
@@ -86,6 +92,8 @@ sh /mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/tools/check_pipeline.
- Просроченная дебиторка (`onec_overdue_receivables_total`) - Просроченная дебиторка (`onec_overdue_receivables_total`)
- Ошибки проведения за 24ч (`onec_posting_errors_total`) - Ошибки проведения за 24ч (`onec_posting_errors_total`)
- Свежесть данных из 1С (`onec_data_freshness_seconds`) - Свежесть данных из 1С (`onec_data_freshness_seconds`)
- Доступность ActivityWatch API (`aw_up`)
- Количество bucket/events ActivityWatch (`aw_buckets_total`, `aw_bucket_events_count`)
## Принципы безопасности ## Принципы безопасности
+5 -4
View File
@@ -6,14 +6,15 @@ services:
container_name: awrus-aw-exporter container_name: awrus-aw-exporter
restart: unless-stopped restart: unless-stopped
environment: environment:
- AW_SERVER_HOST=10.10.10.13 - AW_SERVER_HOST=${AW_SERVER_HOST:-10.10.10.13}
- AW_SERVER_PORT=5600 - AW_SERVER_PORT=${AW_SERVER_PORT:-5600}
- AW_SERVER_SCHEME=http - AW_SERVER_SCHEME=${AW_SERVER_SCHEME:-http}
- EXPORTER_PORT=9398 - EXPORTER_PORT=9398
- SCRAPE_INTERVAL_SECONDS=${AW_SCRAPE_INTERVAL_SECONDS:-30}
volumes: volumes:
- ./sql-exporter/collectors/aw_activitywatch.py:/app/aw_activitywatch.py:ro - ./sql-exporter/collectors/aw_activitywatch.py:/app/aw_activitywatch.py:ro
ports: ports:
- "9398:9398" - "${AW_EXPORTER_PORT:-9398}:9398"
command: command:
- "python3" - "python3"
- "/app/aw_activitywatch.py" - "/app/aw_activitywatch.py"
+104 -16
View File
@@ -1,7 +1,9 @@
{ {
"dashboard": {
"title": "ActivityWatch Overview", "title": "ActivityWatch Overview",
"tags": ["activitywatch", "monitoring"], "tags": [
"activitywatch",
"monitoring"
],
"timezone": "browser", "timezone": "browser",
"panels": [ "panels": [
{ {
@@ -12,7 +14,11 @@
{ {
"expr": "aw_buckets_total", "expr": "aw_buckets_total",
"refId": "A", "refId": "A",
"legendFormat": "Total Buckets" "legendFormat": "Total Buckets",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
}
} }
], ],
"options": { "options": {
@@ -24,6 +30,16 @@
"unit": "short", "unit": "short",
"min": 0 "min": 0
} }
},
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"h": 8,
"w": 6,
"x": 0,
"y": 0
} }
}, },
{ {
@@ -35,7 +51,11 @@
"expr": "aw_bucket_events_count", "expr": "aw_bucket_events_count",
"format": "table", "format": "table",
"instant": true, "instant": true,
"refId": "B" "refId": "B",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
}
} }
], ],
"transformations": [ "transformations": [
@@ -48,7 +68,17 @@
} }
} }
} }
] ],
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"h": 8,
"w": 12,
"x": 6,
"y": 0
}
}, },
{ {
"id": 3, "id": 3,
@@ -59,11 +89,25 @@
"expr": "aw_collector_status", "expr": "aw_collector_status",
"format": "table", "format": "table",
"instant": true, "instant": true,
"refId": "C" "refId": "C",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
}
} }
], ],
"options": { "options": {
"colorMode": "value" "colorMode": "value"
},
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"h": 8,
"w": 6,
"x": 18,
"y": 0
} }
}, },
{ {
@@ -74,7 +118,11 @@
{ {
"expr": "rate(aw_events_total[5m])", "expr": "rate(aw_events_total[5m])",
"legendFormat": "{{bucket}} - {{event_type}}", "legendFormat": "{{bucket}} - {{event_type}}",
"refId": "D" "refId": "D",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
}
} }
], ],
"fieldConfig": { "fieldConfig": {
@@ -84,6 +132,16 @@
"fillOpacity": 10 "fillOpacity": 10
} }
} }
},
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"h": 9,
"w": 12,
"x": 0,
"y": 8
} }
}, },
{ {
@@ -94,7 +152,11 @@
{ {
"expr": "aw_events_last_timestamp", "expr": "aw_events_last_timestamp",
"legendFormat": "{{bucket}}", "legendFormat": "{{bucket}}",
"refId": "E" "refId": "E",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
}
} }
], ],
"options": { "options": {
@@ -107,15 +169,41 @@
"thresholds": { "thresholds": {
"mode": "absolute", "mode": "absolute",
"steps": [ "steps": [
{"color": "red", "value": 0}, {
{"color": "yellow", "value": 3600}, "color": "red",
{"color": "green", "value": 86400} "value": 0
] },
} {
} "color": "yellow",
} "value": 3600
} },
{
"color": "green",
"value": 86400
} }
] ]
} }
}
}
},
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"h": 9,
"w": 12,
"x": 12,
"y": 8
}
}
],
"uid": "activitywatch-overview",
"schemaVersion": 39,
"version": 1,
"refresh": "30s",
"time": {
"from": "now-6h",
"to": "now"
}
} }
@@ -1,20 +1,11 @@
apiVersion: 1 apiVersion: 1
providers: providers:
- name: 1C-Buhgalteria - name: awatch-rus
orgId: 1 orgId: 1
folder: "1C" folder: "AWatch-rus"
type: file type: file
disableDeletion: true disableDeletion: true
editable: false editable: false
options: options:
path: /var/lib/grafana/dashboards path: /var/lib/grafana/dashboards
- name: ActivityWatch
orgId: 2
folder: "ActivityWatch"
type: file
disableDeletion: false
editable: true
options:
path: /var/lib/grafana/dashboards
@@ -1,9 +0,0 @@
apiVersion: 1
datasources:
- name: Prometheus
type: prometheus
access: proxy
url: http://prometheus:9090
isDefault: true
editable: true
@@ -1,9 +0,0 @@
apiVersion: 1
datasources:
- name: Prometheus
type: prometheus
access: proxy
url: http://prometheus:9090
isDefault: true
editable: true
+82 -83
View File
@@ -4,139 +4,138 @@ ActivityWatch Prometheus Exporter
Собирает метрики из ActivityWatch API и экспонирует их в формате Prometheus. Собирает метрики из ActivityWatch API и экспонирует их в формате Prometheus.
""" """
import time
import logging import logging
import os
import time
from datetime import datetime
import requests import requests
from prometheus_client import start_http_server, Gauge, Counter, Histogram, Info from prometheus_client import Counter, Gauge, Info, start_http_server
from datetime import datetime, timedelta
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Configuration # Configuration
AW_SERVER_HOST = "10.10.10.13" AW_SERVER_HOST = os.getenv("AW_SERVER_HOST", "10.10.10.13")
AW_SERVER_PORT = 5600 AW_SERVER_PORT = int(os.getenv("AW_SERVER_PORT", "5600"))
AW_SERVER_SCHEME = "http" AW_SERVER_SCHEME = os.getenv("AW_SERVER_SCHEME", "http")
AW_API_BASE = f"{AW_SERVER_SCHEME}://{AW_SERVER_HOST}:{AW_SERVER_PORT}/api/0" AW_API_BASE = os.getenv(
EXPORTER_PORT = 9398 "AW_API_BASE",
f"{AW_SERVER_SCHEME}://{AW_SERVER_HOST}:{AW_SERVER_PORT}/api/0",
)
EXPORTER_PORT = int(os.getenv("EXPORTER_PORT", "9398"))
SCRAPE_INTERVAL_SECONDS = int(os.getenv("SCRAPE_INTERVAL_SECONDS", "30"))
# Metrics # Metrics
aw_buckets_total = Gauge('aw_buckets_total', 'Total number of ActivityWatch buckets') aw_up = Gauge("aw_up", "ActivityWatch API availability: 1 if the last scrape succeeded, 0 otherwise")
aw_events_total = Counter('aw_events_total', 'Total number of ActivityWatch events', ['bucket', 'event_type']) aw_buckets_total = Gauge("aw_buckets_total", "Total number of ActivityWatch buckets")
aw_events_last_timestamp = Gauge('aw_events_last_timestamp', 'Timestamp of last event in bucket', ['bucket']) aw_events_total = Counter("aw_events_total", "Total number of ActivityWatch events observed", ["bucket", "event_type"])
aw_bucket_events_count = Gauge('aw_bucket_events_count', 'Number of events in bucket', ['bucket']) aw_events_last_timestamp = Gauge("aw_events_last_timestamp", "Timestamp of last event in bucket", ["bucket"])
aw_collector_status = Info('aw_collector_status', 'Status of ActivityWatch collectors') aw_bucket_events_count = Gauge("aw_bucket_events_count", "Number of events sampled from bucket", ["bucket"])
aw_server_info = Info('aw_server_info', 'ActivityWatch server information') aw_collector_status = Gauge(
"aw_collector_status",
"ActivityWatch bucket collector status: 1 if bucket was observed during the last scrape",
["bucket", "client", "hostname", "type"],
)
aw_server_info = Info("aw_server", "ActivityWatch server information")
class ActivityWatchExporter: class ActivityWatchExporter:
def __init__(self, api_base): def __init__(self, api_base):
self.api_base = api_base self.api_base = api_base.rstrip("/")
self.session = requests.Session() self.session = requests.Session()
self.session.headers.update({'Accept': 'application/json'}) self.session.headers.update({"Accept": "application/json"})
self.bucket_cache = {} self.bucket_event_counts = {}
def get_buckets(self): def get_buckets(self):
"""Get all buckets from ActivityWatch API.""" """Get all buckets from ActivityWatch API."""
try:
response = self.session.get(f"{self.api_base}/buckets", timeout=10) response = self.session.get(f"{self.api_base}/buckets", timeout=10)
response.raise_for_status() response.raise_for_status()
return response.json() return response.json()
except Exception as e:
logger.error(f"Failed to get buckets: {e}")
return {}
def get_bucket_events(self, bucket_id, limit=1): def get_bucket_events(self, bucket_id, limit=1000):
"""Get events from a specific bucket.""" """Get events from a specific bucket."""
try:
response = self.session.get( response = self.session.get(
f"{self.api_base}/buckets/{bucket_id}/events", f"{self.api_base}/buckets/{bucket_id}/events",
params={'limit': limit}, params={"limit": limit},
timeout=10 timeout=10,
) )
response.raise_for_status() response.raise_for_status()
return response.json() return response.json()
except Exception as e:
logger.error(f"Failed to get events for {bucket_id}: {e}")
return []
def get_bucket_info(self, bucket_id): @staticmethod
"""Get detailed info about a bucket.""" def event_type(event):
try: data = event.get("data") or {}
response = self.session.get(f"{self.api_base}/buckets/{bucket_id}", timeout=10) return str(data.get("app") or data.get("title") or event.get("$schema") or "unknown")
response.raise_for_status()
return response.json() @staticmethod
except Exception as e: def event_timestamp(event):
logger.error(f"Failed to get info for {bucket_id}: {e}") timestamp = event.get("timestamp", 0)
return {} if isinstance(timestamp, str):
return datetime.fromisoformat(timestamp.replace("Z", "+00:00")).timestamp()
return float(timestamp or 0)
def collect_metrics(self): def collect_metrics(self):
"""Collect metrics from ActivityWatch.""" """Collect metrics from ActivityWatch."""
try:
buckets = self.get_buckets() buckets = self.get_buckets()
aw_up.set(1)
except Exception as exc:
logger.error("Failed to get buckets: %s", exc)
aw_up.set(0)
return
# Update bucket count
aw_buckets_total.set(len(buckets)) aw_buckets_total.set(len(buckets))
aw_server_info.info(
{
"host": AW_SERVER_HOST,
"port": str(AW_SERVER_PORT),
"scheme": AW_SERVER_SCHEME,
"api_base": self.api_base,
}
)
# Server info aw_collector_status.clear()
aw_server_info.info({
'host': AW_SERVER_HOST,
'port': AW_SERVER_PORT,
'scheme': AW_SERVER_SCHEME,
'api_base': self.api_base
})
# Collector status
collectors = {}
for bucket_id, bucket_data in buckets.items(): for bucket_id, bucket_data in buckets.items():
client = bucket_data.get('client', 'unknown') client = str(bucket_data.get("client", "unknown"))
hostname = bucket_data.get('hostname', 'unknown') hostname = str(bucket_data.get("hostname", "unknown"))
bucket_type = bucket_data.get('type', 'unknown') bucket_type = str(bucket_data.get("type", "unknown"))
try:
events = self.get_bucket_events(bucket_id)
except Exception as exc:
logger.error("Failed to get events for %s: %s", bucket_id, exc)
events = []
# Count events
events = self.get_bucket_events(bucket_id, limit=1000)
event_count = len(events) event_count = len(events)
aw_bucket_events_count.labels(bucket=bucket_id).set(event_count) aw_bucket_events_count.labels(bucket=bucket_id).set(event_count)
aw_collector_status.labels(bucket=bucket_id, client=client, hostname=hostname, type=bucket_type).set(1)
previous_count = self.bucket_event_counts.get(bucket_id)
if previous_count is not None and event_count > previous_count:
for event in events[: event_count - previous_count]:
aw_events_total.labels(bucket=bucket_id, event_type=self.event_type(event)).inc()
self.bucket_event_counts[bucket_id] = event_count
# Last event timestamp
if events: if events:
last_event = events[0]
timestamp = last_event.get('timestamp', 0)
try: try:
# Convert to Unix timestamp if needed aw_events_last_timestamp.labels(bucket=bucket_id).set(self.event_timestamp(events[0]))
if isinstance(timestamp, str): except Exception as exc:
dt = datetime.fromisoformat(timestamp.replace('Z', '+00:00')) logger.warning("Failed to parse last event timestamp for %s: %s", bucket_id, exc)
unix_ts = dt.timestamp()
else:
unix_ts = timestamp
aw_events_last_timestamp.labels(bucket=bucket_id).set(unix_ts)
except:
pass
# Collector status
collector_key = f"{hostname}_{client}"
collectors[collector_key] = {
'status': 'active',
'bucket': bucket_id,
'type': bucket_type,
'events': event_count
}
aw_collector_status.info(collectors)
def main(): def main():
exporter = ActivityWatchExporter(AW_API_BASE) exporter = ActivityWatchExporter(AW_API_BASE)
# Initial collection
exporter.collect_metrics() exporter.collect_metrics()
# Start HTTP server
start_http_server(EXPORTER_PORT) start_http_server(EXPORTER_PORT)
logger.info(f"ActivityWatch exporter started on port {EXPORTER_PORT}") logger.info("ActivityWatch exporter started on port %s", EXPORTER_PORT)
logger.info(f"Scraping ActivityWatch API at {AW_API_BASE}") logger.info("Scraping ActivityWatch API at %s", AW_API_BASE)
# Collect metrics every 30 seconds
while True: while True:
time.sleep(30) time.sleep(SCRAPE_INTERVAL_SECONDS)
exporter.collect_metrics() exporter.collect_metrics()
if __name__ == '__main__':
if __name__ == "__main__":
main() main()
+88 -13
View File
@@ -1,20 +1,95 @@
#!/bin/sh #!/bin/sh
set -eu set -eu
STACK_DIR="${1:-/mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c}" SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
STACK_DIR="${1:-$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)}"
ENV_FILE="$STACK_DIR/.env"
echo "[*] Checking endpoints" env_value() {
curl -fsS http://127.0.0.1:9399/metrics >/tmp/awrus-onec-metrics.out key="$1"
curl -fsS http://127.0.0.1:9090/-/healthy >/tmp/awrus-prom-healthy.out default="$2"
curl -fsS "http://127.0.0.1:9090/api/v1/query?query=up%7Bjob%3D%22onec_sql_exporter%22%7D" >/tmp/awrus-prom-up.json current=$(eval "printf '%s' \"\${$key:-}\"")
curl -fsS "http://127.0.0.1:9090/api/v1/query?query=onec_data_freshness_seconds" >/tmp/awrus-prom-freshness.json if [ -n "$current" ]; then
printf '%s' "$current"
return
fi
if [ -f "$ENV_FILE" ]; then
value=$(sed -n "s/^$key=//p" "$ENV_FILE" | tail -n 1)
if [ -n "$value" ]; then
printf '%s' "$value"
return
fi
fi
printf '%s' "$default"
}
echo "[*] Checking container status" GRAFANA_PORT=$(env_value GRAFANA_PORT 3000)
cd "$STACK_DIR" PROMETHEUS_PORT=$(env_value PROMETHEUS_PORT 9090)
docker compose ps SQL_EXPORTER_PORT=$(env_value SQL_EXPORTER_PORT 9399)
AW_EXPORTER_PORT=$(env_value AW_EXPORTER_PORT 9398)
GRAFANA_ADMIN_USER=$(env_value GRAFANA_ADMIN_USER admin)
GRAFANA_ADMIN_PASSWORD=$(env_value GRAFANA_ADMIN_PASSWORD change_me_now)
TMP_DIR="${TMPDIR:-/tmp}"
METRICS_OUT="$TMP_DIR/awrus-onec-metrics.out"
AW_METRICS_OUT="$TMP_DIR/awrus-aw-metrics.out"
PROM_HEALTH_OUT="$TMP_DIR/awrus-prom-healthy.out"
PROM_UP_OUT="$TMP_DIR/awrus-prom-up.json"
PROM_FRESHNESS_OUT="$TMP_DIR/awrus-prom-freshness.json"
GRAFANA_HEALTH_OUT="$TMP_DIR/awrus-grafana-health.json"
GRAFANA_DS_OUT="$TMP_DIR/awrus-grafana-datasources.json"
GRAFANA_DASH_OUT="$TMP_DIR/awrus-grafana-dashboards.json"
require_metric() {
metric_name="$1"
metrics_file="$2"
if ! grep -q "^$metric_name" "$metrics_file"; then
echo "[!] Required metric '$metric_name' was not found in $metrics_file" >&2
exit 1
fi
}
require_prometheus_success() {
file="$1"
if ! grep -q '"status":"success"' "$file"; then
echo "[!] Prometheus query did not return status=success: $file" >&2
cat "$file" >&2
exit 1
fi
}
echo "[*] Checking exporter endpoints"
curl -fsS "http://127.0.0.1:$SQL_EXPORTER_PORT/metrics" >"$METRICS_OUT"
curl -fsS "http://127.0.0.1:$AW_EXPORTER_PORT/metrics" >"$AW_METRICS_OUT"
require_metric "onec_data_freshness_seconds" "$METRICS_OUT"
require_metric "aw_up" "$AW_METRICS_OUT"
echo "[*] Checking Prometheus health and scrape targets"
curl -fsS "http://127.0.0.1:$PROMETHEUS_PORT/-/healthy" >"$PROM_HEALTH_OUT"
curl -fsS "http://127.0.0.1:$PROMETHEUS_PORT/api/v1/query?query=up%7Bjob%3D~%22onec_sql_exporter%7Caw_activitywatch_exporter%22%7D" >"$PROM_UP_OUT"
curl -fsS "http://127.0.0.1:$PROMETHEUS_PORT/api/v1/query?query=onec_data_freshness_seconds" >"$PROM_FRESHNESS_OUT"
require_prometheus_success "$PROM_UP_OUT"
require_prometheus_success "$PROM_FRESHNESS_OUT"
echo "[*] Checking Grafana health, datasource and dashboards"
curl -fsS "http://127.0.0.1:$GRAFANA_PORT/api/health" >"$GRAFANA_HEALTH_OUT"
curl -fsS -u "$GRAFANA_ADMIN_USER:$GRAFANA_ADMIN_PASSWORD" "http://127.0.0.1:$GRAFANA_PORT/api/datasources/uid/prometheus" >"$GRAFANA_DS_OUT"
curl -fsS -u "$GRAFANA_ADMIN_USER:$GRAFANA_ADMIN_PASSWORD" "http://127.0.0.1:$GRAFANA_PORT/api/search?type=dash-db&query=" >"$GRAFANA_DASH_OUT"
if command -v docker >/dev/null 2>&1; then
echo "[*] Checking container status"
cd "$STACK_DIR"
docker compose ps
else
echo "[*] docker command not found; skipping container status"
fi
echo "[+] Pipeline health artifacts:" echo "[+] Pipeline health artifacts:"
echo " /tmp/awrus-onec-metrics.out" echo " $METRICS_OUT"
echo " /tmp/awrus-prom-healthy.out" echo " $AW_METRICS_OUT"
echo " /tmp/awrus-prom-up.json" echo " $PROM_HEALTH_OUT"
echo " /tmp/awrus-prom-freshness.json" echo " $PROM_UP_OUT"
echo " $PROM_FRESHNESS_OUT"
echo " $GRAFANA_HEALTH_OUT"
echo " $GRAFANA_DS_OUT"
echo " $GRAFANA_DASH_OUT"
-1
View File
@@ -1,5 +1,4 @@
#!/bin/sh #!/bin/sh
# shellcheck disable=SC1007
set -eu set -eu
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
-1
View File
@@ -1,5 +1,4 @@
#!/bin/sh #!/bin/sh
# shellcheck disable=SC1007
set -eu set -eu
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
-1
View File
@@ -4,7 +4,6 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR" cd "$ROOT_DIR"
# shellcheck disable=SC2034
KIT_DIR="install-kit-awindows-20260427-211240" KIT_DIR="install-kit-awindows-20260427-211240"
python - <<'PY' python - <<'PY'
+2 -4
View File
@@ -21,11 +21,9 @@ prompt_secret() {
if [[ -n "${!var_name:-}" ]]; then if [[ -n "${!var_name:-}" ]]; then
return 0 return 0
fi fi
local _val read -r -s -p "${prompt}: " "$var_name"
read -r -s -p "${prompt}: " _val
echo echo
printf -v "$var_name" '%s' "$_val" export "$var_name"
declare -gx "$var_name"
} }
require_cmd git require_cmd git
+3 -1
View File
@@ -376,6 +376,7 @@ function New-ActivityWatchDeploymentConfig {
[string]$LaunchScriptPath, [string]$LaunchScriptPath,
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
[string]$RecoveryScriptPath, [string]$RecoveryScriptPath,
[string]$AwHostname,
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
[pscustomobject[]]$UserTasks, [pscustomobject[]]$UserTasks,
[string]$PackageVersion = 'v0.13.2' [string]$PackageVersion = 'v0.13.2'
@@ -386,6 +387,7 @@ function New-ActivityWatchDeploymentConfig {
return [pscustomobject]@{ return [pscustomobject]@{
version = 1 version = 1
generatedAtUtc = (Get-Date).ToUniversalTime().ToString('o') generatedAtUtc = (Get-Date).ToUniversalTime().ToString('o')
awHostname = if ([string]::IsNullOrWhiteSpace($AwHostname)) { [string]$env:COMPUTERNAME } else { [string]$AwHostname }
server = [pscustomobject]@{ server = [pscustomobject]@{
host = $ServerHost host = $ServerHost
port = $ServerPort port = $ServerPort
@@ -742,7 +744,7 @@ function Start-CollectorScriptIfNeeded {
`$installRoot = [string]`$config.paths.installRoot `$installRoot = [string]`$config.paths.installRoot
`$stateRoot = [string]`$config.paths.stateRoot `$stateRoot = [string]`$config.paths.stateRoot
`$script:ApiBase = '{0}://{1}:{2}/api/0' -f [string]`$config.server.scheme, [string]`$config.server.host, [string]`$config.server.port `$script:ApiBase = '{0}://{1}:{2}/api/0' -f [string]`$config.server.scheme, [string]`$config.server.host, [string]`$config.server.port
`$script:Hostname = `$env:COMPUTERNAME `$script:Hostname = if (`$config.PSObject.Properties.Name -contains 'awHostname' -and -not [string]::IsNullOrWhiteSpace([string]`$config.awHostname)) { [string]`$config.awHostname } else { `$env:COMPUTERNAME }
`$script:KnownBuckets = @{} `$script:KnownBuckets = @{}
`$collectorScript = [string]`$config.paths.collectorScript `$collectorScript = [string]`$config.paths.collectorScript
`$endpointCollectorScript = if (`$config.paths.PSObject.Properties.Name -contains 'endpointCollectorScript') { [string]`$config.paths.endpointCollectorScript } else { Join-Path `$stateRoot 'dlp-endpoint-signals-collector.ps1' } `$endpointCollectorScript = if (`$config.paths.PSObject.Properties.Name -contains 'endpointCollectorScript') { [string]`$config.paths.endpointCollectorScript } else { Join-Path `$stateRoot 'dlp-endpoint-signals-collector.ps1' }
+88
View File
@@ -0,0 +1,88 @@
[CmdletBinding()]
param(
[string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json',
[int]$LoopSeconds = 20
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
function Get-Config {
param([string]$Path)
if (-not (Test-Path -LiteralPath $Path)) {
throw "Config not found: $Path"
}
Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json
}
function Write-ServiceLog {
param([string]$Message)
try {
Add-Content -LiteralPath $script:LogPath -Value ('{0} {1}' -f (Get-Date -Format s), $Message)
}
catch {}
}
function Start-CollectorIfNeeded {
param(
[string]$ScriptPath,
[string]$ConfigPath
)
if ([string]::IsNullOrWhiteSpace($ScriptPath) -or -not (Test-Path -LiteralPath $ScriptPath)) {
return
}
$escaped = [Regex]::Escape($ScriptPath)
$running = Get-CimInstance Win32_Process -ErrorAction SilentlyContinue |
Where-Object {
$_.Name -eq 'powershell.exe' -and
$_.CommandLine -match $escaped -and
$_.CommandLine -match [Regex]::Escape($ConfigPath)
} |
Select-Object -First 1
if ($running) {
return
}
$args = @('-NoProfile', '-ExecutionPolicy', 'Bypass')
if ($ScriptPath -like '*dlp-endpoint-signals*') {
$args += '-STA'
}
$args += @('-File', $ScriptPath, '-ConfigPath', $ConfigPath)
Start-Process -FilePath 'powershell.exe' -ArgumentList $args -WindowStyle Hidden | Out-Null
Write-ServiceLog ("started collector: {0}" -f $ScriptPath)
}
$cfg = Get-Config -Path $ConfigPath
$stateRoot = if ($cfg.paths -and $cfg.paths.stateRoot) { [string]$cfg.paths.stateRoot } else { 'C:\ProgramData\AWatch-rus' }
$logsRoot = Join-Path $stateRoot 'logs'
if (-not (Test-Path -LiteralPath $logsRoot)) {
New-Item -Path $logsRoot -ItemType Directory -Force | Out-Null
}
$script:LogPath = Join-Path $logsRoot 'standalone-agent-service.log'
Write-ServiceLog ('service loop started, config={0}' -f $ConfigPath)
while ($true) {
try {
$cfg = Get-Config -Path $ConfigPath
$paths = $cfg.paths
Start-CollectorIfNeeded -ScriptPath ([string]$paths.collectorScript) -ConfigPath $ConfigPath
Start-CollectorIfNeeded -ScriptPath ([string]$paths.endpointCollectorScript) -ConfigPath $ConfigPath
Start-CollectorIfNeeded -ScriptPath ([string]$paths.fileCollectorScript) -ConfigPath $ConfigPath
if ($paths.PSObject.Properties.Name -contains 'emailCollectorScript') {
Start-CollectorIfNeeded -ScriptPath ([string]$paths.emailCollectorScript) -ConfigPath $ConfigPath
}
if ($paths.PSObject.Properties.Name -contains 'sessionCollectorScript') {
Start-CollectorIfNeeded -ScriptPath ([string]$paths.sessionCollectorScript) -ConfigPath $ConfigPath
}
}
catch {
Write-ServiceLog ("loop error: {0}" -f $_.Exception.Message)
}
Start-Sleep -Seconds ([Math]::Max($LoopSeconds, 5))
}
+2 -1
View File
@@ -62,13 +62,14 @@ $resolvedIncidentLogPath = if ($IncidentLogPath) { $IncidentLogPath } else { Joi
$resolvedLocalAgentLogsEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'logging' -and $deploymentConfig.logging.PSObject.Properties.Name -contains 'localAgentLogsEnabled') { [bool]$deploymentConfig.logging.localAgentLogsEnabled } else { $true } $resolvedLocalAgentLogsEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'logging' -and $deploymentConfig.logging.PSObject.Properties.Name -contains 'localAgentLogsEnabled') { [bool]$deploymentConfig.logging.localAgentLogsEnabled } else { $true }
$resolvedIncidentArtifactsRoot = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'artifactsRoot') { [string]$deploymentConfig.incidentCapture.artifactsRoot } else { Join-Path $env:LOCALAPPDATA 'AWatch-rus\\incident-artifacts' } $resolvedIncidentArtifactsRoot = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'artifactsRoot') { [string]$deploymentConfig.incidentCapture.artifactsRoot } else { Join-Path $env:LOCALAPPDATA 'AWatch-rus\\incident-artifacts' }
$resolvedIncidentScreenshotEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'screenshotEnabled') { [bool]$deploymentConfig.incidentCapture.screenshotEnabled } else { $true } $resolvedIncidentScreenshotEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'screenshotEnabled') { [bool]$deploymentConfig.incidentCapture.screenshotEnabled } else { $true }
$resolvedHostname = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'awHostname' -and -not [string]::IsNullOrWhiteSpace([string]$deploymentConfig.awHostname)) { [string]$deploymentConfig.awHostname } else { [string]$env:COMPUTERNAME }
if ($resolvedLocalAgentLogsEnabled -and -not (Test-Path -LiteralPath $resolvedLogsRoot)) { if ($resolvedLocalAgentLogsEnabled -and -not (Test-Path -LiteralPath $resolvedLogsRoot)) {
New-Item -Path $resolvedLogsRoot -ItemType Directory -Force | Out-Null New-Item -Path $resolvedLogsRoot -ItemType Directory -Force | Out-Null
} }
$script:ApiBase = '{0}://{1}:{2}/api/0' -f $resolvedServerScheme, $resolvedServerHost, $resolvedServerPort $script:ApiBase = '{0}://{1}:{2}/api/0' -f $resolvedServerScheme, $resolvedServerHost, $resolvedServerPort
$script:Hostname = $env:COMPUTERNAME $script:Hostname = $resolvedHostname
$script:SessionId = (Get-Process -Id $PID).SessionId $script:SessionId = (Get-Process -Id $PID).SessionId
$script:KnownBuckets = @{} $script:KnownBuckets = @{}
$script:LocalAgentLogsEnabled = $resolvedLocalAgentLogsEnabled $script:LocalAgentLogsEnabled = $resolvedLocalAgentLogsEnabled
+2
View File
@@ -24,6 +24,7 @@ param(
[bool]$IncidentScreenshotEnabled = $true, [bool]$IncidentScreenshotEnabled = $true,
[string]$IncidentArtifactsRoot, [string]$IncidentArtifactsRoot,
[bool]$LogonMarkerEnabled = $true, [bool]$LogonMarkerEnabled = $true,
[string]$AwHostname,
[string]$CustomRulesPath, [string]$CustomRulesPath,
[string]$CustomPolicyPath [string]$CustomPolicyPath
) )
@@ -100,6 +101,7 @@ $config = New-ActivityWatchDeploymentConfig `
-IncidentScreenshotEnabled $IncidentScreenshotEnabled ` -IncidentScreenshotEnabled $IncidentScreenshotEnabled `
-IncidentArtifactsRoot $IncidentArtifactsRoot ` -IncidentArtifactsRoot $IncidentArtifactsRoot `
-LogonMarkerEnabled $LogonMarkerEnabled ` -LogonMarkerEnabled $LogonMarkerEnabled `
-AwHostname $AwHostname `
-LaunchScriptPath $launchScriptPath ` -LaunchScriptPath $launchScriptPath `
-RecoveryScriptPath $recoveryScriptPath ` -RecoveryScriptPath $recoveryScriptPath `
-UserTasks $taskDefinitions ` -UserTasks $taskDefinitions `
+3
View File
@@ -24,6 +24,7 @@ param(
[bool]$IncidentScreenshotEnabled = $true, [bool]$IncidentScreenshotEnabled = $true,
[string]$IncidentArtifactsRoot, [string]$IncidentArtifactsRoot,
[bool]$LogonMarkerEnabled = $true, [bool]$LogonMarkerEnabled = $true,
[string]$AwHostname,
[string]$CustomRulesPath, [string]$CustomRulesPath,
[string]$CustomPolicyPath, [string]$CustomPolicyPath,
[string]$ReportPath, [string]$ReportPath,
@@ -71,6 +72,7 @@ if (-not (Test-Path -LiteralPath $deployScript)) {
-IncidentScreenshotEnabled $IncidentScreenshotEnabled ` -IncidentScreenshotEnabled $IncidentScreenshotEnabled `
-IncidentArtifactsRoot $IncidentArtifactsRoot ` -IncidentArtifactsRoot $IncidentArtifactsRoot `
-LogonMarkerEnabled $LogonMarkerEnabled ` -LogonMarkerEnabled $LogonMarkerEnabled `
-AwHostname $AwHostname `
-CustomRulesPath $CustomRulesPath ` -CustomRulesPath $CustomRulesPath `
-CustomPolicyPath $CustomPolicyPath -CustomPolicyPath $CustomPolicyPath
@@ -94,6 +96,7 @@ if (-not $SkipHardening) {
-IncidentScreenshotEnabled $IncidentScreenshotEnabled ` -IncidentScreenshotEnabled $IncidentScreenshotEnabled `
-IncidentArtifactsRoot $IncidentArtifactsRoot ` -IncidentArtifactsRoot $IncidentArtifactsRoot `
-LogonMarkerEnabled $LogonMarkerEnabled ` -LogonMarkerEnabled $LogonMarkerEnabled `
-AwHostname $AwHostname `
-CustomRulesPath $CustomRulesPath ` -CustomRulesPath $CustomRulesPath `
-CustomPolicyPath $CustomPolicyPath -CustomPolicyPath $CustomPolicyPath
} }
+2
View File
@@ -22,6 +22,7 @@ param(
[bool]$IncidentScreenshotEnabled = $true, [bool]$IncidentScreenshotEnabled = $true,
[string]$IncidentArtifactsRoot, [string]$IncidentArtifactsRoot,
[bool]$LogonMarkerEnabled = $true, [bool]$LogonMarkerEnabled = $true,
[string]$AwHostname,
[string]$CustomRulesPath, [string]$CustomRulesPath,
[string]$CustomPolicyPath [string]$CustomPolicyPath
) )
@@ -92,6 +93,7 @@ $config = New-ActivityWatchDeploymentConfig `
-IncidentScreenshotEnabled $IncidentScreenshotEnabled ` -IncidentScreenshotEnabled $IncidentScreenshotEnabled `
-IncidentArtifactsRoot $IncidentArtifactsRoot ` -IncidentArtifactsRoot $IncidentArtifactsRoot `
-LogonMarkerEnabled $LogonMarkerEnabled ` -LogonMarkerEnabled $LogonMarkerEnabled `
-AwHostname $AwHostname `
-LaunchScriptPath $launchScriptPath ` -LaunchScriptPath $launchScriptPath `
-RecoveryScriptPath $recoveryScriptPath ` -RecoveryScriptPath $recoveryScriptPath `
-UserTasks $taskDefinitions ` -UserTasks $taskDefinitions `
+2 -1
View File
@@ -778,13 +778,14 @@ $resolvedLogPath = if ($LogPath) { $LogPath } else { Join-Path $resolvedLogsRoot
$resolvedLocalAgentLogsEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'logging' -and $deploymentConfig.logging.PSObject.Properties.Name -contains 'localAgentLogsEnabled') { [bool]$deploymentConfig.logging.localAgentLogsEnabled } else { $true } $resolvedLocalAgentLogsEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'logging' -and $deploymentConfig.logging.PSObject.Properties.Name -contains 'localAgentLogsEnabled') { [bool]$deploymentConfig.logging.localAgentLogsEnabled } else { $true }
$resolvedIncidentArtifactsRoot = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'artifactsRoot') { [string]$deploymentConfig.incidentCapture.artifactsRoot } else { Join-Path $env:LOCALAPPDATA 'AWatch-rus\\incident-artifacts' } $resolvedIncidentArtifactsRoot = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'artifactsRoot') { [string]$deploymentConfig.incidentCapture.artifactsRoot } else { Join-Path $env:LOCALAPPDATA 'AWatch-rus\\incident-artifacts' }
$resolvedIncidentScreenshotEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'screenshotEnabled') { [bool]$deploymentConfig.incidentCapture.screenshotEnabled } else { $true } $resolvedIncidentScreenshotEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'screenshotEnabled') { [bool]$deploymentConfig.incidentCapture.screenshotEnabled } else { $true }
$resolvedHostname = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'awHostname' -and -not [string]::IsNullOrWhiteSpace([string]$deploymentConfig.awHostname)) { [string]$deploymentConfig.awHostname } else { [string]$env:COMPUTERNAME }
if ($resolvedLocalAgentLogsEnabled -and -not (Test-Path -LiteralPath $resolvedLogsRoot)) { if ($resolvedLocalAgentLogsEnabled -and -not (Test-Path -LiteralPath $resolvedLogsRoot)) {
New-Item -Path $resolvedLogsRoot -ItemType Directory -Force | Out-Null New-Item -Path $resolvedLogsRoot -ItemType Directory -Force | Out-Null
} }
$script:ApiBase = '{0}://{1}:{2}/api/0' -f $resolvedServerScheme, $resolvedServerHost, $resolvedServerPort $script:ApiBase = '{0}://{1}:{2}/api/0' -f $resolvedServerScheme, $resolvedServerHost, $resolvedServerPort
$script:Hostname = $env:COMPUTERNAME $script:Hostname = $resolvedHostname
$script:SessionId = (Get-Process -Id $PID).SessionId $script:SessionId = (Get-Process -Id $PID).SessionId
$script:KnownBuckets = @{} $script:KnownBuckets = @{}
$script:Cooldown = @{} $script:Cooldown = @{}
+1
View File
@@ -145,6 +145,7 @@ function Send-FileOperationEvent {
$config = Get-DeploymentConfig -Path $ConfigPath $config = Get-DeploymentConfig -Path $ConfigPath
if (-not $config) { throw "Configuration file not found: $ConfigPath" } if (-not $config) { throw "Configuration file not found: $ConfigPath" }
$script:Hostname = if ($config.PSObject.Properties.Name -contains 'awHostname' -and -not [string]::IsNullOrWhiteSpace([string]$config.awHostname)) { [string]$config.awHostname } else { [string]$env:COMPUTERNAME }
$scheme = if ($ServerScheme) { $ServerScheme } elseif ($config.server.scheme) { $config.server.scheme } else { 'http' } $scheme = if ($ServerScheme) { $ServerScheme } elseif ($config.server.scheme) { $config.server.scheme } else { 'http' }
$hostName = if ($ServerHost) { $ServerHost } elseif ($config.server.host) { $config.server.host } else { 'localhost' } $hostName = if ($ServerHost) { $ServerHost } elseif ($config.server.host) { $config.server.host } else { 'localhost' }
+3
View File
@@ -21,6 +21,7 @@ param(
[bool]$IncidentScreenshotEnabled, [bool]$IncidentScreenshotEnabled,
[string]$IncidentArtifactsRoot, [string]$IncidentArtifactsRoot,
[bool]$LogonMarkerEnabled, [bool]$LogonMarkerEnabled,
[string]$AwHostname,
[string]$CustomRulesPath, [string]$CustomRulesPath,
[string]$CustomPolicyPath, [string]$CustomPolicyPath,
[switch]$RepairPackage, [switch]$RepairPackage,
@@ -73,6 +74,7 @@ $effectiveIncidentCaptureEnabled = if ($PSBoundParameters.ContainsKey('IncidentC
$effectiveIncidentScreenshotEnabled = if ($PSBoundParameters.ContainsKey('IncidentScreenshotEnabled')) { [bool]$IncidentScreenshotEnabled } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $existingConfig.incidentCapture.PSObject.Properties.Name -contains 'screenshotEnabled') { [bool]$existingConfig.incidentCapture.screenshotEnabled } else { $true } $effectiveIncidentScreenshotEnabled = if ($PSBoundParameters.ContainsKey('IncidentScreenshotEnabled')) { [bool]$IncidentScreenshotEnabled } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $existingConfig.incidentCapture.PSObject.Properties.Name -contains 'screenshotEnabled') { [bool]$existingConfig.incidentCapture.screenshotEnabled } else { $true }
$effectiveIncidentArtifactsRoot = if ($PSBoundParameters.ContainsKey('IncidentArtifactsRoot') -and $IncidentArtifactsRoot) { $IncidentArtifactsRoot } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $existingConfig.incidentCapture.PSObject.Properties.Name -contains 'artifactsRoot') { [string]$existingConfig.incidentCapture.artifactsRoot } else { Join-Path $effectiveStateRoot 'incident-artifacts' } $effectiveIncidentArtifactsRoot = if ($PSBoundParameters.ContainsKey('IncidentArtifactsRoot') -and $IncidentArtifactsRoot) { $IncidentArtifactsRoot } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $existingConfig.incidentCapture.PSObject.Properties.Name -contains 'artifactsRoot') { [string]$existingConfig.incidentCapture.artifactsRoot } else { Join-Path $effectiveStateRoot 'incident-artifacts' }
$effectiveLogonMarkerEnabled = if ($PSBoundParameters.ContainsKey('LogonMarkerEnabled')) { [bool]$LogonMarkerEnabled } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'sessionEvents' -and $existingConfig.sessionEvents.PSObject.Properties.Name -contains 'logonEnabled') { [bool]$existingConfig.sessionEvents.logonEnabled } else { $true } $effectiveLogonMarkerEnabled = if ($PSBoundParameters.ContainsKey('LogonMarkerEnabled')) { [bool]$LogonMarkerEnabled } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'sessionEvents' -and $existingConfig.sessionEvents.PSObject.Properties.Name -contains 'logonEnabled') { [bool]$existingConfig.sessionEvents.logonEnabled } else { $true }
$effectiveAwHostname = if ($PSBoundParameters.ContainsKey('AwHostname') -and -not [string]::IsNullOrWhiteSpace($AwHostname)) { [string]$AwHostname } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'awHostname' -and -not [string]::IsNullOrWhiteSpace([string]$existingConfig.awHostname)) { [string]$existingConfig.awHostname } else { [string]$env:COMPUTERNAME }
$effectiveVersion = if ($Version) { $Version } elseif ($existingConfig) { [string]$existingConfig.package.version } else { 'v0.13.2' } $effectiveVersion = if ($Version) { $Version } elseif ($existingConfig) { [string]$existingConfig.package.version } else { 'v0.13.2' }
$effectiveUsers = if ($Users -or $UserListPath) { $effectiveUsers = if ($Users -or $UserListPath) {
@@ -139,6 +141,7 @@ $config = New-ActivityWatchDeploymentConfig `
-IncidentScreenshotEnabled $effectiveIncidentScreenshotEnabled ` -IncidentScreenshotEnabled $effectiveIncidentScreenshotEnabled `
-IncidentArtifactsRoot $effectiveIncidentArtifactsRoot ` -IncidentArtifactsRoot $effectiveIncidentArtifactsRoot `
-LogonMarkerEnabled $effectiveLogonMarkerEnabled ` -LogonMarkerEnabled $effectiveLogonMarkerEnabled `
-AwHostname $effectiveAwHostname `
-LaunchScriptPath $effectiveLaunchScript ` -LaunchScriptPath $effectiveLaunchScript `
-RecoveryScriptPath $effectiveRecoveryScript ` -RecoveryScriptPath $effectiveRecoveryScript `
-UserTasks $taskDefinitions ` -UserTasks $taskDefinitions `
+121
View File
@@ -0,0 +1,121 @@
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$ServerHost,
[int]$ServerPort = 5600,
[ValidateSet('http', 'https')]
[string]$ServerScheme = 'http',
[string]$StateRoot = 'C:\ProgramData\AWatch-rus',
[string]$InstallRoot = 'C:\Program Files\AWatch-rus\bin',
[string]$ServiceName = 'AWatchRusStandaloneAgent',
[string]$AwHostname
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
function Assert-Admin {
$id = [Security.Principal.WindowsIdentity]::GetCurrent()
$p = [Security.Principal.WindowsPrincipal]::new($id)
if (-not $p.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
throw 'Run as Administrator.'
}
}
function Ensure-Dir {
param([string]$Path)
if (-not (Test-Path -LiteralPath $Path)) {
New-Item -Path $Path -ItemType Directory -Force | Out-Null
}
}
Assert-Admin
$logsRoot = Join-Path $StateRoot 'logs'
Ensure-Dir -Path $StateRoot
Ensure-Dir -Path $logsRoot
$collectorScript = Join-Path $StateRoot 'browser-domains-native-collector.ps1'
$endpointCollectorScript = Join-Path $StateRoot 'dlp-endpoint-signals-collector.ps1'
$fileCollectorScript = Join-Path $StateRoot 'file-operations-collector.ps1'
$emailCollectorScript = Join-Path $StateRoot 'email-outbound-collector.ps1'
$sessionCollectorScript = Join-Path $StateRoot 'worktime-session-collector.ps1'
$rulesPath = Join-Path $StateRoot 'web-category-rules.json'
$policyPath = Join-Path $StateRoot 'dlp-policy.json'
$configPath = Join-Path $StateRoot 'deployment-config.json'
$serviceScriptPath = Join-Path $PSScriptRoot 'aw-standalone-service.ps1'
Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'browser-domains-native-collector.ps1') -Destination $collectorScript -Force
Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'dlp-endpoint-signals-collector.ps1') -Destination $endpointCollectorScript -Force
Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'file-operations-collector.ps1') -Destination $fileCollectorScript -Force
if (Test-Path -LiteralPath (Join-Path $PSScriptRoot 'email-outbound-collector.ps1')) {
Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'email-outbound-collector.ps1') -Destination $emailCollectorScript -Force
}
if (Test-Path -LiteralPath (Join-Path $PSScriptRoot 'worktime-session-collector.ps1')) {
Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'worktime-session-collector.ps1') -Destination $sessionCollectorScript -Force
}
if (-not (Test-Path -LiteralPath $rulesPath)) {
Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'web-category-rules.example.json') -Destination $rulesPath -Force
}
if (-not (Test-Path -LiteralPath $policyPath)) {
Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'dlp-policy.example.json') -Destination $policyPath -Force
}
$effectiveHostname = if ([string]::IsNullOrWhiteSpace($AwHostname)) { [string]$env:COMPUTERNAME } else { [string]$AwHostname }
$config = [pscustomobject]@{
version = 1
generatedAtUtc = (Get-Date).ToUniversalTime().ToString('o')
awHostname = $effectiveHostname
server = [pscustomobject]@{
host = $ServerHost
port = $ServerPort
scheme = $ServerScheme
}
paths = [pscustomobject]@{
installRoot = $InstallRoot
stateRoot = $StateRoot
logsRoot = $logsRoot
collectorScript = $collectorScript
endpointCollectorScript = $endpointCollectorScript
fileCollectorScript = $fileCollectorScript
emailCollectorScript = $emailCollectorScript
sessionCollectorScript = $sessionCollectorScript
rulesPath = $rulesPath
policyPath = $policyPath
}
collector = [pscustomobject]@{
pollSeconds = 5
pulseSeconds = 30
}
collectors = [pscustomobject]@{
afkEnabled = $false
windowEnabled = $false
fileOpsEnabled = $true
emailEnabled = $true
}
logging = [pscustomobject]@{
localAgentLogsEnabled = $true
}
}
$config | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $configPath -Encoding UTF8
$existing = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
if ($existing) {
sc.exe stop $ServiceName | Out-Null
Start-Sleep -Seconds 1
sc.exe delete $ServiceName | Out-Null
Start-Sleep -Seconds 1
}
$binPath = "`"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe`" -NoProfile -ExecutionPolicy Bypass -File `"$serviceScriptPath`" -ConfigPath `"$configPath`""
sc.exe create $ServiceName binPath= "$binPath" start= auto DisplayName= "AWatch-rus Standalone Agent" | Out-Null
sc.exe description $ServiceName "Standalone AWatch-rus DLP agent service wrapper" | Out-Null
sc.exe failure $ServiceName reset= 60 actions= restart/5000/restart/5000/restart/5000 | Out-Null
sc.exe start $ServiceName | Out-Null
Write-Output "Standalone service installed: $ServiceName"
Write-Output "Config: $configPath"
Write-Output ("Host: {0} -> {1}://{2}:{3}" -f $effectiveHostname, $ServerScheme, $ServerHost, $ServerPort)
@@ -34,6 +34,8 @@ Name: "validate"; Description: "Запустить validate-deployment (чере
[Files] [Files]
Source: "..\..\ActivityWatch.Windows.Common.psd1"; DestDir: "{app}\windows"; Flags: ignoreversion Source: "..\..\ActivityWatch.Windows.Common.psd1"; DestDir: "{app}\windows"; Flags: ignoreversion
Source: "..\..\ActivityWatch.Windows.Common.psm1"; DestDir: "{app}\windows"; Flags: ignoreversion Source: "..\..\ActivityWatch.Windows.Common.psm1"; DestDir: "{app}\windows"; Flags: ignoreversion
Source: "..\..\install-standalone-service.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion
Source: "..\..\aw-standalone-service.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion
Source: "..\..\deploy-single-user.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion Source: "..\..\deploy-single-user.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion
Source: "..\..\deploy-domain-users.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion Source: "..\..\deploy-domain-users.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion
Source: "..\..\deploy-ensemble.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion Source: "..\..\deploy-ensemble.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion
@@ -43,6 +45,7 @@ Source: "..\..\migrate-awatch-rus-paths.ps1"; DestDir: "{app}\windows"; Flags: i
Source: "..\..\worktime-session-collector.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion Source: "..\..\worktime-session-collector.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion
Source: "..\..\browser-domains-native-collector.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion Source: "..\..\browser-domains-native-collector.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion
Source: "..\..\dlp-endpoint-signals-collector.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion Source: "..\..\dlp-endpoint-signals-collector.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion
Source: "..\..\file-operations-collector.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion
Source: "..\..\email-outbound-collector.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion Source: "..\..\email-outbound-collector.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion
Source: "..\..\web-category-rules.example.json"; DestDir: "{app}\windows"; Flags: ignoreversion Source: "..\..\web-category-rules.example.json"; DestDir: "{app}\windows"; Flags: ignoreversion
Source: "..\..\dlp-policy.example.json"; DestDir: "{app}\windows"; Flags: ignoreversion Source: "..\..\dlp-policy.example.json"; DestDir: "{app}\windows"; Flags: ignoreversion
@@ -51,95 +54,11 @@ Source: "payload\{#AwDefaultZipName}"; DestDir: "{app}\payload"; Flags: ignoreve
Source: "innosetup-rdp-package-filelist.md"; DestDir: "{app}\windows\installkit\innosetup"; Flags: ignoreversion Source: "innosetup-rdp-package-filelist.md"; DestDir: "{app}\windows\installkit\innosetup"; Flags: ignoreversion
[Run] [Run]
Filename: "powershell.exe"; Parameters: "{code:GetDeployEnsembleParams}"; Flags: runhidden; Tasks: deploy Filename: "powershell.exe"; Parameters: "{code:GetStandaloneInstallParams}"; Flags: runhidden; Tasks: deploy
[Code] [Code]
var var
ServerHostPage: TInputQueryWizardPage; ServerHostPage: TInputQueryWizardPage;
UsersPage: TInputQueryWizardPage;
OptionsPage: TInputOptionWizardPage;
function NormalizeUserCsv(const UserCsv: string): string;
var
i: Integer;
s: string;
token: string;
begin
Result := '';
s := UserCsv;
while True do
begin
i := Pos(',', s);
if i = 0 then
begin
token := Trim(s);
s := '';
end
else
begin
token := Trim(Copy(s, 1, i - 1));
Delete(s, 1, i);
end;
if token <> '' then
begin
if Result <> '' then
Result := Result + ',';
Result := Result + token;
end;
if s = '' then
Break;
end;
end;
function BuildUsersPowerShellArg(const UserCsv: string): string;
var
i: Integer;
s: string;
token: string;
quoted: string;
begin
Result := '';
s := UserCsv;
while True do
begin
i := Pos(',', s);
if i = 0 then
begin
token := Trim(s);
s := '';
end
else
begin
token := Trim(Copy(s, 1, i - 1));
Delete(s, 1, i);
end;
if token <> '' then
begin
quoted := '"' + token + '"';
if Result <> '' then
Result := Result + ',';
Result := Result + quoted;
end;
if s = '' then
Break;
end;
if Result <> '' then
Result := '-Users ' + Result;
end;
function PayloadZipPath: string;
begin
Result := ExpandConstant('{app}\payload\{#AwDefaultZipName}');
end;
function HasPayloadZip: Boolean;
begin
Result := FileExists(ExpandConstant('{src}\payload\{#AwDefaultZipName}'));
end;
procedure InitializeWizard; procedure InitializeWizard;
begin begin
@@ -155,62 +74,24 @@ begin
ServerHostPage.Add('ServerPort', False); ServerHostPage.Add('ServerPort', False);
ServerHostPage.Values[0] := '{#AwDefaultServerHost}'; ServerHostPage.Values[0] := '{#AwDefaultServerHost}';
ServerHostPage.Values[1] := '{#AwDefaultServerPort}'; ServerHostPage.Values[1] := '{#AwDefaultServerPort}';
UsersPage := CreateInputQueryPage(
ServerHostPage.ID,
'Пользователи (RDP)',
'Перечень пользователей, для которых разворачиваем агенты.',
'Введите список через запятую. Пример: user1,user2,user3'
);
UsersPage.Add('Users (CSV)', False);
UsersPage.Values[0] := '{#AwDefaultUsers}';
OptionsPage := CreateInputOptionPage(
UsersPage.ID,
'Опции деплоя',
'Выберите опции для установки/валидации.',
'',
False,
False
);
OptionsPage.Add('Использовать offline payload (встроенный ZIP)');
OptionsPage.Add('Запустить validate-deployment после деплоя');
OptionsPage.Values[0] := HasPayloadZip;
OptionsPage.Values[1] := True;
end; end;
function GetDeployEnsembleParams(Param: string): string; function GetStandaloneInstallParams(Param: string): string;
var var
serverHost: string; serverHost: string;
serverPort: string; serverPort: string;
usersCsv: string;
usersArg: string;
zipArg: string;
validateArg: string;
begin begin
serverHost := Trim(ServerHostPage.Values[0]); serverHost := Trim(ServerHostPage.Values[0]);
serverPort := Trim(ServerHostPage.Values[1]); serverPort := Trim(ServerHostPage.Values[1]);
usersCsv := NormalizeUserCsv(UsersPage.Values[0]); if serverHost = '' then
RaiseException('ServerHost is empty.');
usersArg := BuildUsersPowerShellArg(usersCsv); if serverPort = '' then
if usersArg = '' then RaiseException('ServerPort is empty.');
RaiseException('Users list is empty.');
zipArg := '';
if OptionsPage.Values[0] then
zipArg := ' -PackageZipPath "' + PayloadZipPath + '"';
validateArg := '';
if OptionsPage.Values[1] and WizardIsTaskSelected('validate') then
validateArg := ' -ValidateAfterDeploy';
Result := Result :=
'-NoProfile -ExecutionPolicy Bypass -File "' + ExpandConstant('{app}\windows\deploy-ensemble.ps1') + '"' + '-NoProfile -ExecutionPolicy Bypass -File "' + ExpandConstant('{app}\windows\install-standalone-service.ps1') + '"' +
' -ServerHost "' + serverHost + '"' + ' -ServerHost "' + serverHost + '"' +
' -ServerPort ' + serverPort + ' -ServerPort ' + serverPort +
' ' + usersArg +
zipArg +
' -InstallRoot "{#AwDefaultInstallRoot}"' + ' -InstallRoot "{#AwDefaultInstallRoot}"' +
' -StateRoot "{#AwDefaultStateRoot}"' + ' -StateRoot "{#AwDefaultStateRoot}"';
validateArg;
end; end;
+16 -6
View File
@@ -25,11 +25,21 @@ The resulting installer `AWatch-rus-InstallKit.exe` is written to the same direc
./build_with_wine.sh ./build_with_wine.sh
``` ```
## Install-time parameters ## Install-time parameters (Standalone agent mode)
The installer wizard asks for: The installer wizard asks only for:
- `ServerHost` / `ServerPort` (defaults to our AW server `10.10.10.13:5600`) - `ServerHost` / `ServerPort` (defaults to `10.10.10.13:5600`)
- `Users` (CSV)
- Whether to use offline payload (auto-enabled when the ZIP exists at compile time) All other values are taken from defaults embedded in installer scripts.
- Whether to validate after deploy (`-ValidateAfterDeploy`, report written to `C:\ProgramData\AWatch-rus\ensemble-report-*.json`)
## Runtime mode
- Installer runs `windows\install-standalone-service.ps1`.
- A Windows service `AWatchRusStandaloneAgent` is created with auto-start and restart-on-failure.
- Service wrapper (`windows\aw-standalone-service.ps1`) keeps DLP collectors running:
- `browser-domains-native-collector.ps1`
- `dlp-endpoint-signals-collector.ps1`
- `file-operations-collector.ps1`
- `email-outbound-collector.ps1` (if present)
- `worktime-session-collector.ps1` (if present)
+148 -91
View File
@@ -4,17 +4,66 @@ param(
[int]$PollSeconds = 30 [int]$PollSeconds = 30
) )
# Force UTF-8 for console I/O
try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 } catch {}
try { [Console]::InputEncoding = [System.Text.Encoding]::UTF8 } catch {}
Set-StrictMode -Version Latest Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Continue'
function Decode-Bytes-Auto {
param([byte[]]$Bytes)
if (-not $Bytes) { return '' }
$candidates = @()
# Try strict UTF8 first (detect invalid sequences)
try {
$utf8Strict = New-Object System.Text.UTF8Encoding($false,$true)
$txt = $utf8Strict.GetString($Bytes)
$candidates += @{enc='utf8'; text=$txt}
}
catch {
# invalid UTF8 sequences; ignore
}
# Try CP866 and CP1251
try { $cp866 = [System.Text.Encoding]::GetEncoding(866); $txt866 = $cp866.GetString($Bytes); $candidates += @{enc='cp866'; text=$txt866} } catch {}
try { $cp1251 = [System.Text.Encoding]::GetEncoding(1251); $txt1251 = $cp1251.GetString($Bytes); $candidates += @{enc='cp1251'; text=$txt1251} } catch {}
# If nothing decoded yet, fallback to UTF8 permissive
if ($candidates.Count -eq 0) {
try { $txt = [System.Text.Encoding]::UTF8.GetString($Bytes); return $txt } catch { return '' }
}
# Score decodings by count of Cyrillic letters; prefer highest
$best = $null; $bestScore = -1
foreach ($c in $candidates) {
$t = $c.text
if (-not $t) { continue }
$score = 0
try { $score = ([regex]::Matches($t,'\p{IsCyrillic}')).Count } catch { $score = 0 }
if ($score -gt $bestScore) { $best = $c; $bestScore = $score }
}
if ($best -ne $null) { return $best.text }
# Final fallback: first candidate text
return $candidates[0].text
}
function Get-Config { function Get-Config {
param([string]$Path) param([string]$Path)
if (-not (Test-Path -LiteralPath $Path)) { if (-not (Test-Path -LiteralPath $Path)) {
throw "Конфигурация не найдена: $Path" throw "Config not found: $Path"
}
try {
$bytes = [System.IO.File]::ReadAllBytes($Path)
$text = Decode-Bytes-Auto -Bytes $bytes
return $text | ConvertFrom-Json -ErrorAction Stop
}
catch {
throw "Failed to read config: $Path - $($_.Exception.Message)"
} }
Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json
} }
function Invoke-AwJsonPost { function Invoke-AwJsonPost {
@@ -22,9 +71,15 @@ function Invoke-AwJsonPost {
[Parameter(Mandatory = $true)][string]$Uri, [Parameter(Mandatory = $true)][string]$Uri,
[Parameter(Mandatory = $true)][string]$Json [Parameter(Mandatory = $true)][string]$Json
) )
try {
$bytes = [Text.Encoding]::UTF8.GetBytes($Json) $bytes = [System.Text.Encoding]::UTF8.GetBytes($Json)
Invoke-RestMethod -Method Post -Uri $Uri -ContentType 'application/json; charset=utf-8' -Body $bytes | Out-Null Invoke-RestMethod -Method Post -Uri $Uri -ContentType 'application/json; charset=utf-8' -Body $bytes -ErrorAction Stop | Out-Null
return $true
}
catch {
Write-Verbose "POST error: $($_.Exception.Message)"
return $false
}
} }
function Ensure-Bucket { function Ensure-Bucket {
@@ -33,132 +88,134 @@ function Ensure-Bucket {
[Parameter(Mandatory = $true)][string]$BucketId, [Parameter(Mandatory = $true)][string]$BucketId,
[Parameter(Mandatory = $true)][string]$HostnameValue [Parameter(Mandatory = $true)][string]$HostnameValue
) )
try { Invoke-RestMethod -Method Get -Uri "$ApiBase/buckets/$BucketId" -ErrorAction Stop | Out-Null; return } catch { Write-Verbose "Bucket not found, creating: $BucketId" }
try { $body = @{ client='aw-worktime-session-collector'; type='aw.worktime.session'; hostname=$HostnameValue } | ConvertTo-Json -Compress
Invoke-RestMethod -Method Get -Uri "$ApiBase/buckets/$BucketId" | Out-Null $attempts = 0
return while ($attempts -lt 3) {
} $attempts++
catch { $ok = Invoke-AwJsonPost -Uri "$ApiBase/buckets/$BucketId" -Json $body
} if ($ok) { return }
Start-Sleep -Seconds (2 * $attempts)
$body = @{
client = 'aw-worktime-session-collector'
type = 'aw.worktime.session'
hostname = $HostnameValue
} | ConvertTo-Json -Compress
try {
Invoke-AwJsonPost -Uri "$ApiBase/buckets/$BucketId" -Json $body
}
catch {
Invoke-RestMethod -Method Get -Uri "$ApiBase/buckets/$BucketId" | Out-Null
} }
try { Invoke-RestMethod -Method Get -Uri "$ApiBase/buckets/$BucketId" -ErrorAction Stop | Out-Null } catch { Write-Verbose "Ensure-Bucket final check failed: $BucketId" }
} }
function Get-SessionRecords { function Run-QueryUser {
$records = @() $tries = @(@{File='quser';Args=''},@{File='query';Args='user'})
foreach ($t in $tries) {
try { try {
$lines = quser 2>$null $psi = New-Object System.Diagnostics.ProcessStartInfo
if (-not $lines) { $psi.FileName = $t.File
return @() if ($t.Args) { $psi.Arguments = $t.Args }
} $psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
$psi.UseShellExecute = $false
$psi.CreateNoWindow = $true
foreach ($line in ($lines | Select-Object -Skip 1)) { $proc = [System.Diagnostics.Process]::Start($psi)
$clean = ($line -replace '^\s*>?', '').Trim() $stream = $proc.StandardOutput.BaseStream
if (-not $clean) { $ms = New-Object System.IO.MemoryStream
continue $buffer = New-Object byte[] 4096
} while (($read = $stream.Read($buffer,0,$buffer.Length)) -gt 0) { $ms.Write($buffer,0,$read) }
$proc.WaitForExit()
$bytes = $ms.ToArray()
$parts = $clean -split '\s+' $text = Decode-Bytes-Auto -Bytes $bytes
if ($parts.Count -lt 4) { if ($text -and $text.Trim()) { return ($text -split "\r?\n") | Where-Object { $_ -ne '' } }
continue
}
$sessionName = ''
$sessionIdIndex = 2
if ($parts[1] -match '^\d+$') {
$sessionIdIndex = 1
}
else {
$sessionName = $parts[1]
}
$sessionId = 0
if ($parts[$sessionIdIndex] -match '^\d+$') {
$sessionId = [int]$parts[$sessionIdIndex]
}
$records += [pscustomobject]@{
username = $parts[0]
sessionName = $sessionName
sessionId = $sessionId
state = $parts[$sessionIdIndex + 1]
}
}
} }
catch { catch {
# try next
}
}
return @()
}
function Parse-SessionLines {
param([string[]]$Lines)
$records = @()
if (-not $Lines) { return $records }
$startIndex = 0
if ($Lines.Count -gt 0 -and $Lines[0] -match '\b(USERNAME|Имя|Имя пользователя|Имя_пользователя)\b') { $startIndex = 1 }
for ($i = $startIndex; $i -lt $Lines.Count; $i++) {
$line = $Lines[$i].Trim()
if (-not $line) { continue }
$m = [regex]::Match($line, '^\s*(?<user>\S+)\s+(?<sess>\S+)?\s+(?<id>\d+)\s+(?<state>\S+)', [System.Text.RegularExpressions.RegexOptions]::None)
if ($m.Success) {
$user = $m.Groups['user'].Value; $sess = $m.Groups['sess'].Value; $id = [int]$m.Groups['id'].Value; $state = $m.Groups['state'].Value
}
else {
$parts = $line -split '\s+'
if ($parts.Count -lt 4) { continue }
$user = $parts[0]
if ($parts[1] -match '^\d+$') { $sess = ''; $id = [int]$parts[1]; $state = $parts[2] } else { $sess = $parts[1]; $id = [int]$parts[2]; $state = $parts[3] }
} }
$records += [pscustomobject]@{ username=$user; sessionName=$sess; sessionId=$id; state=$state }
}
return $records return $records
} }
function Test-SessionIsActive { function Test-SessionIsActive {
param([AllowNull()][string]$State) param([string]$State)
if ([string]::IsNullOrWhiteSpace($State)) { return $false } if (-not $State) { return $false }
$s = $State.Trim().ToLowerInvariant() $s = $State.Trim().ToLowerInvariant()
return ($s -eq 'active') -or ($s -like 'актив*') return ($s -match 'active') -or ($s -match 'актив')
} }
# Main
$cfg = Get-Config -Path $ConfigPath $cfg = Get-Config -Path $ConfigPath
$hostValue = if ($Hostname) { $Hostname } else { [string]$env:COMPUTERNAME } $hostValue = if ($Hostname -and $Hostname.Trim()) { $Hostname.Trim() } elseif ($cfg -and $cfg.PSObject.Properties.Name -contains 'awHostname' -and -not [string]::IsNullOrWhiteSpace([string]$cfg.awHostname)) { [string]$cfg.awHostname } elseif ($cfg -and $cfg.awHostname) { [string]$cfg.awHostname } else { [string]$env:COMPUTERNAME }
$apiBase = '{0}://{1}:{2}/api/0' -f [string]$cfg.server.scheme, [string]$cfg.server.host, [string]$cfg.server.port try { $apiBase = '{0}://{1}:{2}/api/0' -f [string]$cfg.server.scheme, [string]$cfg.server.host, [string]$cfg.server.port } catch { throw 'Invalid server configuration in config file.' }
$bucketId = 'aw-worktime-sessions_' + $hostValue $bucketId = 'aw-worktime-sessions_' + $hostValue
$pulse = 120 $pulse = 120
$sleepSec = if ($PollSeconds -gt 0) { $sleepSec = if ($PollSeconds -gt 0) { $PollSeconds } elseif ($cfg.collector -and $cfg.collector.pollSeconds) { [int]$cfg.collector.pollSeconds } else { 30 }
$PollSeconds
}
elseif ($cfg.collector -and $cfg.collector.pollSeconds) {
[int]$cfg.collector.pollSeconds
}
else {
30
}
Ensure-Bucket -ApiBase $apiBase -BucketId $bucketId -HostnameValue $hostValue Ensure-Bucket -ApiBase $apiBase -BucketId $bucketId -HostnameValue $hostValue
while ($true) { while ($true) {
$now = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') $now = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
$records = Get-SessionRecords try {
$lines = Run-QueryUser
$records = Parse-SessionLines -Lines $lines
}
catch {
Write-Verbose "Session parse error: $($_.Exception.Message)"
$records = @()
}
if (-not $records -or $records.Count -eq 0) { if (-not $records -or $records.Count -eq 0) {
$records = @([pscustomobject]@{ $records = @([pscustomobject]@{ username=$env:USERNAME; sessionName=''; sessionId=(Get-Process -Id $PID).SessionId; state='Unknown' })
username = $env:USERNAME
sessionName = ''
sessionId = (Get-Process -Id $PID).SessionId
state = 'Unknown'
})
} }
foreach ($rec in $records) { foreach ($rec in $records) {
$payload = @{ $payloadObj = [PSCustomObject]@{
timestamp = $now timestamp = $now
duration = 0 duration = 0
data = @{ data = [PSCustomObject]@{
username = [string]$rec.username username = [string]$rec.username
userId = "$($env:USERDOMAIN)\$($rec.username)" userId = "${env:USERDOMAIN}\$($rec.username)"
sessionId = [int]$rec.sessionId sessionId = [int]$rec.sessionId
sessionName = [string]$rec.sessionName sessionName = [string]$rec.sessionName
state = [string]$rec.state state = [string]$rec.state
active = (Test-SessionIsActive -State ([string]$rec.state)) active = Test-SessionIsActive -State ([string]$rec.state)
hostname = $hostValue hostname = $hostValue
source = 'worktime-session-collector' source = 'worktime-session-collector'
} }
} | ConvertTo-Json -Depth 6 -Compress }
$payload = $payloadObj | ConvertTo-Json -Depth 6 -Compress
try { try {
Invoke-AwJsonPost -Uri "$apiBase/buckets/$bucketId/heartbeat?pulsetime=$pulse" -Json $payload $ok = Invoke-AwJsonPost -Uri "$apiBase/buckets/$bucketId/heartbeat?pulsetime=$pulse" -Json $payload
if (-not $ok) { Write-Verbose "Heartbeat not confirmed for user $($rec.username)" }
} }
catch { catch {
Write-Verbose "Heartbeat error: $($_.Exception.Message)"
} }
} }