Compare commits

..
32 changed files with 970 additions and 1069 deletions
@@ -1,20 +0,0 @@
---
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.
-41
View File
@@ -160,9 +160,6 @@
{% if (aw_windows_package_zip_path | default('') | string | length) > 0 %}
$params.PackageZipPath = "{{ aw_windows_package_zip_path }}"
{% endif %}
{% if (aw_windows_hostname_override | default('') | string | length) > 0 %}
$params.AwHostname = "{{ aw_windows_hostname_override }}"
{% endif %}
{% if aw_windows_skip_hardening | bool %}
$params.SkipHardening = $true
{% endif %}
@@ -186,44 +183,6 @@
ansible.windows.win_powershell:
script: |
$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 }}"
Get-ScheduledTask |
Where-Object TaskName -like "{{ aw_windows_launch_task_pattern }}" |
-1
View File
@@ -24,7 +24,6 @@ aw_windows_extra_users: []
aw_windows_install_root: "C:\\Program Files\\AWatch-rus\\bin"
aw_windows_state_root: "C:\\ProgramData\\AWatch-rus"
aw_windows_hostname_override: ""
aw_windows_afk_enabled: true
aw_windows_window_enabled: true
-1
View File
@@ -21,7 +21,6 @@ aw_windows_extra_users: []
# Единые Windows/RDP пути: те же, что использует InnoSetup.
aw_windows_install_root: "C:\\Program Files\\AWatch-rus\\bin"
aw_windows_state_root: "C:\\ProgramData\\AWatch-rus"
aw_windows_hostname_override: "" # Например: SHARKON2025
aw_windows_afk_enabled: true
aw_windows_window_enabled: true
aw_windows_file_ops_enabled: true
+15 -49
View File
@@ -3,7 +3,7 @@ import json
import os
import urllib.error
import urllib.request
from datetime import datetime, timedelta, timezone
from datetime import datetime, timezone
AW_URL = os.environ.get("AW_SERVER_URL", "http://127.0.0.1:5600")
@@ -70,48 +70,25 @@ def to_iso_utc(ts):
return ts.replace("+00:00", "Z")
def parse_iso_utc(ts: str):
if ts.endswith("Z"):
ts = ts[:-1] + "+00:00"
return datetime.fromisoformat(ts).astimezone(timezone.utc)
def build_window_title(users, active_count):
if not users:
return "RDP idle"
return f"RDP active ({active_count}): " + ", ".join(users)
def _is_session_active(row_data):
if isinstance(row_data.get("active"), bool):
return row_data.get("active")
state = str(row_data.get("state", "")).strip().lower()
return state in {"active", "активно"}
def transform(events):
out_afk = []
out_win = []
last_ts = None
grouped = {}
for e in events:
ts = e.get("timestamp")
if not ts:
continue
grouped.setdefault(ts, []).append(e)
for ts in sorted(grouped.keys()):
rows = grouped[ts]
duration = max(float(r.get("duration", 0.0)) for r in rows)
active_users = []
for r in rows:
data = r.get("data") or {}
user = str(data.get("username", "")).strip()
if user and _is_session_active(data):
active_users.append(user)
active_users = sorted(set(active_users))
active_count = len(active_users)
duration = float(e.get("duration", 0.0))
data = e.get("data") or {}
active_users = data.get("activeUsers") or []
active_count = int(data.get("activeCount", len(active_users)))
is_active = active_count > 0
afk_data = {"status": "not-afk" if is_active else "afk", "source": "aw-worktime-ui-bridge"}
@@ -135,29 +112,18 @@ def main():
ensure_bucket(AFK_BUCKET, "afkstatus", "aw-worktime-ui-bridge")
ensure_bucket(WINDOW_BUCKET, "currentwindow", "aw-worktime-ui-bridge")
now_utc = datetime.now(timezone.utc)
recent = _req("GET", f"/api/0/buckets/{SESSIONS_BUCKET}/events?limit=5000") or []
if not recent:
return
try:
last_dt = parse_iso_utc(last_ts)
except Exception:
last_dt = datetime(1970, 1, 1, tzinfo=timezone.utc)
events = []
for e in recent:
ts = e.get("timestamp")
if not ts:
continue
try:
if parse_iso_utc(ts) > last_dt:
events.append(e)
except Exception:
continue
if not events:
query = {
"query": [
"events = query_bucket(find_bucket($bid));",
"RETURN = sort_by_timestamp(events);",
],
"timeperiods": [[last_ts, to_iso_utc(datetime.now(timezone.utc).isoformat())]],
}
rows = _req("POST", f"/api/0/query/?bid={SESSIONS_BUCKET}", query) or []
if not rows or not rows[0]:
return
events = rows[0]
afk_events, win_events, new_last_ts = transform(events)
if not afk_events or not win_events or not new_last_ts:
return
+151
View File
@@ -0,0 +1,151 @@
# DLP Reliability Roadmap
## Scope
Roadmap for improving runtime reliability of:
- `windows/dlp-endpoint-signals-collector.ps1`
- `windows/file-operations-collector.ps1`
Date: 2026-05-04
---
## Stage 1 (1-2 days): Quick wins
### 1) Disk queue + sender loop + retry/backoff/jitter
**Goal:** no data loss on temporary network/server outages.
**Tasks**
- Add local append-only queue file per collector (`*.jsonl`) under ProgramData logs/artifacts root.
- Write events to queue first, then send asynchronously.
- Implement sender loop:
- reads oldest unsent records,
- sends in small batches,
- marks sent records,
- compacts queue periodically.
- Implement retry policy with exponential backoff + jitter.
**Acceptance criteria**
- When API is unavailable, queue grows and collector keeps running.
- When API recovers, queued events are flushed automatically.
- No collector crash during repeated network failures.
### 2) `eventId` + dedupe contract
**Goal:** at-least-once delivery without logical duplicates.
**Tasks**
- Add `eventId` (UUID), `eventCreatedAt`, `collectorType`, `hostname` to every payload.
- Define server dedupe contract:
- dedupe key = `eventId`,
- TTL for dedupe cache,
- idempotent processing semantics.
**Acceptance criteria**
- Retried sends do not create duplicate incidents/events in downstream storage.
- Payload schema documentation updated.
### 3) Basic metrics/logging
**Goal:** visibility into health and data delivery.
**Tasks**
- Emit counters/gauges to log and heartbeat:
- `queueDepth`,
- `oldestUnsentAgeSec`,
- `eventsEnqueued`,
- `eventsSent`,
- `sendFailures`,
- `lastSendStatus`.
**Acceptance criteria**
- Operators can identify stuck queue and send failures from logs only.
---
## Stage 2: Hardening
### 1) Circuit breaker + health probes
**Tasks**
- Add transport circuit breaker (Closed/Open/HalfOpen).
- Open breaker after N consecutive failures.
- In Open state perform probe every M seconds.
- Close breaker on successful probe.
**Acceptance criteria**
- Reduced request storm during outage.
- Deterministic recovery behavior after outage.
### 2) Watcher auto-recreate
**Tasks**
- Handle `FileSystemWatcher` error/overflow events.
- Recreate watcher and subscriptions automatically.
- Keep watchdog timer to ensure watcher health.
**Acceptance criteria**
- Watcher resumes after overflow without manual restart.
### 3) Last-known-good policy
**Tasks**
- Validate new policy before apply.
- Cache last valid policy with checksum/version.
- Rollback to cached policy on parse/validation errors.
**Acceptance criteria**
- Broken policy cannot stop detection loop.
---
## Stage 3: Reliability operations
### 1) Chaos tests
Scenarios:
- network disconnect,
- API 5xx bursts,
- slow disk / queue write delay,
- headless UI context,
- forced collector restart.
**Acceptance criteria**
- For each scenario, documented expected behavior and observed result.
- No silent data loss in tested outage windows.
### 2) SLO + error budget process
**Initial SLO proposals**
- Event delivery latency P95 < 120s under normal conditions.
- Data loss = 0 for outages shorter than 30 minutes (with available disk).
- Collector liveness heartbeat every `pollSeconds * 3` max.
**Process**
- Define SLI dashboards.
- Define release gates tied to error budget burn.
- Freeze risky changes when budget exhausted.
---
## Suggested implementation order inside repository
1. `file-operations-collector.ps1`: queue + sender + metrics (simpler flow).
2. `dlp-endpoint-signals-collector.ps1`: queue + sender + metrics.
3. Shared helper module extraction (`windows/lib/aw-transport.psm1`) for queue, retry, breaker.
4. Policy cache and validation.
5. Chaos test scripts and runbook.
---
## Deliverables checklist
- [ ] Transport queue implementation in both collectors.
- [ ] Payload schema update with `eventId`.
- [ ] Dedupe contract documented for server side.
- [ ] Metrics fields added to heartbeat/logs.
- [ ] Circuit breaker implemented.
- [ ] Watcher auto-recreate implemented.
- [ ] Last-known-good policy implemented.
- [ ] Chaos test runbook and results.
- [ ] SLO/error budget document adopted.
-12
View File
@@ -9,10 +9,6 @@
- `windows/validate-deployment.ps1` — машинная проверка состояния и JSON-отчёт.
- `windows/browser-domains-native-collector.ps1` — native collector доменов браузера с категоризацией.
- `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/dlp-policy.example.json` — пример DLP-политики (phase-1: alerting incidents).
@@ -31,14 +27,6 @@
- Корректно регистрирует задачи через `-LogonType Interactive` (совместимо с Windows Server, где `InteractiveToken` не поддерживается).
- Поддерживает отключение шумных 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`), не по таймеру и не на обычной активности.
-29
View File
@@ -88,35 +88,6 @@ 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
-5
View File
@@ -4,9 +4,4 @@ GRAFANA_ADMIN_PASSWORD=change_me_now
GRAFANA_PORT=3000
PROMETHEUS_PORT=9090
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
+21 -29
View File
@@ -3,46 +3,44 @@
Готовый каркас для непрерывного сбора KPI из 1С и анализа в Grafana:
- `sql-exporter` читает SQL-представления KPI из БД 1С;
- `aw-exporter` собирает метрики ActivityWatch и отдает их Prometheus;
- `prometheus` собирает метрики и применяет alert-rules;
- `grafana` поднимает datasource и дашборд автоматически.
## Полные пути
- `./.env.example`
- `./docker-compose.yml`
- `./sql-exporter/sql_exporter.yml`
- `./sql-exporter/collectors/onec_accounting_kpi.collector.yml`
- `./prometheus/prometheus.yml`
- `./prometheus/alerts.yml`
- `./prometheus/recording_rules.yml`
- `./grafana/dashboards/1c-accounting-overview.json`
- `./grafana/dashboards/1c-accounting-sre.json`
- `./sql/postgres_views_template.sql`
- `./sql/mssql_views_template.sql`
- `./tools/discover_postgres_1c.sh`
- `./tools/validate_kpi_views.sh`
- `./tools/check_pipeline.sh`
- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/.env.example`
- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/docker-compose.yml`
- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/sql-exporter/sql_exporter.yml`
- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/sql-exporter/collectors/onec_accounting_kpi.collector.yml`
- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/prometheus/prometheus.yml`
- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/prometheus/alerts.yml`
- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/prometheus/recording_rules.yml`
- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/grafana/dashboards/1c-accounting-overview.json`
- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/grafana/dashboards/1c-accounting-sre.json`
- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/sql/postgres_views_template.sql`
- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/sql/mssql_views_template.sql`
- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/tools/discover_postgres_1c.sh`
- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/tools/validate_kpi_views.sh`
- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/tools/check_pipeline.sh`
## Быстрый запуск
1. Подготовьте env:
```bash
cd grafana-1c
cd /mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c
cp .env.example .env
```
2. В `.env` задайте:
- `GRAFANA_ADMIN_USER`, `GRAFANA_ADMIN_PASSWORD`;
- `ONEC_DSN` (DSN read-only пользователя в БД 1С);
- при необходимости `AW_SERVER_HOST`, `AW_SERVER_PORT`, `AW_SERVER_SCHEME`, `AW_EXPORTER_PORT` и `AW_SCRAPE_INTERVAL_SECONDS` для ActivityWatch exporter.
- `ONEC_DSN` (DSN read-only пользователя в БД 1С).
3. В БД 1С создайте KPI-представления:
- для PostgreSQL возьмите `./sql/postgres_views_template.sql`;
- для MS SQL возьмите `./sql/mssql_views_template.sql`.
- для PostgreSQL возьмите `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/sql/postgres_views_template.sql`;
- для MS SQL возьмите `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/sql/mssql_views_template.sql`.
4. Поднимите стек:
@@ -54,9 +52,7 @@ docker compose up -d
```bash
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:3000/api/health
```
Откройте Grafana: `http://<host>:3000`.
@@ -66,25 +62,23 @@ curl -fsS http://127.0.0.1:3000/api/health
Профилирование структуры 1С (PostgreSQL):
```bash
sh ./tools/discover_postgres_1c.sh \
sh /mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/tools/discover_postgres_1c.sh \
"postgres://user:pass@db-host:5432/db?sslmode=disable"
```
Проверка KPI views:
```bash
sh ./tools/validate_kpi_views.sh \
sh /mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/tools/validate_kpi_views.sh \
"postgres://user:pass@db-host:5432/db?sslmode=disable"
```
Проверка end-to-end пайплайна:
```bash
sh ./tools/check_pipeline.sh
sh /mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/tools/check_pipeline.sh
```
Скрипт проверяет полный путь сбора данных: `sql-exporter` и `aw-exporter` отдают обязательные метрики, Prometheus успешно выполняет запросы по scrape-targets, а Grafana отвечает на health/API, видит datasource `prometheus` и provisioned dashboards. Если стек запущен не из каталога репозитория, передайте путь к каталогу `grafana-1c` первым аргументом.
## Что контролируется
- Непроведенные документы (`onec_unposted_documents_total`)
@@ -92,8 +86,6 @@ sh ./tools/check_pipeline.sh
- Просроченная дебиторка (`onec_overdue_receivables_total`)
- Ошибки проведения за 24ч (`onec_posting_errors_total`)
- Свежесть данных из 1С (`onec_data_freshness_seconds`)
- Доступность ActivityWatch API (`aw_up`)
- Количество bucket/events ActivityWatch (`aw_buckets_total`, `aw_bucket_events_count`)
## Принципы безопасности
+4 -5
View File
@@ -6,15 +6,14 @@ services:
container_name: awrus-aw-exporter
restart: unless-stopped
environment:
- AW_SERVER_HOST=${AW_SERVER_HOST:-10.10.10.13}
- AW_SERVER_PORT=${AW_SERVER_PORT:-5600}
- AW_SERVER_SCHEME=${AW_SERVER_SCHEME:-http}
- AW_SERVER_HOST=10.10.10.13
- AW_SERVER_PORT=5600
- AW_SERVER_SCHEME=http
- EXPORTER_PORT=9398
- SCRAPE_INTERVAL_SECONDS=${AW_SCRAPE_INTERVAL_SECONDS:-30}
volumes:
- ./sql-exporter/collectors/aw_activitywatch.py:/app/aw_activitywatch.py:ro
ports:
- "${AW_EXPORTER_PORT:-9398}:9398"
- "9398:9398"
command:
- "python3"
- "/app/aw_activitywatch.py"
+102 -190
View File
@@ -1,209 +1,121 @@
{
"title": "ActivityWatch Overview",
"tags": [
"activitywatch",
"monitoring"
],
"timezone": "browser",
"panels": [
{
"id": 1,
"title": "Total Buckets",
"type": "stat",
"targets": [
{
"expr": "aw_buckets_total",
"refId": "A",
"legendFormat": "Total Buckets",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
"dashboard": {
"title": "ActivityWatch Overview",
"tags": ["activitywatch", "monitoring"],
"timezone": "browser",
"panels": [
{
"id": 1,
"title": "Total Buckets",
"type": "stat",
"targets": [
{
"expr": "aw_buckets_total",
"refId": "A",
"legendFormat": "Total Buckets"
}
],
"options": {
"colorMode": "value",
"graphMode": "area"
},
"fieldConfig": {
"defaults": {
"unit": "short",
"min": 0
}
}
],
"options": {
"colorMode": "value",
"graphMode": "area"
},
"fieldConfig": {
"defaults": {
"unit": "short",
"min": 0
}
},
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"h": 8,
"w": 6,
"x": 0,
"y": 0
}
},
{
"id": 2,
"title": "Events per Bucket",
"type": "table",
"targets": [
{
"expr": "aw_bucket_events_count",
"format": "table",
"instant": true,
"refId": "B",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
{
"id": 2,
"title": "Events per Bucket",
"type": "table",
"targets": [
{
"expr": "aw_bucket_events_count",
"format": "table",
"instant": true,
"refId": "B"
}
}
],
"transformations": [
{
"id": "organize",
"options": {
"excludeByName": {
"Time": true,
"Value": true
],
"transformations": [
{
"id": "organize",
"options": {
"excludeByName": {
"Time": true,
"Value": true
}
}
}
}
],
"datasource": {
"type": "prometheus",
"uid": "prometheus"
]
},
"gridPos": {
"h": 8,
"w": 12,
"x": 6,
"y": 0
}
},
{
"id": 3,
"title": "Collector Status",
"type": "stat",
"targets": [
{
"expr": "aw_collector_status",
"format": "table",
"instant": true,
"refId": "C",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
{
"id": 3,
"title": "Collector Status",
"type": "stat",
"targets": [
{
"expr": "aw_collector_status",
"format": "table",
"instant": true,
"refId": "C"
}
],
"options": {
"colorMode": "value"
}
],
"options": {
"colorMode": "value"
},
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"h": 8,
"w": 6,
"x": 18,
"y": 0
}
},
{
"id": 4,
"title": "Events Timeline",
"type": "graph",
"targets": [
{
"expr": "rate(aw_events_total[5m])",
"legendFormat": "{{bucket}} - {{event_type}}",
"refId": "D",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
{
"id": 4,
"title": "Events Timeline",
"type": "graph",
"targets": [
{
"expr": "rate(aw_events_total[5m])",
"legendFormat": "{{bucket}} - {{event_type}}",
"refId": "D"
}
}
],
"fieldConfig": {
"defaults": {
"custom": {
"lineWidth": 2,
"fillOpacity": 10
}
}
},
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"h": 9,
"w": 12,
"x": 0,
"y": 8
}
},
{
"id": 5,
"title": "Last Event Timestamp",
"type": "gauge",
"targets": [
{
"expr": "aw_events_last_timestamp",
"legendFormat": "{{bucket}}",
"refId": "E",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
}
}
],
"options": {
"orientation": "horizontal"
},
"fieldConfig": {
"defaults": {
"unit": "s",
"custom": {
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "red",
"value": 0
},
{
"color": "yellow",
"value": 3600
},
{
"color": "green",
"value": 86400
}
]
],
"fieldConfig": {
"defaults": {
"custom": {
"lineWidth": 2,
"fillOpacity": 10
}
}
}
},
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"h": 9,
"w": 12,
"x": 12,
"y": 8
{
"id": 5,
"title": "Last Event Timestamp",
"type": "gauge",
"targets": [
{
"expr": "aw_events_last_timestamp",
"legendFormat": "{{bucket}}",
"refId": "E"
}
],
"options": {
"orientation": "horizontal"
},
"fieldConfig": {
"defaults": {
"unit": "s",
"custom": {
"thresholds": {
"mode": "absolute",
"steps": [
{"color": "red", "value": 0},
{"color": "yellow", "value": 3600},
{"color": "green", "value": 86400}
]
}
}
}
}
}
}
],
"uid": "activitywatch-overview",
"schemaVersion": 39,
"version": 1,
"refresh": "30s",
"time": {
"from": "now-6h",
"to": "now"
]
}
}
@@ -1,11 +1,20 @@
apiVersion: 1
providers:
- name: awatch-rus
- name: 1C-Buhgalteria
orgId: 1
folder: "AWatch-rus"
folder: "1C"
type: file
disableDeletion: true
editable: false
options:
path: /var/lib/grafana/dashboards
- name: ActivityWatch
orgId: 2
folder: "ActivityWatch"
type: file
disableDeletion: false
editable: true
options:
path: /var/lib/grafana/dashboards
@@ -0,0 +1,9 @@
apiVersion: 1
datasources:
- name: Prometheus
type: prometheus
access: proxy
url: http://prometheus:9090
isDefault: true
editable: true
@@ -0,0 +1,9 @@
apiVersion: 1
datasources:
- name: Prometheus
type: prometheus
access: proxy
url: http://prometheus:9090
isDefault: true
editable: true
+92 -91
View File
@@ -4,138 +4,139 @@ ActivityWatch Prometheus Exporter
Собирает метрики из ActivityWatch API и экспонирует их в формате Prometheus.
"""
import logging
import os
import time
from datetime import datetime
import logging
import requests
from prometheus_client import Counter, Gauge, Info, start_http_server
from prometheus_client import start_http_server, Gauge, Counter, Histogram, Info
from datetime import datetime, timedelta
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# Configuration
AW_SERVER_HOST = os.getenv("AW_SERVER_HOST", "10.10.10.13")
AW_SERVER_PORT = int(os.getenv("AW_SERVER_PORT", "5600"))
AW_SERVER_SCHEME = os.getenv("AW_SERVER_SCHEME", "http")
AW_API_BASE = os.getenv(
"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"))
AW_SERVER_HOST = "10.10.10.13"
AW_SERVER_PORT = 5600
AW_SERVER_SCHEME = "http"
AW_API_BASE = f"{AW_SERVER_SCHEME}://{AW_SERVER_HOST}:{AW_SERVER_PORT}/api/0"
EXPORTER_PORT = 9398
# Metrics
aw_up = Gauge("aw_up", "ActivityWatch API availability: 1 if the last scrape succeeded, 0 otherwise")
aw_buckets_total = Gauge("aw_buckets_total", "Total number of ActivityWatch buckets")
aw_events_total = Counter("aw_events_total", "Total number of ActivityWatch events observed", ["bucket", "event_type"])
aw_events_last_timestamp = Gauge("aw_events_last_timestamp", "Timestamp of last event in bucket", ["bucket"])
aw_bucket_events_count = Gauge("aw_bucket_events_count", "Number of events sampled from bucket", ["bucket"])
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")
aw_buckets_total = Gauge('aw_buckets_total', 'Total number of ActivityWatch buckets')
aw_events_total = Counter('aw_events_total', 'Total number of ActivityWatch events', ['bucket', 'event_type'])
aw_events_last_timestamp = Gauge('aw_events_last_timestamp', 'Timestamp of last event in bucket', ['bucket'])
aw_bucket_events_count = Gauge('aw_bucket_events_count', 'Number of events in bucket', ['bucket'])
aw_collector_status = Info('aw_collector_status', 'Status of ActivityWatch collectors')
aw_server_info = Info('aw_server_info', 'ActivityWatch server information')
class ActivityWatchExporter:
def __init__(self, api_base):
self.api_base = api_base.rstrip("/")
self.api_base = api_base
self.session = requests.Session()
self.session.headers.update({"Accept": "application/json"})
self.bucket_event_counts = {}
self.session.headers.update({'Accept': 'application/json'})
self.bucket_cache = {}
def get_buckets(self):
"""Get all buckets from ActivityWatch API."""
response = self.session.get(f"{self.api_base}/buckets", timeout=10)
response.raise_for_status()
return response.json()
try:
response = self.session.get(f"{self.api_base}/buckets", timeout=10)
response.raise_for_status()
return response.json()
except Exception as e:
logger.error(f"Failed to get buckets: {e}")
return {}
def get_bucket_events(self, bucket_id, limit=1000):
def get_bucket_events(self, bucket_id, limit=1):
"""Get events from a specific bucket."""
response = self.session.get(
f"{self.api_base}/buckets/{bucket_id}/events",
params={"limit": limit},
timeout=10,
)
response.raise_for_status()
return response.json()
try:
response = self.session.get(
f"{self.api_base}/buckets/{bucket_id}/events",
params={'limit': limit},
timeout=10
)
response.raise_for_status()
return response.json()
except Exception as e:
logger.error(f"Failed to get events for {bucket_id}: {e}")
return []
@staticmethod
def event_type(event):
data = event.get("data") or {}
return str(data.get("app") or data.get("title") or event.get("$schema") or "unknown")
@staticmethod
def event_timestamp(event):
timestamp = event.get("timestamp", 0)
if isinstance(timestamp, str):
return datetime.fromisoformat(timestamp.replace("Z", "+00:00")).timestamp()
return float(timestamp or 0)
def get_bucket_info(self, bucket_id):
"""Get detailed info about a bucket."""
try:
response = self.session.get(f"{self.api_base}/buckets/{bucket_id}", timeout=10)
response.raise_for_status()
return response.json()
except Exception as e:
logger.error(f"Failed to get info for {bucket_id}: {e}")
return {}
def collect_metrics(self):
"""Collect metrics from ActivityWatch."""
try:
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
buckets = self.get_buckets()
# Update bucket count
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,
}
)
aw_collector_status.clear()
# Server info
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():
client = str(bucket_data.get("client", "unknown"))
hostname = str(bucket_data.get("hostname", "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 = []
client = bucket_data.get('client', 'unknown')
hostname = bucket_data.get('hostname', 'unknown')
bucket_type = bucket_data.get('type', 'unknown')
# Count events
events = self.get_bucket_events(bucket_id, limit=1000)
event_count = len(events)
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:
last_event = events[0]
timestamp = last_event.get('timestamp', 0)
try:
aw_events_last_timestamp.labels(bucket=bucket_id).set(self.event_timestamp(events[0]))
except Exception as exc:
logger.warning("Failed to parse last event timestamp for %s: %s", bucket_id, exc)
# Convert to Unix timestamp if needed
if isinstance(timestamp, str):
dt = datetime.fromisoformat(timestamp.replace('Z', '+00:00'))
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():
exporter = ActivityWatchExporter(AW_API_BASE)
# Initial collection
exporter.collect_metrics()
# Start HTTP server
start_http_server(EXPORTER_PORT)
logger.info("ActivityWatch exporter started on port %s", EXPORTER_PORT)
logger.info("Scraping ActivityWatch API at %s", AW_API_BASE)
logger.info(f"ActivityWatch exporter started on port {EXPORTER_PORT}")
logger.info(f"Scraping ActivityWatch API at {AW_API_BASE}")
# Collect metrics every 30 seconds
while True:
time.sleep(SCRAPE_INTERVAL_SECONDS)
time.sleep(30)
exporter.collect_metrics()
if __name__ == "__main__":
if __name__ == '__main__':
main()
+13 -88
View File
@@ -1,95 +1,20 @@
#!/bin/sh
set -eu
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
STACK_DIR="${1:-$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)}"
ENV_FILE="$STACK_DIR/.env"
STACK_DIR="${1:-/mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c}"
env_value() {
key="$1"
default="$2"
current=$(eval "printf '%s' \"\${$key:-}\"")
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 endpoints"
curl -fsS http://127.0.0.1:9399/metrics >/tmp/awrus-onec-metrics.out
curl -fsS http://127.0.0.1:9090/-/healthy >/tmp/awrus-prom-healthy.out
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
curl -fsS "http://127.0.0.1:9090/api/v1/query?query=onec_data_freshness_seconds" >/tmp/awrus-prom-freshness.json
GRAFANA_PORT=$(env_value GRAFANA_PORT 3000)
PROMETHEUS_PORT=$(env_value PROMETHEUS_PORT 9090)
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 "[*] Checking container status"
cd "$STACK_DIR"
docker compose ps
echo "[+] Pipeline health artifacts:"
echo " $METRICS_OUT"
echo " $AW_METRICS_OUT"
echo " $PROM_HEALTH_OUT"
echo " $PROM_UP_OUT"
echo " $PROM_FRESHNESS_OUT"
echo " $GRAFANA_HEALTH_OUT"
echo " $GRAFANA_DS_OUT"
echo " $GRAFANA_DASH_OUT"
echo " /tmp/awrus-onec-metrics.out"
echo " /tmp/awrus-prom-healthy.out"
echo " /tmp/awrus-prom-up.json"
echo " /tmp/awrus-prom-freshness.json"
@@ -1,4 +1,4 @@
[CmdletBinding()]
[CmdletBinding()]
param(
[string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json',
[string]$ServerHost,
@@ -516,6 +516,35 @@ function Test-LooksLikeMojibakeQuestionMarks {
return $Value -match '\?{2,}'
}
function Test-IsGenericDocumentName {
param([AllowNull()][string]$Value)
if ([string]::IsNullOrWhiteSpace($Value)) { return $true }
$generic = @(
'^\s*Печать документа\s*$',
'^\s*Print Document\s*$',
'^\s*Document\s*$',
'^\s*Документ\s*$',
'^\s*Remote Downlevel Document\s*$',
'^\s*Local Downlevel Document\s*$',
'^\s*Untitled\s*$',
'^\s*Без имени\s*$',
'^\s*Без названия\s*$'
)
foreach ($pattern in $generic) {
if ($Value -match $pattern) { return $true }
}
return $false
}
function Test-NeedsBetterDocumentName {
param([AllowNull()][string]$Value)
if ([string]::IsNullOrWhiteSpace($Value)) { return $true }
if (Test-LooksLikeMojibakeQuestionMarks -Value $Value) { return $true }
if (Test-IsGenericDocumentName -Value $Value) { return $true }
if ($Value -match '^[0-9]+$') { return $true }
return $false
}
function Normalize-OwnerForMatch {
param([AllowNull()][string]$Value)
if ([string]::IsNullOrWhiteSpace($Value)) { return '' }
@@ -602,7 +631,7 @@ function Get-PrintServiceDocumentFallback {
)
$preferred = [string]$EventSummary.DocumentName
if (-not (Test-LooksLikeMojibakeQuestionMarks -Value $preferred) -and $preferred -notmatch '^[0-9]+$') {
if (-not (Test-NeedsBetterDocumentName -Value $preferred)) {
return $preferred
}
@@ -615,17 +644,13 @@ function Get-PrintServiceDocumentFallback {
if ($candidate -eq $preferred) { continue }
if ($Owner -and $candidate -like "*$Owner*") { continue }
if ($PrinterName -and $candidate -like "*$PrinterName*") { continue }
if (Test-LooksLikeMojibakeQuestionMarks -Value $candidate) { continue }
if (Test-NeedsBetterDocumentName -Value $candidate) { continue }
if ($candidate -match '[\\/:]' -and $candidate -match '\.[A-Za-z0-9]{1,8}$') {
$pathCandidates.Add($candidate)
continue
}
if ($candidate -match '^[0-9]+$') {
continue
}
$textCandidates.Add($candidate)
}
@@ -755,8 +780,6 @@ $script:SeenPrintJob = @{}
$script:SeenPrintEvent = @{}
$script:LastClipboardHash = $null
$script:PulseSeconds = [Math]::Max($resolvedPollSeconds * 3, 30)
$script:SelfTestIntervalSeconds = [Math]::Max($resolvedPollSeconds * 10, 60)
$script:LastSelfTestAt = [datetime]::MinValue
$script:LocalAgentLogsEnabled = $resolvedLocalAgentLogsEnabled
$script:LogPath = $resolvedLogPath
$script:IncidentArtifactsRoot = $resolvedIncidentArtifactsRoot
@@ -768,15 +791,6 @@ Write-EndpointLog ("endpoint collector started against {0}" -f $script:ApiBase)
while ($true) {
try {
$nowUtc = (Get-Date).ToUniversalTime()
if (($nowUtc - $script:LastSelfTestAt).TotalSeconds -ge $script:SelfTestIntervalSeconds) {
Send-EndpointSignalHeartbeat -SignalType 'self_test' -Data @{
collector = 'dlp-endpoint-signals'
policyEnabled = [bool]$script:Policy.defaults.enabled
}
$script:LastSelfTestAt = $nowUtc
}
if (-not $script:Policy.defaults.enabled) {
Start-Sleep -Seconds $resolvedPollSeconds
continue
@@ -834,12 +848,12 @@ while ($true) {
if ($script:SeenPrintJob.ContainsKey($jobId)) { continue }
$script:SeenPrintJob[$jobId] = (Get-Date).ToUniversalTime()
$printerName = [string]$job.Name
$printerName = Normalize-PrinterForMatch -Value ([string]$job.Name)
$documentName = [string]$job.Document
$owner = [string]$job.Owner
$documentNameOriginal = $documentName
if (Test-LooksLikeMojibakeQuestionMarks -Value $documentName) {
if (Test-NeedsBetterDocumentName -Value $documentName) {
$eventDocumentName = Get-BetterDocumentNameFromPrintServiceEvents -Owner $owner -PrinterName $printerName
if ($eventDocumentName) {
$documentName = $eventDocumentName
@@ -98,13 +98,6 @@ function Get-SessionRecords {
return $records
}
function Test-SessionIsActive {
param([AllowNull()][string]$State)
if ([string]::IsNullOrWhiteSpace($State)) { return $false }
$s = $State.Trim().ToLowerInvariant()
return ($s -eq 'active') -or ($s -like 'актив*')
}
$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
@@ -144,7 +137,7 @@ while ($true) {
sessionId = [int]$rec.sessionId
sessionName = [string]$rec.sessionName
state = [string]$rec.state
active = (Test-SessionIsActive -State ([string]$rec.state))
active = ($rec.state -match 'Active')
hostname = $hostValue
source = 'worktime-session-collector'
}
+1 -3
View File
@@ -376,7 +376,6 @@ function New-ActivityWatchDeploymentConfig {
[string]$LaunchScriptPath,
[Parameter(Mandatory = $true)]
[string]$RecoveryScriptPath,
[string]$AwHostname,
[Parameter(Mandatory = $true)]
[pscustomobject[]]$UserTasks,
[string]$PackageVersion = 'v0.13.2'
@@ -387,7 +386,6 @@ function New-ActivityWatchDeploymentConfig {
return [pscustomobject]@{
version = 1
generatedAtUtc = (Get-Date).ToUniversalTime().ToString('o')
awHostname = if ([string]::IsNullOrWhiteSpace($AwHostname)) { [string]$env:COMPUTERNAME } else { [string]$AwHostname }
server = [pscustomobject]@{
host = $ServerHost
port = $ServerPort
@@ -744,7 +742,7 @@ function Start-CollectorScriptIfNeeded {
`$installRoot = [string]`$config.paths.installRoot
`$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:Hostname = if (`$config.PSObject.Properties.Name -contains 'awHostname' -and -not [string]::IsNullOrWhiteSpace([string]`$config.awHostname)) { [string]`$config.awHostname } else { `$env:COMPUTERNAME }
`$script:Hostname = `$env:COMPUTERNAME
`$script:KnownBuckets = @{}
`$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' }
-88
View File
@@ -1,88 +0,0 @@
[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))
}
+1 -2
View File
@@ -62,14 +62,13 @@ $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 }
$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 }
$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)) {
New-Item -Path $resolvedLogsRoot -ItemType Directory -Force | Out-Null
}
$script:ApiBase = '{0}://{1}:{2}/api/0' -f $resolvedServerScheme, $resolvedServerHost, $resolvedServerPort
$script:Hostname = $resolvedHostname
$script:Hostname = $env:COMPUTERNAME
$script:SessionId = (Get-Process -Id $PID).SessionId
$script:KnownBuckets = @{}
$script:LocalAgentLogsEnabled = $resolvedLocalAgentLogsEnabled
-2
View File
@@ -24,7 +24,6 @@ param(
[bool]$IncidentScreenshotEnabled = $true,
[string]$IncidentArtifactsRoot,
[bool]$LogonMarkerEnabled = $true,
[string]$AwHostname,
[string]$CustomRulesPath,
[string]$CustomPolicyPath
)
@@ -101,7 +100,6 @@ $config = New-ActivityWatchDeploymentConfig `
-IncidentScreenshotEnabled $IncidentScreenshotEnabled `
-IncidentArtifactsRoot $IncidentArtifactsRoot `
-LogonMarkerEnabled $LogonMarkerEnabled `
-AwHostname $AwHostname `
-LaunchScriptPath $launchScriptPath `
-RecoveryScriptPath $recoveryScriptPath `
-UserTasks $taskDefinitions `
-3
View File
@@ -24,7 +24,6 @@ param(
[bool]$IncidentScreenshotEnabled = $true,
[string]$IncidentArtifactsRoot,
[bool]$LogonMarkerEnabled = $true,
[string]$AwHostname,
[string]$CustomRulesPath,
[string]$CustomPolicyPath,
[string]$ReportPath,
@@ -72,7 +71,6 @@ if (-not (Test-Path -LiteralPath $deployScript)) {
-IncidentScreenshotEnabled $IncidentScreenshotEnabled `
-IncidentArtifactsRoot $IncidentArtifactsRoot `
-LogonMarkerEnabled $LogonMarkerEnabled `
-AwHostname $AwHostname `
-CustomRulesPath $CustomRulesPath `
-CustomPolicyPath $CustomPolicyPath
@@ -96,7 +94,6 @@ if (-not $SkipHardening) {
-IncidentScreenshotEnabled $IncidentScreenshotEnabled `
-IncidentArtifactsRoot $IncidentArtifactsRoot `
-LogonMarkerEnabled $LogonMarkerEnabled `
-AwHostname $AwHostname `
-CustomRulesPath $CustomRulesPath `
-CustomPolicyPath $CustomPolicyPath
}
-2
View File
@@ -22,7 +22,6 @@ param(
[bool]$IncidentScreenshotEnabled = $true,
[string]$IncidentArtifactsRoot,
[bool]$LogonMarkerEnabled = $true,
[string]$AwHostname,
[string]$CustomRulesPath,
[string]$CustomPolicyPath
)
@@ -93,7 +92,6 @@ $config = New-ActivityWatchDeploymentConfig `
-IncidentScreenshotEnabled $IncidentScreenshotEnabled `
-IncidentArtifactsRoot $IncidentArtifactsRoot `
-LogonMarkerEnabled $LogonMarkerEnabled `
-AwHostname $AwHostname `
-LaunchScriptPath $launchScriptPath `
-RecoveryScriptPath $recoveryScriptPath `
-UserTasks $taskDefinitions `
+184 -32
View File
@@ -1,4 +1,4 @@
[CmdletBinding()]
[CmdletBinding()]
param(
[string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json',
[string]$ServerHost,
@@ -33,6 +33,66 @@ function Write-EndpointLog {
}
}
function Get-NewEventId { return ([guid]::NewGuid().ToString()) }
function Initialize-TransportQueue {
param([Parameter(Mandatory = $true)][string]$QueuePath)
$script:QueuePath = $QueuePath
try {
$dir = Split-Path -Path $QueuePath -Parent
if ($dir -and -not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
if (-not (Test-Path -LiteralPath $QueuePath)) { New-Item -ItemType File -Path $QueuePath -Force | Out-Null }
}
catch {
Write-EndpointLog ("Queue init error: {0}" -f $_.Exception.Message)
}
}
function Add-TransportQueueRecord {
param([string]$Uri,[string]$Json)
if (-not $script:QueuePath) { return }
$record = @{ id = (Get-NewEventId); createdAt = (Get-Date).ToUniversalTime().ToString('o'); uri = $Uri; payload = $Json }
Add-Content -LiteralPath $script:QueuePath -Value ($record | ConvertTo-Json -Compress)
$script:TransportStats.eventsEnqueued++
}
function Get-QueueDepth {
if (-not $script:QueuePath -or -not (Test-Path -LiteralPath $script:QueuePath)) { return 0 }
return @((Get-Content -LiteralPath $script:QueuePath)).Count
}
function Send-WithQueue {
param([string]$Uri,[string]$Json)
Add-TransportQueueRecord -Uri $Uri -Json $Json
Try-FlushTransportQueue -MaxItems 20
}
function Try-FlushTransportQueue {
param([int]$MaxItems = 20)
if (-not $script:QueuePath -or -not (Test-Path -LiteralPath $script:QueuePath)) { return }
$lines = @(Get-Content -LiteralPath $script:QueuePath)
if ($lines.Count -eq 0) { return }
$remaining = New-Object System.Collections.Generic.List[string]
$sent = 0
foreach ($line in $lines) {
if ($sent -ge $MaxItems) { $remaining.Add($line); continue }
try { $rec = $line | ConvertFrom-Json } catch { $remaining.Add($line); continue }
if (Invoke-AwJsonPost -Uri ([string]$rec.uri) -Json ([string]$rec.payload)) {
$script:TransportStats.eventsSent++
$script:TransportStats.lastSendStatus = 'ok'
$sent++
}
else {
$script:TransportStats.sendFailures++
$script:TransportStats.lastSendStatus = 'failed'
$remaining.Add($line)
break
}
}
Set-Content -LiteralPath $script:QueuePath -Value $remaining
}
function Invoke-AwJsonPost {
param(
[Parameter(Mandatory = $true)][string]$Uri,
@@ -40,7 +100,14 @@ function Invoke-AwJsonPost {
)
$bytes = [Text.Encoding]::UTF8.GetBytes($Json)
Invoke-RestMethod -Method Post -Uri $Uri -ContentType 'application/json; charset=utf-8' -Body $bytes -TimeoutSec 15 -DisableKeepAlive | Out-Null
try {
Invoke-RestMethod -Method Post -Uri $Uri -ContentType 'application/json; charset=utf-8' -Body $bytes | Out-Null
return $true
}
catch {
Write-EndpointLog ("POST Error: {0}" -f $_.Exception.Message)
return $false
}
}
function Ensure-Bucket {
@@ -54,13 +121,21 @@ function Ensure-Bucket {
return
}
try {
Invoke-RestMethod -Method Get -Uri "$($script:ApiBase)/buckets/$BucketId" | Out-Null
$script:KnownBuckets[$BucketId] = $true
return
}
catch {
}
$body = @{
client = $ClientName
type = $BucketType
hostname = $script:Hostname
} | ConvertTo-Json -Compress
Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$BucketId" -Json $body
Send-WithQueue -Uri "$($script:ApiBase)/buckets/$BucketId" -Json $body
$script:KnownBuckets[$BucketId] = $true
}
@@ -77,6 +152,8 @@ function Send-EndpointSignalHeartbeat {
timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
duration = 0
data = @{
eventId = (Get-NewEventId)
eventCreatedAt = (Get-Date).ToUniversalTime().ToString('o')
signalType = $SignalType
username = $env:USERNAME
sessionId = $script:SessionId
@@ -85,7 +162,7 @@ function Send-EndpointSignalHeartbeat {
} + $Data
} | ConvertTo-Json -Depth 6 -Compress
Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$script:PulseSeconds" -Json $payload
Send-WithQueue -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$script:PulseSeconds" -Json $payload
}
function Send-DlpIncidentHeartbeat {
@@ -114,6 +191,8 @@ function Send-DlpIncidentHeartbeat {
timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
duration = 0
data = @{
eventId = (Get-NewEventId)
eventCreatedAt = (Get-Date).ToUniversalTime().ToString('o')
ruleId = $RuleId
action = $Action
severity = $Severity
@@ -126,7 +205,7 @@ function Send-DlpIncidentHeartbeat {
} + $Data + $captureData
} | ConvertTo-Json -Depth 7 -Compress
Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$script:PulseSeconds" -Json $payload
Send-WithQueue -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$script:PulseSeconds" -Json $payload
}
function Get-FileSha256Hex {
@@ -224,6 +303,10 @@ function Show-EnforcementNotification {
[Parameter(Mandatory = $true)][string]$Title,
[Parameter(Mandatory = $true)][string]$Body
)
if ($script:HeadlessMode) {
Write-EndpointLog ("headless mode: skip notification title={0}" -f $Title)
return $false
}
try {
Add-Type -AssemblyName System.Windows.Forms -ErrorAction SilentlyContinue
$icon = New-Object System.Windows.Forms.NotifyIcon
@@ -235,9 +318,11 @@ function Show-EnforcementNotification {
$icon.ShowBalloonTip(5000)
Start-Sleep -Milliseconds 200
$icon.Dispose()
return $true
}
catch {
Write-EndpointLog ("notification failed: {0}" -f $_.Exception.Message)
return $false
}
}
@@ -424,6 +509,9 @@ function Evaluate-ClipboardRules {
[string]$ClipboardText,
[string]$ClipboardHash
)
if ([string]::IsNullOrEmpty($ClipboardText) -or [string]::IsNullOrEmpty($ClipboardHash)) {
return
}
foreach ($rule in @($script:Policy.endpoint.clipboard)) {
if (-not $rule) { continue }
@@ -454,8 +542,13 @@ function Evaluate-ClipboardRules {
$enforced = $false
if ($action -eq 'block') {
$enforced = Invoke-ClipboardEnforcement
Show-EnforcementNotification -Title 'DLP: буфер обмена очищен' -Body $message
if ($script:HeadlessMode) {
Write-EndpointLog ("headless fallback: clipboard rule={0} requires block, skipped interactive enforcement" -f $ruleId)
}
else {
$enforced = Invoke-ClipboardEnforcement
[void](Show-EnforcementNotification -Title 'DLP: буфер обмена очищен' -Body $message)
}
}
Send-DlpIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -SignalType 'clipboard' -Data @{
@@ -489,8 +582,13 @@ function Evaluate-UsbRules {
$enforced = $false
if ($action -eq 'block') {
$enforced = Invoke-UsbWriteBlockEnforcement -DriveLetter $DriveLetter
Show-EnforcementNotification -Title 'DLP: USB заблокирован для записи' -Body $message
if ($script:HeadlessMode) {
Write-EndpointLog ("headless fallback: usb rule={0} requires block, skipped interactive enforcement drive={1}" -f $ruleId, $DriveLetter)
}
else {
$enforced = Invoke-UsbWriteBlockEnforcement -DriveLetter $DriveLetter
[void](Show-EnforcementNotification -Title 'DLP: USB заблокирован для записи' -Body $message)
}
}
Send-DlpIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -SignalType 'usb_insert' -Data @{
@@ -534,8 +632,13 @@ function Evaluate-PrintRules {
$enforced = $false
if ($action -eq 'block') {
$enforced = Invoke-PrintJobEnforcement -PrinterName $PrinterName -DocumentName $DocumentName -Owner $Owner
Show-EnforcementNotification -Title 'DLP: печать заблокирована' -Body $message
if ($script:HeadlessMode) {
Write-EndpointLog ("headless fallback: print rule={0} requires block, skipped interactive enforcement printer={1}" -f $ruleId, $PrinterName)
}
else {
$enforced = Invoke-PrintJobEnforcement -PrinterName $PrinterName -DocumentName $DocumentName -Owner $Owner
[void](Show-EnforcementNotification -Title 'DLP: печать заблокирована' -Body $message)
}
}
Send-DlpIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -SignalType 'print_job' -Data @{
@@ -554,6 +657,35 @@ function Test-LooksLikeMojibakeQuestionMarks {
return $Value -match '\?{2,}'
}
function Test-IsGenericDocumentName {
param([AllowNull()][string]$Value)
if ([string]::IsNullOrWhiteSpace($Value)) { return $true }
$generic = @(
'^\s*Печать документа\s*$',
'^\s*Print Document\s*$',
'^\s*Document\s*$',
'^\s*Документ\s*$',
'^\s*Remote Downlevel Document\s*$',
'^\s*Local Downlevel Document\s*$',
'^\s*Untitled\s*$',
'^\s*Без имени\s*$',
'^\s*Без названия\s*$'
)
foreach ($pattern in $generic) {
if ($Value -match $pattern) { return $true }
}
return $false
}
function Test-NeedsBetterDocumentName {
param([AllowNull()][string]$Value)
if ([string]::IsNullOrWhiteSpace($Value)) { return $true }
if (Test-LooksLikeMojibakeQuestionMarks -Value $Value) { return $true }
if (Test-IsGenericDocumentName -Value $Value) { return $true }
if ($Value -match '^[0-9]+$') { return $true }
return $false
}
function Normalize-OwnerForMatch {
param([AllowNull()][string]$Value)
if ([string]::IsNullOrWhiteSpace($Value)) { return '' }
@@ -640,7 +772,7 @@ function Get-PrintServiceDocumentFallback {
)
$preferred = [string]$EventSummary.DocumentName
if (-not (Test-LooksLikeMojibakeQuestionMarks -Value $preferred) -and $preferred -notmatch '^[0-9]+$') {
if (-not (Test-NeedsBetterDocumentName -Value $preferred)) {
return $preferred
}
@@ -653,17 +785,13 @@ function Get-PrintServiceDocumentFallback {
if ($candidate -eq $preferred) { continue }
if ($Owner -and $candidate -like "*$Owner*") { continue }
if ($PrinterName -and $candidate -like "*$PrinterName*") { continue }
if (Test-LooksLikeMojibakeQuestionMarks -Value $candidate) { continue }
if (Test-NeedsBetterDocumentName -Value $candidate) { continue }
if ($candidate -match '[\\/:]' -and $candidate -match '\.[A-Za-z0-9]{1,8}$') {
$pathCandidates.Add($candidate)
continue
}
if ($candidate -match '^[0-9]+$') {
continue
}
$textCandidates.Add($candidate)
}
@@ -762,6 +890,7 @@ function Get-BetterDocumentNameFromPrintServiceEvents {
}
}
catch {
Write-EndpointLog ("printservice fallback failed: {0}" -f $_.Exception.Message)
}
return $null
@@ -778,14 +907,13 @@ $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 }
$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 }
$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)) {
New-Item -Path $resolvedLogsRoot -ItemType Directory -Force | Out-Null
}
$script:ApiBase = '{0}://{1}:{2}/api/0' -f $resolvedServerScheme, $resolvedServerHost, $resolvedServerPort
$script:Hostname = $resolvedHostname
$script:Hostname = $env:COMPUTERNAME
$script:SessionId = (Get-Process -Id $PID).SessionId
$script:KnownBuckets = @{}
$script:Cooldown = @{}
@@ -794,28 +922,25 @@ $script:SeenPrintJob = @{}
$script:SeenPrintEvent = @{}
$script:LastClipboardHash = $null
$script:PulseSeconds = [Math]::Max($resolvedPollSeconds * 3, 30)
$script:SelfTestIntervalSeconds = [Math]::Max($resolvedPollSeconds * 10, 60)
$script:LastSelfTestAt = [datetime]::MinValue
$script:LocalAgentLogsEnabled = $resolvedLocalAgentLogsEnabled
$script:LogPath = $resolvedLogPath
$script:IncidentArtifactsRoot = $resolvedIncidentArtifactsRoot
$script:IncidentScreenshotEnabled = $resolvedIncidentScreenshotEnabled
$script:ScreenshotTypesLoaded = $false
$script:TransportStats = @{ eventsEnqueued = 0; eventsSent = 0; sendFailures = 0; lastSendStatus = 'init' }
$script:QueuePath = $null
$queueFile = Join-Path $resolvedLogsRoot ("endpoint-queue-{0}.jsonl" -f $env:USERNAME)
Initialize-TransportQueue -QueuePath $queueFile
Load-DlpPolicy -Path $resolvedPolicyPath
Write-EndpointLog ("endpoint collector started against {0}" -f $script:ApiBase)
if ($script:HeadlessMode) {
Write-EndpointLog "headless mode enabled: enforcement UI is disabled, incident heartbeat and logs only"
}
while ($true) {
try {
$nowUtc = (Get-Date).ToUniversalTime()
if (($nowUtc - $script:LastSelfTestAt).TotalSeconds -ge $script:SelfTestIntervalSeconds) {
Send-EndpointSignalHeartbeat -SignalType 'self_test' -Data @{
collector = 'dlp-endpoint-signals'
policyEnabled = [bool]$script:Policy.defaults.enabled
}
$script:LastSelfTestAt = $nowUtc
}
if (-not $script:Policy.defaults.enabled) {
Start-Sleep -Seconds $resolvedPollSeconds
continue
@@ -836,6 +961,7 @@ while ($true) {
}
}
catch {
Write-EndpointLog ("clipboard poll failed: {0}" -f $_.Exception.Message)
}
try {
@@ -863,6 +989,7 @@ while ($true) {
}
}
catch {
Write-EndpointLog ("usb poll failed: {0}" -f $_.Exception.Message)
}
try {
@@ -873,23 +1000,33 @@ while ($true) {
if ($script:SeenPrintJob.ContainsKey($jobId)) { continue }
$script:SeenPrintJob[$jobId] = (Get-Date).ToUniversalTime()
$printerName = [string]$job.Name
$printerName = Normalize-PrinterForMatch -Value ([string]$job.Name)
$documentName = [string]$job.Document
$owner = [string]$job.Owner
$documentNameOriginal = $documentName
if (Test-LooksLikeMojibakeQuestionMarks -Value $documentName) {
if (Test-NeedsBetterDocumentName -Value $documentName) {
$eventDocumentName = Get-BetterDocumentNameFromPrintServiceEvents -Owner $owner -PrinterName $printerName
if ($eventDocumentName) {
$documentName = $eventDocumentName
}
}
$printDocumentNorm = if ($documentName) { [string]$documentName } else { '' }
$printSignalKey = ('{0}|{1}|{2}|{3}' -f
(Normalize-PrinterForMatch -Value $printerName),
(Normalize-OwnerForMatch -Value $owner),
$printDocumentNorm.ToLowerInvariant(),
'print_job')
if (-not (Should-EmitByCooldown -Fingerprint $printSignalKey -CooldownSeconds 90)) {
continue
}
Send-EndpointSignalHeartbeat -SignalType 'print_job' -Data @{
printerName = $printerName
documentName = $documentName
documentNameOriginal = $documentNameOriginal
owner = $owner
eventSource = 'win32_printjob'
}
Evaluate-PrintRules -PrinterName $printerName -DocumentName $documentName -Owner $owner
}
@@ -903,6 +1040,7 @@ while ($true) {
}
}
catch {
Write-EndpointLog ("printjob poll failed: {0}" -f $_.Exception.Message)
}
try {
@@ -930,6 +1068,17 @@ while ($true) {
continue
}
$effectiveDocument = if ($resolvedDocument) { [string]$resolvedDocument } else { [string]$documentName }
$printSignalKey = ('{0}|{1}|{2}|{3}' -f
(Normalize-PrinterForMatch -Value $printerName),
(Normalize-OwnerForMatch -Value $owner),
$effectiveDocument.ToLowerInvariant(),
'print_job')
if (-not (Should-EmitByCooldown -Fingerprint $printSignalKey -CooldownSeconds 90)) {
Write-PrintServiceEventTrace -EventSummary $summary -Phase 'skip' -MatchReason 'dedupe-recent-printjob' -ResolvedDocument $resolvedDocument
continue
}
Send-EndpointSignalHeartbeat -SignalType 'print_job' -Data @{
printerName = $printerName
documentName = if ($resolvedDocument) { $resolvedDocument } else { $documentName }
@@ -950,11 +1099,14 @@ while ($true) {
}
}
catch {
Write-EndpointLog ("printservice poll failed: {0}" -f $_.Exception.Message)
}
}
catch {
Write-EndpointLog ("collector error: {0}" -f $_.Exception.Message)
}
Try-FlushTransportQueue -MaxItems 50
Write-EndpointLog ("transport metrics queueDepth={0} enqueued={1} sent={2} failures={3} lastStatus={4}" -f (Get-QueueDepth), $script:TransportStats.eventsEnqueued, $script:TransportStats.eventsSent, $script:TransportStats.sendFailures, $script:TransportStats.lastSendStatus)
Start-Sleep -Seconds $resolvedPollSeconds
}
+85 -30
View File
@@ -26,6 +26,8 @@ $script:SessionId = [System.Diagnostics.Process]::GetCurrentProcess().SessionId
# Настройка логирования
$script:LogPath = $LogPath
$script:LocalAgentLogsEnabled = [bool]$LogPath
$script:TransportStats = @{ eventsEnqueued = 0; eventsSent = 0; sendFailures = 0; lastSendStatus = 'init' }
$script:QueuePath = $null
function Get-DeploymentConfig {
param([string]$Path)
@@ -43,29 +45,89 @@ function Write-FileCollectorLog {
} catch {}
}
function Get-NewEventId { return ([guid]::NewGuid().ToString()) }
function Initialize-TransportQueue {
param([Parameter(Mandatory = $true)][string]$QueuePath)
$script:QueuePath = $QueuePath
try {
$dir = Split-Path -Path $QueuePath -Parent
if ($dir -and -not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
if (-not (Test-Path -LiteralPath $QueuePath)) { New-Item -ItemType File -Path $QueuePath -Force | Out-Null }
} catch {
Write-FileCollectorLog "Queue init error: $($_.Exception.Message)"
}
}
function Add-TransportQueueRecord {
param([string]$Uri,[string]$Json)
if (-not $script:QueuePath) { return }
$record = @{ id = (Get-NewEventId); createdAt = (Get-Date).ToUniversalTime().ToString('o'); uri = $Uri; payload = $Json }
Add-Content -LiteralPath $script:QueuePath -Value ($record | ConvertTo-Json -Compress)
$script:TransportStats.eventsEnqueued++
}
function Get-QueueDepth {
if (-not $script:QueuePath -or -not (Test-Path -LiteralPath $script:QueuePath)) { return 0 }
return @((Get-Content -LiteralPath $script:QueuePath)).Count
}
function Try-FlushTransportQueue {
param([int]$MaxItems = 20)
if (-not $script:QueuePath -or -not (Test-Path -LiteralPath $script:QueuePath)) { return }
$lines = @(Get-Content -LiteralPath $script:QueuePath)
if ($lines.Count -eq 0) { return }
$remaining = New-Object System.Collections.Generic.List[string]
$sentInRun = 0
foreach ($line in $lines) {
if ($sentInRun -ge $MaxItems) { $remaining.Add($line); continue }
try { $rec = $line | ConvertFrom-Json } catch { $remaining.Add($line); continue }
if (Invoke-AwJsonPost -Uri ([string]$rec.uri) -Json ([string]$rec.payload)) {
$script:TransportStats.eventsSent++
$script:TransportStats.lastSendStatus = 'ok'
$sentInRun++
} else {
$script:TransportStats.sendFailures++
$script:TransportStats.lastSendStatus = 'failed'
$remaining.Add($line)
break
}
}
if ($sentInRun -lt $lines.Count) {
for ($i=$sentInRun+($lines.Count-$remaining.Count); $i -lt $lines.Count; $i++) { }
}
Set-Content -LiteralPath $script:QueuePath -Value $remaining
}
function Send-WithQueue {
param([string]$Uri,[string]$Json)
Add-TransportQueueRecord -Uri $Uri -Json $Json
Try-FlushTransportQueue -MaxItems 10
}
function Invoke-AwJsonPost {
param(
[Parameter(Mandatory = $true)][string]$Uri,
[Parameter(Mandatory = $true)][string]$Json
)
$httpClient = $null
$httpClient = New-Object System.Net.Http.HttpClient
try {
$httpClient = New-Object System.Net.Http.HttpClient
$content = New-Object System.Net.Http.StringContent($Json, [System.Text.Encoding]::UTF8, "application/json")
$response = $httpClient.PostAsync($Uri, $content).Result
if (-not $response.IsSuccessStatusCode) {
$status = [int]$response.StatusCode
$statusCode = [int]$response.StatusCode
$reason = [string]$response.ReasonPhrase
$body = $response.Content.ReadAsStringAsync().Result
Write-FileCollectorLog ("POST failed: uri={0} status={1} reason={2} body={3}" -f $Uri, $status, $reason, $body)
$responseBody = $response.Content.ReadAsStringAsync().Result
Write-FileCollectorLog ("POST failed uri={0} status={1} reason={2} body={3}" -f $Uri, $statusCode, $reason, $responseBody)
}
} catch {
Write-FileCollectorLog "POST Error: $($_.Exception.Message)"
return $false
} finally {
if ($null -ne $httpClient) {
$httpClient.Dispose()
}
$httpClient.Dispose()
}
return $response.IsSuccessStatusCode
}
function Ensure-Bucket {
@@ -92,19 +154,7 @@ function Ensure-Bucket {
type = $BucketType
hostname = $script:Hostname
} | ConvertTo-Json -Compress
try {
Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$BucketId" -Json $body
}
catch {
try {
Invoke-RestMethod -Method Get -Uri "$($script:ApiBase)/buckets/$BucketId" | Out-Null
}
catch {
Write-FileCollectorLog "Bucket create/check failed for ${BucketId}: $($_.Exception.Message)"
throw
}
}
Send-WithQueue -Uri "$($script:ApiBase)/buckets/$BucketId" -Json $body
$script:KnownBuckets[$BucketId] = $true
}
@@ -120,6 +170,9 @@ function Send-FileOperationEvent {
Ensure-Bucket -BucketId $bucketId -ClientName 'aw-file-operations' -BucketType 'aw.file.operation'
$data = @{
eventId = (Get-NewEventId)
eventCreatedAt = (Get-Date).ToUniversalTime().ToString('o')
operation = $Operation
path = $FilePath
extension = [System.IO.Path]::GetExtension($FilePath)
@@ -140,18 +193,20 @@ function Send-FileOperationEvent {
data = $data
} | ConvertTo-Json -Depth 5 -Compress
Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=15" -Json $payload
Send-WithQueue -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=15" -Json $payload
}
$config = Get-DeploymentConfig -Path $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' }
$hostName = if ($ServerHost) { $ServerHost } elseif ($config.server.host) { $config.server.host } else { 'localhost' }
$port = if ($ServerPort) { $ServerPort } elseif ($config.server.port) { $config.server.port } else { 5600 }
$script:ApiBase = "{0}://{1}:{2}/api/0" -f $scheme, $hostName, $port
$queueFile = Join-Path ([System.IO.Path]::GetDirectoryName($script:LogPath)) ("file-collector-queue-{0}.jsonl" -f $env:USERNAME)
Initialize-TransportQueue -QueuePath $queueFile
$bucketId = 'aw-file-operations_' + $script:Hostname
Ensure-Bucket -BucketId $bucketId -ClientName 'aw-file-operations' -BucketType 'aw.file.operation'
@@ -198,7 +253,7 @@ foreach ($path in $resolvedPaths) {
$onRenamed = Register-ObjectEvent $watcher "Renamed" -Action {
Send-FileOperationEvent -Operation 'Renamed' -FilePath $Event.SourceEventArgs.FullPath -OldFilePath $Event.SourceEventArgs.OldFullPath
}
$subscriptions += @($onChanged, $onDeleted, $onRenamed)
$watchers += $watcher
$subscriptions += @($onChanged, $onDeleted, $onRenamed)
}
@@ -207,18 +262,18 @@ Write-FileCollectorLog "Collector started. Waiting for events..."
try {
while ($true) {
Try-FlushTransportQueue -MaxItems 50
Write-FileCollectorLog ("transport metrics queueDepth={0} enqueued={1} sent={2} failures={3} lastStatus={4}" -f (Get-QueueDepth), $script:TransportStats.eventsEnqueued, $script:TransportStats.eventsSent, $script:TransportStats.sendFailures, $script:TransportStats.lastSendStatus)
Start-Sleep -Seconds $PollSeconds
}
}
finally {
Write-FileCollectorLog "Stopping collector..."
foreach ($sub in @($subscriptions)) {
try {
if ($sub -and $sub.Id) {
Unregister-Event -SubscriptionId $sub.Id -ErrorAction SilentlyContinue
Remove-Job -Id $sub.Id -Force -ErrorAction SilentlyContinue
}
} catch {}
if ($null -ne $sub) {
try { Unregister-Event -SubscriptionId $sub.Id -ErrorAction SilentlyContinue } catch {}
try { Remove-Job -Id $sub.Id -Force -ErrorAction SilentlyContinue } catch {}
}
}
foreach ($w in $watchers) {
$w.EnableRaisingEvents = $false
-3
View File
@@ -21,7 +21,6 @@ param(
[bool]$IncidentScreenshotEnabled,
[string]$IncidentArtifactsRoot,
[bool]$LogonMarkerEnabled,
[string]$AwHostname,
[string]$CustomRulesPath,
[string]$CustomPolicyPath,
[switch]$RepairPackage,
@@ -74,7 +73,6 @@ $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 }
$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 }
$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' }
$effectiveUsers = if ($Users -or $UserListPath) {
@@ -141,7 +139,6 @@ $config = New-ActivityWatchDeploymentConfig `
-IncidentScreenshotEnabled $effectiveIncidentScreenshotEnabled `
-IncidentArtifactsRoot $effectiveIncidentArtifactsRoot `
-LogonMarkerEnabled $effectiveLogonMarkerEnabled `
-AwHostname $effectiveAwHostname `
-LaunchScriptPath $effectiveLaunchScript `
-RecoveryScriptPath $effectiveRecoveryScript `
-UserTasks $taskDefinitions `
-121
View File
@@ -1,121 +0,0 @@
[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,8 +34,6 @@ Name: "validate"; Description: "Запустить validate-deployment (чере
[Files]
Source: "..\..\ActivityWatch.Windows.Common.psd1"; 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-domain-users.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion
Source: "..\..\deploy-ensemble.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion
@@ -45,7 +43,6 @@ Source: "..\..\migrate-awatch-rus-paths.ps1"; DestDir: "{app}\windows"; Flags: i
Source: "..\..\worktime-session-collector.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion
Source: "..\..\browser-domains-native-collector.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion
Source: "..\..\dlp-endpoint-signals-collector.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion
Source: "..\..\file-operations-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: "..\..\dlp-policy.example.json"; DestDir: "{app}\windows"; Flags: ignoreversion
@@ -54,11 +51,95 @@ Source: "payload\{#AwDefaultZipName}"; DestDir: "{app}\payload"; Flags: ignoreve
Source: "innosetup-rdp-package-filelist.md"; DestDir: "{app}\windows\installkit\innosetup"; Flags: ignoreversion
[Run]
Filename: "powershell.exe"; Parameters: "{code:GetStandaloneInstallParams}"; Flags: runhidden; Tasks: deploy
Filename: "powershell.exe"; Parameters: "{code:GetDeployEnsembleParams}"; Flags: runhidden; Tasks: deploy
[Code]
var
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;
begin
@@ -74,24 +155,62 @@ begin
ServerHostPage.Add('ServerPort', False);
ServerHostPage.Values[0] := '{#AwDefaultServerHost}';
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;
function GetStandaloneInstallParams(Param: string): string;
function GetDeployEnsembleParams(Param: string): string;
var
serverHost: string;
serverPort: string;
usersCsv: string;
usersArg: string;
zipArg: string;
validateArg: string;
begin
serverHost := Trim(ServerHostPage.Values[0]);
serverPort := Trim(ServerHostPage.Values[1]);
if serverHost = '' then
RaiseException('ServerHost is empty.');
if serverPort = '' then
RaiseException('ServerPort is empty.');
usersCsv := NormalizeUserCsv(UsersPage.Values[0]);
usersArg := BuildUsersPowerShellArg(usersCsv);
if usersArg = '' then
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 :=
'-NoProfile -ExecutionPolicy Bypass -File "' + ExpandConstant('{app}\windows\install-standalone-service.ps1') + '"' +
'-NoProfile -ExecutionPolicy Bypass -File "' + ExpandConstant('{app}\windows\deploy-ensemble.ps1') + '"' +
' -ServerHost "' + serverHost + '"' +
' -ServerPort ' + serverPort +
' ' + usersArg +
zipArg +
' -InstallRoot "{#AwDefaultInstallRoot}"' +
' -StateRoot "{#AwDefaultStateRoot}"';
' -StateRoot "{#AwDefaultStateRoot}"' +
validateArg;
end;
+6 -16
View File
@@ -25,21 +25,11 @@ The resulting installer `AWatch-rus-InstallKit.exe` is written to the same direc
./build_with_wine.sh
```
## Install-time parameters (Standalone agent mode)
## Install-time parameters
The installer wizard asks only for:
The installer wizard asks for:
- `ServerHost` / `ServerPort` (defaults to `10.10.10.13:5600`)
All other values are taken from defaults embedded in installer scripts.
## 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)
- `ServerHost` / `ServerPort` (defaults to our AW server `10.10.10.13:5600`)
- `Users` (CSV)
- Whether to use offline payload (auto-enabled when the ZIP exists at compile time)
- Whether to validate after deploy (`-ValidateAfterDeploy`, report written to `C:\ProgramData\AWatch-rus\ensemble-report-*.json`)
+90 -154
View File
@@ -4,66 +4,17 @@ param(
[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
$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
}
$ErrorActionPreference = 'Stop'
function Get-Config {
param([string]$Path)
if (-not (Test-Path -LiteralPath $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)"
throw "Конфигурация не найдена: $Path"
}
Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json
}
function Invoke-AwJsonPost {
@@ -71,15 +22,9 @@ function Invoke-AwJsonPost {
[Parameter(Mandatory = $true)][string]$Uri,
[Parameter(Mandatory = $true)][string]$Json
)
try {
$bytes = [System.Text.Encoding]::UTF8.GetBytes($Json)
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
}
$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 {
@@ -88,134 +33,125 @@ function Ensure-Bucket {
[Parameter(Mandatory = $true)][string]$BucketId,
[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" }
$body = @{ client='aw-worktime-session-collector'; type='aw.worktime.session'; hostname=$HostnameValue } | ConvertTo-Json -Compress
$attempts = 0
while ($attempts -lt 3) {
$attempts++
$ok = Invoke-AwJsonPost -Uri "$ApiBase/buckets/$BucketId" -Json $body
if ($ok) { return }
Start-Sleep -Seconds (2 * $attempts)
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
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 Run-QueryUser {
$tries = @(@{File='quser';Args=''},@{File='query';Args='user'})
foreach ($t in $tries) {
try {
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = $t.File
if ($t.Args) { $psi.Arguments = $t.Args }
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
$psi.UseShellExecute = $false
$psi.CreateNoWindow = $true
$proc = [System.Diagnostics.Process]::Start($psi)
$stream = $proc.StandardOutput.BaseStream
$ms = New-Object System.IO.MemoryStream
$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()
$text = Decode-Bytes-Auto -Bytes $bytes
if ($text -and $text.Trim()) { return ($text -split "\r?\n") | Where-Object { $_ -ne '' } }
}
catch {
# try next
}
}
return @()
}
function Parse-SessionLines {
param([string[]]$Lines)
function Get-SessionRecords {
$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] }
try {
$lines = quser 2>$null
if (-not $lines) {
return @()
}
$records += [pscustomobject]@{ username=$user; sessionName=$sess; sessionId=$id; state=$state }
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
}
$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 {
}
return $records
}
function Test-SessionIsActive {
param([string]$State)
if (-not $State) { return $false }
$s = $State.Trim().ToLowerInvariant()
return ($s -match 'active') -or ($s -match 'актив')
}
# Main
$cfg = Get-Config -Path $ConfigPath
$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 }
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.' }
$hostValue = if ($Hostname) { $Hostname } else { [string]$env:COMPUTERNAME }
$apiBase = '{0}://{1}:{2}/api/0' -f [string]$cfg.server.scheme, [string]$cfg.server.host, [string]$cfg.server.port
$bucketId = 'aw-worktime-sessions_' + $hostValue
$pulse = 120
$sleepSec = if ($PollSeconds -gt 0) { $PollSeconds } elseif ($cfg.collector -and $cfg.collector.pollSeconds) { [int]$cfg.collector.pollSeconds } else { 30 }
$sleepSec = if ($PollSeconds -gt 0) {
$PollSeconds
}
elseif ($cfg.collector -and $cfg.collector.pollSeconds) {
[int]$cfg.collector.pollSeconds
}
else {
30
}
Ensure-Bucket -ApiBase $apiBase -BucketId $bucketId -HostnameValue $hostValue
while ($true) {
$now = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
try {
$lines = Run-QueryUser
$records = Parse-SessionLines -Lines $lines
}
catch {
Write-Verbose "Session parse error: $($_.Exception.Message)"
$records = @()
}
$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' })
$records = @([pscustomobject]@{
username = $env:USERNAME
sessionName = ''
sessionId = (Get-Process -Id $PID).SessionId
state = 'Unknown'
})
}
foreach ($rec in $records) {
$payloadObj = [PSCustomObject]@{
$payload = @{
timestamp = $now
duration = 0
data = [PSCustomObject]@{
data = @{
username = [string]$rec.username
userId = "${env:USERDOMAIN}\$($rec.username)"
userId = "$($env:USERDOMAIN)\$($rec.username)"
sessionId = [int]$rec.sessionId
sessionName = [string]$rec.sessionName
state = [string]$rec.state
active = Test-SessionIsActive -State ([string]$rec.state)
active = ($rec.state -match 'Active')
hostname = $hostValue
source = 'worktime-session-collector'
}
}
$payload = $payloadObj | ConvertTo-Json -Depth 6 -Compress
} | ConvertTo-Json -Depth 6 -Compress
try {
$ok = Invoke-AwJsonPost -Uri "$apiBase/buckets/$bucketId/heartbeat?pulsetime=$pulse" -Json $payload
if (-not $ok) { Write-Verbose "Heartbeat not confirmed for user $($rec.username)" }
Invoke-AwJsonPost -Uri "$apiBase/buckets/$bucketId/heartbeat?pulsetime=$pulse" -Json $payload
}
catch {
Write-Verbose "Heartbeat error: $($_.Exception.Message)"
}
}