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 %} {% if (aw_windows_package_zip_path | default('') | string | length) > 0 %}
$params.PackageZipPath = "{{ aw_windows_package_zip_path }}" $params.PackageZipPath = "{{ aw_windows_package_zip_path }}"
{% endif %} {% endif %}
{% if (aw_windows_hostname_override | default('') | string | length) > 0 %}
$params.AwHostname = "{{ aw_windows_hostname_override }}"
{% endif %}
{% if aw_windows_skip_hardening | bool %} {% if aw_windows_skip_hardening | bool %}
$params.SkipHardening = $true $params.SkipHardening = $true
{% endif %} {% endif %}
@@ -186,44 +183,6 @@
ansible.windows.win_powershell: ansible.windows.win_powershell:
script: | script: |
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
function Get-CollectorKey {
param([string]$CommandLine)
if (-not $CommandLine) { return $null }
$cl = $CommandLine.ToLowerInvariant()
if ($cl -like '*browser-domains-native-collector.ps1*') { return 'browser' }
if ($cl -like '*file-operations-collector.ps1*') { return 'fileops' }
if ($cl -like '*dlp-endpoint-signals-collector.ps1*') { return 'endpoint' }
if ($cl -like '*email-outbound-collector.ps1*') { return 'email' }
if ($cl -like '*worktime-session-collector.ps1*') { return 'worktime' }
return $null
}
$collectorProcs = Get-CimInstance Win32_Process |
Where-Object { $_.Name -eq 'powershell.exe' -and $_.CommandLine } |
ForEach-Object {
$key = Get-CollectorKey -CommandLine $_.CommandLine
if ($key) {
[pscustomobject]@{
ProcessId = [int]$_.ProcessId
SessionId = [int]$_.SessionId
CreationDate = $_.CreationDate
CollectorKey = $key
}
}
} |
Where-Object { $_ -ne $null }
# Keep only one process per (collector, session): newest survives, older duplicates are stopped.
foreach ($group in ($collectorProcs | Group-Object CollectorKey, SessionId)) {
$ordered = @($group.Group | Sort-Object CreationDate -Descending)
if ($ordered.Count -le 1) { continue }
foreach ($dup in $ordered | Select-Object -Skip 1) {
Stop-Process -Id $dup.ProcessId -Force -ErrorAction SilentlyContinue
}
}
Start-Sleep -Seconds 2
Start-ScheduledTask -TaskName "{{ aw_windows_recovery_task_name }}" Start-ScheduledTask -TaskName "{{ aw_windows_recovery_task_name }}"
Get-ScheduledTask | Get-ScheduledTask |
Where-Object TaskName -like "{{ aw_windows_launch_task_pattern }}" | Where-Object TaskName -like "{{ aw_windows_launch_task_pattern }}" |
-1
View File
@@ -24,7 +24,6 @@ aw_windows_extra_users: []
aw_windows_install_root: "C:\\Program Files\\AWatch-rus\\bin" aw_windows_install_root: "C:\\Program Files\\AWatch-rus\\bin"
aw_windows_state_root: "C:\\ProgramData\\AWatch-rus" aw_windows_state_root: "C:\\ProgramData\\AWatch-rus"
aw_windows_hostname_override: ""
aw_windows_afk_enabled: true aw_windows_afk_enabled: true
aw_windows_window_enabled: true aw_windows_window_enabled: true
-1
View File
@@ -21,7 +21,6 @@ aw_windows_extra_users: []
# Единые Windows/RDP пути: те же, что использует InnoSetup. # Единые Windows/RDP пути: те же, что использует InnoSetup.
aw_windows_install_root: "C:\\Program Files\\AWatch-rus\\bin" aw_windows_install_root: "C:\\Program Files\\AWatch-rus\\bin"
aw_windows_state_root: "C:\\ProgramData\\AWatch-rus" aw_windows_state_root: "C:\\ProgramData\\AWatch-rus"
aw_windows_hostname_override: "" # Например: SHARKON2025
aw_windows_afk_enabled: true aw_windows_afk_enabled: true
aw_windows_window_enabled: true aw_windows_window_enabled: true
aw_windows_file_ops_enabled: true aw_windows_file_ops_enabled: true
+15 -49
View File
@@ -3,7 +3,7 @@ import json
import os import os
import urllib.error import urllib.error
import urllib.request 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") 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") 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): def build_window_title(users, active_count):
if not users: if not users:
return "RDP idle" return "RDP idle"
return f"RDP active ({active_count}): " + ", ".join(users) 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): def transform(events):
out_afk = [] out_afk = []
out_win = [] out_win = []
last_ts = None last_ts = None
grouped = {}
for e in events: for e in events:
ts = e.get("timestamp") ts = e.get("timestamp")
if not ts: if not ts:
continue continue
grouped.setdefault(ts, []).append(e) duration = float(e.get("duration", 0.0))
data = e.get("data") or {}
for ts in sorted(grouped.keys()): active_users = data.get("activeUsers") or []
rows = grouped[ts] active_count = int(data.get("activeCount", len(active_users)))
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)
is_active = active_count > 0 is_active = active_count > 0
afk_data = {"status": "not-afk" if is_active else "afk", "source": "aw-worktime-ui-bridge"} 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(AFK_BUCKET, "afkstatus", "aw-worktime-ui-bridge")
ensure_bucket(WINDOW_BUCKET, "currentwindow", "aw-worktime-ui-bridge") ensure_bucket(WINDOW_BUCKET, "currentwindow", "aw-worktime-ui-bridge")
now_utc = datetime.now(timezone.utc) query = {
recent = _req("GET", f"/api/0/buckets/{SESSIONS_BUCKET}/events?limit=5000") or [] "query": [
if not recent: "events = query_bucket(find_bucket($bid));",
return "RETURN = sort_by_timestamp(events);",
],
try: "timeperiods": [[last_ts, to_iso_utc(datetime.now(timezone.utc).isoformat())]],
last_dt = parse_iso_utc(last_ts) }
except Exception: rows = _req("POST", f"/api/0/query/?bid={SESSIONS_BUCKET}", query) or []
last_dt = datetime(1970, 1, 1, tzinfo=timezone.utc) if not rows or not rows[0]:
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:
return return
events = rows[0]
afk_events, win_events, new_last_ts = transform(events) afk_events, win_events, new_last_ts = transform(events)
if not afk_events or not win_events or not new_last_ts: if not afk_events or not win_events or not new_last_ts:
return 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/validate-deployment.ps1` — машинная проверка состояния и JSON-отчёт.
- `windows/browser-domains-native-collector.ps1` — native collector доменов браузера с категоризацией. - `windows/browser-domains-native-collector.ps1` — native collector доменов браузера с категоризацией.
- `windows/dlp-endpoint-signals-collector.ps1` — Windows/RDP collector (clipboard/USB/print signals). - `windows/dlp-endpoint-signals-collector.ps1` — Windows/RDP collector (clipboard/USB/print signals).
- `windows/file-operations-collector.ps1` — collector файловых операций (create/delete/rename/archive hints).
- `windows/worktime-session-collector.ps1` — collector RDP-сессий и активности.
- `windows/install-standalone-service.ps1` — standalone установка агента как Windows Service (без Task Scheduler).
- `windows/aw-standalone-service.ps1` — service wrapper для поддержания collector-процессов.
- `windows/web-category-rules.example.json` — пример кастомных правил категоризации. - `windows/web-category-rules.example.json` — пример кастомных правил категоризации.
- `windows/dlp-policy.example.json` — пример DLP-политики (phase-1: alerting incidents). - `windows/dlp-policy.example.json` — пример DLP-политики (phase-1: alerting incidents).
@@ -31,14 +27,6 @@
- Корректно регистрирует задачи через `-LogonType Interactive` (совместимо с Windows Server, где `InteractiveToken` не поддерживается). - Корректно регистрирует задачи через `-LogonType Interactive` (совместимо с Windows Server, где `InteractiveToken` не поддерживается).
- Поддерживает отключение шумных watcher'ов через `-AfkEnabled:$false` и `-WindowEnabled:$false`. - Поддерживает отключение шумных watcher'ов через `-AfkEnabled:$false` и `-WindowEnabled:$false`.
### Standalone InnoSetup (без Ansible, без Task Scheduler)
- InnoSetup запускает `install-standalone-service.ps1`.
- Мастер спрашивает только `ServerHost` и `ServerPort`.
- Создаётся сервис `AWatchRusStandaloneAgent` (auto-start, restart-on-failure).
- Сервис управляет collector-скриптами и держит по одной рабочей копии каждого коллектора.
- `deployment-config.json` формируется в `C:\ProgramData\AWatch-rus\deployment-config.json`.
Важно: Важно:
- Скриншот делается только при DLP-инциденте (`Send-DlpIncidentHeartbeat`), не по таймеру и не на обычной активности. - Скриншот делается только при DLP-инциденте (`Send-DlpIncidentHeartbeat`), не по таймеру и не на обычной активности.
-29
View File
@@ -88,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 ```powershell
-5
View File
@@ -4,9 +4,4 @@ GRAFANA_ADMIN_PASSWORD=change_me_now
GRAFANA_PORT=3000 GRAFANA_PORT=3000
PROMETHEUS_PORT=9090 PROMETHEUS_PORT=9090
SQL_EXPORTER_PORT=9399 SQL_EXPORTER_PORT=9399
AW_EXPORTER_PORT=9398
AW_SERVER_HOST=10.10.10.13
AW_SERVER_PORT=5600
AW_SERVER_SCHEME=http
AW_SCRAPE_INTERVAL_SECONDS=30
ONEC_DSN=postgres://onec_reader:change_me@10.10.10.20:5432/onec_db?sslmode=disable ONEC_DSN=postgres://onec_reader:change_me@10.10.10.20:5432/onec_db?sslmode=disable
+21 -29
View File
@@ -3,46 +3,44 @@
Готовый каркас для непрерывного сбора KPI из 1С и анализа в Grafana: Готовый каркас для непрерывного сбора KPI из 1С и анализа в Grafana:
- `sql-exporter` читает SQL-представления KPI из БД 1С; - `sql-exporter` читает SQL-представления KPI из БД 1С;
- `aw-exporter` собирает метрики ActivityWatch и отдает их Prometheus;
- `prometheus` собирает метрики и применяет alert-rules; - `prometheus` собирает метрики и применяет alert-rules;
- `grafana` поднимает datasource и дашборд автоматически. - `grafana` поднимает datasource и дашборд автоматически.
## Полные пути ## Полные пути
- `./.env.example` - `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/.env.example`
- `./docker-compose.yml` - `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/docker-compose.yml`
- `./sql-exporter/sql_exporter.yml` - `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/sql-exporter/sql_exporter.yml`
- `./sql-exporter/collectors/onec_accounting_kpi.collector.yml` - `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/sql-exporter/collectors/onec_accounting_kpi.collector.yml`
- `./prometheus/prometheus.yml` - `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/prometheus/prometheus.yml`
- `./prometheus/alerts.yml` - `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/prometheus/alerts.yml`
- `./prometheus/recording_rules.yml` - `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/prometheus/recording_rules.yml`
- `./grafana/dashboards/1c-accounting-overview.json` - `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/grafana/dashboards/1c-accounting-overview.json`
- `./grafana/dashboards/1c-accounting-sre.json` - `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/grafana/dashboards/1c-accounting-sre.json`
- `./sql/postgres_views_template.sql` - `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/sql/postgres_views_template.sql`
- `./sql/mssql_views_template.sql` - `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/sql/mssql_views_template.sql`
- `./tools/discover_postgres_1c.sh` - `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/tools/discover_postgres_1c.sh`
- `./tools/validate_kpi_views.sh` - `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/tools/validate_kpi_views.sh`
- `./tools/check_pipeline.sh` - `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/tools/check_pipeline.sh`
## Быстрый запуск ## Быстрый запуск
1. Подготовьте env: 1. Подготовьте env:
```bash ```bash
cd grafana-1c cd /mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c
cp .env.example .env cp .env.example .env
``` ```
2. В `.env` задайте: 2. В `.env` задайте:
- `GRAFANA_ADMIN_USER`, `GRAFANA_ADMIN_PASSWORD`; - `GRAFANA_ADMIN_USER`, `GRAFANA_ADMIN_PASSWORD`;
- `ONEC_DSN` (DSN read-only пользователя в БД 1С); - `ONEC_DSN` (DSN read-only пользователя в БД 1С).
- при необходимости `AW_SERVER_HOST`, `AW_SERVER_PORT`, `AW_SERVER_SCHEME`, `AW_EXPORTER_PORT` и `AW_SCRAPE_INTERVAL_SECONDS` для ActivityWatch exporter.
3. В БД 1С создайте KPI-представления: 3. В БД 1С создайте KPI-представления:
- для PostgreSQL возьмите `./sql/postgres_views_template.sql`; - для PostgreSQL возьмите `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/sql/postgres_views_template.sql`;
- для MS SQL возьмите `./sql/mssql_views_template.sql`. - для MS SQL возьмите `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c/sql/mssql_views_template.sql`.
4. Поднимите стек: 4. Поднимите стек:
@@ -54,9 +52,7 @@ docker compose up -d
```bash ```bash
curl -fsS http://127.0.0.1:9399/metrics | head curl -fsS http://127.0.0.1:9399/metrics | head
curl -fsS http://127.0.0.1:9398/metrics | head
curl -fsS http://127.0.0.1:9090/-/healthy curl -fsS http://127.0.0.1:9090/-/healthy
curl -fsS http://127.0.0.1:3000/api/health
``` ```
Откройте Grafana: `http://<host>:3000`. Откройте Grafana: `http://<host>:3000`.
@@ -66,25 +62,23 @@ curl -fsS http://127.0.0.1:3000/api/health
Профилирование структуры 1С (PostgreSQL): Профилирование структуры 1С (PostgreSQL):
```bash ```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" "postgres://user:pass@db-host:5432/db?sslmode=disable"
``` ```
Проверка KPI views: Проверка KPI views:
```bash ```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" "postgres://user:pass@db-host:5432/db?sslmode=disable"
``` ```
Проверка end-to-end пайплайна: Проверка end-to-end пайплайна:
```bash ```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`) - Непроведенные документы (`onec_unposted_documents_total`)
@@ -92,8 +86,6 @@ sh ./tools/check_pipeline.sh
- Просроченная дебиторка (`onec_overdue_receivables_total`) - Просроченная дебиторка (`onec_overdue_receivables_total`)
- Ошибки проведения за 24ч (`onec_posting_errors_total`) - Ошибки проведения за 24ч (`onec_posting_errors_total`)
- Свежесть данных из 1С (`onec_data_freshness_seconds`) - Свежесть данных из 1С (`onec_data_freshness_seconds`)
- Доступность ActivityWatch API (`aw_up`)
- Количество bucket/events ActivityWatch (`aw_buckets_total`, `aw_bucket_events_count`)
## Принципы безопасности ## Принципы безопасности
+4 -5
View File
@@ -6,15 +6,14 @@ services:
container_name: awrus-aw-exporter container_name: awrus-aw-exporter
restart: unless-stopped restart: unless-stopped
environment: environment:
- AW_SERVER_HOST=${AW_SERVER_HOST:-10.10.10.13} - AW_SERVER_HOST=10.10.10.13
- AW_SERVER_PORT=${AW_SERVER_PORT:-5600} - AW_SERVER_PORT=5600
- AW_SERVER_SCHEME=${AW_SERVER_SCHEME:-http} - AW_SERVER_SCHEME=http
- EXPORTER_PORT=9398 - EXPORTER_PORT=9398
- SCRAPE_INTERVAL_SECONDS=${AW_SCRAPE_INTERVAL_SECONDS:-30}
volumes: volumes:
- ./sql-exporter/collectors/aw_activitywatch.py:/app/aw_activitywatch.py:ro - ./sql-exporter/collectors/aw_activitywatch.py:/app/aw_activitywatch.py:ro
ports: ports:
- "${AW_EXPORTER_PORT:-9398}:9398" - "9398:9398"
command: command:
- "python3" - "python3"
- "/app/aw_activitywatch.py" - "/app/aw_activitywatch.py"
+12 -100
View File
@@ -1,9 +1,7 @@
{ {
"dashboard": {
"title": "ActivityWatch Overview", "title": "ActivityWatch Overview",
"tags": [ "tags": ["activitywatch", "monitoring"],
"activitywatch",
"monitoring"
],
"timezone": "browser", "timezone": "browser",
"panels": [ "panels": [
{ {
@@ -14,11 +12,7 @@
{ {
"expr": "aw_buckets_total", "expr": "aw_buckets_total",
"refId": "A", "refId": "A",
"legendFormat": "Total Buckets", "legendFormat": "Total Buckets"
"datasource": {
"type": "prometheus",
"uid": "prometheus"
}
} }
], ],
"options": { "options": {
@@ -30,16 +24,6 @@
"unit": "short", "unit": "short",
"min": 0 "min": 0
} }
},
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"h": 8,
"w": 6,
"x": 0,
"y": 0
} }
}, },
{ {
@@ -51,11 +35,7 @@
"expr": "aw_bucket_events_count", "expr": "aw_bucket_events_count",
"format": "table", "format": "table",
"instant": true, "instant": true,
"refId": "B", "refId": "B"
"datasource": {
"type": "prometheus",
"uid": "prometheus"
}
} }
], ],
"transformations": [ "transformations": [
@@ -68,17 +48,7 @@
} }
} }
} }
], ]
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"h": 8,
"w": 12,
"x": 6,
"y": 0
}
}, },
{ {
"id": 3, "id": 3,
@@ -89,25 +59,11 @@
"expr": "aw_collector_status", "expr": "aw_collector_status",
"format": "table", "format": "table",
"instant": true, "instant": true,
"refId": "C", "refId": "C"
"datasource": {
"type": "prometheus",
"uid": "prometheus"
}
} }
], ],
"options": { "options": {
"colorMode": "value" "colorMode": "value"
},
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"h": 8,
"w": 6,
"x": 18,
"y": 0
} }
}, },
{ {
@@ -118,11 +74,7 @@
{ {
"expr": "rate(aw_events_total[5m])", "expr": "rate(aw_events_total[5m])",
"legendFormat": "{{bucket}} - {{event_type}}", "legendFormat": "{{bucket}} - {{event_type}}",
"refId": "D", "refId": "D"
"datasource": {
"type": "prometheus",
"uid": "prometheus"
}
} }
], ],
"fieldConfig": { "fieldConfig": {
@@ -132,16 +84,6 @@
"fillOpacity": 10 "fillOpacity": 10
} }
} }
},
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"h": 9,
"w": 12,
"x": 0,
"y": 8
} }
}, },
{ {
@@ -152,11 +94,7 @@
{ {
"expr": "aw_events_last_timestamp", "expr": "aw_events_last_timestamp",
"legendFormat": "{{bucket}}", "legendFormat": "{{bucket}}",
"refId": "E", "refId": "E"
"datasource": {
"type": "prometheus",
"uid": "prometheus"
}
} }
], ],
"options": { "options": {
@@ -169,41 +107,15 @@
"thresholds": { "thresholds": {
"mode": "absolute", "mode": "absolute",
"steps": [ "steps": [
{ {"color": "red", "value": 0},
"color": "red", {"color": "yellow", "value": 3600},
"value": 0 {"color": "green", "value": 86400}
},
{
"color": "yellow",
"value": 3600
},
{
"color": "green",
"value": 86400
}
] ]
} }
} }
} }
},
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"h": 9,
"w": 12,
"x": 12,
"y": 8
} }
} }
], ]
"uid": "activitywatch-overview",
"schemaVersion": 39,
"version": 1,
"refresh": "30s",
"time": {
"from": "now-6h",
"to": "now"
} }
} }
@@ -1,11 +1,20 @@
apiVersion: 1 apiVersion: 1
providers: providers:
- name: awatch-rus - name: 1C-Buhgalteria
orgId: 1 orgId: 1
folder: "AWatch-rus" folder: "1C"
type: file type: file
disableDeletion: true disableDeletion: true
editable: false editable: false
options: options:
path: /var/lib/grafana/dashboards path: /var/lib/grafana/dashboards
- name: ActivityWatch
orgId: 2
folder: "ActivityWatch"
type: file
disableDeletion: false
editable: true
options:
path: /var/lib/grafana/dashboards
@@ -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
+83 -82
View File
@@ -4,138 +4,139 @@ ActivityWatch Prometheus Exporter
Собирает метрики из ActivityWatch API и экспонирует их в формате Prometheus. Собирает метрики из ActivityWatch API и экспонирует их в формате Prometheus.
""" """
import logging
import os
import time import time
from datetime import datetime import logging
import requests 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) logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Configuration # Configuration
AW_SERVER_HOST = os.getenv("AW_SERVER_HOST", "10.10.10.13") AW_SERVER_HOST = "10.10.10.13"
AW_SERVER_PORT = int(os.getenv("AW_SERVER_PORT", "5600")) AW_SERVER_PORT = 5600
AW_SERVER_SCHEME = os.getenv("AW_SERVER_SCHEME", "http") AW_SERVER_SCHEME = "http"
AW_API_BASE = os.getenv( AW_API_BASE = f"{AW_SERVER_SCHEME}://{AW_SERVER_HOST}:{AW_SERVER_PORT}/api/0"
"AW_API_BASE", EXPORTER_PORT = 9398
f"{AW_SERVER_SCHEME}://{AW_SERVER_HOST}:{AW_SERVER_PORT}/api/0",
)
EXPORTER_PORT = int(os.getenv("EXPORTER_PORT", "9398"))
SCRAPE_INTERVAL_SECONDS = int(os.getenv("SCRAPE_INTERVAL_SECONDS", "30"))
# Metrics # Metrics
aw_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_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_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_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_bucket_events_count = Gauge("aw_bucket_events_count", "Number of events sampled from bucket", ["bucket"]) aw_collector_status = Info('aw_collector_status', 'Status of ActivityWatch collectors')
aw_collector_status = Gauge( aw_server_info = Info('aw_server_info', 'ActivityWatch server information')
"aw_collector_status",
"ActivityWatch bucket collector status: 1 if bucket was observed during the last scrape",
["bucket", "client", "hostname", "type"],
)
aw_server_info = Info("aw_server", "ActivityWatch server information")
class ActivityWatchExporter: class ActivityWatchExporter:
def __init__(self, api_base): def __init__(self, api_base):
self.api_base = api_base.rstrip("/") self.api_base = api_base
self.session = requests.Session() self.session = requests.Session()
self.session.headers.update({"Accept": "application/json"}) self.session.headers.update({'Accept': 'application/json'})
self.bucket_event_counts = {} self.bucket_cache = {}
def get_buckets(self): def get_buckets(self):
"""Get all buckets from ActivityWatch API.""" """Get all buckets from ActivityWatch API."""
try:
response = self.session.get(f"{self.api_base}/buckets", timeout=10) response = self.session.get(f"{self.api_base}/buckets", timeout=10)
response.raise_for_status() response.raise_for_status()
return response.json() return response.json()
except Exception as e:
logger.error(f"Failed to get buckets: {e}")
return {}
def get_bucket_events(self, bucket_id, limit=1000): def get_bucket_events(self, bucket_id, limit=1):
"""Get events from a specific bucket.""" """Get events from a specific bucket."""
try:
response = self.session.get( response = self.session.get(
f"{self.api_base}/buckets/{bucket_id}/events", f"{self.api_base}/buckets/{bucket_id}/events",
params={"limit": limit}, params={'limit': limit},
timeout=10, timeout=10
) )
response.raise_for_status() response.raise_for_status()
return response.json() return response.json()
except Exception as e:
logger.error(f"Failed to get events for {bucket_id}: {e}")
return []
@staticmethod def get_bucket_info(self, bucket_id):
def event_type(event): """Get detailed info about a bucket."""
data = event.get("data") or {} try:
return str(data.get("app") or data.get("title") or event.get("$schema") or "unknown") response = self.session.get(f"{self.api_base}/buckets/{bucket_id}", timeout=10)
response.raise_for_status()
@staticmethod return response.json()
def event_timestamp(event): except Exception as e:
timestamp = event.get("timestamp", 0) logger.error(f"Failed to get info for {bucket_id}: {e}")
if isinstance(timestamp, str): return {}
return datetime.fromisoformat(timestamp.replace("Z", "+00:00")).timestamp()
return float(timestamp or 0)
def collect_metrics(self): def collect_metrics(self):
"""Collect metrics from ActivityWatch.""" """Collect metrics from ActivityWatch."""
try:
buckets = self.get_buckets() buckets = self.get_buckets()
aw_up.set(1)
except Exception as exc:
logger.error("Failed to get buckets: %s", exc)
aw_up.set(0)
return
# Update bucket count
aw_buckets_total.set(len(buckets)) aw_buckets_total.set(len(buckets))
aw_server_info.info(
{
"host": AW_SERVER_HOST,
"port": str(AW_SERVER_PORT),
"scheme": AW_SERVER_SCHEME,
"api_base": self.api_base,
}
)
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(): for bucket_id, bucket_data in buckets.items():
client = str(bucket_data.get("client", "unknown")) client = bucket_data.get('client', 'unknown')
hostname = str(bucket_data.get("hostname", "unknown")) hostname = bucket_data.get('hostname', 'unknown')
bucket_type = str(bucket_data.get("type", "unknown")) bucket_type = bucket_data.get('type', 'unknown')
try:
events = self.get_bucket_events(bucket_id)
except Exception as exc:
logger.error("Failed to get events for %s: %s", bucket_id, exc)
events = []
# Count events
events = self.get_bucket_events(bucket_id, limit=1000)
event_count = len(events) event_count = len(events)
aw_bucket_events_count.labels(bucket=bucket_id).set(event_count) aw_bucket_events_count.labels(bucket=bucket_id).set(event_count)
aw_collector_status.labels(bucket=bucket_id, client=client, hostname=hostname, type=bucket_type).set(1)
previous_count = self.bucket_event_counts.get(bucket_id)
if previous_count is not None and event_count > previous_count:
for event in events[: event_count - previous_count]:
aw_events_total.labels(bucket=bucket_id, event_type=self.event_type(event)).inc()
self.bucket_event_counts[bucket_id] = event_count
# Last event timestamp
if events: if events:
last_event = events[0]
timestamp = last_event.get('timestamp', 0)
try: try:
aw_events_last_timestamp.labels(bucket=bucket_id).set(self.event_timestamp(events[0])) # Convert to Unix timestamp if needed
except Exception as exc: if isinstance(timestamp, str):
logger.warning("Failed to parse last event timestamp for %s: %s", bucket_id, exc) 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(): def main():
exporter = ActivityWatchExporter(AW_API_BASE) exporter = ActivityWatchExporter(AW_API_BASE)
# Initial collection
exporter.collect_metrics() exporter.collect_metrics()
# Start HTTP server
start_http_server(EXPORTER_PORT) start_http_server(EXPORTER_PORT)
logger.info("ActivityWatch exporter started on port %s", EXPORTER_PORT) logger.info(f"ActivityWatch exporter started on port {EXPORTER_PORT}")
logger.info("Scraping ActivityWatch API at %s", AW_API_BASE) logger.info(f"Scraping ActivityWatch API at {AW_API_BASE}")
# Collect metrics every 30 seconds
while True: while True:
time.sleep(SCRAPE_INTERVAL_SECONDS) time.sleep(30)
exporter.collect_metrics() exporter.collect_metrics()
if __name__ == '__main__':
if __name__ == "__main__":
main() main()
+13 -88
View File
@@ -1,95 +1,20 @@
#!/bin/sh #!/bin/sh
set -eu set -eu
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) STACK_DIR="${1:-/mnt/usb_hdd2/Projects/ActivityWatch-Russian/grafana-1c}"
STACK_DIR="${1:-$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)}"
ENV_FILE="$STACK_DIR/.env"
env_value() { echo "[*] Checking endpoints"
key="$1" curl -fsS http://127.0.0.1:9399/metrics >/tmp/awrus-onec-metrics.out
default="$2" curl -fsS http://127.0.0.1:9090/-/healthy >/tmp/awrus-prom-healthy.out
current=$(eval "printf '%s' \"\${$key:-}\"") 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
if [ -n "$current" ]; then curl -fsS "http://127.0.0.1:9090/api/v1/query?query=onec_data_freshness_seconds" >/tmp/awrus-prom-freshness.json
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"
}
GRAFANA_PORT=$(env_value GRAFANA_PORT 3000) echo "[*] Checking container status"
PROMETHEUS_PORT=$(env_value PROMETHEUS_PORT 9090) cd "$STACK_DIR"
SQL_EXPORTER_PORT=$(env_value SQL_EXPORTER_PORT 9399) docker compose ps
AW_EXPORTER_PORT=$(env_value AW_EXPORTER_PORT 9398)
GRAFANA_ADMIN_USER=$(env_value GRAFANA_ADMIN_USER admin)
GRAFANA_ADMIN_PASSWORD=$(env_value GRAFANA_ADMIN_PASSWORD change_me_now)
TMP_DIR="${TMPDIR:-/tmp}"
METRICS_OUT="$TMP_DIR/awrus-onec-metrics.out"
AW_METRICS_OUT="$TMP_DIR/awrus-aw-metrics.out"
PROM_HEALTH_OUT="$TMP_DIR/awrus-prom-healthy.out"
PROM_UP_OUT="$TMP_DIR/awrus-prom-up.json"
PROM_FRESHNESS_OUT="$TMP_DIR/awrus-prom-freshness.json"
GRAFANA_HEALTH_OUT="$TMP_DIR/awrus-grafana-health.json"
GRAFANA_DS_OUT="$TMP_DIR/awrus-grafana-datasources.json"
GRAFANA_DASH_OUT="$TMP_DIR/awrus-grafana-dashboards.json"
require_metric() {
metric_name="$1"
metrics_file="$2"
if ! grep -q "^$metric_name" "$metrics_file"; then
echo "[!] Required metric '$metric_name' was not found in $metrics_file" >&2
exit 1
fi
}
require_prometheus_success() {
file="$1"
if ! grep -q '"status":"success"' "$file"; then
echo "[!] Prometheus query did not return status=success: $file" >&2
cat "$file" >&2
exit 1
fi
}
echo "[*] Checking exporter endpoints"
curl -fsS "http://127.0.0.1:$SQL_EXPORTER_PORT/metrics" >"$METRICS_OUT"
curl -fsS "http://127.0.0.1:$AW_EXPORTER_PORT/metrics" >"$AW_METRICS_OUT"
require_metric "onec_data_freshness_seconds" "$METRICS_OUT"
require_metric "aw_up" "$AW_METRICS_OUT"
echo "[*] Checking Prometheus health and scrape targets"
curl -fsS "http://127.0.0.1:$PROMETHEUS_PORT/-/healthy" >"$PROM_HEALTH_OUT"
curl -fsS "http://127.0.0.1:$PROMETHEUS_PORT/api/v1/query?query=up%7Bjob%3D~%22onec_sql_exporter%7Caw_activitywatch_exporter%22%7D" >"$PROM_UP_OUT"
curl -fsS "http://127.0.0.1:$PROMETHEUS_PORT/api/v1/query?query=onec_data_freshness_seconds" >"$PROM_FRESHNESS_OUT"
require_prometheus_success "$PROM_UP_OUT"
require_prometheus_success "$PROM_FRESHNESS_OUT"
echo "[*] Checking Grafana health, datasource and dashboards"
curl -fsS "http://127.0.0.1:$GRAFANA_PORT/api/health" >"$GRAFANA_HEALTH_OUT"
curl -fsS -u "$GRAFANA_ADMIN_USER:$GRAFANA_ADMIN_PASSWORD" "http://127.0.0.1:$GRAFANA_PORT/api/datasources/uid/prometheus" >"$GRAFANA_DS_OUT"
curl -fsS -u "$GRAFANA_ADMIN_USER:$GRAFANA_ADMIN_PASSWORD" "http://127.0.0.1:$GRAFANA_PORT/api/search?type=dash-db&query=" >"$GRAFANA_DASH_OUT"
if command -v docker >/dev/null 2>&1; then
echo "[*] Checking container status"
cd "$STACK_DIR"
docker compose ps
else
echo "[*] docker command not found; skipping container status"
fi
echo "[+] Pipeline health artifacts:" echo "[+] Pipeline health artifacts:"
echo " $METRICS_OUT" echo " /tmp/awrus-onec-metrics.out"
echo " $AW_METRICS_OUT" echo " /tmp/awrus-prom-healthy.out"
echo " $PROM_HEALTH_OUT" echo " /tmp/awrus-prom-up.json"
echo " $PROM_UP_OUT" echo " /tmp/awrus-prom-freshness.json"
echo " $PROM_FRESHNESS_OUT"
echo " $GRAFANA_HEALTH_OUT"
echo " $GRAFANA_DS_OUT"
echo " $GRAFANA_DASH_OUT"
@@ -1,4 +1,4 @@
[CmdletBinding()] [CmdletBinding()]
param( param(
[string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json', [string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json',
[string]$ServerHost, [string]$ServerHost,
@@ -516,6 +516,35 @@ function Test-LooksLikeMojibakeQuestionMarks {
return $Value -match '\?{2,}' 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 { function Normalize-OwnerForMatch {
param([AllowNull()][string]$Value) param([AllowNull()][string]$Value)
if ([string]::IsNullOrWhiteSpace($Value)) { return '' } if ([string]::IsNullOrWhiteSpace($Value)) { return '' }
@@ -602,7 +631,7 @@ function Get-PrintServiceDocumentFallback {
) )
$preferred = [string]$EventSummary.DocumentName $preferred = [string]$EventSummary.DocumentName
if (-not (Test-LooksLikeMojibakeQuestionMarks -Value $preferred) -and $preferred -notmatch '^[0-9]+$') { if (-not (Test-NeedsBetterDocumentName -Value $preferred)) {
return $preferred return $preferred
} }
@@ -615,17 +644,13 @@ function Get-PrintServiceDocumentFallback {
if ($candidate -eq $preferred) { continue } if ($candidate -eq $preferred) { continue }
if ($Owner -and $candidate -like "*$Owner*") { continue } if ($Owner -and $candidate -like "*$Owner*") { continue }
if ($PrinterName -and $candidate -like "*$PrinterName*") { 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}$') { if ($candidate -match '[\\/:]' -and $candidate -match '\.[A-Za-z0-9]{1,8}$') {
$pathCandidates.Add($candidate) $pathCandidates.Add($candidate)
continue continue
} }
if ($candidate -match '^[0-9]+$') {
continue
}
$textCandidates.Add($candidate) $textCandidates.Add($candidate)
} }
@@ -755,8 +780,6 @@ $script:SeenPrintJob = @{}
$script:SeenPrintEvent = @{} $script:SeenPrintEvent = @{}
$script:LastClipboardHash = $null $script:LastClipboardHash = $null
$script:PulseSeconds = [Math]::Max($resolvedPollSeconds * 3, 30) $script:PulseSeconds = [Math]::Max($resolvedPollSeconds * 3, 30)
$script:SelfTestIntervalSeconds = [Math]::Max($resolvedPollSeconds * 10, 60)
$script:LastSelfTestAt = [datetime]::MinValue
$script:LocalAgentLogsEnabled = $resolvedLocalAgentLogsEnabled $script:LocalAgentLogsEnabled = $resolvedLocalAgentLogsEnabled
$script:LogPath = $resolvedLogPath $script:LogPath = $resolvedLogPath
$script:IncidentArtifactsRoot = $resolvedIncidentArtifactsRoot $script:IncidentArtifactsRoot = $resolvedIncidentArtifactsRoot
@@ -768,15 +791,6 @@ Write-EndpointLog ("endpoint collector started against {0}" -f $script:ApiBase)
while ($true) { while ($true) {
try { 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) { if (-not $script:Policy.defaults.enabled) {
Start-Sleep -Seconds $resolvedPollSeconds Start-Sleep -Seconds $resolvedPollSeconds
continue continue
@@ -834,12 +848,12 @@ while ($true) {
if ($script:SeenPrintJob.ContainsKey($jobId)) { continue } if ($script:SeenPrintJob.ContainsKey($jobId)) { continue }
$script:SeenPrintJob[$jobId] = (Get-Date).ToUniversalTime() $script:SeenPrintJob[$jobId] = (Get-Date).ToUniversalTime()
$printerName = [string]$job.Name $printerName = Normalize-PrinterForMatch -Value ([string]$job.Name)
$documentName = [string]$job.Document $documentName = [string]$job.Document
$owner = [string]$job.Owner $owner = [string]$job.Owner
$documentNameOriginal = $documentName $documentNameOriginal = $documentName
if (Test-LooksLikeMojibakeQuestionMarks -Value $documentName) { if (Test-NeedsBetterDocumentName -Value $documentName) {
$eventDocumentName = Get-BetterDocumentNameFromPrintServiceEvents -Owner $owner -PrinterName $printerName $eventDocumentName = Get-BetterDocumentNameFromPrintServiceEvents -Owner $owner -PrinterName $printerName
if ($eventDocumentName) { if ($eventDocumentName) {
$documentName = $eventDocumentName $documentName = $eventDocumentName
@@ -98,13 +98,6 @@ function Get-SessionRecords {
return $records 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 $cfg = Get-Config -Path $ConfigPath
$hostValue = if ($Hostname) { $Hostname } else { [string]$env:COMPUTERNAME } $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 $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 sessionId = [int]$rec.sessionId
sessionName = [string]$rec.sessionName sessionName = [string]$rec.sessionName
state = [string]$rec.state state = [string]$rec.state
active = (Test-SessionIsActive -State ([string]$rec.state)) active = ($rec.state -match 'Active')
hostname = $hostValue hostname = $hostValue
source = 'worktime-session-collector' source = 'worktime-session-collector'
} }
+1 -3
View File
@@ -376,7 +376,6 @@ function New-ActivityWatchDeploymentConfig {
[string]$LaunchScriptPath, [string]$LaunchScriptPath,
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
[string]$RecoveryScriptPath, [string]$RecoveryScriptPath,
[string]$AwHostname,
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
[pscustomobject[]]$UserTasks, [pscustomobject[]]$UserTasks,
[string]$PackageVersion = 'v0.13.2' [string]$PackageVersion = 'v0.13.2'
@@ -387,7 +386,6 @@ function New-ActivityWatchDeploymentConfig {
return [pscustomobject]@{ return [pscustomobject]@{
version = 1 version = 1
generatedAtUtc = (Get-Date).ToUniversalTime().ToString('o') generatedAtUtc = (Get-Date).ToUniversalTime().ToString('o')
awHostname = if ([string]::IsNullOrWhiteSpace($AwHostname)) { [string]$env:COMPUTERNAME } else { [string]$AwHostname }
server = [pscustomobject]@{ server = [pscustomobject]@{
host = $ServerHost host = $ServerHost
port = $ServerPort port = $ServerPort
@@ -744,7 +742,7 @@ function Start-CollectorScriptIfNeeded {
`$installRoot = [string]`$config.paths.installRoot `$installRoot = [string]`$config.paths.installRoot
`$stateRoot = [string]`$config.paths.stateRoot `$stateRoot = [string]`$config.paths.stateRoot
`$script:ApiBase = '{0}://{1}:{2}/api/0' -f [string]`$config.server.scheme, [string]`$config.server.host, [string]`$config.server.port `$script:ApiBase = '{0}://{1}:{2}/api/0' -f [string]`$config.server.scheme, [string]`$config.server.host, [string]`$config.server.port
`$script:Hostname = 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 = @{} `$script:KnownBuckets = @{}
`$collectorScript = [string]`$config.paths.collectorScript `$collectorScript = [string]`$config.paths.collectorScript
`$endpointCollectorScript = if (`$config.paths.PSObject.Properties.Name -contains 'endpointCollectorScript') { [string]`$config.paths.endpointCollectorScript } else { Join-Path `$stateRoot 'dlp-endpoint-signals-collector.ps1' } `$endpointCollectorScript = if (`$config.paths.PSObject.Properties.Name -contains 'endpointCollectorScript') { [string]`$config.paths.endpointCollectorScript } else { Join-Path `$stateRoot 'dlp-endpoint-signals-collector.ps1' }
-88
View File
@@ -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 } $resolvedLocalAgentLogsEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'logging' -and $deploymentConfig.logging.PSObject.Properties.Name -contains 'localAgentLogsEnabled') { [bool]$deploymentConfig.logging.localAgentLogsEnabled } else { $true }
$resolvedIncidentArtifactsRoot = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'artifactsRoot') { [string]$deploymentConfig.incidentCapture.artifactsRoot } else { Join-Path $env:LOCALAPPDATA 'AWatch-rus\\incident-artifacts' } $resolvedIncidentArtifactsRoot = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'artifactsRoot') { [string]$deploymentConfig.incidentCapture.artifactsRoot } else { Join-Path $env:LOCALAPPDATA 'AWatch-rus\\incident-artifacts' }
$resolvedIncidentScreenshotEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'screenshotEnabled') { [bool]$deploymentConfig.incidentCapture.screenshotEnabled } else { $true } $resolvedIncidentScreenshotEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'screenshotEnabled') { [bool]$deploymentConfig.incidentCapture.screenshotEnabled } else { $true }
$resolvedHostname = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'awHostname' -and -not [string]::IsNullOrWhiteSpace([string]$deploymentConfig.awHostname)) { [string]$deploymentConfig.awHostname } else { [string]$env:COMPUTERNAME }
if ($resolvedLocalAgentLogsEnabled -and -not (Test-Path -LiteralPath $resolvedLogsRoot)) { if ($resolvedLocalAgentLogsEnabled -and -not (Test-Path -LiteralPath $resolvedLogsRoot)) {
New-Item -Path $resolvedLogsRoot -ItemType Directory -Force | Out-Null New-Item -Path $resolvedLogsRoot -ItemType Directory -Force | Out-Null
} }
$script:ApiBase = '{0}://{1}:{2}/api/0' -f $resolvedServerScheme, $resolvedServerHost, $resolvedServerPort $script:ApiBase = '{0}://{1}:{2}/api/0' -f $resolvedServerScheme, $resolvedServerHost, $resolvedServerPort
$script:Hostname = $resolvedHostname $script:Hostname = $env:COMPUTERNAME
$script:SessionId = (Get-Process -Id $PID).SessionId $script:SessionId = (Get-Process -Id $PID).SessionId
$script:KnownBuckets = @{} $script:KnownBuckets = @{}
$script:LocalAgentLogsEnabled = $resolvedLocalAgentLogsEnabled $script:LocalAgentLogsEnabled = $resolvedLocalAgentLogsEnabled
-2
View File
@@ -24,7 +24,6 @@ param(
[bool]$IncidentScreenshotEnabled = $true, [bool]$IncidentScreenshotEnabled = $true,
[string]$IncidentArtifactsRoot, [string]$IncidentArtifactsRoot,
[bool]$LogonMarkerEnabled = $true, [bool]$LogonMarkerEnabled = $true,
[string]$AwHostname,
[string]$CustomRulesPath, [string]$CustomRulesPath,
[string]$CustomPolicyPath [string]$CustomPolicyPath
) )
@@ -101,7 +100,6 @@ $config = New-ActivityWatchDeploymentConfig `
-IncidentScreenshotEnabled $IncidentScreenshotEnabled ` -IncidentScreenshotEnabled $IncidentScreenshotEnabled `
-IncidentArtifactsRoot $IncidentArtifactsRoot ` -IncidentArtifactsRoot $IncidentArtifactsRoot `
-LogonMarkerEnabled $LogonMarkerEnabled ` -LogonMarkerEnabled $LogonMarkerEnabled `
-AwHostname $AwHostname `
-LaunchScriptPath $launchScriptPath ` -LaunchScriptPath $launchScriptPath `
-RecoveryScriptPath $recoveryScriptPath ` -RecoveryScriptPath $recoveryScriptPath `
-UserTasks $taskDefinitions ` -UserTasks $taskDefinitions `
-3
View File
@@ -24,7 +24,6 @@ param(
[bool]$IncidentScreenshotEnabled = $true, [bool]$IncidentScreenshotEnabled = $true,
[string]$IncidentArtifactsRoot, [string]$IncidentArtifactsRoot,
[bool]$LogonMarkerEnabled = $true, [bool]$LogonMarkerEnabled = $true,
[string]$AwHostname,
[string]$CustomRulesPath, [string]$CustomRulesPath,
[string]$CustomPolicyPath, [string]$CustomPolicyPath,
[string]$ReportPath, [string]$ReportPath,
@@ -72,7 +71,6 @@ if (-not (Test-Path -LiteralPath $deployScript)) {
-IncidentScreenshotEnabled $IncidentScreenshotEnabled ` -IncidentScreenshotEnabled $IncidentScreenshotEnabled `
-IncidentArtifactsRoot $IncidentArtifactsRoot ` -IncidentArtifactsRoot $IncidentArtifactsRoot `
-LogonMarkerEnabled $LogonMarkerEnabled ` -LogonMarkerEnabled $LogonMarkerEnabled `
-AwHostname $AwHostname `
-CustomRulesPath $CustomRulesPath ` -CustomRulesPath $CustomRulesPath `
-CustomPolicyPath $CustomPolicyPath -CustomPolicyPath $CustomPolicyPath
@@ -96,7 +94,6 @@ if (-not $SkipHardening) {
-IncidentScreenshotEnabled $IncidentScreenshotEnabled ` -IncidentScreenshotEnabled $IncidentScreenshotEnabled `
-IncidentArtifactsRoot $IncidentArtifactsRoot ` -IncidentArtifactsRoot $IncidentArtifactsRoot `
-LogonMarkerEnabled $LogonMarkerEnabled ` -LogonMarkerEnabled $LogonMarkerEnabled `
-AwHostname $AwHostname `
-CustomRulesPath $CustomRulesPath ` -CustomRulesPath $CustomRulesPath `
-CustomPolicyPath $CustomPolicyPath -CustomPolicyPath $CustomPolicyPath
} }
-2
View File
@@ -22,7 +22,6 @@ param(
[bool]$IncidentScreenshotEnabled = $true, [bool]$IncidentScreenshotEnabled = $true,
[string]$IncidentArtifactsRoot, [string]$IncidentArtifactsRoot,
[bool]$LogonMarkerEnabled = $true, [bool]$LogonMarkerEnabled = $true,
[string]$AwHostname,
[string]$CustomRulesPath, [string]$CustomRulesPath,
[string]$CustomPolicyPath [string]$CustomPolicyPath
) )
@@ -93,7 +92,6 @@ $config = New-ActivityWatchDeploymentConfig `
-IncidentScreenshotEnabled $IncidentScreenshotEnabled ` -IncidentScreenshotEnabled $IncidentScreenshotEnabled `
-IncidentArtifactsRoot $IncidentArtifactsRoot ` -IncidentArtifactsRoot $IncidentArtifactsRoot `
-LogonMarkerEnabled $LogonMarkerEnabled ` -LogonMarkerEnabled $LogonMarkerEnabled `
-AwHostname $AwHostname `
-LaunchScriptPath $launchScriptPath ` -LaunchScriptPath $launchScriptPath `
-RecoveryScriptPath $recoveryScriptPath ` -RecoveryScriptPath $recoveryScriptPath `
-UserTasks $taskDefinitions ` -UserTasks $taskDefinitions `
+181 -29
View File
@@ -1,4 +1,4 @@
[CmdletBinding()] [CmdletBinding()]
param( param(
[string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json', [string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json',
[string]$ServerHost, [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 { function Invoke-AwJsonPost {
param( param(
[Parameter(Mandatory = $true)][string]$Uri, [Parameter(Mandatory = $true)][string]$Uri,
@@ -40,7 +100,14 @@ function Invoke-AwJsonPost {
) )
$bytes = [Text.Encoding]::UTF8.GetBytes($Json) $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 { function Ensure-Bucket {
@@ -54,13 +121,21 @@ function Ensure-Bucket {
return return
} }
try {
Invoke-RestMethod -Method Get -Uri "$($script:ApiBase)/buckets/$BucketId" | Out-Null
$script:KnownBuckets[$BucketId] = $true
return
}
catch {
}
$body = @{ $body = @{
client = $ClientName client = $ClientName
type = $BucketType type = $BucketType
hostname = $script:Hostname hostname = $script:Hostname
} | ConvertTo-Json -Compress } | 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 $script:KnownBuckets[$BucketId] = $true
} }
@@ -77,6 +152,8 @@ function Send-EndpointSignalHeartbeat {
timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
duration = 0 duration = 0
data = @{ data = @{
eventId = (Get-NewEventId)
eventCreatedAt = (Get-Date).ToUniversalTime().ToString('o')
signalType = $SignalType signalType = $SignalType
username = $env:USERNAME username = $env:USERNAME
sessionId = $script:SessionId sessionId = $script:SessionId
@@ -85,7 +162,7 @@ function Send-EndpointSignalHeartbeat {
} + $Data } + $Data
} | ConvertTo-Json -Depth 6 -Compress } | 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 { function Send-DlpIncidentHeartbeat {
@@ -114,6 +191,8 @@ function Send-DlpIncidentHeartbeat {
timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
duration = 0 duration = 0
data = @{ data = @{
eventId = (Get-NewEventId)
eventCreatedAt = (Get-Date).ToUniversalTime().ToString('o')
ruleId = $RuleId ruleId = $RuleId
action = $Action action = $Action
severity = $Severity severity = $Severity
@@ -126,7 +205,7 @@ function Send-DlpIncidentHeartbeat {
} + $Data + $captureData } + $Data + $captureData
} | ConvertTo-Json -Depth 7 -Compress } | 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 { function Get-FileSha256Hex {
@@ -224,6 +303,10 @@ function Show-EnforcementNotification {
[Parameter(Mandatory = $true)][string]$Title, [Parameter(Mandatory = $true)][string]$Title,
[Parameter(Mandatory = $true)][string]$Body [Parameter(Mandatory = $true)][string]$Body
) )
if ($script:HeadlessMode) {
Write-EndpointLog ("headless mode: skip notification title={0}" -f $Title)
return $false
}
try { try {
Add-Type -AssemblyName System.Windows.Forms -ErrorAction SilentlyContinue Add-Type -AssemblyName System.Windows.Forms -ErrorAction SilentlyContinue
$icon = New-Object System.Windows.Forms.NotifyIcon $icon = New-Object System.Windows.Forms.NotifyIcon
@@ -235,9 +318,11 @@ function Show-EnforcementNotification {
$icon.ShowBalloonTip(5000) $icon.ShowBalloonTip(5000)
Start-Sleep -Milliseconds 200 Start-Sleep -Milliseconds 200
$icon.Dispose() $icon.Dispose()
return $true
} }
catch { catch {
Write-EndpointLog ("notification failed: {0}" -f $_.Exception.Message) Write-EndpointLog ("notification failed: {0}" -f $_.Exception.Message)
return $false
} }
} }
@@ -424,6 +509,9 @@ function Evaluate-ClipboardRules {
[string]$ClipboardText, [string]$ClipboardText,
[string]$ClipboardHash [string]$ClipboardHash
) )
if ([string]::IsNullOrEmpty($ClipboardText) -or [string]::IsNullOrEmpty($ClipboardHash)) {
return
}
foreach ($rule in @($script:Policy.endpoint.clipboard)) { foreach ($rule in @($script:Policy.endpoint.clipboard)) {
if (-not $rule) { continue } if (-not $rule) { continue }
@@ -454,8 +542,13 @@ function Evaluate-ClipboardRules {
$enforced = $false $enforced = $false
if ($action -eq 'block') { if ($action -eq 'block') {
if ($script:HeadlessMode) {
Write-EndpointLog ("headless fallback: clipboard rule={0} requires block, skipped interactive enforcement" -f $ruleId)
}
else {
$enforced = Invoke-ClipboardEnforcement $enforced = Invoke-ClipboardEnforcement
Show-EnforcementNotification -Title 'DLP: буфер обмена очищен' -Body $message [void](Show-EnforcementNotification -Title 'DLP: буфер обмена очищен' -Body $message)
}
} }
Send-DlpIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -SignalType 'clipboard' -Data @{ Send-DlpIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -SignalType 'clipboard' -Data @{
@@ -489,8 +582,13 @@ function Evaluate-UsbRules {
$enforced = $false $enforced = $false
if ($action -eq 'block') { if ($action -eq 'block') {
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 $enforced = Invoke-UsbWriteBlockEnforcement -DriveLetter $DriveLetter
Show-EnforcementNotification -Title 'DLP: USB заблокирован для записи' -Body $message [void](Show-EnforcementNotification -Title 'DLP: USB заблокирован для записи' -Body $message)
}
} }
Send-DlpIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -SignalType 'usb_insert' -Data @{ Send-DlpIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -SignalType 'usb_insert' -Data @{
@@ -534,8 +632,13 @@ function Evaluate-PrintRules {
$enforced = $false $enforced = $false
if ($action -eq 'block') { if ($action -eq 'block') {
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 $enforced = Invoke-PrintJobEnforcement -PrinterName $PrinterName -DocumentName $DocumentName -Owner $Owner
Show-EnforcementNotification -Title 'DLP: печать заблокирована' -Body $message [void](Show-EnforcementNotification -Title 'DLP: печать заблокирована' -Body $message)
}
} }
Send-DlpIncidentHeartbeat -RuleId $ruleId -Action $action -Severity $severity -Message $message -SignalType 'print_job' -Data @{ 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,}' 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 { function Normalize-OwnerForMatch {
param([AllowNull()][string]$Value) param([AllowNull()][string]$Value)
if ([string]::IsNullOrWhiteSpace($Value)) { return '' } if ([string]::IsNullOrWhiteSpace($Value)) { return '' }
@@ -640,7 +772,7 @@ function Get-PrintServiceDocumentFallback {
) )
$preferred = [string]$EventSummary.DocumentName $preferred = [string]$EventSummary.DocumentName
if (-not (Test-LooksLikeMojibakeQuestionMarks -Value $preferred) -and $preferred -notmatch '^[0-9]+$') { if (-not (Test-NeedsBetterDocumentName -Value $preferred)) {
return $preferred return $preferred
} }
@@ -653,17 +785,13 @@ function Get-PrintServiceDocumentFallback {
if ($candidate -eq $preferred) { continue } if ($candidate -eq $preferred) { continue }
if ($Owner -and $candidate -like "*$Owner*") { continue } if ($Owner -and $candidate -like "*$Owner*") { continue }
if ($PrinterName -and $candidate -like "*$PrinterName*") { 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}$') { if ($candidate -match '[\\/:]' -and $candidate -match '\.[A-Za-z0-9]{1,8}$') {
$pathCandidates.Add($candidate) $pathCandidates.Add($candidate)
continue continue
} }
if ($candidate -match '^[0-9]+$') {
continue
}
$textCandidates.Add($candidate) $textCandidates.Add($candidate)
} }
@@ -762,6 +890,7 @@ function Get-BetterDocumentNameFromPrintServiceEvents {
} }
} }
catch { catch {
Write-EndpointLog ("printservice fallback failed: {0}" -f $_.Exception.Message)
} }
return $null 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 } $resolvedLocalAgentLogsEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'logging' -and $deploymentConfig.logging.PSObject.Properties.Name -contains 'localAgentLogsEnabled') { [bool]$deploymentConfig.logging.localAgentLogsEnabled } else { $true }
$resolvedIncidentArtifactsRoot = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'artifactsRoot') { [string]$deploymentConfig.incidentCapture.artifactsRoot } else { Join-Path $env:LOCALAPPDATA 'AWatch-rus\\incident-artifacts' } $resolvedIncidentArtifactsRoot = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'artifactsRoot') { [string]$deploymentConfig.incidentCapture.artifactsRoot } else { Join-Path $env:LOCALAPPDATA 'AWatch-rus\\incident-artifacts' }
$resolvedIncidentScreenshotEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'screenshotEnabled') { [bool]$deploymentConfig.incidentCapture.screenshotEnabled } else { $true } $resolvedIncidentScreenshotEnabled = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $deploymentConfig.incidentCapture.PSObject.Properties.Name -contains 'screenshotEnabled') { [bool]$deploymentConfig.incidentCapture.screenshotEnabled } else { $true }
$resolvedHostname = if ($deploymentConfig -and $deploymentConfig.PSObject.Properties.Name -contains 'awHostname' -and -not [string]::IsNullOrWhiteSpace([string]$deploymentConfig.awHostname)) { [string]$deploymentConfig.awHostname } else { [string]$env:COMPUTERNAME }
if ($resolvedLocalAgentLogsEnabled -and -not (Test-Path -LiteralPath $resolvedLogsRoot)) { if ($resolvedLocalAgentLogsEnabled -and -not (Test-Path -LiteralPath $resolvedLogsRoot)) {
New-Item -Path $resolvedLogsRoot -ItemType Directory -Force | Out-Null New-Item -Path $resolvedLogsRoot -ItemType Directory -Force | Out-Null
} }
$script:ApiBase = '{0}://{1}:{2}/api/0' -f $resolvedServerScheme, $resolvedServerHost, $resolvedServerPort $script:ApiBase = '{0}://{1}:{2}/api/0' -f $resolvedServerScheme, $resolvedServerHost, $resolvedServerPort
$script:Hostname = $resolvedHostname $script:Hostname = $env:COMPUTERNAME
$script:SessionId = (Get-Process -Id $PID).SessionId $script:SessionId = (Get-Process -Id $PID).SessionId
$script:KnownBuckets = @{} $script:KnownBuckets = @{}
$script:Cooldown = @{} $script:Cooldown = @{}
@@ -794,28 +922,25 @@ $script:SeenPrintJob = @{}
$script:SeenPrintEvent = @{} $script:SeenPrintEvent = @{}
$script:LastClipboardHash = $null $script:LastClipboardHash = $null
$script:PulseSeconds = [Math]::Max($resolvedPollSeconds * 3, 30) $script:PulseSeconds = [Math]::Max($resolvedPollSeconds * 3, 30)
$script:SelfTestIntervalSeconds = [Math]::Max($resolvedPollSeconds * 10, 60)
$script:LastSelfTestAt = [datetime]::MinValue
$script:LocalAgentLogsEnabled = $resolvedLocalAgentLogsEnabled $script:LocalAgentLogsEnabled = $resolvedLocalAgentLogsEnabled
$script:LogPath = $resolvedLogPath $script:LogPath = $resolvedLogPath
$script:IncidentArtifactsRoot = $resolvedIncidentArtifactsRoot $script:IncidentArtifactsRoot = $resolvedIncidentArtifactsRoot
$script:IncidentScreenshotEnabled = $resolvedIncidentScreenshotEnabled $script:IncidentScreenshotEnabled = $resolvedIncidentScreenshotEnabled
$script:ScreenshotTypesLoaded = $false $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 Load-DlpPolicy -Path $resolvedPolicyPath
Write-EndpointLog ("endpoint collector started against {0}" -f $script:ApiBase) 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) { while ($true) {
try { 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) { if (-not $script:Policy.defaults.enabled) {
Start-Sleep -Seconds $resolvedPollSeconds Start-Sleep -Seconds $resolvedPollSeconds
continue continue
@@ -836,6 +961,7 @@ while ($true) {
} }
} }
catch { catch {
Write-EndpointLog ("clipboard poll failed: {0}" -f $_.Exception.Message)
} }
try { try {
@@ -863,6 +989,7 @@ while ($true) {
} }
} }
catch { catch {
Write-EndpointLog ("usb poll failed: {0}" -f $_.Exception.Message)
} }
try { try {
@@ -873,23 +1000,33 @@ while ($true) {
if ($script:SeenPrintJob.ContainsKey($jobId)) { continue } if ($script:SeenPrintJob.ContainsKey($jobId)) { continue }
$script:SeenPrintJob[$jobId] = (Get-Date).ToUniversalTime() $script:SeenPrintJob[$jobId] = (Get-Date).ToUniversalTime()
$printerName = [string]$job.Name $printerName = Normalize-PrinterForMatch -Value ([string]$job.Name)
$documentName = [string]$job.Document $documentName = [string]$job.Document
$owner = [string]$job.Owner $owner = [string]$job.Owner
$documentNameOriginal = $documentName $documentNameOriginal = $documentName
if (Test-LooksLikeMojibakeQuestionMarks -Value $documentName) { if (Test-NeedsBetterDocumentName -Value $documentName) {
$eventDocumentName = Get-BetterDocumentNameFromPrintServiceEvents -Owner $owner -PrinterName $printerName $eventDocumentName = Get-BetterDocumentNameFromPrintServiceEvents -Owner $owner -PrinterName $printerName
if ($eventDocumentName) { if ($eventDocumentName) {
$documentName = $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 @{ Send-EndpointSignalHeartbeat -SignalType 'print_job' -Data @{
printerName = $printerName printerName = $printerName
documentName = $documentName documentName = $documentName
documentNameOriginal = $documentNameOriginal documentNameOriginal = $documentNameOriginal
owner = $owner owner = $owner
eventSource = 'win32_printjob'
} }
Evaluate-PrintRules -PrinterName $printerName -DocumentName $documentName -Owner $owner Evaluate-PrintRules -PrinterName $printerName -DocumentName $documentName -Owner $owner
} }
@@ -903,6 +1040,7 @@ while ($true) {
} }
} }
catch { catch {
Write-EndpointLog ("printjob poll failed: {0}" -f $_.Exception.Message)
} }
try { try {
@@ -930,6 +1068,17 @@ while ($true) {
continue 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 @{ Send-EndpointSignalHeartbeat -SignalType 'print_job' -Data @{
printerName = $printerName printerName = $printerName
documentName = if ($resolvedDocument) { $resolvedDocument } else { $documentName } documentName = if ($resolvedDocument) { $resolvedDocument } else { $documentName }
@@ -950,11 +1099,14 @@ while ($true) {
} }
} }
catch { catch {
Write-EndpointLog ("printservice poll failed: {0}" -f $_.Exception.Message)
} }
} }
catch { catch {
Write-EndpointLog ("collector error: {0}" -f $_.Exception.Message) 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 Start-Sleep -Seconds $resolvedPollSeconds
} }
+83 -28
View File
@@ -26,6 +26,8 @@ $script:SessionId = [System.Diagnostics.Process]::GetCurrentProcess().SessionId
# Настройка логирования # Настройка логирования
$script:LogPath = $LogPath $script:LogPath = $LogPath
$script:LocalAgentLogsEnabled = [bool]$LogPath $script:LocalAgentLogsEnabled = [bool]$LogPath
$script:TransportStats = @{ eventsEnqueued = 0; eventsSent = 0; sendFailures = 0; lastSendStatus = 'init' }
$script:QueuePath = $null
function Get-DeploymentConfig { function Get-DeploymentConfig {
param([string]$Path) param([string]$Path)
@@ -43,29 +45,89 @@ function Write-FileCollectorLog {
} catch {} } 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 { function Invoke-AwJsonPost {
param( param(
[Parameter(Mandatory = $true)][string]$Uri, [Parameter(Mandatory = $true)][string]$Uri,
[Parameter(Mandatory = $true)][string]$Json [Parameter(Mandatory = $true)][string]$Json
) )
$httpClient = $null
try {
$httpClient = New-Object System.Net.Http.HttpClient $httpClient = New-Object System.Net.Http.HttpClient
try {
$content = New-Object System.Net.Http.StringContent($Json, [System.Text.Encoding]::UTF8, "application/json") $content = New-Object System.Net.Http.StringContent($Json, [System.Text.Encoding]::UTF8, "application/json")
$response = $httpClient.PostAsync($Uri, $content).Result $response = $httpClient.PostAsync($Uri, $content).Result
if (-not $response.IsSuccessStatusCode) { if (-not $response.IsSuccessStatusCode) {
$status = [int]$response.StatusCode $statusCode = [int]$response.StatusCode
$reason = [string]$response.ReasonPhrase $reason = [string]$response.ReasonPhrase
$body = $response.Content.ReadAsStringAsync().Result $responseBody = $response.Content.ReadAsStringAsync().Result
Write-FileCollectorLog ("POST failed: uri={0} status={1} reason={2} body={3}" -f $Uri, $status, $reason, $body) Write-FileCollectorLog ("POST failed uri={0} status={1} reason={2} body={3}" -f $Uri, $statusCode, $reason, $responseBody)
} }
} catch { } catch {
Write-FileCollectorLog "POST Error: $($_.Exception.Message)" Write-FileCollectorLog "POST Error: $($_.Exception.Message)"
return $false
} finally { } finally {
if ($null -ne $httpClient) {
$httpClient.Dispose() $httpClient.Dispose()
} }
} return $response.IsSuccessStatusCode
} }
function Ensure-Bucket { function Ensure-Bucket {
@@ -92,19 +154,7 @@ function Ensure-Bucket {
type = $BucketType type = $BucketType
hostname = $script:Hostname hostname = $script:Hostname
} | ConvertTo-Json -Compress } | ConvertTo-Json -Compress
Send-WithQueue -Uri "$($script:ApiBase)/buckets/$BucketId" -Json $body
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
}
}
$script:KnownBuckets[$BucketId] = $true $script:KnownBuckets[$BucketId] = $true
} }
@@ -120,6 +170,9 @@ function Send-FileOperationEvent {
Ensure-Bucket -BucketId $bucketId -ClientName 'aw-file-operations' -BucketType 'aw.file.operation' Ensure-Bucket -BucketId $bucketId -ClientName 'aw-file-operations' -BucketType 'aw.file.operation'
$data = @{ $data = @{
eventId = (Get-NewEventId)
eventCreatedAt = (Get-Date).ToUniversalTime().ToString('o')
operation = $Operation operation = $Operation
path = $FilePath path = $FilePath
extension = [System.IO.Path]::GetExtension($FilePath) extension = [System.IO.Path]::GetExtension($FilePath)
@@ -140,18 +193,20 @@ function Send-FileOperationEvent {
data = $data data = $data
} | ConvertTo-Json -Depth 5 -Compress } | 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 $config = Get-DeploymentConfig -Path $ConfigPath
if (-not $config) { throw "Configuration file not found: $ConfigPath" } if (-not $config) { throw "Configuration file not found: $ConfigPath" }
$script:Hostname = if ($config.PSObject.Properties.Name -contains 'awHostname' -and -not [string]::IsNullOrWhiteSpace([string]$config.awHostname)) { [string]$config.awHostname } else { [string]$env:COMPUTERNAME }
$scheme = if ($ServerScheme) { $ServerScheme } elseif ($config.server.scheme) { $config.server.scheme } else { 'http' } $scheme = if ($ServerScheme) { $ServerScheme } elseif ($config.server.scheme) { $config.server.scheme } else { 'http' }
$hostName = if ($ServerHost) { $ServerHost } elseif ($config.server.host) { $config.server.host } else { 'localhost' } $hostName = if ($ServerHost) { $ServerHost } elseif ($config.server.host) { $config.server.host } else { 'localhost' }
$port = if ($ServerPort) { $ServerPort } elseif ($config.server.port) { $config.server.port } else { 5600 } $port = if ($ServerPort) { $ServerPort } elseif ($config.server.port) { $config.server.port } else { 5600 }
$script:ApiBase = "{0}://{1}:{2}/api/0" -f $scheme, $hostName, $port $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 $bucketId = 'aw-file-operations_' + $script:Hostname
Ensure-Bucket -BucketId $bucketId -ClientName 'aw-file-operations' -BucketType 'aw.file.operation' 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 { $onRenamed = Register-ObjectEvent $watcher "Renamed" -Action {
Send-FileOperationEvent -Operation 'Renamed' -FilePath $Event.SourceEventArgs.FullPath -OldFilePath $Event.SourceEventArgs.OldFullPath Send-FileOperationEvent -Operation 'Renamed' -FilePath $Event.SourceEventArgs.FullPath -OldFilePath $Event.SourceEventArgs.OldFullPath
} }
$subscriptions += @($onChanged, $onDeleted, $onRenamed)
$watchers += $watcher $watchers += $watcher
$subscriptions += @($onChanged, $onDeleted, $onRenamed) $subscriptions += @($onChanged, $onDeleted, $onRenamed)
} }
@@ -207,18 +262,18 @@ Write-FileCollectorLog "Collector started. Waiting for events..."
try { try {
while ($true) { 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 Start-Sleep -Seconds $PollSeconds
} }
} }
finally { finally {
Write-FileCollectorLog "Stopping collector..." Write-FileCollectorLog "Stopping collector..."
foreach ($sub in @($subscriptions)) { foreach ($sub in @($subscriptions)) {
try { if ($null -ne $sub) {
if ($sub -and $sub.Id) { try { Unregister-Event -SubscriptionId $sub.Id -ErrorAction SilentlyContinue } catch {}
Unregister-Event -SubscriptionId $sub.Id -ErrorAction SilentlyContinue try { Remove-Job -Id $sub.Id -Force -ErrorAction SilentlyContinue } catch {}
Remove-Job -Id $sub.Id -Force -ErrorAction SilentlyContinue
} }
} catch {}
} }
foreach ($w in $watchers) { foreach ($w in $watchers) {
$w.EnableRaisingEvents = $false $w.EnableRaisingEvents = $false
-3
View File
@@ -21,7 +21,6 @@ param(
[bool]$IncidentScreenshotEnabled, [bool]$IncidentScreenshotEnabled,
[string]$IncidentArtifactsRoot, [string]$IncidentArtifactsRoot,
[bool]$LogonMarkerEnabled, [bool]$LogonMarkerEnabled,
[string]$AwHostname,
[string]$CustomRulesPath, [string]$CustomRulesPath,
[string]$CustomPolicyPath, [string]$CustomPolicyPath,
[switch]$RepairPackage, [switch]$RepairPackage,
@@ -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 } $effectiveIncidentScreenshotEnabled = if ($PSBoundParameters.ContainsKey('IncidentScreenshotEnabled')) { [bool]$IncidentScreenshotEnabled } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $existingConfig.incidentCapture.PSObject.Properties.Name -contains 'screenshotEnabled') { [bool]$existingConfig.incidentCapture.screenshotEnabled } else { $true }
$effectiveIncidentArtifactsRoot = if ($PSBoundParameters.ContainsKey('IncidentArtifactsRoot') -and $IncidentArtifactsRoot) { $IncidentArtifactsRoot } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $existingConfig.incidentCapture.PSObject.Properties.Name -contains 'artifactsRoot') { [string]$existingConfig.incidentCapture.artifactsRoot } else { Join-Path $effectiveStateRoot 'incident-artifacts' } $effectiveIncidentArtifactsRoot = if ($PSBoundParameters.ContainsKey('IncidentArtifactsRoot') -and $IncidentArtifactsRoot) { $IncidentArtifactsRoot } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'incidentCapture' -and $existingConfig.incidentCapture.PSObject.Properties.Name -contains 'artifactsRoot') { [string]$existingConfig.incidentCapture.artifactsRoot } else { Join-Path $effectiveStateRoot 'incident-artifacts' }
$effectiveLogonMarkerEnabled = if ($PSBoundParameters.ContainsKey('LogonMarkerEnabled')) { [bool]$LogonMarkerEnabled } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'sessionEvents' -and $existingConfig.sessionEvents.PSObject.Properties.Name -contains 'logonEnabled') { [bool]$existingConfig.sessionEvents.logonEnabled } else { $true } $effectiveLogonMarkerEnabled = if ($PSBoundParameters.ContainsKey('LogonMarkerEnabled')) { [bool]$LogonMarkerEnabled } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'sessionEvents' -and $existingConfig.sessionEvents.PSObject.Properties.Name -contains 'logonEnabled') { [bool]$existingConfig.sessionEvents.logonEnabled } else { $true }
$effectiveAwHostname = if ($PSBoundParameters.ContainsKey('AwHostname') -and -not [string]::IsNullOrWhiteSpace($AwHostname)) { [string]$AwHostname } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'awHostname' -and -not [string]::IsNullOrWhiteSpace([string]$existingConfig.awHostname)) { [string]$existingConfig.awHostname } else { [string]$env:COMPUTERNAME }
$effectiveVersion = if ($Version) { $Version } elseif ($existingConfig) { [string]$existingConfig.package.version } else { 'v0.13.2' } $effectiveVersion = if ($Version) { $Version } elseif ($existingConfig) { [string]$existingConfig.package.version } else { 'v0.13.2' }
$effectiveUsers = if ($Users -or $UserListPath) { $effectiveUsers = if ($Users -or $UserListPath) {
@@ -141,7 +139,6 @@ $config = New-ActivityWatchDeploymentConfig `
-IncidentScreenshotEnabled $effectiveIncidentScreenshotEnabled ` -IncidentScreenshotEnabled $effectiveIncidentScreenshotEnabled `
-IncidentArtifactsRoot $effectiveIncidentArtifactsRoot ` -IncidentArtifactsRoot $effectiveIncidentArtifactsRoot `
-LogonMarkerEnabled $effectiveLogonMarkerEnabled ` -LogonMarkerEnabled $effectiveLogonMarkerEnabled `
-AwHostname $effectiveAwHostname `
-LaunchScriptPath $effectiveLaunchScript ` -LaunchScriptPath $effectiveLaunchScript `
-RecoveryScriptPath $effectiveRecoveryScript ` -RecoveryScriptPath $effectiveRecoveryScript `
-UserTasks $taskDefinitions ` -UserTasks $taskDefinitions `
-121
View File
@@ -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] [Files]
Source: "..\..\ActivityWatch.Windows.Common.psd1"; DestDir: "{app}\windows"; Flags: ignoreversion Source: "..\..\ActivityWatch.Windows.Common.psd1"; DestDir: "{app}\windows"; Flags: ignoreversion
Source: "..\..\ActivityWatch.Windows.Common.psm1"; DestDir: "{app}\windows"; Flags: ignoreversion Source: "..\..\ActivityWatch.Windows.Common.psm1"; DestDir: "{app}\windows"; Flags: ignoreversion
Source: "..\..\install-standalone-service.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion
Source: "..\..\aw-standalone-service.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion
Source: "..\..\deploy-single-user.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion Source: "..\..\deploy-single-user.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion
Source: "..\..\deploy-domain-users.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion Source: "..\..\deploy-domain-users.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion
Source: "..\..\deploy-ensemble.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion Source: "..\..\deploy-ensemble.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion
@@ -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: "..\..\worktime-session-collector.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion
Source: "..\..\browser-domains-native-collector.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion Source: "..\..\browser-domains-native-collector.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion
Source: "..\..\dlp-endpoint-signals-collector.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion Source: "..\..\dlp-endpoint-signals-collector.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion
Source: "..\..\file-operations-collector.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion
Source: "..\..\email-outbound-collector.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion Source: "..\..\email-outbound-collector.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion
Source: "..\..\web-category-rules.example.json"; DestDir: "{app}\windows"; Flags: ignoreversion Source: "..\..\web-category-rules.example.json"; DestDir: "{app}\windows"; Flags: ignoreversion
Source: "..\..\dlp-policy.example.json"; DestDir: "{app}\windows"; Flags: ignoreversion Source: "..\..\dlp-policy.example.json"; DestDir: "{app}\windows"; Flags: ignoreversion
@@ -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 Source: "innosetup-rdp-package-filelist.md"; DestDir: "{app}\windows\installkit\innosetup"; Flags: ignoreversion
[Run] [Run]
Filename: "powershell.exe"; Parameters: "{code:GetStandaloneInstallParams}"; Flags: runhidden; Tasks: deploy Filename: "powershell.exe"; Parameters: "{code:GetDeployEnsembleParams}"; Flags: runhidden; Tasks: deploy
[Code] [Code]
var var
ServerHostPage: TInputQueryWizardPage; ServerHostPage: TInputQueryWizardPage;
UsersPage: TInputQueryWizardPage;
OptionsPage: TInputOptionWizardPage;
function NormalizeUserCsv(const UserCsv: string): string;
var
i: Integer;
s: string;
token: string;
begin
Result := '';
s := UserCsv;
while True do
begin
i := Pos(',', s);
if i = 0 then
begin
token := Trim(s);
s := '';
end
else
begin
token := Trim(Copy(s, 1, i - 1));
Delete(s, 1, i);
end;
if token <> '' then
begin
if Result <> '' then
Result := Result + ',';
Result := Result + token;
end;
if s = '' then
Break;
end;
end;
function BuildUsersPowerShellArg(const UserCsv: string): string;
var
i: Integer;
s: string;
token: string;
quoted: string;
begin
Result := '';
s := UserCsv;
while True do
begin
i := Pos(',', s);
if i = 0 then
begin
token := Trim(s);
s := '';
end
else
begin
token := Trim(Copy(s, 1, i - 1));
Delete(s, 1, i);
end;
if token <> '' then
begin
quoted := '"' + token + '"';
if Result <> '' then
Result := Result + ',';
Result := Result + quoted;
end;
if s = '' then
Break;
end;
if Result <> '' then
Result := '-Users ' + Result;
end;
function PayloadZipPath: string;
begin
Result := ExpandConstant('{app}\payload\{#AwDefaultZipName}');
end;
function HasPayloadZip: Boolean;
begin
Result := FileExists(ExpandConstant('{src}\payload\{#AwDefaultZipName}'));
end;
procedure InitializeWizard; procedure InitializeWizard;
begin begin
@@ -74,24 +155,62 @@ begin
ServerHostPage.Add('ServerPort', False); ServerHostPage.Add('ServerPort', False);
ServerHostPage.Values[0] := '{#AwDefaultServerHost}'; ServerHostPage.Values[0] := '{#AwDefaultServerHost}';
ServerHostPage.Values[1] := '{#AwDefaultServerPort}'; ServerHostPage.Values[1] := '{#AwDefaultServerPort}';
UsersPage := CreateInputQueryPage(
ServerHostPage.ID,
'Пользователи (RDP)',
'Перечень пользователей, для которых разворачиваем агенты.',
'Введите список через запятую. Пример: user1,user2,user3'
);
UsersPage.Add('Users (CSV)', False);
UsersPage.Values[0] := '{#AwDefaultUsers}';
OptionsPage := CreateInputOptionPage(
UsersPage.ID,
'Опции деплоя',
'Выберите опции для установки/валидации.',
'',
False,
False
);
OptionsPage.Add('Использовать offline payload (встроенный ZIP)');
OptionsPage.Add('Запустить validate-deployment после деплоя');
OptionsPage.Values[0] := HasPayloadZip;
OptionsPage.Values[1] := True;
end; end;
function GetStandaloneInstallParams(Param: string): string; function GetDeployEnsembleParams(Param: string): string;
var var
serverHost: string; serverHost: string;
serverPort: string; serverPort: string;
usersCsv: string;
usersArg: string;
zipArg: string;
validateArg: string;
begin begin
serverHost := Trim(ServerHostPage.Values[0]); serverHost := Trim(ServerHostPage.Values[0]);
serverPort := Trim(ServerHostPage.Values[1]); serverPort := Trim(ServerHostPage.Values[1]);
if serverHost = '' then usersCsv := NormalizeUserCsv(UsersPage.Values[0]);
RaiseException('ServerHost is empty.');
if serverPort = '' then usersArg := BuildUsersPowerShellArg(usersCsv);
RaiseException('ServerPort is empty.'); 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 := Result :=
'-NoProfile -ExecutionPolicy Bypass -File "' + ExpandConstant('{app}\windows\install-standalone-service.ps1') + '"' + '-NoProfile -ExecutionPolicy Bypass -File "' + ExpandConstant('{app}\windows\deploy-ensemble.ps1') + '"' +
' -ServerHost "' + serverHost + '"' + ' -ServerHost "' + serverHost + '"' +
' -ServerPort ' + serverPort + ' -ServerPort ' + serverPort +
' ' + usersArg +
zipArg +
' -InstallRoot "{#AwDefaultInstallRoot}"' + ' -InstallRoot "{#AwDefaultInstallRoot}"' +
' -StateRoot "{#AwDefaultStateRoot}"'; ' -StateRoot "{#AwDefaultStateRoot}"' +
validateArg;
end; 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 ./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`) - `ServerHost` / `ServerPort` (defaults to our AW server `10.10.10.13:5600`)
- `Users` (CSV)
All other values are taken from defaults embedded in installer scripts. - 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`)
## 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)
+82 -146
View File
@@ -4,66 +4,17 @@ param(
[int]$PollSeconds = 30 [int]$PollSeconds = 30
) )
# Force UTF-8 for console I/O
try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 } catch {}
try { [Console]::InputEncoding = [System.Text.Encoding]::UTF8 } catch {}
Set-StrictMode -Version Latest Set-StrictMode -Version Latest
$ErrorActionPreference = 'Continue' $ErrorActionPreference = 'Stop'
function Decode-Bytes-Auto {
param([byte[]]$Bytes)
if (-not $Bytes) { return '' }
$candidates = @()
# Try strict UTF8 first (detect invalid sequences)
try {
$utf8Strict = New-Object System.Text.UTF8Encoding($false,$true)
$txt = $utf8Strict.GetString($Bytes)
$candidates += @{enc='utf8'; text=$txt}
}
catch {
# invalid UTF8 sequences; ignore
}
# Try CP866 and CP1251
try { $cp866 = [System.Text.Encoding]::GetEncoding(866); $txt866 = $cp866.GetString($Bytes); $candidates += @{enc='cp866'; text=$txt866} } catch {}
try { $cp1251 = [System.Text.Encoding]::GetEncoding(1251); $txt1251 = $cp1251.GetString($Bytes); $candidates += @{enc='cp1251'; text=$txt1251} } catch {}
# If nothing decoded yet, fallback to UTF8 permissive
if ($candidates.Count -eq 0) {
try { $txt = [System.Text.Encoding]::UTF8.GetString($Bytes); return $txt } catch { return '' }
}
# Score decodings by count of Cyrillic letters; prefer highest
$best = $null; $bestScore = -1
foreach ($c in $candidates) {
$t = $c.text
if (-not $t) { continue }
$score = 0
try { $score = ([regex]::Matches($t,'\p{IsCyrillic}')).Count } catch { $score = 0 }
if ($score -gt $bestScore) { $best = $c; $bestScore = $score }
}
if ($best -ne $null) { return $best.text }
# Final fallback: first candidate text
return $candidates[0].text
}
function Get-Config { function Get-Config {
param([string]$Path) param([string]$Path)
if (-not (Test-Path -LiteralPath $Path)) { if (-not (Test-Path -LiteralPath $Path)) {
throw "Config not found: $Path" throw "Конфигурация не найдена: $Path"
}
try {
$bytes = [System.IO.File]::ReadAllBytes($Path)
$text = Decode-Bytes-Auto -Bytes $bytes
return $text | ConvertFrom-Json -ErrorAction Stop
}
catch {
throw "Failed to read config: $Path - $($_.Exception.Message)"
} }
Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json
} }
function Invoke-AwJsonPost { function Invoke-AwJsonPost {
@@ -71,15 +22,9 @@ function Invoke-AwJsonPost {
[Parameter(Mandatory = $true)][string]$Uri, [Parameter(Mandatory = $true)][string]$Uri,
[Parameter(Mandatory = $true)][string]$Json [Parameter(Mandatory = $true)][string]$Json
) )
try {
$bytes = [System.Text.Encoding]::UTF8.GetBytes($Json) $bytes = [Text.Encoding]::UTF8.GetBytes($Json)
Invoke-RestMethod -Method Post -Uri $Uri -ContentType 'application/json; charset=utf-8' -Body $bytes -ErrorAction Stop | Out-Null Invoke-RestMethod -Method Post -Uri $Uri -ContentType 'application/json; charset=utf-8' -Body $bytes | Out-Null
return $true
}
catch {
Write-Verbose "POST error: $($_.Exception.Message)"
return $false
}
} }
function Ensure-Bucket { function Ensure-Bucket {
@@ -88,134 +33,125 @@ function Ensure-Bucket {
[Parameter(Mandatory = $true)][string]$BucketId, [Parameter(Mandatory = $true)][string]$BucketId,
[Parameter(Mandatory = $true)][string]$HostnameValue [Parameter(Mandatory = $true)][string]$HostnameValue
) )
try { Invoke-RestMethod -Method Get -Uri "$ApiBase/buckets/$BucketId" -ErrorAction Stop | Out-Null; return } catch { Write-Verbose "Bucket not found, creating: $BucketId" }
$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" -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 { try {
$psi = New-Object System.Diagnostics.ProcessStartInfo Invoke-RestMethod -Method Get -Uri "$ApiBase/buckets/$BucketId" | Out-Null
$psi.FileName = $t.File return
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 { catch {
# try next
} }
$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
} }
return @()
} }
function Parse-SessionLines { function Get-SessionRecords {
param([string[]]$Lines)
$records = @() $records = @()
if (-not $Lines) { return $records }
$startIndex = 0 try {
if ($Lines.Count -gt 0 -and $Lines[0] -match '\b(USERNAME|Имя|Имя пользователя|Имя_пользователя)\b') { $startIndex = 1 } $lines = quser 2>$null
if (-not $lines) {
return @()
}
for ($i = $startIndex; $i -lt $Lines.Count; $i++) { foreach ($line in ($lines | Select-Object -Skip 1)) {
$line = $Lines[$i].Trim() $clean = ($line -replace '^\s*>?', '').Trim()
if (-not $line) { continue } if (-not $clean) {
continue
}
$m = [regex]::Match($line, '^\s*(?<user>\S+)\s+(?<sess>\S+)?\s+(?<id>\d+)\s+(?<state>\S+)', [System.Text.RegularExpressions.RegexOptions]::None) $parts = $clean -split '\s+'
if ($m.Success) { if ($parts.Count -lt 4) {
$user = $m.Groups['user'].Value; $sess = $m.Groups['sess'].Value; $id = [int]$m.Groups['id'].Value; $state = $m.Groups['state'].Value continue
}
$sessionName = ''
$sessionIdIndex = 2
if ($parts[1] -match '^\d+$') {
$sessionIdIndex = 1
} }
else { else {
$parts = $line -split '\s+' $sessionName = $parts[1]
if ($parts.Count -lt 4) { continue }
$user = $parts[0]
if ($parts[1] -match '^\d+$') { $sess = ''; $id = [int]$parts[1]; $state = $parts[2] } else { $sess = $parts[1]; $id = [int]$parts[2]; $state = $parts[3] }
} }
$records += [pscustomobject]@{ username=$user; sessionName=$sess; sessionId=$id; state=$state } $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 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 $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 } $hostValue = if ($Hostname) { $Hostname } 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.' } $apiBase = '{0}://{1}:{2}/api/0' -f [string]$cfg.server.scheme, [string]$cfg.server.host, [string]$cfg.server.port
$bucketId = 'aw-worktime-sessions_' + $hostValue $bucketId = 'aw-worktime-sessions_' + $hostValue
$pulse = 120 $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 Ensure-Bucket -ApiBase $apiBase -BucketId $bucketId -HostnameValue $hostValue
while ($true) { while ($true) {
$now = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ') $now = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
try { $records = Get-SessionRecords
$lines = Run-QueryUser
$records = Parse-SessionLines -Lines $lines
}
catch {
Write-Verbose "Session parse error: $($_.Exception.Message)"
$records = @()
}
if (-not $records -or $records.Count -eq 0) { if (-not $records -or $records.Count -eq 0) {
$records = @([pscustomobject]@{ 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) { foreach ($rec in $records) {
$payloadObj = [PSCustomObject]@{ $payload = @{
timestamp = $now timestamp = $now
duration = 0 duration = 0
data = [PSCustomObject]@{ data = @{
username = [string]$rec.username username = [string]$rec.username
userId = "${env:USERDOMAIN}\$($rec.username)" userId = "$($env:USERDOMAIN)\$($rec.username)"
sessionId = [int]$rec.sessionId sessionId = [int]$rec.sessionId
sessionName = [string]$rec.sessionName sessionName = [string]$rec.sessionName
state = [string]$rec.state state = [string]$rec.state
active = Test-SessionIsActive -State ([string]$rec.state) active = ($rec.state -match 'Active')
hostname = $hostValue hostname = $hostValue
source = 'worktime-session-collector' source = 'worktime-session-collector'
} }
} } | ConvertTo-Json -Depth 6 -Compress
$payload = $payloadObj | ConvertTo-Json -Depth 6 -Compress
try { try {
$ok = Invoke-AwJsonPost -Uri "$apiBase/buckets/$bucketId/heartbeat?pulsetime=$pulse" -Json $payload Invoke-AwJsonPost -Uri "$apiBase/buckets/$bucketId/heartbeat?pulsetime=$pulse" -Json $payload
if (-not $ok) { Write-Verbose "Heartbeat not confirmed for user $($rec.username)" }
} }
catch { catch {
Write-Verbose "Heartbeat error: $($_.Exception.Message)"
} }
} }