Compare commits
327
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
106d796d95 | ||
|
|
7919051688 | ||
|
|
61a9887ebb | ||
|
|
aece5c381f | ||
|
|
163a57237c | ||
|
|
5b21e03726 | ||
|
|
2104da912d | ||
|
|
2ccf33e1f5 | ||
|
|
1c710dfe94 | ||
|
|
4d21b7a32e | ||
|
|
c1d6f97096 | ||
|
|
95ceee421f | ||
|
|
bae67494e2 | ||
|
|
da84a9a800 | ||
|
|
f2b966d5d4 | ||
|
|
32e1a97877 | ||
|
|
8310bc3616 | ||
|
|
c751035374 | ||
|
|
347347b835 | ||
|
|
1b817866a4 | ||
|
|
037d47b592 | ||
|
|
5820de1d85 | ||
|
|
a2f5d83662 | ||
|
|
0b34782b8e | ||
|
|
cff6e7c425 | ||
|
|
56134e832a | ||
|
|
cb943d1328 | ||
|
|
3318c89629 | ||
|
|
9fa085a53e | ||
|
|
7c57bac03c | ||
|
|
f99633cabd | ||
|
|
755c8b6f0f | ||
|
|
888dbc32ef | ||
|
|
4c39dd3fb0 | ||
|
|
2b41fb14c7 | ||
|
|
307170b128 | ||
|
|
a734815a63 | ||
|
|
41e28adbf9 | ||
|
|
731478411a | ||
|
|
00ff5e7ddf | ||
|
|
8fcb49f2c7 | ||
|
|
5277097032 | ||
|
|
0b0bab61bb | ||
|
|
2fb3271558 | ||
|
|
07dfb95b6b | ||
|
|
7df665a457 | ||
|
|
e43c502bcb | ||
|
|
77ad99d8a7 | ||
|
|
46d3ca5622 | ||
|
|
80af0acbd4 | ||
|
|
aa641cba2f | ||
|
|
9c72539401 | ||
|
|
b062237168 | ||
|
|
53a0e4b2ab | ||
|
|
838a94f265 | ||
|
|
eac9e9da83 | ||
|
|
2358dcc453 | ||
|
|
43c6fda4ae | ||
|
|
47381dc8e9 | ||
|
|
53997ad861 | ||
|
|
05e9af02db | ||
|
|
b8cde77d12 | ||
|
|
38b65e97d6 | ||
|
|
f3fd655ada | ||
|
|
bff85094ec | ||
|
|
d68c46e359 | ||
|
|
2eb96855c7 | ||
|
|
9297d60a33 | ||
|
|
067ad0939c | ||
|
|
9c57d6d2ce | ||
|
|
2ec1836e8c | ||
|
|
bc2f56ef8f | ||
|
|
5e6bbd47a1 | ||
|
|
ec9de0e039 | ||
|
|
a74fa24996 | ||
|
|
36fbcb1d63 | ||
|
|
afc40fd3ca | ||
|
|
6a6c69e803 | ||
|
|
2cb1afad4f | ||
|
|
88dcd8ecaa | ||
|
|
8957161317 | ||
|
|
da17047b38 | ||
|
|
90378229ae | ||
|
|
f13a15f9db | ||
|
|
10f0d15407 | ||
|
|
0992679100 | ||
|
|
dfecc637b5 | ||
|
|
a672941cc7 | ||
|
|
f29d85eeb2 | ||
|
|
d32fa041cf | ||
|
|
bf41e43315 | ||
|
|
9b94c7747e | ||
|
|
10c44dd1f0 | ||
|
|
68674a3c6f | ||
|
|
da6e80a90c | ||
|
|
f60cca7f59 | ||
|
|
d0e502f622 | ||
|
|
81cc69196a | ||
|
|
1b233dfd83 | ||
|
|
3ccf8b50cb | ||
|
|
fca5b57455 | ||
|
|
cd95792196 | ||
|
|
20fb47b457 | ||
|
|
4c66e67661 | ||
|
|
3c7e1de96c | ||
|
|
0341e114a9 | ||
|
|
313c49eb4e | ||
|
|
27e9ef2046 | ||
|
|
a30ab9cc95 | ||
|
|
bb00651bce | ||
|
|
437da860d4 | ||
|
|
e55b68868d | ||
|
|
95e7c0f314 | ||
|
|
d7616fd9a9 | ||
|
|
ff234af701 | ||
|
|
152705a999 | ||
|
|
3f19efb7e3 | ||
|
|
9f47dcaefa | ||
|
|
9f5f99ae65 | ||
|
|
2645082183 | ||
|
|
96090f7c10 | ||
|
|
96c90efbde | ||
|
|
4197efe482 | ||
|
|
8b621a62d6 | ||
|
|
1e869e1a14 | ||
|
|
a297220353 | ||
|
|
a75a412801 | ||
|
|
ad9a3c24cb | ||
|
|
8db9f180ab | ||
|
|
5aab90f27d | ||
|
|
59d5e1f1fc | ||
|
|
3dc7214b58 | ||
|
|
ea5bedc3c3 | ||
|
|
f5f0cebfc4 | ||
|
|
1312ee3ee5 | ||
|
|
2e64f9b26c | ||
|
|
efd09012d1 | ||
|
|
c2e2f64c08 | ||
|
|
2cb2260bb2 | ||
|
|
2bf8ecfea8 | ||
|
|
d155aed779 | ||
|
|
468496f83a | ||
|
|
b99d349121 | ||
|
|
3d50a1bea7 | ||
|
|
7bc1d4170a | ||
|
|
0302d888f6 | ||
|
|
01ff3b8e29 | ||
|
|
c1119b460b | ||
|
|
233524049e | ||
|
|
5ec9f508db | ||
|
|
3a4e70c3b7 | ||
|
|
bf4e38f35e | ||
|
|
1aa35b1f8e | ||
|
|
e94174a89c | ||
|
|
5598894717 | ||
|
|
a1801fca5f | ||
|
|
18653e1a2f | ||
|
|
35653b45b6 | ||
|
|
c2d1f40033 | ||
|
|
4ca1c30bc6 | ||
|
|
9fc218dc49 | ||
|
|
dd3163624b | ||
|
|
dbef90a09e | ||
|
|
109c31f291 | ||
|
|
5f217b6dbb | ||
|
|
083c962982 | ||
|
|
ec0a5fdd55 | ||
|
|
122c4bbfc0 | ||
|
|
189885d73c | ||
|
|
17e8e7b56a | ||
|
|
87453a4ef0 | ||
|
|
8cf45de4da | ||
|
|
ca4944b978 | ||
|
|
ac2901c0dc | ||
|
|
f513836396 | ||
|
|
e4b49e9284 | ||
|
|
e1f78b7735 | ||
|
|
63774cb8c4 | ||
|
|
632962a90e | ||
|
|
19e3682bc8 | ||
|
|
60670d30a8 | ||
|
|
32e5c1eb4f | ||
|
|
af4d354b54 | ||
|
|
09e58b4170 | ||
|
|
e4a25ab9c0 | ||
|
|
8b8f3e362d | ||
|
|
05e8856bb2 | ||
|
|
fb66378a1c | ||
|
|
ec11d8b303 | ||
|
|
79eff54774 | ||
|
|
cc9e4a0669 | ||
|
|
524ab33f63 | ||
|
|
51cb43666a | ||
|
|
3042bd5042 | ||
|
|
033ae810f3 | ||
|
|
0d56ab20d6 | ||
|
|
71b614125f | ||
|
|
f1d4bdfc2c | ||
|
|
61770c1658 | ||
|
|
6b246aaa14 | ||
|
|
6cb1e0a95b | ||
|
|
8af3fd27f4 | ||
|
|
ba12146e43 | ||
|
|
be4609cd25 | ||
|
|
4f1b935bdc | ||
|
|
5670ecd84b | ||
|
|
11c0f06e0a | ||
|
|
ee4eea7e1a | ||
|
|
70d12dca1d | ||
|
|
ff93ddd3cd | ||
|
|
45f869fa6b | ||
|
|
151778dde6 | ||
|
|
798bbd9a7b | ||
|
|
68a82a95ee | ||
|
|
d70f121b5a | ||
|
|
6a68b77ffc | ||
|
|
6fa86a1880 | ||
|
|
e440681b3a | ||
|
|
cde039f785 | ||
|
|
aac57064e0 | ||
|
|
10d63d41f0 | ||
|
|
729f06200c | ||
|
|
31a5e30984 | ||
|
|
4da6e68b96 | ||
|
|
8993fbf0be | ||
|
|
2e682ec0c3 | ||
|
|
5c4966f1ff | ||
|
|
243edccdd3 | ||
|
|
02e896b05a | ||
|
|
31fe43f4bc | ||
|
|
1ca43398a4 | ||
|
|
f7114bde03 | ||
|
|
da58d682c1 | ||
|
|
172d113536 | ||
|
|
1e23d5acb6 | ||
|
|
0d4300dfaf | ||
|
|
5c6c23ba0f | ||
|
|
41f7a869e5 | ||
|
|
04b45ecf03 | ||
|
|
b420104f1f | ||
|
|
0c88b519ee | ||
|
|
4e4898828b | ||
|
|
712fccb630 | ||
|
|
c0b9dad534 | ||
|
|
dc2240d635 | ||
|
|
1b77bd6a47 | ||
|
|
63f00587a6 | ||
|
|
99143b108b | ||
|
|
45f9907450 | ||
|
|
77e073c20c | ||
|
|
9b22f6f3fb | ||
|
|
4f11094081 | ||
|
|
25c3ac3d2d | ||
|
|
4fb1c9335e | ||
|
|
9927cdb195 | ||
|
|
15941e48cb | ||
|
|
6620d52f6c | ||
|
|
ab0ee7d49d | ||
|
|
45f990e838 | ||
|
|
93a3169203 | ||
|
|
97b458528c | ||
|
|
174d65181b | ||
|
|
873e43fb2e | ||
|
|
22de305c8a | ||
|
|
0eaaf82432 | ||
|
|
6c41717ef9 | ||
|
|
1399eaf605 | ||
|
|
0098abe426 | ||
|
|
76d1377736 | ||
|
|
59e93c1c69 | ||
|
|
0cce6fd08e | ||
|
|
563bd910d1 | ||
|
|
91c3b46f16 | ||
|
|
22aadd5c03 | ||
|
|
f6ee903b10 | ||
|
|
b69f4d83cc | ||
|
|
eb09251118 | ||
|
|
2e5242f5ba | ||
|
|
6ebf0ac67e | ||
|
|
0f1b82233a | ||
|
|
14f36f502d | ||
|
|
068c5a8b0b | ||
|
|
9153d85d8e | ||
|
|
070a8f8f7e | ||
|
|
28a4dae67f | ||
|
|
bc44052391 | ||
|
|
fd2e9ac59d | ||
|
|
067457198d | ||
|
|
8619c14735 | ||
|
|
0bc0fe7d78 | ||
|
|
4e99100e6c | ||
|
|
bfa7c5bff2 | ||
|
|
64158e97a4 | ||
|
|
012bfb266f | ||
|
|
f180e2c3dc | ||
|
|
c624db5dd7 | ||
|
|
518c445157 | ||
|
|
d812a8bb80 | ||
|
|
b213552d9d | ||
|
|
8be0de1c85 | ||
|
|
9342e8b907 | ||
|
|
5d7137379c | ||
|
|
544304765f | ||
|
|
e0561bf865 | ||
|
|
24dd5ae2b4 | ||
|
|
4b5c75bdbb | ||
|
|
c43f9b8708 | ||
|
|
249ba67511 | ||
|
|
ce428fb5f8 | ||
|
|
2bf667fccc | ||
|
|
380983b546 | ||
|
|
6ab29a9590 | ||
|
|
f22cd27e31 | ||
|
|
c9df042d96 | ||
|
|
aa0ccc4ecf | ||
|
|
2997e7430a | ||
|
|
7a320d7862 | ||
|
|
5ddceb4dc3 | ||
|
|
95bf1edd37 | ||
|
|
dc8988e5f7 | ||
|
|
230a9c6936 | ||
|
|
6a36febfeb | ||
|
|
e80272a55f | ||
|
|
1c3d7896cd | ||
|
|
211a6a6eac | ||
|
|
6f5e5eb751 | ||
|
|
e643576aa9 |
@@ -0,0 +1 @@
|
||||
windows/installkit/innosetup/AWatch-rus-InstallKit.exe filter=lfs diff=lfs merge=lfs -text
|
||||
@@ -1,89 +0,0 @@
|
||||
# Copilot instructions for ActivityWatch-Russian
|
||||
|
||||
Purpose: help future Copilot sessions quickly understand how to build, validate, and modify this repo.
|
||||
|
||||
---
|
||||
|
||||
## Build / test / lint (how-to)
|
||||
|
||||
- Shell script checks (CI & local):
|
||||
- Full: ./scripts/quality-gate.sh
|
||||
- Single file (syntax): bash -n <script>. Example: bash -n scripts/install_aw_linux_client.sh
|
||||
- Run shellcheck locally (same checks as CI): install shellcheck then run:
|
||||
find . -type f -name "*.sh" -print0 | xargs -0 -r shellcheck -e SC1007,SC1090,SC2016
|
||||
|
||||
- PowerShell checks (Windows / CI):
|
||||
- Single-file analysis (locally in PowerShell):
|
||||
Invoke-ScriptAnalyzer -Path windows/deploy-ensemble.ps1
|
||||
- CI installs PSScriptAnalyzer and runs against windows/*.ps1, *.psm1, *.psd1
|
||||
|
||||
- Python scripts / utilities:
|
||||
- Run a single utility: python3 scripts/aggregate_dlp_events.py
|
||||
- Many scripts are helpers for operations; no test harness in repo.
|
||||
|
||||
- Monitoring stack (Docker Compose):
|
||||
- Start: cd grafana-1c && docker-compose up -d
|
||||
- Start a single service: docker-compose up -d grafana
|
||||
|
||||
- Server install / deploy helpers:
|
||||
- AW server install: aw-server/install_aw_server.sh
|
||||
- Apply RU WebUI patch: aw-server/apply_webui_ru_patch.sh
|
||||
- Windows deploy/validation: windows/deploy-ensemble.ps1 and windows/validate-deployment.ps1
|
||||
|
||||
Notes: there is no unified unit-test suite. Use the script checks and CI pipeline (.github/workflows/ci.yml) as the canonical validation steps.
|
||||
|
||||
---
|
||||
|
||||
## High-level architecture (short)
|
||||
|
||||
- Windows collectors (PowerShell) run on endpoints and POST events to the ActivityWatch Server HTTP API.
|
||||
- ActivityWatch Server (deployed on Linux CT/LXC via Proxmox or Debian VM) stores events in PostgreSQL and serves WebUI.
|
||||
- Integration layer: pollers and aggregators (Python) for pfSense, DLP aggregation, Prometheus exporter.
|
||||
- Monitoring: Prometheus + Grafana (docker-compose in grafana-1c) and a SQL exporter for direct DB dashboards.
|
||||
|
||||
Key ports: AW API 5600/5666, PostgreSQL 5432, Prometheus 9090, Grafana 3000, exporter 9398.
|
||||
|
||||
---
|
||||
|
||||
## Key repository conventions
|
||||
|
||||
- Branching / commits:
|
||||
- Use feature branches. Commit style follows Conventional Commits (feat/fix/docs/chore).
|
||||
|
||||
- Secrets and envs:
|
||||
- Secrets live in secrets/*.env templates and must NOT be committed. Use secrets/deploy.secrets.env locally; CI and scripts expect templates (.example).
|
||||
|
||||
- Preflight / PR checks:
|
||||
- Run bash -n for shell scripts and Invoke-ScriptAnalyzer for PowerShell before opening PRs.
|
||||
- Update docs/runbook.md and related runbooks when behavior changes.
|
||||
|
||||
- RU patching:
|
||||
- WebUI localization is applied via aw-server/aw-ru-patch.js and aw-server/apply_webui_ru_patch.sh — treat these as idempotent patch steps during deploy.
|
||||
|
||||
- Systemd / deploy units:
|
||||
- activitywatch-server.service / aw-worktime-api.service / aw-worktime-ui-bridge.service are included in aw-server/ for production use.
|
||||
|
||||
- CI expectations:
|
||||
- .github/workflows/ci.yml runs shellcheck and PSScriptAnalyzer. Use scripts/quality-gate.sh locally to replicate preflight.
|
||||
|
||||
---
|
||||
|
||||
## Important files & quick references
|
||||
|
||||
- docs/ (onboarding, deployment, runbook) — start here for operational context.
|
||||
- aw-server/ — server install script, env template, RU patch, systemd units.
|
||||
- ansible/ — automated provisioning playbooks for CT/Proxmox and Windows deploys.
|
||||
- windows/ — PowerShell collectors and orchestration; validation scripts are here.
|
||||
- scripts/ — helpers (aggregate_dlp_events.py, installers, quality-gate.sh).
|
||||
- grafana-1c/ — docker-compose monitoring stack and dashboards.
|
||||
|
||||
---
|
||||
|
||||
## AI assistant & other tool configs to check
|
||||
|
||||
- No Copilot-specific instruction file existed before this addition.
|
||||
- No CLAUDE.md, .cursorrules, AGENTS.md, .windsurfrules, CONVENTIONS.md, or AIDER_CONVENTIONS.md detected at repo root. If adding automated assistant rules, place them in repo root or .github and document cross-references here.
|
||||
|
||||
---
|
||||
|
||||
If you need the Copilot instructions extended (e.g., adding run examples for specific scripts, more detailed CI breakdown, or mapping tests to files), say which area to expand.
|
||||
@@ -18,7 +18,22 @@ jobs:
|
||||
|
||||
- name: Run shellcheck
|
||||
run: |
|
||||
find . -type f -name "*.sh" -print0 | xargs -0 -r shellcheck -e SC1007,SC1090,SC2016
|
||||
find . -type f -name "*.sh" -print0 | xargs -0 -r shellcheck -S error -e SC1007,SC1090,SC2016
|
||||
|
||||
- name: Run production inventory placeholder guard self-test
|
||||
run: bash scripts/check_production_inventory_placeholders.sh --self-test
|
||||
|
||||
rust-runtime-guard:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Test detmir-core runtime guard
|
||||
run: cargo test --manifest-path adk-rust/Cargo.toml -p detmir-core runtime_guard
|
||||
|
||||
- name: Test detmir readiness crate
|
||||
run: cargo test --manifest-path adk-rust/Cargo.toml -p detmir-readiness
|
||||
|
||||
powershell-analyzer:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
name: release-assets
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
|
||||
jobs:
|
||||
sbom-and-release-assets:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install stable Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Generate machine SBOM artifacts
|
||||
run: bash scripts/generate_release_sbom_v0_2.sh dist/release-v0.2
|
||||
|
||||
- name: Validate generated JSON artifacts
|
||||
run: |
|
||||
python3 -m json.tool dist/release-v0.2/sbom/cyclonedx-rust-v0.2.json >/dev/null
|
||||
python3 -m json.tool dist/release-v0.2/sbom/spdx-rust-v0.2.json >/dev/null
|
||||
python3 -m json.tool dist/release-v0.2/RELEASE_ASSETS_MANIFEST-v0.2.json >/dev/null
|
||||
|
||||
- name: Verify release asset checksum/signature script
|
||||
run: bash scripts/verify_release_assets.sh --self-test
|
||||
+33
-25
@@ -1,6 +1,9 @@
|
||||
# Local secrets
|
||||
secrets/deploy.secrets.env
|
||||
secrets/runtime.env
|
||||
/secrets/
|
||||
/private-config/*.env
|
||||
/private-config/*.local
|
||||
/ansible/inventory.ini
|
||||
/codex_history.txt
|
||||
|
||||
# Runtime / reports
|
||||
*.log
|
||||
@@ -8,32 +11,37 @@ secrets/runtime.env
|
||||
*.bak
|
||||
windows/*.report.json
|
||||
.rollout-logs/
|
||||
reports/
|
||||
tmp/
|
||||
graphify-out/cache/
|
||||
graphify-out/powershell-parse-results*.json
|
||||
graphify-out/powershell-pssa-warn-results.json
|
||||
graphify-out/shellcheck-*.txt
|
||||
graphify-out/validate_dryrun_out*.txt
|
||||
graphify-out/pssa_diffs.txt
|
||||
.graphify_analysis.json
|
||||
.graphify_ast.json
|
||||
.graphify_cached.json
|
||||
.graphify_chunk_list_*.txt
|
||||
.graphify_detect.json
|
||||
.graphify_extract.json
|
||||
.graphify_labels.json
|
||||
.graphify_python
|
||||
.graphify_semantic.json
|
||||
.graphify_uncached.txt
|
||||
graphify-out/GRAPH_REPORT.md
|
||||
graphify-out/graph.html
|
||||
graphify-out/graph.json
|
||||
.pssa_run.ps1
|
||||
data/
|
||||
output/
|
||||
artifacts/browser-smoke/
|
||||
dist/
|
||||
.ai/
|
||||
.autonomous/
|
||||
.ops/
|
||||
.playwright-cli/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
__pycache__/
|
||||
|
||||
# Graphify analysis artifacts
|
||||
.graphify_*.json
|
||||
.graphify_*.txt
|
||||
.graphify_python/
|
||||
graphify-out/cache/
|
||||
.planning/
|
||||
.pssa_run.ps1
|
||||
data/
|
||||
|
||||
# Generated analysis artifacts
|
||||
/graphify-out/
|
||||
/.graphify_*.json
|
||||
/.graphify_*.txt
|
||||
/.graphify_python/
|
||||
|
||||
# Release assets kept outside git
|
||||
/install-kit-awindows-*.zip
|
||||
/install-kit-awindows-*.tar.gz
|
||||
/install-kit-awindows-*/
|
||||
/scripts/configure_pfsense_gateway_nat.py
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
# ActivityWatch-Russian
|
||||
|
||||
## Current Milestone: v1.0 Data Pipeline Stability
|
||||
|
||||
**Goal:** Ensure stable collection and rendering of endpoint activity data in AW Web UI.
|
||||
|
||||
**Target features:**
|
||||
- Reliable ingestion from Windows collectors (`endpoint-signals`, `browser-domains`).
|
||||
- Stable AW server query/render path for worktime/activity pages.
|
||||
- Deployment/runbook consistency (Ansible + install kit + rollback notes).
|
||||
|
||||
## Evolution
|
||||
|
||||
This document evolves at phase transitions and milestone boundaries.
|
||||
@@ -1,14 +0,0 @@
|
||||
# ROADMAP
|
||||
|
||||
## 🚧 v1.0 Data Pipeline Stability
|
||||
|
||||
- [ ] Phase 1: Collectors and API flow hardening
|
||||
- [ ] Phase 2: UI reports consistency and release proof
|
||||
|
||||
### Phase 1: Collectors and API flow hardening
|
||||
**Goal:** Stabilize collectors-to-server pipeline and remove known transport/runtime failure modes.
|
||||
**Depends on:** none
|
||||
|
||||
### Phase 2: UI reports consistency and release proof
|
||||
**Goal:** Validate report links/output, enforce rollout checks, and freeze reproducible release state.
|
||||
**Depends on:** Phase 1
|
||||
@@ -1,26 +0,0 @@
|
||||
---
|
||||
milestone: v1.0
|
||||
milestone_name: Data Pipeline Stability
|
||||
phase: "0"
|
||||
phase_name: Not started
|
||||
current_plan: 0
|
||||
total_plans_in_phase: 0
|
||||
status: planning
|
||||
progress_percent: 0
|
||||
last_activity: 2026-05-07
|
||||
---
|
||||
|
||||
## Current Position
|
||||
|
||||
Phase: Not started (defining requirements)
|
||||
Plan: —
|
||||
Status: Defining requirements
|
||||
Last activity: 2026-05-07 — Milestone v1.0 started
|
||||
|
||||
## Decisions
|
||||
|
||||
- Prioritize reliable data presence in activity/worktime views over UI extensions.
|
||||
|
||||
## Blockers
|
||||
|
||||
- None.
|
||||
@@ -1,17 +0,0 @@
|
||||
# Phase 1 Context
|
||||
|
||||
## Phase
|
||||
|
||||
Phase 1: Collectors and API flow hardening
|
||||
|
||||
## Focus
|
||||
|
||||
- Endpoint collectors must continuously send data without silent hangs.
|
||||
- AW server must accept/query data for UI pages consistently.
|
||||
- Failure points around transport/CORS/runtime must be explicitly checked.
|
||||
|
||||
## Initial Acceptance Targets
|
||||
|
||||
- Endpoint collector heartbeats arrive regularly.
|
||||
- Browser domains and endpoint signals appear in corresponding buckets.
|
||||
- Activity page for target host shows non-zero timeline/events for active period.
|
||||
@@ -1,42 +0,0 @@
|
||||
# PLAN — Phase 01: collectors-and-api-flow-hardening
|
||||
|
||||
## Goal
|
||||
|
||||
Deliver stable collector-to-server data flow so activity/worktime pages have consistent data.
|
||||
|
||||
## Work Items
|
||||
|
||||
1. Validate collector runtime and log rotation behavior.
|
||||
2. Validate server ingest endpoints and bucket write/read checks.
|
||||
3. Validate CORS/origin and report link consistency.
|
||||
4. Add/adjust scripts or runbook checks to detect zero-data regressions early.
|
||||
|
||||
## Verification
|
||||
|
||||
- Manual and scripted checks show fresh events in target buckets.
|
||||
- Host activity page reflects real activity (not `0s`) for active sessions.
|
||||
- No repeating transport errors in collector logs during test window.
|
||||
|
||||
## Status
|
||||
|
||||
Planned.
|
||||
|
||||
## 2. Варианты доработки DLP
|
||||
|
||||
### Вариант A: “Hardening” — Стабилизация текущего
|
||||
|
||||
Цель: довести текущие коллекторы до production-grade уровня надёжности.
|
||||
|
||||
| # | Задача | Усилие | Влияние |
|
||||
|---|---|---|---|
|
||||
| A1 | HTTP retry + exponential backoff во всех коллекторах | 3-5 дней | Высокое — перестанут теряться события |
|
||||
| A2 | Локальный WAL (Write-Ahead Log) — буферизация событий при недоступности сервера | 1-2 нед | Критическое — гарантия доставки |
|
||||
| A3 | Healthcheck endpoint и self-diagnostics в каждом коллекторе | 3-5 дней | Среднее — видимость состояния агентов |
|
||||
| A4 | Расширить aggregator: добавить `aw-email-monitor_` и `aw-dlp-endpoint-signals_` в сбор | 1 день | Среднее |
|
||||
| A5 | Systemd timer / Windows Task для aggregator (автоматический запуск) | 1 день | Среднее |
|
||||
| A6 | Убрать пароль из `inventory.ini` → использовать Ansible Vault или env var | 1 час | Критическое (безопасность) |
|
||||
| A7 | Graceful shutdown и cleanup event subscriptions во всех коллекторах | 2-3 дня | Среднее |
|
||||
|
||||
Общее усилие: ~3-4 недели.
|
||||
|
||||
Рекомендация: обязательно сделать перед любым масштабированием. Без этого DLP — “best effort” мониторинг, а не надёжная система.
|
||||
@@ -0,0 +1,246 @@
|
||||
# AGENTS.md
|
||||
|
||||
Operational rules for OpenCode/Codex agents in AWatch-rus.
|
||||
|
||||
## Defaults
|
||||
|
||||
- Rust is the primary runtime: use `adk-rust/`, build with `cargo build --release -p <crate>`, test with `cargo test -p <crate>`.
|
||||
- Root scripts (`check-aw-data.sh`, `check-aw-full.sh`, `scripts/prod_rollout.sh`, install-kit helpers) are Rust-first wrappers with legacy fallback.
|
||||
- Python is allowed only in `aw-server/dlp-content-analysis/`, `clickhouse-1c/ai/`, `clickhouse-1c/etl/`, `detmir-mcp/main.py`, `grafana-1c/`, `pfsense/`, `proxmox/tsj_guardian_bot.py`.
|
||||
- Never add real secrets from `secrets/`, private `.env`, or host credentials.
|
||||
- When auditing private/ignored files, report only path, secret type, and remediation. Never copy secret values into docs, logs, markdown, terminal summaries, commits, or handoff reports.
|
||||
|
||||
## Required Checks
|
||||
|
||||
- General: `scripts/quality-gate.sh`.
|
||||
- Rust: targeted `cargo test -p <crate>`.
|
||||
- Windows: parse PowerShell; CI also runs PSScriptAnalyzer on `windows/*.ps1`, `.psm1`, `.psd1`.
|
||||
- Ansible: affected `ansible-playbook --syntax-check ...`.
|
||||
|
||||
## Map
|
||||
|
||||
- `adk-rust/`: operational crates.
|
||||
- `aw-server/`: server install, env examples, RU WebUI patch, systemd.
|
||||
- `windows/`: RDP deployment, collectors, recovery, validation.
|
||||
- `ansible/`: deployment playbooks.
|
||||
- `proxmox/`: CT/gateway/bot automation.
|
||||
- `clickhouse-1c/`, `grafana-1c/`, `pfsense/`: integration stacks.
|
||||
- `grafana/`: flat version-controlled dashboard JSON; use Ansible to import/check it.
|
||||
|
||||
## Entrypoints
|
||||
|
||||
Use `proxmox/create-ct.sh`, `proxmox/push-aw-artifacts.sh`, `aw-server/install_aw_server.sh`, `aw-server/apply_webui_ru_patch.sh`, `windows/deploy-ensemble.ps1`, and docs in `docs/preparation.md`, `docs/deployment.md`, `docs/runbook.md`, `docs/operations.md`.
|
||||
|
||||
## Incident Handling
|
||||
|
||||
OpenCode must handle AWatch-rus incidents as evidence-based operational triage,
|
||||
not as guesswork from one red dashboard card.
|
||||
|
||||
### Assessment Basis
|
||||
|
||||
Assess every incident from these signals, in this order:
|
||||
|
||||
- **User impact:** portal/report/dashboard unavailable, stale, slow, or wrong;
|
||||
which role is affected: executive, manager, security, forensics, admin.
|
||||
- **Data freshness:** ActivityWatch bucket `metadata.end`, collector heartbeats,
|
||||
Windows scheduled task recency, queue depth, and upload/send failure counters.
|
||||
- **Service health:** systemd failed units, active timers, bounded HTTP checks,
|
||||
`/health` or `/api/health` responses, container health where relevant.
|
||||
- **Pipeline layer:** identify the first broken layer in the chain
|
||||
`Windows/RDP collectors -> ActivityWatch buckets -> Rust services -> exporters
|
||||
-> Grafana/Portal -> ClickHouse/1C where configured`.
|
||||
- **Risk/evidence:** DLP endpoint signals, incident candidates, evidence
|
||||
artifacts, UEBA/risk narrative inputs, coverage gaps, and security
|
||||
correlation indicators.
|
||||
- **Blast radius:** one user/session/collector, one host, one service, one
|
||||
dashboard, or the full contour.
|
||||
- **Recoverability:** known rollback, stale-cache availability, safe restart
|
||||
boundary, and whether a human approval is required.
|
||||
|
||||
Risk Narrative is only decision support. It can raise priority and explain
|
||||
why a manual check is needed, but it does not prove a policy violation, DLP
|
||||
incident, or SIEM finding by itself.
|
||||
|
||||
### Severity
|
||||
|
||||
Use this practical severity model:
|
||||
|
||||
- `P0`: data loss risk, auth/security boundary broken, raw private service
|
||||
exposed, production report chain unavailable with no stale fallback, or
|
||||
repeated collector process storms/memory pressure.
|
||||
- `P1`: executive/security workflows degraded, fresh data missing for a critical
|
||||
host, DLP evidence sync broken, ClickHouse/1C ingest stopped, or portal health
|
||||
degraded with user-visible effect.
|
||||
- `P2`: one collector stale, one dashboard/panel wrong, delayed timer, bad label
|
||||
normalization, missing noncritical evidence, or recoverable stale report.
|
||||
- `P3`: documentation drift, cosmetic UI issue, non-production demo fixture,
|
||||
or a warning with fresh data still confirmed.
|
||||
|
||||
Escalate severity when the same symptom repeats after recovery, when coverage
|
||||
is unknown, or when evidence contradicts dashboard status.
|
||||
|
||||
### Mechanisms To Use
|
||||
|
||||
Start with the repo wrappers before ad hoc probing:
|
||||
|
||||
```bash
|
||||
cd /mnt/usb_hdd2/Projects/ActivityWatch-Russian
|
||||
./check-aw-data.sh
|
||||
./check-aw-full.sh
|
||||
```
|
||||
|
||||
Then narrow by layer:
|
||||
|
||||
- ActivityWatch API: `/api/0/info`, `/api/0/buckets`, bucket metadata and recent
|
||||
events with explicit `no_proxy` and short `curl --max-time`.
|
||||
- Worktime: `aw-worktime-api` `/health`, `/reports/worktime/today`,
|
||||
`/reports/worktime/management?allow_stale=1`, prewarm logs, stale-cache
|
||||
fields, `AW_WORKTIME_EVENTS_LIMIT`, and `aw_query_timeout_count`.
|
||||
- Windows/RDP: `validate-deployment.ps1`, exact `ActivityWatch Launch [...]`
|
||||
scheduled tasks, `ActivityWatch Recovery`, collector guard state, session
|
||||
collectors, local queue depth, and send failure counters.
|
||||
- DLP: `aw-dlp-policy-engine`, `aw-dlp-case-management`, `dlp-health-check`,
|
||||
`aw-dlp-endpoint-signals_<HOST>`, evidence artifact sync, policy audit, and
|
||||
case/compliance services.
|
||||
- Portal/Gateway/Grafana: `/portal/api/health`, `/api/reports`, gateway
|
||||
`/healthz`, protected `/d/...` Grafana routes, role gates, and browser smoke
|
||||
scripts.
|
||||
- ClickHouse/1C: only for file-1C/analytics incidents. Do not blame ClickHouse
|
||||
for worktime report failures unless the affected path explicitly uses it.
|
||||
|
||||
Use existing guards and bounded mechanisms before broad restarts:
|
||||
|
||||
- stale-cache and fail-closed worktime behavior;
|
||||
- `aw-worktime-autoheal`, `aw-worktime-prewarm`, `aw-worktime-ui-bridge`,
|
||||
`aw-rus-healthd` timers;
|
||||
- Windows collector guard and exact localized scheduled tasks;
|
||||
- DLP evidence sync and health timers;
|
||||
- targeted service restart only after evidence identifies the layer.
|
||||
|
||||
### DLP Rule Update System
|
||||
|
||||
Do not describe AWatch-rus DLP rules as manual local JSON entry, and do not
|
||||
collapse all DLP updates into one mechanism. There are two related but separate
|
||||
contours:
|
||||
|
||||
1. policy lifecycle and endpoint synchronization through the DLP Policy Engine;
|
||||
2. automatic IOC/signature replenishment from the open-source Hayabusa/Sigma
|
||||
ruleset.
|
||||
|
||||
The centralized policy update contour is:
|
||||
|
||||
- Server service: `aw-dlp-policy-engine.service`, Rust binary
|
||||
`/usr/local/bin/aw-dlp-policy-engine-rust`, default API port `5601`.
|
||||
- Storage: SQLite DB from `AW_DLP_POLICY_ENGINE_DB_PATH`, with policy records,
|
||||
policy versions, active policy pointer, rollback versions, and `policy_audit`.
|
||||
- API contract:
|
||||
- `GET /healthz`;
|
||||
- CRUD: `/api/0/dlp/policies`;
|
||||
- active bundle: `GET /api/0/dlp/policies/active`;
|
||||
- active version/checksum: `GET /api/0/dlp/policies/active/version`;
|
||||
- approval lifecycle:
|
||||
`draft -> pending_approval -> approved -> deployed`;
|
||||
- workflow calls:
|
||||
`POST /submit`, `POST /approve`, `POST /draft`, `POST /activate`;
|
||||
- rollback: `POST /api/0/dlp/policies/rollback`;
|
||||
- audit:
|
||||
`GET /api/0/dlp/policies/audit?limit=N` and
|
||||
`GET /api/0/dlp/policies/{id}/audit?limit=N`;
|
||||
- endpoint sync:
|
||||
`POST /api/0/dlp/policies/agents/{agent_id}/heartbeat` and
|
||||
`GET /api/0/dlp/policies/agents/{agent_id}/desired`.
|
||||
- Windows side is configured for server-driven policy mode:
|
||||
`aw_windows_policy_mode: "server"`,
|
||||
`aw_windows_policy_engine_enabled: true`,
|
||||
`aw_windows_policy_refresh_seconds: 300`, and policy engine host/port from
|
||||
Ansible group vars.
|
||||
- Agents report their current policy version/checksum by heartbeat. The server
|
||||
compares it with the active deployed policy and returns `desired` with
|
||||
`refreshNow=true` when the endpoint must update.
|
||||
- `dlp-admin-cli` is the operator CLI for read-side checks such as
|
||||
`policies list`, `policies active`, incident/case listing, and combined DLP
|
||||
health checks. It is not a replacement for the lifecycle API when changing
|
||||
policy state.
|
||||
|
||||
Automatic IOC/signature replenishment:
|
||||
|
||||
- Name it precisely as `DLP IOC Enrichment from Hayabusa/Sigma` or
|
||||
`Hayabusa Sigma IOC refresh pipeline`.
|
||||
- Source rules come from the open-source GitHub ruleset
|
||||
`Yamato-Security/hayabusa-rules`, configured by
|
||||
`aw_dlp_ioc_rules_zip_url`.
|
||||
- Deployment is controlled by `ansible/deploy_aw_server.yml` when
|
||||
`aw_dlp_ioc_enabled=true`.
|
||||
- The refresh wrapper `/usr/local/bin/aw-dlp-ioc-refresh.sh` downloads the
|
||||
latest `hayabusa-rules` ZIP, unpacks Sigma YAML rules, and runs the Rust
|
||||
extractor `/usr/local/bin/aw-extract-ioc-from-sigma`.
|
||||
- The Rust extractor is built from
|
||||
`adk-rust/crates/extract-ioc-from-sigma`; local/manual builds use
|
||||
`scripts/build_dlp_ioc_from_hayabusa.sh`.
|
||||
- Extracted IOC-like values include process image suffixes, command-line
|
||||
substrings, original filenames, and SHA256 hashes. They are de-duplicated and
|
||||
emitted as `ioc_blacklist.json`, `ioc_blacklist.csv`, and
|
||||
`ioc_blacklist.sql`.
|
||||
- Production artifacts live under `/opt/activitywatch/dlp-ioc/output` and are
|
||||
served by `aw-worktime-api` on `/dlp-ioc/ioc_blacklist.json`,
|
||||
`/dlp-ioc/ioc_blacklist.csv`, and `/dlp-ioc/ioc_blacklist.sql`.
|
||||
- Windows DLP policy can consume this feed through the `ioc.source` field with
|
||||
format `hayabusa_sigma_v1`; endpoint health/heartbeat should expose loaded
|
||||
IOC state such as `iocRulesLoaded`.
|
||||
- Runtime automation is `aw-dlp-ioc-refresh.service` plus
|
||||
`aw-dlp-ioc-refresh.timer` with interval `aw_dlp_ioc_refresh_interval`
|
||||
(default `6h`). Health/diagnostics should check this timer before assuming
|
||||
signatures are static or manually maintained.
|
||||
- This Hayabusa/Sigma IOC pipeline enriches the DLP rule base automatically; it
|
||||
is not the same thing as hand-editing endpoint JSON and is also distinct from
|
||||
the server-side Hayabusa EVTX forensics runner.
|
||||
|
||||
Operational meaning:
|
||||
|
||||
1. To update rules, create or update a policy draft through the policy engine.
|
||||
2. Submit it for approval, approve it, then activate/deploy it. Activation is
|
||||
allowed only from `approved`.
|
||||
3. For policy changes, verify `active/version`, audit entries, Windows agent
|
||||
heartbeat/desired, and downstream DLP signals after endpoints refresh.
|
||||
4. For automatic signature replenishment, verify
|
||||
`aw-dlp-ioc-refresh.timer`, the last `aw-dlp-ioc-refresh.service` run,
|
||||
non-empty `ioc_blacklist.json/csv/sql`, Worktime API `/dlp-ioc/...`
|
||||
exports, and Windows IOC load counters.
|
||||
5. If a policy causes noise or misses, use policy rollback through the API; do
|
||||
not hand-edit endpoint policy files as the normal rollback path.
|
||||
|
||||
Manual edits of `C:\Program Files\AWatch-rus\windows\dlp-policy.example.json`
|
||||
or `C:\ProgramData\AWatch-rus\dlp-policy.json` are diagnostic or emergency
|
||||
fallback only. If such an edit is unavoidable, document it as configuration
|
||||
drift and bring the rule back into the central policy engine.
|
||||
|
||||
### Response Workflow
|
||||
|
||||
1. Capture current state first: command, timestamp, host, service, and exact
|
||||
failing endpoint. Do not restart before collecting evidence unless the
|
||||
system is in active resource exhaustion.
|
||||
2. Find the first broken layer. If buckets are stale, fix collectors before
|
||||
Grafana. If `aw-worktime-api` is degraded, fix/report that before portal.
|
||||
3. Separate real outage from presentation drift: dashboards can be stale or
|
||||
mislabeled while buckets and services are healthy.
|
||||
4. Apply the narrowest safe recovery: restart a collector/task/service, reduce
|
||||
unsafe limits, clear process storms, or restore a known-good binary/config.
|
||||
Back up config/binaries before replacement.
|
||||
5. Verify with the same failing check plus one upstream and one downstream
|
||||
check. For collector incidents, require bucket freshness and guard/healthd
|
||||
consistency, not just one green command.
|
||||
6. Record closure evidence: root cause, affected layer, action taken, commands
|
||||
run, post-check results, remaining risk, and rollback path.
|
||||
|
||||
### Safety Rules
|
||||
|
||||
- Old snapshots, memory, dashboards, and handoff notes are hints; live runtime
|
||||
evidence wins.
|
||||
- Never expose passwords, tokens, private host credentials, private URLs, raw
|
||||
security events, or customer identifiers in incident writeups.
|
||||
- Do not run broad deploys, full restarts, or `cargo build --workspace` during
|
||||
incident triage unless the scope demands it and rollback is clear.
|
||||
- Do not treat `status=ok` as sufficient when freshness, queue depth, or
|
||||
coverage evidence says otherwise.
|
||||
- For owner-facing reports, publish only protected gateway/Grafana routes, not
|
||||
raw `:5600`, `:5610`, `:8720`, or ClickHouse endpoints.
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
# Журнал изменений
|
||||
|
||||
## release-readiness-v0.3 - 2026-06-03
|
||||
|
||||
Назначение этапа: audit package для реестра российского ПО и коммерческого
|
||||
пилота.
|
||||
|
||||
### Добавлено
|
||||
|
||||
- `docs/THIRD_PARTY_LICENSES_RU.md` - audit-facing таблица компонент,
|
||||
версий, лицензий, назначения и рисков.
|
||||
- `docs/SECURITY_MODEL_RU.md` - роли, trust boundaries, собираемые данные,
|
||||
хранение и доступ.
|
||||
- `docs/REGISTRY_RUSSIAN_SO_POSITIONING_RU.md` - безопасное позиционирование
|
||||
по классам: не SIEM/DLP/EDR как основной класс, а операционный контроль,
|
||||
техаудит, мониторинг активности и аналитика событий.
|
||||
- `docs/PILOT_DEPLOYMENT_CHECKLIST_RU.md` - чек-лист внедрения у заказчика.
|
||||
- `docs/RELEASE_READINESS_V0.3_RU.md` - состав audit package v0.3.
|
||||
|
||||
## release-readiness-v0.2 - 2026-06-03
|
||||
|
||||
Назначение этапа: усилить пакет для коммерческого релиза и реестра российского
|
||||
ПО за счет машинного SBOM, проверки release assets, пилотного акта приемки и
|
||||
корректного позиционирования pfSense.
|
||||
|
||||
### Добавлено
|
||||
|
||||
- `scripts/generate_release_sbom_v0_2.sh` - генерация CycloneDX/SPDX SBOM
|
||||
artifacts в `dist/release-v0.2/`.
|
||||
- `scripts/verify_release_assets.sh` - проверка `SHA256SUMS*.txt` и detached
|
||||
signature release assets.
|
||||
- `.github/workflows/release-assets.yml` - CI self-test checksum/signature
|
||||
verifier и генерации SBOM.
|
||||
- `docs/RELEASE_READINESS_V0.2_RU.md` - контрольная карта v0.2.
|
||||
- `docs/CUSTOMER_PILOT_ACCEPTANCE_RU.md` - шаблон акта приемки пилота.
|
||||
- `docs/NETWORK_PERIMETER_PFSENSE_RU.md` - pfSense как опциональный
|
||||
интеграционный слой, не обязательная часть продукта.
|
||||
|
||||
## release-readiness-v0.1 - 2026-06-03
|
||||
|
||||
Назначение этапа: довести AWatch-rus до проверяемого release-readiness
|
||||
пакета для пилота, экспертной оценки и последующей публикации релиза без
|
||||
раскрытия приватного коммерческого контура.
|
||||
|
||||
### Добавлено
|
||||
|
||||
- UI-блок портала `Готовность системы`: статус `OK/WARN/FAIL`, дата bundle,
|
||||
статус checksum, статус detached signature, fingerprint публичного ключа и
|
||||
ручная кнопка проверки bundle.
|
||||
- Prometheus/Grafana alert rules:
|
||||
`detmir_readiness_ok == 0` и
|
||||
`detmir_readiness_signature_verified == 0`.
|
||||
- Подпись readiness bundle через detached signature `sha256sums.txt.sig`.
|
||||
- Retention для readiness archives и unit-тесты на подпись/retention.
|
||||
- `docs/RELEASE_READINESS_V0.1_RU.md` - сводный акт готовности релиза v0.1.
|
||||
- `docs/SBOM_V0.1_RU.md` - human-readable SBOM profile и команды генерации
|
||||
машинных SBOM artifacts.
|
||||
- `docs/PORTAL_SCREENSHOTS_RU.md` - перечень обезличенных screenshots портала.
|
||||
- `docs/diagrams/release-readiness-v0.1.md` - схема release-readiness path.
|
||||
|
||||
### Проверено
|
||||
|
||||
- `cargo fmt --manifest-path adk-rust/Cargo.toml --all -- --check`.
|
||||
- `cargo test --manifest-path adk-rust/Cargo.toml -p detmir-readiness -p detmir-portal`.
|
||||
- `cargo clippy --manifest-path adk-rust/Cargo.toml -p detmir-readiness -p detmir-portal --all-targets -- -D warnings`.
|
||||
- `node --check adk-rust/crates/detmir-portal/src/static/app.js`.
|
||||
- Ansible syntax-check для AW server и AWatch-rus portal deploy playbooks.
|
||||
- Runtime deployment на проектные сервисы AWatch-rus без изменения pfSense или
|
||||
Proxmox platform layer.
|
||||
|
||||
## v1.0.1-public-review - 2026-06-03
|
||||
|
||||
Назначение релиза: публичный пакет для экспертной оценки AWatch-rus и
|
||||
подготовки к реестровой проверке. Релиз не меняет работающий коммерческий
|
||||
runtime AWatch-rus; изменения относятся к source/release package, документации,
|
||||
обезличиванию и проверяемости поставки.
|
||||
|
||||
### Добавлено
|
||||
|
||||
- `docs/INSTALL_FOR_EXPERT_RU.md` - воспроизводимая установка: чистая VM,
|
||||
сборка, установка, проверка, ожидаемый результат.
|
||||
- `docs/EXPERT_TEST_SCENARIO_RU.md` - ручной сценарий экспертной проверки:
|
||||
вход в web UI, status, clipboard/USB/print, DLP incident, case/evidence,
|
||||
export report.
|
||||
- `docs/SBOM_RELEASE_CHECKLIST_RU.md` - checklist подготовки SBOM/release.
|
||||
- `docs/RELEASE_AUDIT_2026-06.md` - audit приватных маркеров и секретов.
|
||||
- `docs/RELEASE_MANIFEST_2026-06.md` - manifest release artifacts, checksums,
|
||||
SBOM inputs и выполненных gates.
|
||||
- `docs/RELEASE_NOTES_2026-06.md` - release notes для GitHub release.
|
||||
|
||||
### Изменено
|
||||
|
||||
- Публичные docs, examples, defaults и test fixtures обезличены: live hostnames,
|
||||
private IPs, operator domains, local operator home paths, private root paths,
|
||||
live case IDs и forensic paths заменены на placeholders/TEST-NET значения.
|
||||
- README и register docs теперь ведут эксперта по полному маршруту:
|
||||
описание продукта -> установка -> ручной сценарий -> audit -> SBOM/release
|
||||
checklist.
|
||||
- Install-kit пересобирается из sanitized source files и валидируется через
|
||||
Rust tooling.
|
||||
|
||||
### Удалено из tracked source
|
||||
|
||||
- `.planning` generated artifacts.
|
||||
- Распакованный `install-kit-awindows-20260427-211240/` как tracked source.
|
||||
Install-kit archives публикуются как GitHub Release assets.
|
||||
|
||||
### Проверено
|
||||
|
||||
- `cargo build --release --workspace` в отдельном target-dir.
|
||||
- `scripts/check_detmir_rust_release_artifacts.sh`: все Rust release binaries
|
||||
найдены.
|
||||
- `scripts/rebuild_install_kit.sh` и `scripts/validate_install_kit.sh`:
|
||||
install-kit пересобран и валиден.
|
||||
- `scripts/quality-gate.sh`: `OK`.
|
||||
- Public hygiene grep по tracked release surface: старые приватные маркеры
|
||||
отсутствуют; оставшиеся root-word совпадения классифицированы как
|
||||
ложноположительные technical path terms.
|
||||
|
||||
### Известные ограничения
|
||||
|
||||
- `v1.0.1-public-review` является source/review release. Коммерческий runtime
|
||||
AWatch-rus продолжает использовать private runtime config вне Git.
|
||||
- Python остается для Telegram runtime, OCR/content-analysis, 1C/AI/ETL и MCP
|
||||
helpers. Это отражено в registry docs как допустимое исключение.
|
||||
- pfSense/infrastructure runtime не менялся в рамках этого релиза.
|
||||
|
||||
## v1.0.0 - 2026-04-25
|
||||
|
||||
Базовый professional baseline с install-kit artifacts.
|
||||
@@ -0,0 +1,8 @@
|
||||
# Установка экземпляра для эксперта
|
||||
|
||||
Основной пошаговый документ находится здесь:
|
||||
|
||||
- [`docs/INSTALL_FOR_EXPERT_RU.md`](docs/INSTALL_FOR_EXPERT_RU.md)
|
||||
|
||||
Файл в корне оставлен как совместимая ссылка для внешних проверок и старых
|
||||
закладок.
|
||||
@@ -0,0 +1,202 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,40 @@
|
||||
# Описание программного продукта AWatch-rus
|
||||
|
||||
`AWatch-rus` - программный комплекс операционного контроля,
|
||||
технического аудита, оценки трудоотдачи сотрудников и мониторинга
|
||||
ИТ-инфраструктуры.
|
||||
|
||||
## Назначение
|
||||
|
||||
Продукт предназначен для централизованного контроля состояния рабочих мест,
|
||||
оценки активности сотрудников, анализа загрузки подразделений,
|
||||
контроля серверных сервисов, ActivityWatch telemetry, рабочих интервалов,
|
||||
операторских runbook-проверок и прикладных событий расследования.
|
||||
|
||||
## Коммерческие модули
|
||||
|
||||
- `AWatch-rus Workforce` - ежедневный управленческий слой: активность,
|
||||
активное время, простои, RDP/1C/рабочие приложения, загрузка сотрудников и
|
||||
отчеты для руководителя.
|
||||
- `AWatch-rus Security` - прикладной слой ИБ: DLP-сигналы, evidence metadata,
|
||||
доступные скриншоты, review/case queue и audit действий оператора.
|
||||
- `AWatch-rus Forensics` - расследовательский слой: цепочки событий,
|
||||
Hayabusa/offline-разбор, кейсы и материалы для внутреннего расследования.
|
||||
|
||||
## Основные функции
|
||||
|
||||
- сбор и нормализация ActivityWatch telemetry;
|
||||
- оценка полезной активности и загрузки сотрудников;
|
||||
- управленческие отчеты по worktime, RDP, 1C и рабочим приложениям;
|
||||
- контроль свежести данных и состояния сборщиков;
|
||||
- учет активности пользователей и удаленных сессий;
|
||||
- витрины Grafana и портал оператора;
|
||||
- runbook automation, health-check, SLO и безопасный auto-heal;
|
||||
- evidence workflow для прикладных инцидентов;
|
||||
- аудит действий оператора.
|
||||
|
||||
## Позиционирование
|
||||
|
||||
Продукт относится к классу средств управления ИТ-службой,
|
||||
ИТ-инфраструктурой и ИТ-активами. Продукт не заявляется как
|
||||
сертифицированная DLP, SIEM, EDR/XDR или средство защиты информации.
|
||||
@@ -1,107 +1,303 @@
|
||||
# AWatch-rus
|
||||
|
||||
Практический каркас проекта для повторного развёртывания ActivityWatch Server в новом окружении с LXC-контейнером на Proxmox, русифицированным Web UI, systemd-юнитами, шаблонными скриптами деплоя и эксплуатационной документацией.
|
||||
AWatch-rus - программный комплекс операционного контроля,
|
||||
технического аудита, оценки трудоотдачи сотрудников и мониторинга
|
||||
корпоративной ИТ-инфраструктуры на базе ActivityWatch, Rust-сервисов
|
||||
автоматизации, Grafana/Prometheus-витрин и модулей расследования инцидентов.
|
||||
|
||||
## Что входит
|
||||
Проект не позиционируется как сертифицированная DLP/SIEM/EDR/XDR/СЗИ. DLP,
|
||||
evidence и Hayabusa используются как прикладные модули внутри платформы
|
||||
операционного контроля и технического аудита.
|
||||
|
||||
- `docs/preparation.md` — подготовка инфраструктуры и входных параметров.
|
||||
- `docs/codebase-onboarding.md` — обзор структуры репозитория и маршрут изучения для новичка.
|
||||
- `docs/deployment.md` — пошаговый деплой LXC и ActivityWatch Server.
|
||||
- `docs/runbook.md` — быстрый runbook для оператора.
|
||||
- `docs/operations.md` — регламент сопровождения, бэкапов, обновлений и rollback.
|
||||
- `docs/artifacts-policy.md` — политика generated-артефактов и rollout-gates.
|
||||
- `docs/windows/ensemble.md` — orchestration-пакет для Windows-деплоя и проверки.
|
||||
- `docs/linux-client.md` — user-space rollout Linux-клиента ActivityWatch на удалённый `AW server`.
|
||||
- `docs/linux-remote-worker.md` — полный Linux remote-worker stack: GUI, SSH/console и browser admin UI вроде Proxmox `:8006`.
|
||||
- `docs/console-ssh-logger.md` — логирование только консольных команд и SSH-сессий в AW.
|
||||
- `docs/dlp-gap-analysis.md` — разрыв до enterprise DLP и roadmap.
|
||||
- `docs/dlp-aggregator.md` — прототип централизованной агрегации DLP/file-operation событий.
|
||||
- `docs/dlp-reliability-roadmap.md` — roadmap повышения надёжности DLP-коллекторов.
|
||||
- `docs/powershell-analysis.md` — статический анализ работоспособности DLP PowerShell-скриптов.
|
||||
- `proxmox/` — шаблонные скрипты подготовки и наполнения CT на стороне Proxmox.
|
||||
- `aw-server/` — установочные скрипты, env-шаблон, systemd unit и RU patch для Web UI.
|
||||
- `ansible/` — Ansible-ensemble для автоматизированного сервера (Debian/CT).
|
||||
- `pfsense/` — внешний poller для pfSense API и systemd unit под Debian/Ubuntu utility VM.
|
||||
- `windows/` — PowerShell toolkit: single-user, domain-users, ensemble orchestration, hardening/recovery, validation, Windows/RDP DLP telemetry (`aw-dlp-incidents_*`, `aw-dlp-endpoint-signals_*`) и session-level presence для удалённых Windows/RDP пользователей (`aw-worktime-sessions_*`).
|
||||
- `scripts/quality-gate.sh` — локальный preflight-пайплайн проверок.
|
||||
- `scripts/aggregate_dlp_events.py` — сбор `aw-file-operations_*` и `aw-dlp-incidents_*` в SQLite/PostgreSQL.
|
||||
- `scripts/install_aw_linux_client.sh` — установка Linux bundle + autostart для remote AW server.
|
||||
- `scripts/install_aw_console_ssh_logger.sh` — user-space установка console/ssh logger.
|
||||
- `scripts/install_aw_linux_web_category_logger.sh` — user-space классификация browser admin UI по title/class.
|
||||
- `scripts/install_aw_linux_remote_worker.sh` — полный Linux remote-worker installer.
|
||||
## Назначение
|
||||
|
||||
## Базовый сценарий
|
||||
- AWatch-rus Workforce: активность сотрудников, загрузка, RDP/1C/рабочие
|
||||
приложения и управленческие отчеты для владельца бизнеса.
|
||||
- AWatch-rus Security: DLP-сигналы, evidence, очередь кейсов и audit действий
|
||||
оператора без заявления продукта как сертифицированной СЗИ.
|
||||
- AWatch-rus Forensics: цепочки событий, Hayabusa/offline-разбор и материалы для
|
||||
внутреннего расследования.
|
||||
- Контроль доступности и свежести данных ActivityWatch.
|
||||
- Учет активного времени, RDP-сессий, окон, приложений и рабочих интервалов.
|
||||
- Витрины Grafana для администратора, оператора ИБ и руководителя.
|
||||
- Автоматизация runbook-проверок, health-check, SLO и безопасного auto-heal.
|
||||
- Сбор evidence по инцидентам и аудит действий оператора.
|
||||
|
||||
1. Подготовить параметры окружения по `docs/preparation.md`.
|
||||
2. Заполнить единый файл секретов `secrets/deploy.secrets.env` (автоподключение).
|
||||
3. На узле Proxmox создать контейнер через `proxmox/create-ct.sh`.
|
||||
4. Загрузить артефакты и серверный env в CT через `proxmox/push-aw-artifacts.sh`.
|
||||
5. Внутри контейнера выполнить `aw-server/install_aw_server.sh`.
|
||||
6. Применить русификацию Web UI через `aw-server/apply_webui_ru_patch.sh`.
|
||||
7. Проверить API, Web UI и состояние systemd по `docs/runbook.md`.
|
||||
8. Развернуть Windows-клиентов через `windows/deploy-ensemble.ps1`.
|
||||
9. Проверить итог через `windows/validate-deployment.ps1`.
|
||||
## Rust-first runtime
|
||||
|
||||
Для полного Ansible-сценария “с нуля” в Proxmox используйте:
|
||||
Основной серверный runtime AWatch-rus переведен на Rust: status/check/auto-heal,
|
||||
SLO, worktime, DLP server-side helpers, evidence и install-kit tooling.
|
||||
|
||||
- `ansible/provision_proxmox_ct_and_deploy_aw.yml`
|
||||
- `ansible/provision_proxmox_ct_matrix_and_deploy_aw.yml` (массово по матрице CT)
|
||||
Python в репозитории остается для вспомогательных направлений: Telegram bot
|
||||
runtime, OCR/content-analysis, 1C/AI/ETL integration и MCP/dev helpers. Эти
|
||||
части не являются ядром Rust-first runtime.
|
||||
|
||||
Для централизованного деплоя Windows/RDP-клиентов через WinRM:
|
||||
Портальный слой зафиксирован как Rust server-rendered HTML + HTMX-compatible
|
||||
JSON API, OpenAPI и TypeScript declarations. Dioxus не используется и не
|
||||
рассматривается для Pilot v1.0. React, Tauri и Electron также не входят в
|
||||
текущий основной UI.
|
||||
|
||||
- `ansible/deploy_aw_windows.yml`
|
||||
## Product Evolution
|
||||
|
||||
Для внешнего pfSense poller'а:
|
||||
AWatch-rus уже является рабочей платформой Workforce + Security + Forensics.
|
||||
Архитектура предусматривает расширение на агентные и agentless-источники
|
||||
данных. Planned/Future элементы ниже не являются реализованной функциональностью
|
||||
и не должны трактоваться как готовые collectors или integrations.
|
||||
|
||||
- `ansible/deploy_aw_pfsense_poller.yml`
|
||||
Implemented:
|
||||
|
||||
Для Linux desktop/admin host, который должен слать watcher'ы на удалённый AW server:
|
||||
- Rust Backend.
|
||||
- Rust Agent.
|
||||
- HTML/HTMX Portal.
|
||||
- Role-based Pilot v1 contracts.
|
||||
- Product architecture page `/portal/architecture`.
|
||||
- Workforce reports.
|
||||
- UEBA v1.
|
||||
- Forensics reporting.
|
||||
- pfSense contract/readiness layer со статусом `contract_only`, без заявления
|
||||
production ingestion.
|
||||
|
||||
- `docs/linux-client.md`
|
||||
- `scripts/install_aw_linux_client.sh`
|
||||
Planned:
|
||||
|
||||
Для полного Linux remote-worker сценария:
|
||||
- Provider detail expansion under `/portal/architecture`.
|
||||
- PowerShell Provider.
|
||||
- SSH Provider.
|
||||
- Syslog Provider.
|
||||
- 1C Provider как формализация текущего file-based 1C analytics направления.
|
||||
- Russian OS support validation.
|
||||
|
||||
- `docs/linux-remote-worker.md`
|
||||
- `scripts/install_aw_linux_remote_worker.sh`
|
||||
Future:
|
||||
|
||||
Для режима “только консоль/ssh” без GUI watcher'ов:
|
||||
- Extended Enterprise connectors.
|
||||
- SCUD/VPN integrations.
|
||||
- React/TypeScript Enterprise UI.
|
||||
- Tauri Desktop Forensics.
|
||||
|
||||
- `docs/console-ssh-logger.md`
|
||||
- `scripts/install_aw_console_ssh_logger.sh`
|
||||
## Pilot v1 demo
|
||||
|
||||
Скрипты `proxmox/create-ct.sh` и `proxmox/push-aw-artifacts.sh` по умолчанию читают:
|
||||
Pilot v1 demo показывает AWatch-rus как рабочую платформу Workforce Analytics +
|
||||
Security Analytics + Forensics для ролей `executive`, `manager`, `security`,
|
||||
`forensics` и `admin`.
|
||||
|
||||
- `secrets/deploy.secrets.env`
|
||||
Демо-материалы:
|
||||
|
||||
## Принципы
|
||||
- [сценарий Pilot v1 demo](docs/PILOT_DEMO_SCENARIO_RU.md);
|
||||
- [сценарий руководителя](docs/demo/DEMO_SCENARIO_EXECUTIVE_RU.md);
|
||||
- [сценарий ИБ](docs/demo/DEMO_SCENARIO_SECURITY_RU.md);
|
||||
- [сценарий расследований](docs/demo/DEMO_SCENARIO_FORENSICS_RU.md);
|
||||
- [demo seed data](docs/fixtures/pilot-v1-demo/demo-seed-data.json);
|
||||
- [demo evidence pack](docs/fixtures/pilot-v1-demo/evidence-pack/);
|
||||
- [пример итогового demo-отчета](docs/DEMO_REPORT_EXAMPLE_RU.md);
|
||||
- [ценность пилота для заказчика](docs/PILOT_VALUE_PROPOSITION_RU.md);
|
||||
- [преддемо-runbook](docs/DEMO_RUNBOOK_RU.md).
|
||||
|
||||
- Никаких реальных секретов, токенов и боевых IP в репозитории.
|
||||
- Все переменные вынесены в `.example` / `.env` шаблоны.
|
||||
- Документация ориентирована на повторяемое развёртывание, а не на одноразовую ручную установку.
|
||||
- Rollback и backup описаны как обязательная часть каждой операции.
|
||||
Pilot validation:
|
||||
|
||||
## Минимальная структура
|
||||
- [чеклист проверки пилота](docs/PILOT_VALIDATION_CHECKLIST_RU.md);
|
||||
- [gap analysis пилота](docs/PILOT_GAP_ANALYSIS_RU.md);
|
||||
- [вопросы для discovery с заказчиком](docs/CUSTOMER_DISCOVERY_QUESTIONS_RU.md);
|
||||
- [критерии успеха пилота](docs/PILOT_SUCCESS_CRITERIA_RU.md);
|
||||
- [конкурентное позиционирование](docs/COMPETITIVE_POSITIONING_RU.md).
|
||||
|
||||
- CT/LXC на Debian 12
|
||||
- ActivityWatch Server Rust release
|
||||
- Web UI override в `/opt/activitywatch/webui-ru`
|
||||
- systemd unit `activitywatch-server.service`
|
||||
- bind/listen через переменные окружения
|
||||
Границы показа:
|
||||
|
||||
## Ограничения
|
||||
- pfSense показывается только как `contract_only/readiness`, без заявления
|
||||
production ingestion или SIEM;
|
||||
- UEBA Score v1 является прозрачной rule-based моделью, без ML/LLM;
|
||||
- demo fixtures не содержат реальных IP-адресов, hostname, логинов, ФИО,
|
||||
подразделений заказчика или событий безопасности;
|
||||
- planned/future providers не являются реализованными collectors.
|
||||
|
||||
- Интеграции с InfluxDB/Grafana/LDAP оставлены как следующий слой, не как обязательная база.
|
||||
## Что видит оператор
|
||||
|
||||
## Быстрые ссылки
|
||||
- Работал ли пользователь за компьютером или в удаленной сессии.
|
||||
- Когда была активность, простой и переключение окон.
|
||||
- Какие приложения, сайты и процессы чаще всего были в работе.
|
||||
- Есть ли события, важные для ИБ: копирование, печать, USB, подозрительные сайты.
|
||||
- Не пропали ли данные с рабочих компьютеров и RDP-сессий.
|
||||
|
||||
- `/mnt/usb_hdd2/Projects/ActivityWatch-Russian/docs/FULL_DEPLOYMENT_MANUAL_RU.md`
|
||||
- `/home/igor/tmp/AWatch-rus/docs/windows/ensemble.md`
|
||||
- `docs/preparation.md`
|
||||
- `docs/deployment.md`
|
||||
- `docs/runbook.md`
|
||||
- `docs/operations.md`
|
||||
- `proxmox/create-ct.sh`
|
||||
- `aw-server/install_aw_server.sh`
|
||||
- `windows/deploy-ensemble.ps1`
|
||||
- `windows/validate-deployment.ps1`
|
||||
## Кому это полезно
|
||||
|
||||
- Владельцу и руководителю - видеть активность, загрузку команды,
|
||||
простои, перегрузки и рабочие приложения без просмотра логов.
|
||||
- ИБ - заметить DLP-сигналы и подозрительную активность.
|
||||
- Администратору - проверить, что сборщики и сервер работают стабильно.
|
||||
|
||||
## Интерфейс
|
||||
|
||||
Скриншоты ниже подготовлены на демонстрационных данных: без реальных IP-адресов,
|
||||
hostname, логинов, сотрудников, подразделений заказчика и событий безопасности.
|
||||
|
||||
Все демонстрационные скриншоты от 2026-06-06 лежат в
|
||||
[docs/screenshots/](docs/screenshots/):
|
||||
[главный вывод](docs/screenshots/01-executive-overview.png),
|
||||
[карта рисков](docs/screenshots/02-risk-heatmap.png),
|
||||
[безопасность](docs/screenshots/03-security-view.png),
|
||||
[эксплуатация](docs/screenshots/04-operations-view.png),
|
||||
[пакет расследования](docs/screenshots/05-investigation-pack.png),
|
||||
[итоговый отчет](docs/screenshots/06-markdown-report.png),
|
||||
[архитектура продукта](docs/screenshots/07-product-architecture.png).
|
||||
Сводный список и правила публикации: [docs/PORTAL_SCREENSHOTS_RU.md](docs/PORTAL_SCREENSHOTS_RU.md).
|
||||
|
||||
### Главный вывод
|
||||
|
||||

|
||||
|
||||
Руководитель видит главный риск первым, затем сводку по достоверности
|
||||
показателей, полноте данных, кандидатам на проверку и рискам подразделений.
|
||||
|
||||
### Карта рисков подразделений
|
||||
|
||||

|
||||
|
||||
Карта рисков показывает, где одновременно проседают активность, покрытие
|
||||
агентов, доверие к показателям и количество ситуаций для проверки.
|
||||
|
||||
### Представление безопасности
|
||||
|
||||

|
||||
|
||||
ИБ получает очередь кандидатов на проверку, связанные расследования и материалы
|
||||
без просмотра сырых логов и без автоматического принятия решений.
|
||||
|
||||
### Представление эксплуатации
|
||||
|
||||

|
||||
|
||||
Эксплуатация видит полноту данных, качество агентского сбора, ошибки сбора и
|
||||
понятный статус событий безопасности через ClickHouse.
|
||||
|
||||
### Пакет расследования
|
||||
|
||||

|
||||
|
||||
Пакет расследования связывает материалы, историю проверки и итоговый вывод,
|
||||
который ответственный сотрудник может подтвердить вручную.
|
||||
|
||||
### Итоговый отчет
|
||||
|
||||

|
||||
|
||||
Markdown-отчет собирает главный вывод, риски подразделений, материалы
|
||||
расследований и рекомендации в формате, удобном для передачи руководителю.
|
||||
|
||||
### Архитектура продукта
|
||||
|
||||

|
||||
|
||||
Страница `/portal/architecture` показывает текущие компоненты, planned
|
||||
extensions и future-направления без создания новых API или фиктивных
|
||||
collectors.
|
||||
|
||||
## Если дашборд пустой
|
||||
|
||||
Обычно это значит одно из трех: выбран слишком узкий период времени, рабочий компьютер давно не присылал события или временно не обновилась витрина в Grafana. Начните с периода `Last 24 hours`, затем переходите к техническим разделам ниже.
|
||||
|
||||
## Поставка и регистрация
|
||||
|
||||
- Enterprise deployment documentation:
|
||||
[deployment guide](docs/ENTERPRISE_DEPLOYMENT_GUIDE_RU.md),
|
||||
[topologies](docs/DEPLOYMENT_TOPOLOGIES_RU.md),
|
||||
[sizing](docs/SIZING_GUIDE_RU.md),
|
||||
[backup and recovery](docs/BACKUP_AND_RECOVERY_RU.md),
|
||||
[operations runbook](docs/OPERATIONS_RUNBOOK_RU.md),
|
||||
[security hardening](docs/SECURITY_HARDENING_RU.md),
|
||||
[acceptance checklist](docs/ENTERPRISE_ACCEPTANCE_CHECKLIST_RU.md).
|
||||
|
||||
- Registry readiness documentation:
|
||||
[product passport](docs/REGISTRY_PRODUCT_PASSPORT_RU.md),
|
||||
[architecture](docs/REGISTRY_ARCHITECTURE_RU.md),
|
||||
[functional scope](docs/REGISTRY_FUNCTIONAL_SCOPE_RU.md),
|
||||
[dependency statement](docs/REGISTRY_DEPENDENCY_STATEMENT_RU.md),
|
||||
[deployment model](docs/REGISTRY_DEPLOYMENT_MODEL_RU.md),
|
||||
[commercial positioning](docs/REGISTRY_COMMERCIAL_POSITIONING_RU.md),
|
||||
[readiness checklist](docs/REGISTRY_READINESS_CHECKLIST_RU.md).
|
||||
|
||||
- [Позиционирование для реестра российского ПО](docs/RUSSIAN_SOFTWARE_REGISTRY_POSITIONING_RU.md)
|
||||
- [Сведения для подачи в реестр](REGISTER_RU_SOFTWARE.md)
|
||||
- [Registry product passport](docs/REGISTRY_PRODUCT_PASSPORT_RU.md)
|
||||
- [Registry architecture](docs/REGISTRY_ARCHITECTURE_RU.md)
|
||||
- [Registry functional scope](docs/REGISTRY_FUNCTIONAL_SCOPE_RU.md)
|
||||
- [Registry dependency statement](docs/REGISTRY_DEPENDENCY_STATEMENT_RU.md)
|
||||
- [Registry deployment model](docs/REGISTRY_DEPLOYMENT_MODEL_RU.md)
|
||||
- [Registry commercial positioning](docs/REGISTRY_COMMERCIAL_POSITIONING_RU.md)
|
||||
- [Registry readiness checklist](docs/REGISTRY_READINESS_CHECKLIST_RU.md)
|
||||
- [Описание продукта](PRODUCT_DESCRIPTION_RU.md)
|
||||
- [Журнал изменений](CHANGELOG_RU.md)
|
||||
- [Установка для эксперта](INSTALL_FOR_EXPERT_RU.md)
|
||||
- [Сценарий экспертной проверки](docs/EXPERT_TEST_SCENARIO_RU.md)
|
||||
- [Release manifest 2026-06](docs/RELEASE_MANIFEST_2026-06.md)
|
||||
- [Эксплуатационный профиль](docs/OPERATIONAL_PROOF_PROFILE_RU.md)
|
||||
- [Коммерческие модули AWatch-rus](docs/COMMERCIAL_MODULES_RU.md)
|
||||
- [Архитектурный baseline](docs/ARCHITECTURE_BASELINE_RU.md)
|
||||
- [Пакет пилота для заказчика](docs/CUSTOMER_PILOT_PACK_RU.md)
|
||||
- [Enterprise deployment guide](docs/ENTERPRISE_DEPLOYMENT_GUIDE_RU.md)
|
||||
- [Deployment topologies](docs/DEPLOYMENT_TOPOLOGIES_RU.md)
|
||||
- [Sizing guide](docs/SIZING_GUIDE_RU.md)
|
||||
- [Backup and recovery](docs/BACKUP_AND_RECOVERY_RU.md)
|
||||
- [Operations runbook](docs/OPERATIONS_RUNBOOK_RU.md)
|
||||
- [Security hardening](docs/SECURITY_HARDENING_RU.md)
|
||||
- [Enterprise acceptance checklist](docs/ENTERPRISE_ACCEPTANCE_CHECKLIST_RU.md)
|
||||
- [Pilot v1.0](docs/PILOT_V1_RU.md)
|
||||
- [Pilot v1 demo](docs/PILOT_DEMO_SCENARIO_RU.md)
|
||||
- [Demo scenario: руководитель](docs/demo/DEMO_SCENARIO_EXECUTIVE_RU.md)
|
||||
- [Demo scenario: ИБ](docs/demo/DEMO_SCENARIO_SECURITY_RU.md)
|
||||
- [Demo scenario: расследования](docs/demo/DEMO_SCENARIO_FORENSICS_RU.md)
|
||||
- [Demo report example](docs/DEMO_REPORT_EXAMPLE_RU.md)
|
||||
- [Pilot value proposition](docs/PILOT_VALUE_PROPOSITION_RU.md)
|
||||
- [Pilot v1.0 acceptance checklist](docs/PILOT_V1_ACCEPTANCE_CHECKLIST_RU.md)
|
||||
- [Pilot v1.0 evidence](docs/PILOT_V1_EVIDENCE_RU.md)
|
||||
- [Pilot validation checklist](docs/PILOT_VALIDATION_CHECKLIST_RU.md)
|
||||
- [Pilot gap analysis](docs/PILOT_GAP_ANALYSIS_RU.md)
|
||||
- [Customer discovery questions](docs/CUSTOMER_DISCOVERY_QUESTIONS_RU.md)
|
||||
- [Pilot success criteria](docs/PILOT_SUCCESS_CRITERIA_RU.md)
|
||||
- [Competitive positioning](docs/COMPETITIVE_POSITIONING_RU.md)
|
||||
- [Roadmap conformance audit](docs/ROADMAP_CONFORMANCE_AUDIT_RU.md)
|
||||
- [Browser conformance smoke](docs/BROWSER_CONFORMANCE_RU.md)
|
||||
- [Production readiness портала](docs/PRODUCTION_READINESS_RU.md)
|
||||
- [Explainable Workforce KPI](docs/EXPLAINABLE_KPI_RU.md)
|
||||
- [Risk Narrative](docs/RISK_NARRATIVE_RU.md)
|
||||
- [Executive Action Center](docs/EXECUTIVE_ACTION_CENTER_RU.md)
|
||||
- [Rust Agent baseline](docs/RUST_AGENT_BASELINE_RU.md)
|
||||
- [Итог production-расследования 2026-06-07](docs/PRODUCTION_INCIDENT_REPORT_2026-06-07_RU.md)
|
||||
- [Runbook восстановления worktime reports](docs/OPERATIONS_RUNBOOK_WORKTIME_RU.md)
|
||||
- [Позиционирование продукта](docs/PRODUCT_POSITIONING_RU.md)
|
||||
- [Экосистема сборщиков](docs/COLLECTOR_ECOSYSTEM_RU.md)
|
||||
- [Стратегия внедрения](docs/DEPLOYMENT_STRATEGY_RU.md)
|
||||
- [Стратегия платформ](docs/PLATFORM_STRATEGY_RU.md)
|
||||
- [Ролевая модель портала](docs/ROLES_RU.md)
|
||||
- [UEBA Score v1](docs/UEBA_SCORE_RU.md)
|
||||
- [pfSense integration readiness](docs/PFSENSE_INTEGRATION_RU.md)
|
||||
- [Сценарий демонстрации заказчику](docs/CUSTOMER_DEMO_SCENARIO_RU.md)
|
||||
- [Аудит готовности к пилоту](docs/PILOT_READINESS_AUDIT_RU.md)
|
||||
- [Позиционирование для первой встречи](docs/SALES_POSITIONING_RU.md)
|
||||
- [Преддемо-сценарий](docs/DEMO_RUNBOOK_RU.md)
|
||||
- [Сторонние компоненты](THIRD_PARTY_COMPONENTS.md)
|
||||
- [Сторонние лицензии](THIRD_PARTY_LICENSES_RU.md)
|
||||
- [Архитектура](docs/ARCHITECTURE_RU.md)
|
||||
- [Установка](docs/INSTALL_RU.md)
|
||||
- [Руководство администратора](docs/ADMIN_GUIDE_RU.md)
|
||||
- [Руководство оператора](docs/OPERATOR_GUIDE_RU.md)
|
||||
- [Лицензия](LICENSE)
|
||||
|
||||
## Техническая документация
|
||||
|
||||
Для эксплуатации и настройки:
|
||||
|
||||
- [Wiki home](docs/wiki/Home.md)
|
||||
- [Getting Started and Prerequisites](docs/wiki/Getting-Started-and-Prerequisites.md)
|
||||
- [Server Infrastructure](docs/wiki/Server-Infrastructure.md)
|
||||
- [Operations, CI/CD, and Quality Assurance](docs/wiki/Operations-CI-CD-and-Quality-Assurance.md)
|
||||
- [Full deployment manual](docs/FULL_DEPLOYMENT_MANUAL_RU.md)
|
||||
|
||||
Для мониторинга:
|
||||
|
||||
- [Grafana and Prometheus Monitoring Stack](docs/wiki/Grafana-and-Prometheus-Monitoring-Stack.md)
|
||||
- [Grafana dashboards guide](docs/GRAFANA_DASHBOARDS_RU.md)
|
||||
- [Prometheus Exporter](docs/wiki/Prometheus-Exporter.md)
|
||||
|
||||
Для сборщиков и интерфейса:
|
||||
|
||||
- [Windows Collector Suite](docs/wiki/Windows-Collector-Suite.md)
|
||||
- [Worktime API and UI Bridge](docs/wiki/Worktime-API-and-UI-Bridge.md)
|
||||
- [Russian WebUI Patch and Localization](docs/wiki/Russian-WebUI-Patch-and-Localization.md)
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
# Сведения для подачи в реестр российского ПО
|
||||
|
||||
Статус документа: рабочий пакет для подготовки продукта `AWatch-rus` к
|
||||
экспертной проверке и возможной подаче в реестр российского ПО.
|
||||
|
||||
Документ намеренно описывает продукт как программный комплекс операционного
|
||||
контроля, технического аудита и управления ИТ-инфраструктурой. Продукт не
|
||||
заявляется как сертифицированная DLP, SIEM, EDR/XDR или средство защиты
|
||||
информации.
|
||||
|
||||
## 1. Наименование продукта
|
||||
|
||||
Публичное наименование:
|
||||
|
||||
- `AWatch-rus`.
|
||||
|
||||
Техническая база и репозиторий:
|
||||
|
||||
- `AWatch-rus`.
|
||||
|
||||
Рекомендуемая формула для документов:
|
||||
|
||||
```text
|
||||
Программный продукт AWatch-rus.
|
||||
```
|
||||
|
||||
Для публичных материалов использовать единую формулу: `Программный продукт
|
||||
AWatch-rus`. Это не создает второго бренда и не отделяет продукт от
|
||||
репозитория.
|
||||
|
||||
## 2. Назначение ПО
|
||||
|
||||
`AWatch-rus` предназначен для централизованного операционного контроля,
|
||||
технического аудита и мониторинга ИТ-инфраструктуры организации.
|
||||
|
||||
Основные задачи:
|
||||
|
||||
- контроль состояния серверных сервисов, endpoint-сборщиков и витрин данных;
|
||||
- учет пользовательской активности, рабочих интервалов и удаленных сессий;
|
||||
- мониторинг свежести данных ActivityWatch и связанных buckets;
|
||||
- контроль выполнения эксплуатационных регламентов и runbook-проверок;
|
||||
- SLO/health мониторинг и безопасная автоматизация восстановления;
|
||||
- отображение управленческих и технических dashboards;
|
||||
- фиксация evidence по прикладным инцидентам;
|
||||
- аудит действий оператора и техническая трассировка расследований.
|
||||
|
||||
Продукт закрывает задачу эксплуатационной видимости: администратор,
|
||||
оператор ИБ или руководитель видит, что сбор данных идет, инфраструктурные
|
||||
компоненты доступны, данные обновляются, а прикладные инциденты имеют
|
||||
прослеживаемую evidence-цепочку.
|
||||
|
||||
## 3. Класс ПО
|
||||
|
||||
Основной целевой класс для реестра:
|
||||
|
||||
```text
|
||||
09.10 Средства управления ИТ-службой, ИТ-инфраструктурой и ИТ-активами
|
||||
```
|
||||
|
||||
Обоснование:
|
||||
|
||||
- продукт контролирует состояние ИТ-сервисов и инфраструктурных компонентов;
|
||||
- содержит operational dashboards, health-check и SLO-мониторинг;
|
||||
- автоматизирует эксплуатационные проверки и безопасные recovery-действия;
|
||||
- хранит технические состояния, отчеты, evidence и audit trail;
|
||||
- применяется для контроля работоспособности и наблюдаемости корпоративного
|
||||
контура.
|
||||
|
||||
Дополнительный контекст, который можно использовать в описании:
|
||||
|
||||
- технический аудит;
|
||||
- интеллектуальный мониторинг инфраструктуры;
|
||||
- автоматизация runbook-процессов;
|
||||
- контроль регламентов эксплуатации.
|
||||
|
||||
Не рекомендуется заявлять продукт как:
|
||||
|
||||
- сертифицированную DLP;
|
||||
- SIEM;
|
||||
- EDR/XDR;
|
||||
- средство защиты информации;
|
||||
- продукт с формальной ФСТЭК-моделью угроз.
|
||||
|
||||
Модули DLP/evidence/Hayabusa описываются как прикладные модули операционного
|
||||
контроля и расследования событий, а не как самостоятельная сертифицированная
|
||||
система защиты информации.
|
||||
|
||||
## 4. Правообладатель
|
||||
|
||||
Правообладатель: владелец репозитория и программного продукта `AWatch-rus`.
|
||||
|
||||
Перед подачей в реестр рекомендуется подготовить отдельный
|
||||
правообладательский пакет:
|
||||
|
||||
- сведения о правообладателе;
|
||||
- описание прав на собственные модули;
|
||||
- подтверждение авторства или передачи прав на разработанные компоненты;
|
||||
- перечень сторонних компонентов и лицензий;
|
||||
- описание модели распространения;
|
||||
- при необходимости - свидетельство Роспатента о регистрации программы для ЭВМ.
|
||||
|
||||
Собственными компонентами считаются:
|
||||
|
||||
- Rust helpers и runtime-модули AWatch-rus;
|
||||
- портал оператора;
|
||||
- Ansible deployment automation;
|
||||
- Windows collectors/deployment scripts;
|
||||
- ActivityWatch RU customization;
|
||||
- Grafana dashboards проекта;
|
||||
- документация, runbooks и install-kit packaging.
|
||||
|
||||
Сторонние компоненты перечислены отдельно в `THIRD_PARTY_LICENSES_RU.md` и
|
||||
`docs/THIRD_PARTY_LICENSES_RU.md`.
|
||||
|
||||
## 5. Состав поставки
|
||||
|
||||
Публичная поставка состоит из исходного кода, документации и шаблонов
|
||||
конфигурации. Индивидуальные параметры конкретного стенда не входят в
|
||||
публичную поставку.
|
||||
|
||||
В состав входят:
|
||||
|
||||
- `adk-rust/` - Rust workspace с основными runtime helpers;
|
||||
- `ansible/` - playbooks и examples для установки серверных и endpoint
|
||||
компонентов;
|
||||
- `aw-server/` - ActivityWatch server customization, service files,
|
||||
RU WebUI patches и server-side helpers;
|
||||
- `windows/` - Windows collectors, scheduled task deployment и common module;
|
||||
- `grafana/` - dashboards для технического и управленческого мониторинга;
|
||||
- `proxmox/` - операторские helpers, включая Telegram runtime, если он
|
||||
используется в конкретном экземпляре;
|
||||
- `docs/` - руководства администратора, оператора, архитектура, threat model,
|
||||
registry positioning и runbooks;
|
||||
- `private-config/*.example` - шаблоны приватной конфигурации;
|
||||
- release assets - install-kit archives для проверяемых сборок.
|
||||
|
||||
Не входят в публичный репозиторий:
|
||||
|
||||
- production inventory;
|
||||
- пароли;
|
||||
- токены;
|
||||
- реальные IP-адреса и домены экземпляра;
|
||||
- runtime базы данных и evidence;
|
||||
- customer deployment snapshots;
|
||||
- локальная история работы операторских ИИ-агентов.
|
||||
|
||||
## 6. Функциональный состав
|
||||
|
||||
### 6.1. Контроль ActivityWatch telemetry
|
||||
|
||||
- проверка доступности AW API;
|
||||
- контроль свежести buckets;
|
||||
- учет event-driven buckets без ложного dead/stale статуса;
|
||||
- health summary для оператора;
|
||||
- SLO sampling и summary.
|
||||
|
||||
### 6.2. Учет активности и рабочего времени
|
||||
|
||||
- обработка window/AFK/session данных;
|
||||
- отчеты по активному времени;
|
||||
- поддержка RDP/Windows collector flow;
|
||||
- InfluxDB/Grafana витрины;
|
||||
- heartbeat freshness для контроля работы exporter-а.
|
||||
|
||||
### 6.3. Операционный контроль и auto-heal
|
||||
|
||||
- `detmir-check`;
|
||||
- `detmir-status`;
|
||||
- `detmir-auto`;
|
||||
- безопасные recovery paths;
|
||||
- контроль systemd timers/services;
|
||||
- исключение опасных destructive actions из автоматического режима.
|
||||
|
||||
### 6.4. Evidence и расследования
|
||||
|
||||
- хранение evidence metadata;
|
||||
- screenshot/evidence viewer в портале оператора;
|
||||
- audit записи просмотра evidence;
|
||||
- Hayabusa/offline DFIR flow как прикладной модуль расследования.
|
||||
|
||||
### 6.5. Визуализация
|
||||
|
||||
- Grafana dashboards;
|
||||
- портал оператора;
|
||||
- management views для руководителя;
|
||||
- technical views для администратора и оператора ИБ.
|
||||
|
||||
## 7. Архитектура
|
||||
|
||||
Типовая архитектура экземпляра:
|
||||
|
||||
```text
|
||||
Windows/Linux endpoints
|
||||
|
|
||||
v
|
||||
ActivityWatch collectors / endpoint helpers
|
||||
|
|
||||
v
|
||||
AW server + AWatch-rus Rust helpers
|
||||
|
|
||||
+--> SQLite state/cases/policy/evidence metadata
|
||||
+--> InfluxDB/metrics storage
|
||||
+--> Grafana dashboards
|
||||
+--> AWatch-rus operator portal
|
||||
+--> Telegram/operator runtime, если включен
|
||||
```
|
||||
|
||||
Ядро AWatch-rus реализовано как Rust-first runtime:
|
||||
|
||||
- health/status/check helpers;
|
||||
- worktime exporters/API/bridges;
|
||||
- DLP server-side processing helpers;
|
||||
- evidence API/portal helpers;
|
||||
- install-kit validation tools;
|
||||
- operational quality gates.
|
||||
|
||||
Python в составе проекта не является основным ядром продукта. Он остается для:
|
||||
|
||||
- Telegram bot runtime, если используется заказчиком;
|
||||
- OCR/content-analysis path, где нужны Python OCR/ML библиотеки;
|
||||
- 1C/AI/ETL интеграций;
|
||||
- отдельных MCP/dev helper сценариев.
|
||||
|
||||
Такое разделение фиксируется как архитектурное: критичные серверные проверки,
|
||||
status path, SLO, worktime, DLP server-side helpers и install-kit tooling
|
||||
переведены на Rust-first модель.
|
||||
|
||||
## 8. Зависимости
|
||||
|
||||
Основные runtime dependencies:
|
||||
|
||||
- Linux/systemd;
|
||||
- ActivityWatch;
|
||||
- Rust runtime artifacts, собранные из `adk-rust`;
|
||||
- SQLite;
|
||||
- Grafana;
|
||||
- InfluxDB или совместимое хранилище временных рядов, если включены metrics;
|
||||
- Ansible для установки;
|
||||
- PowerShell/Windows Task Scheduler для Windows collectors;
|
||||
- Hayabusa для offline DFIR workflow, если включен;
|
||||
- Python только для согласованных вспомогательных модулей.
|
||||
|
||||
Сторонние лицензии и риски AGPL/GPL/weak copyleft описаны в
|
||||
`THIRD_PARTY_LICENSES_RU.md`.
|
||||
|
||||
## 9. Установка экземпляра
|
||||
|
||||
Короткий порядок для эксперта:
|
||||
|
||||
1. Склонировать репозиторий.
|
||||
2. Подготовить приватную конфигурацию:
|
||||
|
||||
```bash
|
||||
cp private-config/deploy.env.example private-config/deploy.env
|
||||
cp ansible/inventory.example.ini ansible/inventory.ini
|
||||
```
|
||||
|
||||
3. Заполнить параметры конкретного тестового экземпляра.
|
||||
4. Собрать Rust artifacts:
|
||||
|
||||
```bash
|
||||
cd adk-rust
|
||||
cargo build --release --workspace
|
||||
```
|
||||
|
||||
5. Выполнить syntax и quality checks:
|
||||
|
||||
```bash
|
||||
scripts/quality-gate.sh
|
||||
ansible-playbook --syntax-check -i ansible/inventory.ini ansible/deploy_aw_server.yml
|
||||
```
|
||||
|
||||
6. Установить серверные компоненты и collectors по `docs/INSTALL_RU.md`.
|
||||
7. Проверить работоспособность:
|
||||
|
||||
```bash
|
||||
detmir-check
|
||||
detmir-status
|
||||
```
|
||||
|
||||
Ожидаемый результат: статус `OK`, отсутствуют критичные service failures и
|
||||
stale/dead buckets для обязательных источников.
|
||||
|
||||
## 10. Ограничения
|
||||
|
||||
- Продукт не заменяет формально сертифицированные средства защиты информации
|
||||
без отдельной сертификации.
|
||||
- Реальные сетевые адреса, домены, токены и inventory являются параметрами
|
||||
экземпляра и не публикуются.
|
||||
- Для endpoint deployment нужны административные права.
|
||||
- Для некоторых прикладных модулей нужны внешние сервисы: Grafana, InfluxDB,
|
||||
Hayabusa или Python OCR stack.
|
||||
- License compatibility сторонних компонентов должна проверяться перед
|
||||
коммерческой поставкой.
|
||||
|
||||
## 11. Документы пакета
|
||||
|
||||
- `PRODUCT_DESCRIPTION_RU.md` - краткое описание продукта.
|
||||
- `CHANGELOG_RU.md` - журнал изменений и статус публичных release-пакетов.
|
||||
- `INSTALL_FOR_EXPERT_RU.md` - короткая инструкция установки экземпляра.
|
||||
- `docs/EXPERT_TEST_SCENARIO_RU.md` - ручной сценарий экспертной проверки после установки.
|
||||
- `docs/RELEASE_MANIFEST_2026-06.md` - manifest release artifacts, checksums и gates.
|
||||
- `THIRD_PARTY_COMPONENTS.md` - обзор сторонних компонентов.
|
||||
- `THIRD_PARTY_LICENSES_RU.md` - лицензии и license-audit checklist.
|
||||
- `docs/ARCHITECTURE_RU.md` - архитектура.
|
||||
- `docs/ADMIN_GUIDE_RU.md` - руководство администратора.
|
||||
- `docs/OPERATOR_GUIDE_RU.md` - руководство оператора.
|
||||
- `docs/RUSSIAN_SOFTWARE_REGISTRY_POSITIONING_RU.md` - стратегия
|
||||
позиционирования.
|
||||
@@ -0,0 +1,430 @@
|
||||
# AWatch-rus: обзор решения для продажи и коммерческого представления
|
||||
|
||||
## Executive Summary
|
||||
|
||||
`AWatch-rus` — это корпоративная система мониторинга активности сотрудников с DLP-функциями, управленческим слоем и интеграцией в существующий ИТ/ИБ-контур компании.
|
||||
|
||||
По сути это практичный средний слой между простыми time-tracker решениями и тяжелыми enterprise DLP-платформами:
|
||||
|
||||
- есть контроль действий пользователей и DLP-сигналы;
|
||||
- есть реальный management layer, а не только сырые события;
|
||||
- есть интеграции с `1С`, Grafana, Linux-инфраструктурой и forensic follow-up;
|
||||
- при этом стоимость входа и сопровождения обычно ниже, чем у классических enterprise-комплексов.
|
||||
|
||||
Решение особенно уместно там, где:
|
||||
|
||||
- есть `Windows` и `RDP`-сценарии;
|
||||
- важен контроль активности и дисциплины данных;
|
||||
- уже используется `1С`;
|
||||
- нужен open-source контур без жесткой привязки к одному вендору.
|
||||
|
||||
## Основные возможности
|
||||
|
||||
### DLP мониторинг
|
||||
|
||||
Система уже собирает и обрабатывает:
|
||||
|
||||
- события `clipboard`;
|
||||
- печать;
|
||||
- `USB`;
|
||||
- браузерные домены и web-категории;
|
||||
- исходящую почту;
|
||||
- файловые операции;
|
||||
- DLP-инциденты и review workflow.
|
||||
|
||||
### Enforcement
|
||||
|
||||
`AWatch-rus` умеет не только наблюдать, но и ограничивать:
|
||||
|
||||
- `clipboard block`;
|
||||
- `USB write-block`;
|
||||
- отмену печати;
|
||||
- block path для email в поддерживаемом Outlook-сценарии.
|
||||
|
||||
Это дает возможность внедрять контур поэтапно: сначала `monitor`, затем `enforce`.
|
||||
|
||||
### Браузеры
|
||||
|
||||
Поддерживается:
|
||||
|
||||
- сбор доменов и web-контекста;
|
||||
- категоризация активности;
|
||||
- связка браузерной телеметрии с DLP и worktime;
|
||||
- использование данных в dashboard и incident path.
|
||||
|
||||
### Email
|
||||
|
||||
Поддерживается:
|
||||
|
||||
- мониторинг исходящей почты;
|
||||
- правила `endpoint.email[]`;
|
||||
- сигналы по теме, адресатам и вложениям;
|
||||
- блокирующий сценарий в Outlook mode.
|
||||
|
||||
### Worktime
|
||||
|
||||
Система дает:
|
||||
|
||||
- фактический worktime по RDP-сессиям;
|
||||
- ежедневные отчеты;
|
||||
- `HTML/CSV/JSON` выдачу;
|
||||
- server-side management reporting на `:5610`.
|
||||
|
||||
### Management Report Layer
|
||||
|
||||
Это одна из самых сильных частей решения. Поверх телеметрии строится:
|
||||
|
||||
- управленческий отчет;
|
||||
- очередь действий по сотрудникам и подразделениям;
|
||||
- source freshness;
|
||||
- executive summary;
|
||||
- trend-анализ.
|
||||
|
||||
### Интеграция с 1С
|
||||
|
||||
Есть отдельный file-based `1С` analytics contour:
|
||||
|
||||
- telemetry по файловым базам;
|
||||
- `ClickHouse`-модели;
|
||||
- company intelligence;
|
||||
- manager brief и management actions;
|
||||
- Grafana boards для руководителя и операционного контура.
|
||||
|
||||
### Linux поддержка
|
||||
|
||||
Решение не замкнуто только на Windows:
|
||||
|
||||
- Linux server-side runtime;
|
||||
- Linux operational integrations;
|
||||
- SSH/console logging;
|
||||
- смешанный Windows/Linux operational model.
|
||||
|
||||
### pfSense
|
||||
|
||||
Система может быть включена в perimeter/security contour компании через:
|
||||
|
||||
- внешний `pfSense` poller;
|
||||
- передачу network telemetry в общий контур;
|
||||
- единый operator visibility path.
|
||||
|
||||
### Forensic анализ
|
||||
|
||||
`Hayabusa` интегрирован как bounded DFIR layer:
|
||||
|
||||
- EVTX export с Windows;
|
||||
- server-side processing;
|
||||
- case linkage;
|
||||
- Telegram alert path для follow-up.
|
||||
|
||||
Это усиливает ценность решения для ИБ без превращения продукта в отдельную SIEM/DFIR-платформу.
|
||||
|
||||
## Управленческие функции
|
||||
|
||||
### Management Report Layer
|
||||
|
||||
Руководитель получает не просто технические bucket-данные, а:
|
||||
|
||||
- картину по активности сотрудников;
|
||||
- сводку по owner/department;
|
||||
- проблемные зоны и приоритеты;
|
||||
- понятную очередь действий.
|
||||
|
||||
### Actions с приоритетами
|
||||
|
||||
Система умеет формировать:
|
||||
|
||||
- `critical/high` actions;
|
||||
- рекомендации, кого проверять первым;
|
||||
- причины для escalation;
|
||||
- управленческий список действий без ручного разбора сырых событий.
|
||||
|
||||
### Executive summary
|
||||
|
||||
Management API и `1С` management brief формируют human-readable summary уровня:
|
||||
|
||||
- что сломалось;
|
||||
- где данные stale;
|
||||
- кто не показывает активность;
|
||||
- какие пользователи и предприятия требуют внимания в первую очередь.
|
||||
|
||||
### Trend-анализ
|
||||
|
||||
В продукт уже встроены:
|
||||
|
||||
- несколько дней тренда по worktime;
|
||||
- trend и weekly views в `1С` intelligence contour;
|
||||
- сравнительный анализ текущего и исторического состояния.
|
||||
|
||||
### Source freshness
|
||||
|
||||
Это критически важная функция для менеджмента и ИБ:
|
||||
|
||||
- система показывает, где проблема в поведении пользователя, а где в деградации источника;
|
||||
- решения не принимаются вслепую по сломанной телеметрии.
|
||||
|
||||
### Алиасы пользователей
|
||||
|
||||
Поддерживаются:
|
||||
|
||||
- normalized user aliases;
|
||||
- owner/department mapping;
|
||||
- manager-facing каталоги ответственных.
|
||||
|
||||
За счет этого отчеты пригодны для бизнеса, а не только для инженеров.
|
||||
|
||||
## Архитектура и компоненты
|
||||
|
||||
Архитектура строится как цепочка:
|
||||
|
||||
- `Windows Clients / RDP host`;
|
||||
- `Linux Server`;
|
||||
- `Integration Layer`;
|
||||
- `Monitoring Stack`;
|
||||
- выделенный `Forensic Layer`.
|
||||
|
||||
Практически это означает:
|
||||
|
||||
- Windows PowerShell collectors;
|
||||
- Linux `AW-rus` server;
|
||||
- DLP Policy API и Case API;
|
||||
- Grafana/Prometheus/ClickHouse analytics;
|
||||
- Proxmox/operator gateway;
|
||||
- `Hayabusa` follow-up path.
|
||||
|
||||
### Windows коллекторы
|
||||
|
||||
В состав входят:
|
||||
|
||||
- endpoint DLP collector;
|
||||
- browser domains collector;
|
||||
- file operations collector;
|
||||
- email outbound collector;
|
||||
- worktime session collector;
|
||||
- deploy/hardening/validation toolkit.
|
||||
|
||||
### Linux сервер
|
||||
|
||||
Серверный слой включает:
|
||||
|
||||
- `ActivityWatch` API и WebUI;
|
||||
- RU patch и DLP overlay;
|
||||
- `aw-worktime-api` на `:5610`;
|
||||
- policy engine;
|
||||
- case management;
|
||||
- health/autoheal path.
|
||||
|
||||
### Monitoring стек
|
||||
|
||||
Визуализация и наблюдаемость строятся через:
|
||||
|
||||
- Grafana;
|
||||
- Prometheus-compatible monitoring path;
|
||||
- `1С` analytics dashboards;
|
||||
- Proxmox Web Gateway как operator entrypoint.
|
||||
|
||||
### Proxmox Web Gateway
|
||||
|
||||
Gateway дает:
|
||||
|
||||
- одну точку входа для операторов и руководства;
|
||||
- маршруты на Proxmox GUI, AW-rus UI, management pages, Grafana;
|
||||
- HTTPS access path для внутреннего management contour.
|
||||
|
||||
## Преимущества перед конкурентами
|
||||
|
||||
### Open-source
|
||||
|
||||
- нет vendor lock-in;
|
||||
- прозрачный код и архитектура;
|
||||
- можно дорабатывать под процессы заказчика;
|
||||
- проще аудитировать и сопровождать.
|
||||
|
||||
### Легкий агент
|
||||
|
||||
- PowerShell collector model;
|
||||
- нет обязательного тяжелого kernel-level агента;
|
||||
- легче пилот и проще сопровождение.
|
||||
|
||||
### Гибкая DLP политика
|
||||
|
||||
- JSON-based policy;
|
||||
- server-side policy API;
|
||||
- monitor/enforce режимы;
|
||||
- адаптация под реальные каналы утечки и корпоративные правила.
|
||||
|
||||
### Русификация
|
||||
|
||||
- русифицированный WebUI;
|
||||
- русские Grafana dashboards;
|
||||
- русская эксплуатационная документация;
|
||||
- нормальная operator terminology без англоязычного vendor-noise.
|
||||
|
||||
### Linux поддержка
|
||||
|
||||
- Linux server-side runtime;
|
||||
- Linux operational integrations;
|
||||
- гибридный Windows/Linux контур.
|
||||
|
||||
### Management Layer
|
||||
|
||||
Это сильная дифференциация относительно простых time-tracker решений:
|
||||
|
||||
- actions;
|
||||
- executive summary;
|
||||
- source freshness;
|
||||
- owner/department rollups;
|
||||
- trend и management pages.
|
||||
|
||||
### Низкая стоимость владения
|
||||
|
||||
По сравнению с классическими enterprise DLP-платформами заказчик получает шанс:
|
||||
|
||||
- снизить лицензионную нагрузку;
|
||||
- не переплачивать за лишний функционал;
|
||||
- дешевле входить в пилот;
|
||||
- лучше контролировать стоимость масштабирования.
|
||||
|
||||
Корректная подача здесь простая: это не “бесплатная замена любому enterprise DLP”, а прагматичный контур с сильным TCO-профилем.
|
||||
|
||||
## Сценарии использования
|
||||
|
||||
### Защита от утечек
|
||||
|
||||
Подходит, если нужно:
|
||||
|
||||
- видеть рискованные действия по `clipboard`, `USB`, печати, email, browser и files;
|
||||
- фиксировать инциденты;
|
||||
- в нужных каналах включать block/restrict path.
|
||||
|
||||
### Мониторинг продуктивности
|
||||
|
||||
Подходит, если компании нужно:
|
||||
|
||||
- учитывать активность в RDP;
|
||||
- получать реальные worktime-данные;
|
||||
- понимать, кто неактивен по факту, а не по формальному входу в систему.
|
||||
|
||||
### Комплаенс 152-ФЗ
|
||||
|
||||
Система полезна как practical control/evidence layer:
|
||||
|
||||
- DLP incidents;
|
||||
- compliance reports;
|
||||
- operator review;
|
||||
- контроль работы с чувствительными данными.
|
||||
|
||||
Это не “автоматическая сертификация”, а инструмент реального operational compliance support.
|
||||
|
||||
### Интеграция с 1С
|
||||
|
||||
Подходит для компаний, где важно:
|
||||
|
||||
- видеть состояние файловых баз;
|
||||
- понимать активность и риски по предприятиям;
|
||||
- связывать ИТ, ИБ и управленческий слой.
|
||||
|
||||
### Управленческий контроль
|
||||
|
||||
Подходит для:
|
||||
|
||||
- руководителей подразделений;
|
||||
- операционных менеджеров;
|
||||
- ИБ и ИТ, которым нужны единые summary и actions;
|
||||
- сменных и распределенных управленческих контуров.
|
||||
|
||||
### Forensic анализ
|
||||
|
||||
Полезен для заказчиков, которым нужен:
|
||||
|
||||
- bounded forensic follow-up;
|
||||
- EVTX-based post-incident path;
|
||||
- связка инцидента, кейса и расследования в одном operational контуре.
|
||||
|
||||
## Технические требования
|
||||
|
||||
Базовый practical profile:
|
||||
|
||||
- `Windows 10/11` для рабочих станций;
|
||||
- `Windows Server` / RDP-host сценарии, включая текущий production-target `Windows Server 2025`;
|
||||
- `Linux` серверный контур на `Debian/Ubuntu`;
|
||||
- `Docker` для части monitoring/analytics stack;
|
||||
- `PostgreSQL` и/или другие аналитические БД в интеграционных сценариях;
|
||||
- `ClickHouse` для file-based `1С` analytics;
|
||||
- Grafana для визуализации.
|
||||
|
||||
Иными словами, продукт не требует exotic stack и нормально ложится в типовую инфраструктуру компании.
|
||||
|
||||
## Уровни зрелости продукта
|
||||
|
||||
Состояние продукта корректно описывать так:
|
||||
|
||||
- operational phases `1-3` по production health, operator path и Windows hardening уже закрыты;
|
||||
- server-side DLP chain, content-analysis base и docs/release sync уже реализованы;
|
||||
- maturity по DLP roadmap сейчас выглядит так:
|
||||
- `Phase 1` — сделано;
|
||||
- `Phase 2` — внедрено частично;
|
||||
- `Phase 2.5` enforcement и email outbound — внедрены;
|
||||
- `Phase 3+` — дальнейшее развитие policy/correlation/SIEM/advanced analytics.
|
||||
|
||||
Roadmap дальше идет в сторону:
|
||||
|
||||
- deeper DLP runtime;
|
||||
- дополнительных regression guards;
|
||||
- усиления management и integration layer.
|
||||
|
||||
То есть продукт уже production-usable, но остается пространством для целевых enterprise-усилений под конкретного заказчика.
|
||||
|
||||
## Стоимость и ROI
|
||||
|
||||
### Сравнение с enterprise решениями
|
||||
|
||||
Типовой enterprise DLP-проект часто означает:
|
||||
|
||||
- дорогое лицензирование;
|
||||
- тяжелый агент;
|
||||
- длительный rollout;
|
||||
- дорогое сопровождение изменений.
|
||||
|
||||
`AWatch-rus` выигрывает там, где заказчику важны:
|
||||
|
||||
- lower entry cost;
|
||||
- управляемый пилот;
|
||||
- понятная архитектура;
|
||||
- возможность адаптации без полной смены платформы.
|
||||
|
||||
### Экономия на лицензиях
|
||||
|
||||
Корректная коммерческая формулировка такая:
|
||||
|
||||
- заказчик потенциально экономит на лицензиях и внедрении по сравнению с тяжелыми enterprise-пакетами;
|
||||
- итоговая экономия зависит от числа endpoint'ов, объема enforcement, требований к SIEM/SSO/RBAC и объема кастомизации;
|
||||
- сильная сторона решения — контролируемая стоимость владения, а не обещание “заменить все enterprise DLP в один клик”.
|
||||
|
||||
## Поддержка и обучение
|
||||
|
||||
Проект уже опирается на:
|
||||
|
||||
- подробную русскую документацию;
|
||||
- runbook и deployment guides;
|
||||
- Ansible и PowerShell automation;
|
||||
- community-style support model;
|
||||
- возможность кастомизации под нужды конкретного заказчика.
|
||||
|
||||
Для коммерческого внедрения это означает, что можно предложить:
|
||||
|
||||
- пилот;
|
||||
- rollout;
|
||||
- обучение операторов и ИБ;
|
||||
- кастомизацию dashboard, policy и integration path.
|
||||
|
||||
## Контакты и следующий шаг
|
||||
|
||||
Практический следующий шаг для потенциального заказчика:
|
||||
|
||||
1. Провести короткий discovery по инфраструктуре, числу Windows/RDP-host'ов и наличию `1С`.
|
||||
2. Определить, нужен ли только monitor-mode или сразу важен enforcement path.
|
||||
3. Выделить пилотный сегмент.
|
||||
4. Поднять pilot deployment с management report layer и базовым DLP/monitoring контуром.
|
||||
5. После пилота решить, какие enterprise-усиления действительно нужны, а какие не дадут окупаемого эффекта.
|
||||
|
||||
Самая сильная подача продукта простая: не обещать “всё для всех”, а показывать, что `AWatch-rus` уже дает работающий operational control contour с DLP, management и forensic follow-up там, где многие компании либо переплачивают за тяжелые платформы, либо вообще живут без управляемого контроля.
|
||||
@@ -0,0 +1,454 @@
|
||||
# AWatch-rus: обзор системы для службы информационной безопасности
|
||||
|
||||
## Обзор системы
|
||||
|
||||
`AWatch-rus` в текущем состоянии — это не только русифицированный `ActivityWatch`, а полный production-контур контроля пользовательской активности, DLP-сигналов, управленческой отчетности и bounded forensic follow-up.
|
||||
|
||||
Архитектурно систему удобно рассматривать как **четыре основных operational tiers с выделенным forensic layer**:
|
||||
|
||||
1. `Windows Clients / RDP host`
|
||||
На рабочих станциях и RDP-хостах работают PowerShell-коллекторы, которые собирают активность и DLP-сигналы.
|
||||
2. `Linux Server`
|
||||
Серверный контур `AW-rus` на Linux принимает события, хранит bucket-данные, отдает WebUI и server-side API.
|
||||
3. `Integration Layer`
|
||||
Здесь живут policy engine, case management, SIEM/webhook/syslog/CEF интеграции, Telegram operator path, `1C`-аналитика и внешние poller'ы.
|
||||
4. `Monitoring Stack`
|
||||
Grafana, Prometheus, SQL/ClickHouse аналитические слои и operator gateway для обзорных и управленческих экранов.
|
||||
5. `Forensic Layer`
|
||||
Отдельный bounded DFIR-путь через `Hayabusa`, который используется для post-incident enrichment, а не как основной real-time detector.
|
||||
|
||||
Подтвержденный runtime для `AWatch-rus`:
|
||||
|
||||
- `<AW_SERVER_HOST>` — основной `AW-rus` server, health, worktime/reporting, DLP server-side services, `Hayabusa` processing.
|
||||
- `<GATEWAY_HOST>` — operator/gateway host, Telegram bot, web gateway, часть `1C` analytics runtime.
|
||||
- `<WINDOWS_HOST>` — `HOST-EXAMPLE`, Windows/RDP host с collector toolkit.
|
||||
- `<GRAFANA_HOST>` — Grafana.
|
||||
- `<FIREWALL_HOST>` — `pfSense`, сетевой perimeter и VPN.
|
||||
|
||||
Ключевые потоки данных:
|
||||
|
||||
- endpoint collector -> `AW-rus` API -> `aw-dlp-endpoint-signals_*`, `aw-file-operations_*`, `aw-worktime-sessions_*`, `aw-dlp-incidents_*`;
|
||||
- server-side policy/case/integration services -> operator workflows и compliance artifacts;
|
||||
- worktime/management API на `:5610` -> management pages, executive summary, trend/source freshness;
|
||||
- `1C` file telemetry -> ClickHouse/API/Grafana management contour;
|
||||
- EVTX package -> `Hayabusa` intake -> case linkage / Telegram alert / bounded metadata.
|
||||
|
||||
## DLP функционал
|
||||
|
||||
### Endpoint Signals Collector
|
||||
|
||||
Файл: `windows/dlp-endpoint-signals-collector.ps1`
|
||||
|
||||
Реализует:
|
||||
|
||||
- мониторинг `clipboard`;
|
||||
- мониторинг печати;
|
||||
- мониторинг `USB`;
|
||||
- загрузку локальной или server-side DLP policy;
|
||||
- генерацию heartbeat и incident событий;
|
||||
- transport queue на диске с lock-файлом и безопасным flush-потоком;
|
||||
- telemetry по `queueDepth`, `eventsEnqueued`, `eventsFlushed`, `sendFailures`.
|
||||
|
||||
Для `action: "block"` реализованы активные меры:
|
||||
|
||||
- `clipboard` — очистка буфера обмена;
|
||||
- `USB` — write-block через `Set-Disk -IsReadOnly`;
|
||||
- `print` — отмена print jobs.
|
||||
|
||||
Важно:
|
||||
|
||||
- enforcement уже реализован, но его scope ограничен endpoint/email каналами;
|
||||
- это не inline network DLP и не full-content gateway.
|
||||
|
||||
### Browser Domains Collector с категоризацией
|
||||
|
||||
Файл: `windows/browser-domains-native-collector.ps1`
|
||||
|
||||
Реализует:
|
||||
|
||||
- сбор доменов и web-контекста;
|
||||
- нормализацию в `aw-detmir-web-category_*`;
|
||||
- сопоставление доменов с policy rules;
|
||||
- генерацию DLP incident событий по web-правилам.
|
||||
|
||||
Практическое ограничение:
|
||||
|
||||
- web-контур в текущей модели в первую очередь наблюдающий и аналитический;
|
||||
- Telegram DLP toggle не превращает browser path в настоящий inline web-block.
|
||||
|
||||
### Email Outbound Collector
|
||||
|
||||
Файл: `windows/email-outbound-collector.ps1`
|
||||
|
||||
Реализует:
|
||||
|
||||
- мониторинг исходящей почты через Outlook COM и сетевые SMTP-сигналы;
|
||||
- DLP-правила `endpoint.email[]`;
|
||||
- reaction path для `action: "block"` через перемещение письма в Drafts в Outlook mode;
|
||||
- privacy-preserving подход: тема и получатели могут храниться как hash/metadata, без постоянного чтения тела письма.
|
||||
|
||||
### DLP Aggregator
|
||||
|
||||
Файл: `scripts/aggregate_dlp_events.py`
|
||||
|
||||
Реализует:
|
||||
|
||||
- сбор `aw-file-operations_*` и `aw-dlp-incidents_*` в нормализованную БД;
|
||||
- SQLite/PostgreSQL режимы;
|
||||
- `PRAGMA journal_mode=WAL` для SQLite;
|
||||
- основу для Grafana/SIEM-style reporting и поиска по событиям.
|
||||
|
||||
### DLP Policy API
|
||||
|
||||
Каталог: `aw-server/dlp-policy-engine/`
|
||||
|
||||
Реализует:
|
||||
|
||||
- централизованную активную policy;
|
||||
- versioning и checksum;
|
||||
- endpoint pull-model;
|
||||
- API `GET /api/0/dlp/policies/active`;
|
||||
- API `GET /api/0/dlp/policies/active/version`;
|
||||
- agent heartbeat / desired state path.
|
||||
|
||||
Это уже production-usable server-side policy layer, но не enterprise policy suite с RBAC, approval matrix и криптографической подписью policy bundle.
|
||||
|
||||
## Управленческий мониторинг
|
||||
|
||||
### Management Report Layer на `:5610`
|
||||
|
||||
Файл: `aw-server/aw-worktime-api.py`
|
||||
|
||||
Контур включает:
|
||||
|
||||
- `GET /reports/worktime/today`;
|
||||
- `GET /reports/worktime/management`;
|
||||
- форматы `json`, `csv`, `html`;
|
||||
- отдельную управленческую интерпретацию рабочего окна против календарной активности.
|
||||
|
||||
### Алиасы пользователей
|
||||
|
||||
Контур поддерживает:
|
||||
|
||||
- alias-файл сотрудников;
|
||||
- owner/department mapping;
|
||||
- manager-facing rollups по `owner` и `department`;
|
||||
- нормализацию display names и руководителей.
|
||||
|
||||
### Actions с приоритетами
|
||||
|
||||
Management report строит:
|
||||
|
||||
- очередь действий;
|
||||
- `critical/high` приоритеты;
|
||||
- owner/department scope;
|
||||
- executive interpretation уровня “что делать сегодня”.
|
||||
|
||||
### Source freshness monitoring
|
||||
|
||||
В management layer уже встроен контроль свежести источников:
|
||||
|
||||
- `aw-worktime-sessions_*`;
|
||||
- `aw-watcher-window_*`;
|
||||
- `aw-watcher-afk_*`;
|
||||
- `aw-file-operations_*`;
|
||||
- `aw-detmir-web-category_*`;
|
||||
- смежные operational buckets.
|
||||
|
||||
Это важно с ИБ-позиции: система различает “данные есть, но пользователь не работал” и “данные stale, поэтому вывод ненадежен”.
|
||||
|
||||
### Executive summary и trend-анализ
|
||||
|
||||
Server-side management report уже выдает:
|
||||
|
||||
- summary по active/inactive users;
|
||||
- actions queue;
|
||||
- executive summary;
|
||||
- trend за несколько дней;
|
||||
- filtered management view по owner/department.
|
||||
|
||||
Практический смысл:
|
||||
|
||||
- это не просто тайм-трекер;
|
||||
- это управленческий слой поверх telemetry, который помогает различать operational drift, real inactivity и collector degradation.
|
||||
|
||||
## Мониторинг и визуализация
|
||||
|
||||
### Prometheus Exporter
|
||||
|
||||
В проекте есть operational contour с Prometheus-compatible health/metrics logic и E2E проверками. Это используется для контроля server-side доступности и для внешних dashboard/alert workflows.
|
||||
|
||||
### Grafana дашборды
|
||||
|
||||
Version-controlled dashboard JSON находятся в `grafana/` и `clickhouse-1c/grafana/...`.
|
||||
|
||||
Основные экраны:
|
||||
|
||||
- RDP/worktime activity;
|
||||
- DLP и ИБ overview;
|
||||
- management/security boards;
|
||||
- `1C` file telemetry;
|
||||
- `1C` management board;
|
||||
- financial reporting board.
|
||||
|
||||
### SQL Exporter для 1С KPI
|
||||
|
||||
Для `1C` контура реализован отдельный analytics stack:
|
||||
|
||||
- `ClickHouse`;
|
||||
- ETL;
|
||||
- company intelligence marts;
|
||||
- management pages;
|
||||
- Grafana dashboards.
|
||||
|
||||
Это read-only аналитический слой поверх telemetry и выгрузок, а не write-back path в production `1C`.
|
||||
|
||||
### E2E мониторинг
|
||||
|
||||
Контур уже содержит:
|
||||
|
||||
- `aw-health-check`;
|
||||
- `scripts/dlp-health-check.py`;
|
||||
- `check-aw-full.sh`;
|
||||
- `check-aw-data.sh`;
|
||||
- autoheal для worktime/reporting;
|
||||
- внешний операторский контроль через Telegram bot.
|
||||
|
||||
### Proxmox Web Gateway
|
||||
|
||||
Развертывание: `ansible/deploy_proxmox_web_gateway.yml`
|
||||
|
||||
Назначение:
|
||||
|
||||
- единая внутренняя точка входа для operator/management pages;
|
||||
- HTTPS reverse entrypoint;
|
||||
- маршруты на Proxmox GUI, AW-rus UI, management reports, Grafana и `1C` pages.
|
||||
|
||||
## Надежность и отказоустойчивость
|
||||
|
||||
### WAL buffering
|
||||
|
||||
В проекте используются два близких, но разных механизма устойчивости:
|
||||
|
||||
- **server-side SQLite WAL** в policy/case/aggregation storage;
|
||||
- **Windows collector disk queue** с lock-файлами и последующим flush в AW API.
|
||||
|
||||
Это снижает риск потери событий при кратковременной сетевой недоступности и при transient server-side сбоях.
|
||||
|
||||
### Graceful shutdown
|
||||
|
||||
Collector и server-side сервисы проектировались так, чтобы:
|
||||
|
||||
- не терять queued данные при штатной остановке;
|
||||
- не держать transport lock во время network I/O;
|
||||
- не блокировать весь pipeline одним зависшим POST.
|
||||
|
||||
### Health snapshots
|
||||
|
||||
Реализованы:
|
||||
|
||||
- `aw-rus-healthd.py`;
|
||||
- state snapshots в `AW_RUS_HEALTH_STATE_DIR`;
|
||||
- validation snapshots по Windows deploy/validation path;
|
||||
- `Hayabusa` state snapshots (`latest-intake.json`).
|
||||
|
||||
Это дает operator и ИБ-команде не только “жив/мертв”, но и подтвержденное состояние последней валидации.
|
||||
|
||||
### Retry с exponential backoff
|
||||
|
||||
Реализован retry/backoff path минимум в:
|
||||
|
||||
- Windows transport queue flush;
|
||||
- webhook sender;
|
||||
- ряде integration/ingest контуров.
|
||||
|
||||
Это защищает от transient network/API failure, не превращая ошибку в постоянный incident storm.
|
||||
|
||||
### Предотвращение дубликатов процессов
|
||||
|
||||
В проекте есть отдельная работа против multi-instance regressions:
|
||||
|
||||
- lock-файлы для recovery/launch loops;
|
||||
- проверки на stale queue + held lock;
|
||||
- hardening deployment для Windows/RDP;
|
||||
- частичное dedupe по incident/case semantics.
|
||||
|
||||
Практически это уменьшает риск process storm и ложных дублей telemetry.
|
||||
|
||||
### Ротация архивов деплоя
|
||||
|
||||
В Windows deploy toolkit и forensic/ingest контурах есть архивирование и ротация:
|
||||
|
||||
- deploy/install archives;
|
||||
- backup/rollback roots;
|
||||
- `Hayabusa` package archive и extracted payload archive;
|
||||
- install-kit snapshots.
|
||||
|
||||
Это важно для расследований и rollback, потому что артефакты не исчезают после первой обработки.
|
||||
|
||||
## Интеграции
|
||||
|
||||
### Hayabusa forensic анализ
|
||||
|
||||
`Hayabusa` интегрирован как bounded DFIR enrichment:
|
||||
|
||||
- Windows экспортирует EVTX package;
|
||||
- сервер принимает пакет в drop/inbox;
|
||||
- `aw-hayabusa` строит forensic report;
|
||||
- case linkage пишет bounded metadata;
|
||||
- `high`-severity path может триггерить Telegram alert.
|
||||
|
||||
Ключевая граница:
|
||||
|
||||
- `Hayabusa` не является primary runtime detector;
|
||||
- это forensic follow-up после инцидентов.
|
||||
|
||||
### pfSense poller
|
||||
|
||||
Файл: `pfsense/pfsense-aw-poller.py`
|
||||
|
||||
Реализует:
|
||||
|
||||
- внешний poller для `pfSense` API;
|
||||
- отправку сетевой telemetry в `ActivityWatch`;
|
||||
- включение firewall/VPN perimeter в единый observability contour.
|
||||
|
||||
### File-1C telemetry
|
||||
|
||||
Файл: `windows/export-upload-file-1c-telemetry.ps1`
|
||||
|
||||
Реализует:
|
||||
|
||||
- read-only telemetry по файловым базам `1C`;
|
||||
- snapshots по `db size`, `reglog`, active locks, temp markers, scheduler activity;
|
||||
- передачу данных в аналитический `ClickHouse` контур.
|
||||
|
||||
### TSJ Guardian Bot
|
||||
|
||||
Файл: `proxmox/tsj_guardian_bot.py`
|
||||
|
||||
Реализует:
|
||||
|
||||
- operator-facing health checks;
|
||||
- DLP mode control;
|
||||
- bounded auto-heal;
|
||||
- status, support and investigation commands;
|
||||
- human-readable operator menu для DLP и forensic path.
|
||||
|
||||
### MCP / PowerShell remote для AWatch-rus
|
||||
|
||||
Документ: `docs/POWERSHELL_MCP_REMOTE_RU.md`
|
||||
|
||||
Реализует:
|
||||
|
||||
- operator/Codex remote path к Windows host;
|
||||
- `SSH + powershell.exe` вместо `WSMan` для interactive operations;
|
||||
- преднастроенный управляемый PowerShell path для `<WINDOWS_HOST>`.
|
||||
|
||||
## Деплой и эксплуатация
|
||||
|
||||
### Proxmox LXC
|
||||
|
||||
Базовый production deployment рассчитан на:
|
||||
|
||||
- Proxmox;
|
||||
- LXC/CT для `AW-rus` server и смежных сервисов;
|
||||
- отдельные runtime-host'ы для Grafana и operator/gateway paths.
|
||||
|
||||
### Ansible automation
|
||||
|
||||
Репозиторий содержит playbook'и для:
|
||||
|
||||
- server deployment;
|
||||
- Windows deployment через `WinRM`;
|
||||
- Grafana dashboard import;
|
||||
- `pfSense` poller rollout;
|
||||
- Proxmox web gateway rollout;
|
||||
- bot/operator infrastructure.
|
||||
|
||||
### Windows deploy modes
|
||||
|
||||
Поддерживаются:
|
||||
|
||||
- `single-user`;
|
||||
- `domain-users`;
|
||||
- `ensemble`;
|
||||
- standalone-service deployment mode;
|
||||
- validation и hardening/recovery paths.
|
||||
|
||||
### Backup / rollback
|
||||
|
||||
В эксплуатационной модели уже предусмотрены:
|
||||
|
||||
- backup-first approach;
|
||||
- deploy archives;
|
||||
- rollback roots;
|
||||
- `vzdump`/snapshot сценарии для LXC;
|
||||
- forensic archive paths для intake payloads.
|
||||
|
||||
### Health validation publishing
|
||||
|
||||
Операционная модель уже поддерживает публикацию validation/health state:
|
||||
|
||||
- server-side health snapshots;
|
||||
- Windows validation reports;
|
||||
- transport freshness checks;
|
||||
- operator-visible status через runbook и Telegram path.
|
||||
|
||||
## Безопасность и приватность
|
||||
|
||||
### Хранение секретов
|
||||
|
||||
Проектный принцип:
|
||||
|
||||
- реальные секреты не должны лежать в репозитории;
|
||||
- используются `.example` и local secret files;
|
||||
- для PowerShell/MCP отдельно оговорен локальный secret-config с правами `600`.
|
||||
|
||||
### Приватность данных
|
||||
|
||||
Ключевые ограничения и свойства:
|
||||
|
||||
- система не ведет постоянную запись экрана;
|
||||
- OCR применяется к incident artifacts, а не к постоянному screen stream;
|
||||
- email path не обязан хранить тело писем в открытом виде;
|
||||
- management и `1C` слои строятся на read-only telemetry/выгрузках.
|
||||
|
||||
### Сетевая безопасность
|
||||
|
||||
Целевой operational подход:
|
||||
|
||||
- внутренний/VPN access вместо лишней публикации сервисов наружу;
|
||||
- `pfSense` как perimeter control;
|
||||
- operator access через gateway и управляемые entrypoints;
|
||||
- `SSH` и `WinRM` разделены по назначению.
|
||||
|
||||
### Права доступа
|
||||
|
||||
Практическая модель прав:
|
||||
|
||||
- endpoint collectors и enforcement-функции требуют локальные Windows-права по своему каналу;
|
||||
- часть enforcement logic требует admin/SYSTEM scope;
|
||||
- server-side operator actions должны идти через ограниченные operational paths, а не прямой произвольный shell everywhere.
|
||||
|
||||
### TLS для Proxmox gateway
|
||||
|
||||
`Proxmox Web Gateway` разворачивается через `nginx` с TLS:
|
||||
|
||||
- HTTP redirect на HTTPS;
|
||||
- `TLSv1.2` / `TLSv1.3`;
|
||||
- отдельные certificate/key paths;
|
||||
- по умолчанию возможен self-signed режим;
|
||||
- для production рекомендуется заменить self-signed на корпоративный сертификат и держать gateway во внутреннем management contour.
|
||||
|
||||
## Вывод для ИБ
|
||||
|
||||
`AWatch-rus` уже дает практический DLP/monitoring/investigation contour для Windows/RDP и связанного Linux/operator слоя:
|
||||
|
||||
- endpoint и email DLP;
|
||||
- management и source-freshness layer;
|
||||
- case/integration/reporting path;
|
||||
- bounded `Hayabusa` follow-up;
|
||||
- production automation и health/autoheal.
|
||||
|
||||
При этом систему нужно честно оценивать как **open-source industrial scaffold с реализованными production-механиками**, а не как полностью завершенную enterprise DLP-платформу со встроенным RBAC, SSO и hardware-grade isolation.
|
||||
@@ -1,5 +1,4 @@
|
||||
#!/bin/sh
|
||||
# shellcheck disable=SC1007
|
||||
set -eu
|
||||
|
||||
REPO_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
#!/bin/sh
|
||||
# shellcheck disable=SC1007
|
||||
set -eu
|
||||
|
||||
REPO_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# Сторонние компоненты
|
||||
|
||||
Основной перечень сторонних компонентов ведется в
|
||||
`docs/THIRD_PARTY_LICENSES_RU.md`.
|
||||
|
||||
## Runtime и инфраструктура
|
||||
|
||||
- ActivityWatch;
|
||||
- Rust crates ecosystem;
|
||||
- Grafana;
|
||||
- Prometheus / InfluxDB compatible metrics stack;
|
||||
- Ansible;
|
||||
- PowerShell / Windows Task Scheduler;
|
||||
- SQLite;
|
||||
- Hayabusa и связанные DFIR-инструменты при включении модуля расследования.
|
||||
|
||||
## Правило поставки
|
||||
|
||||
В публичную поставку не входят production inventory, пароли, токены, домены,
|
||||
IP-адреса конкретного экземпляра, customer runtime data и локальные операторские
|
||||
пути. Такие параметры задаются в приватной конфигурации экземпляра.
|
||||
|
||||
## Лицензии
|
||||
|
||||
Для собственных частей проекта используется лицензия, указанная в `LICENSE`.
|
||||
Лицензии сторонних компонентов должны проверяться перед коммерческой поставкой
|
||||
и фиксироваться в составе release package.
|
||||
@@ -0,0 +1,185 @@
|
||||
# AWatch-rus: сторонние компоненты и лицензии
|
||||
|
||||
Статус документа: рабочий license inventory для подготовки поставки и
|
||||
экспертной проверки. Документ не является юридическим заключением. Перед
|
||||
коммерческой поставкой или подачей в реестр нужно выполнить полный
|
||||
автоматизированный SBOM/license audit по конкретной release-сборке.
|
||||
|
||||
## 1. Собственный код проекта
|
||||
|
||||
Собственные компоненты `AWatch-rus`:
|
||||
|
||||
- Rust workspace `adk-rust/`;
|
||||
- AWatch-rus status/check/auto/heal helpers;
|
||||
- worktime exporters/API/bridge/autoheal;
|
||||
- DLP server-side helpers;
|
||||
- evidence API и portal helpers;
|
||||
- Ansible playbooks и deployment automation;
|
||||
- Windows PowerShell collectors/deployment scripts;
|
||||
- ActivityWatch RU WebUI patches;
|
||||
- Grafana dashboards проекта;
|
||||
- install-kit tooling;
|
||||
- документация.
|
||||
|
||||
Для собственного кода в корне репозитория указан `LICENSE`:
|
||||
|
||||
```text
|
||||
Apache License 2.0
|
||||
```
|
||||
|
||||
Это применимо только к собственным частям проекта. Сторонние компоненты
|
||||
сохраняют свои лицензии.
|
||||
|
||||
## 2. Ключевые сторонние компоненты
|
||||
|
||||
| Компонент | Роль в продукте | Типовая лицензия upstream | Статус поставки | Комментарий для аудита |
|
||||
|---|---|---|---|---|
|
||||
| ActivityWatch | Базовый сбор и API событий активности | MPL-2.0 | Устанавливается/используется как внешний компонент | Weak copyleft на измененные MPL-файлы; модификации ActivityWatch нужно учитывать отдельно. |
|
||||
| Grafana OSS | Dashboards и визуализация | AGPL-3.0 для современных версий Grafana OSS | Обычно внешний сервис/контейнер, не собственный код AWatch-rus | AGPL требует отдельной проверки модели распространения и сетевого использования. |
|
||||
| Prometheus | Monitoring ecosystem, exporters, scrape model | Apache-2.0 | Внешний компонент при включении мониторинга | Совместим с Apache-поставкой при соблюдении notice/license требований. |
|
||||
| InfluxDB / compatible TSDB | Хранилище временных рядов `aw_metrics` | Зависит от версии/дистрибутива | Внешний компонент | Зафиксировать конкретную версию в release notes. |
|
||||
| Hayabusa | Offline/DFIR timeline и enrichment | AGPLv3; rules могут иметь Detection Rule License | Опциональный прикладной модуль расследования | Не позиционировать как ядро продукта; проверить obligations при включении в поставку. |
|
||||
| Ansible | Deployment automation | GPL-3.0-or-later для Ansible core | Инструмент установки | Обычно не линкуется с кодом продукта; входит в toolchain. |
|
||||
| PowerShell | Windows deployment/collectors runtime | MIT для PowerShell Core; Windows PowerShell как компонент ОС | Runtime/tooling | Уточнять окружение заказчика: Windows PowerShell или PowerShell 7. |
|
||||
| SQLite | Local state/warehouse DB | Public domain/blessing style | Embedded/library/runtime | Обычно низкий license risk. |
|
||||
| ClickHouse clients/tooling | 1C/file analytics integration | Зависит от клиента; ClickHouse server Apache-2.0 | Отдельный 1C/business-data слой | Не является обязательным ядром AWatch-rus. |
|
||||
| OpenAI/Pollinations-compatible integrations | AI assistant/integration paths | API terms, не open-source license | Опционально | Не включать ключи/API credentials в поставку. |
|
||||
|
||||
## 3. Rust dependencies
|
||||
|
||||
Rust является основным runtime-слоем AWatch-rus. Точный список зависимостей должен
|
||||
фиксироваться по `Cargo.lock` конкретного релиза.
|
||||
|
||||
Ключевые crates, используемые в workspace:
|
||||
|
||||
| Crate | Назначение | Типичные лицензии ecosystem | Действие перед релизом |
|
||||
|---|---|---|---|
|
||||
| `anyhow` | Error handling | MIT OR Apache-2.0 | Проверить через `cargo about`. |
|
||||
| `clap` | CLI parsing | MIT OR Apache-2.0 | Проверить transitive deps. |
|
||||
| `chrono` | Date/time | MIT OR Apache-2.0 | Зафиксировать версию. |
|
||||
| `serde`, `serde_json`, `serde_yaml` | Serialization | MIT OR Apache-2.0 | Проверить YAML transitive deps. |
|
||||
| `reqwest` | HTTP client | MIT OR Apache-2.0 | Проверить TLS backend и transitive deps. |
|
||||
| `rusqlite` | SQLite access | MIT | Проверить bundled/system SQLite режим. |
|
||||
| `regex` | Matching rules | MIT OR Apache-2.0 | Низкий риск. |
|
||||
| `sha2` | Hashing | MIT OR Apache-2.0 | Низкий риск. |
|
||||
| `base64` | Encoding/decoding | MIT OR Apache-2.0 | Низкий риск. |
|
||||
| `tiny_http` | Lightweight HTTP service | MIT OR Apache-2.0 | Проверить версию. |
|
||||
| `url`, `urlencoding` | URL handling | MIT OR Apache-2.0 | Проверить transitive deps. |
|
||||
| `tempfile` | Tests/temp files | MIT OR Apache-2.0 | Test/dev dependency. |
|
||||
|
||||
Обязательные команды для release audit:
|
||||
|
||||
```bash
|
||||
cargo install cargo-about cargo-deny cargo-auditable
|
||||
cd adk-rust
|
||||
cargo metadata --locked --format-version 1 > ../docs/sbom-cargo-metadata.json
|
||||
cargo deny check
|
||||
cargo about generate about.hbs > ../docs/licenses-rust.html
|
||||
```
|
||||
|
||||
Если шаблон `about.hbs` отсутствует, его нужно добавить в release tooling или
|
||||
использовать стандартный шаблон организации.
|
||||
|
||||
## 4. Python-зависимости
|
||||
|
||||
Python не является основным runtime-ядром AWatch-rus. Он остается для
|
||||
согласованных вспомогательных направлений:
|
||||
|
||||
- Telegram bot runtime, если включен в экземпляре;
|
||||
- OCR/content-analysis path;
|
||||
- 1C/AI/ETL integration layer;
|
||||
- MCP/dev helper tools;
|
||||
- legacy-compatible scripts, не входящие в Rust-first server core.
|
||||
|
||||
Известные requirements:
|
||||
|
||||
| Файл | Назначение | Основные зависимости | License-audit действие |
|
||||
|---|---|---|---|
|
||||
| `aw-server/dlp-content-analysis/requirements.txt` | OCR/content analysis | `pytesseract`, `Pillow` | Проверить OCR stack и system Tesseract license отдельно. |
|
||||
| `aw-server/dlp-case-management/requirements.txt` | Legacy/reference case API | `fastapi`, `uvicorn`, `pydantic` | Проверить, поставляется ли как runtime или только reference. |
|
||||
| `aw-server/dlp-policy-engine/requirements.txt` | Legacy/reference policy API | `fastapi`, `uvicorn`, `pydantic` | Rust replacement должен быть primary runtime. |
|
||||
| `aw-server/dlp-compliance/requirements.txt` | Legacy/reference reports | `requests` | Проверить, не входит ли в active runtime. |
|
||||
| `aw-server/dlp-integrations/requirements.txt` | Legacy/reference integrations | `PyYAML` | Проверить статус после Rust migration. |
|
||||
| `clickhouse-1c/ai/requirements.txt` | 1C/AI APIs | `fastapi`, `uvicorn`, `clickhouse-connect` | Отдельный business-data слой. |
|
||||
| `clickhouse-1c/etl/requirements.txt` | 1C ETL | `clickhouse-connect`, `PyYAML`, `python-dateutil`, `openpyxl` | Отдельный ETL слой. |
|
||||
| `detmir-mcp` | MCP helper | Python MCP stack | Не основное runtime-ядро продукта. |
|
||||
|
||||
Команды для Python license report:
|
||||
|
||||
```bash
|
||||
python3 -m venv /tmp/detmir-license-audit
|
||||
. /tmp/detmir-license-audit/bin/activate
|
||||
python -m pip install -U pip pip-licenses
|
||||
pip-licenses --from=mixed --format=markdown > docs/licenses-python.md
|
||||
```
|
||||
|
||||
Команду нужно выполнять в окружении, где установлены зависимости конкретного
|
||||
release profile.
|
||||
|
||||
## 5. Frontend, dashboards и browser tooling
|
||||
|
||||
| Компонент | Роль | License-audit действие |
|
||||
|---|---|---|
|
||||
| Grafana dashboards JSON | Собственные dashboards AWatch-rus | Входят в собственную поставку; проверить отсутствие embedded secrets/URLs. |
|
||||
| ActivityWatch WebUI patches | Собственный overlay/patch слой | Учитывать MPL-2.0 границы ActivityWatch, если изменяются upstream файлы. |
|
||||
| Playwright/browser smoke tooling | Проверки UI | Обычно dev/test dependency; не включать в runtime claim. |
|
||||
| JavaScript snippets | WebUI patching/helper scripts | Проверить зависимости, если добавляются npm packages. |
|
||||
|
||||
## 6. Компоненты с повышенным вниманием
|
||||
|
||||
| Компонент | Причина внимания | Рекомендация |
|
||||
|---|---|---|
|
||||
| Grafana OSS | AGPL-3.0 для современных версий | В реестровой поставке описывать как внешний компонент или проверить obligations. |
|
||||
| Hayabusa | AGPLv3 + отдельная лицензия rules | Держать как optional offline module; не смешивать с закрытым ядром без аудита. |
|
||||
| Ansible | GPL toolchain | Описывать как инструмент установки, не как linked library продукта. |
|
||||
| OCR/Tesseract stack | Несколько уровней зависимостей | Фиксировать конкретные пакеты ОС и Python packages. |
|
||||
| Python legacy paths | Могут выглядеть как ядро | В документации указывать, что Rust-first runtime является основным. |
|
||||
|
||||
## 7. Что поставляется вместе с продуктом
|
||||
|
||||
В публичной поставке могут присутствовать:
|
||||
|
||||
- исходный код собственных модулей;
|
||||
- шаблоны конфигурации;
|
||||
- Ansible playbooks;
|
||||
- Grafana dashboards;
|
||||
- Windows collectors scripts;
|
||||
- install-kit archives как GitHub Release assets;
|
||||
- документация.
|
||||
|
||||
Не должны поставляться в публичном git:
|
||||
|
||||
- production inventory;
|
||||
- реальные домены/IP конкретного экземпляра;
|
||||
- пароли и токены;
|
||||
- runtime базы данных;
|
||||
- customer evidence;
|
||||
- локальная история разработки;
|
||||
- случайные binary archives в корне репозитория.
|
||||
|
||||
## 8. Release checklist по лицензиям
|
||||
|
||||
Перед каждым публичным release:
|
||||
|
||||
1. Собрать Rust SBOM по `Cargo.lock`.
|
||||
2. Выполнить `cargo deny check`.
|
||||
3. Сформировать Rust license report.
|
||||
4. Сформировать Python license report для включенных profiles.
|
||||
5. Проверить Grafana/Hayabusa/Ansible как внешние компоненты.
|
||||
6. Проверить, что root репозитория не содержит случайных архивов сборки.
|
||||
7. Проверить отсутствие secrets, private inventory, customer paths.
|
||||
8. Зафиксировать версию ActivityWatch и способ ее установки.
|
||||
9. Зафиксировать, какие optional modules включены в релиз.
|
||||
10. Сохранить отчеты в release artifacts или `docs/licenses-*`.
|
||||
|
||||
## 9. Источники для проверки upstream лицензий
|
||||
|
||||
- ActivityWatch repository/license: `https://github.com/ActivityWatch/activitywatch`
|
||||
- Grafana licensing: `https://grafana.com/licensing/`
|
||||
- Grafana repository/license: `https://github.com/grafana/grafana`
|
||||
- Prometheus repository/license: `https://github.com/prometheus/prometheus`
|
||||
- Hayabusa repository/license: `https://github.com/Yamato-Security/hayabusa`
|
||||
- Ansible repository/license: `https://github.com/ansible/ansible`
|
||||
|
||||
Финальная версия документа должна ссылаться на конкретные версии компонентов,
|
||||
использованные в release build.
|
||||
@@ -0,0 +1,4 @@
|
||||
target/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
Generated
+3498
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,95 @@
|
||||
[workspace]
|
||||
resolver = "3"
|
||||
members = [
|
||||
"crates/aw-1c-ingest",
|
||||
"crates/aw-windows-telemetry",
|
||||
"crates/awatch-agent",
|
||||
"crates/awatch-agent-rs",
|
||||
"crates/detmir-auto",
|
||||
"crates/detmir-aw-client",
|
||||
"crates/aw-db-health",
|
||||
"crates/aw-db-maintenance",
|
||||
"crates/aw-ensure-reliability",
|
||||
"crates/aw-health-check",
|
||||
"crates/aw-linux-install",
|
||||
"crates/aw-prune-local-state",
|
||||
"crates/check-aw-data",
|
||||
"crates/check-aw-full",
|
||||
"crates/check-install-kit-vs-repo",
|
||||
"crates/validate-install-kit",
|
||||
"crates/verify-innosetup-installer",
|
||||
"crates/rebuild-install-kit",
|
||||
"crates/quality-gate",
|
||||
"crates/extract-ioc-from-sigma",
|
||||
"crates/merge-aw-server-dbs",
|
||||
"crates/prod-backup-restore",
|
||||
"crates/prod-rollout",
|
||||
"crates/rdp-worktime-report",
|
||||
"crates/aw-contour-smoke",
|
||||
"crates/aw-browser-smoke",
|
||||
"crates/diag-and-manual-restart",
|
||||
"crates/detmir-grafana-check",
|
||||
"crates/detmir-readiness",
|
||||
"crates/detmir-portal",
|
||||
"crates/aw-slo-monitor",
|
||||
"crates/aw-rus-healthd",
|
||||
"crates/detmir-check",
|
||||
"crates/detmir-core",
|
||||
"crates/dlp-health-check",
|
||||
"crates/dlp-content-analyzer",
|
||||
"crates/dlp-admin-cli",
|
||||
"crates/dlp-policy-engine",
|
||||
"crates/dlp-case-management",
|
||||
"crates/dlp-compliance",
|
||||
"crates/hayabusa-tools",
|
||||
"crates/dlp-cef-exporter",
|
||||
"crates/dlp-aggregator",
|
||||
"crates/dlp-influx-exporter",
|
||||
"crates/worktime-influx-exporter",
|
||||
"crates/worktime-autoheal",
|
||||
"crates/worktime-api",
|
||||
"crates/worktime-prewarm",
|
||||
"crates/worktime-ui-bridge",
|
||||
"crates/dlp-syslog-forwarder",
|
||||
"crates/dlp-webhook-sender",
|
||||
"crates/detmir-dlp",
|
||||
"crates/detmir-heal-safe",
|
||||
"crates/detmir-status",
|
||||
"crates/detmir-state",
|
||||
"crates/tsj-guardian-status",
|
||||
"crates/tsj-guardian-watchdog",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
edition = "2024"
|
||||
rust-version = "1.85"
|
||||
license = "Apache-2.0"
|
||||
publish = false
|
||||
|
||||
[workspace.dependencies]
|
||||
adk-rust = { version = "0.9.1", default-features = false }
|
||||
anyhow = "1"
|
||||
base64 = "0.22"
|
||||
calamine = "=0.24.0"
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock", "serde", "std"] }
|
||||
clap = { version = "4", features = ["derive", "env"] }
|
||||
csv = "1"
|
||||
detmir-aw-client = { path = "crates/detmir-aw-client" }
|
||||
detmir-core = { path = "crates/detmir-core" }
|
||||
detmir-state = { path = "crates/detmir-state" }
|
||||
filetime = "0.2"
|
||||
fs2 = "0.4"
|
||||
notify = "6"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
|
||||
regex = "1"
|
||||
rusqlite = "0.32"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
serde_yaml = "0.9"
|
||||
sha1 = "0.10"
|
||||
sha2 = "0.10"
|
||||
tempfile = "3"
|
||||
tiny_http = "0.12"
|
||||
url = "2"
|
||||
urlencoding = "2"
|
||||
windows-sys = "0.59"
|
||||
@@ -0,0 +1,93 @@
|
||||
# DetMir ADK-Rust Workspace
|
||||
|
||||
This directory is the Rust workspace for replacing operational Python and shell
|
||||
scripts with durable standalone Rust modules.
|
||||
|
||||
## Layout
|
||||
|
||||
- `Cargo.toml` - workspace manifest and shared dependency versions.
|
||||
- `Cargo.lock` - pinned dependency graph for reproducible builds.
|
||||
- `crates/<module>` - one binary or library module per operational function.
|
||||
- `target/` - local build output, ignored by git.
|
||||
|
||||
## Current Modules
|
||||
|
||||
- `detmir-auto` - no-heal autonomous orchestration shadow binary for running
|
||||
`detmir-check`, `detmir-dlp`, state/report writes, latest symlink updates, and
|
||||
retention cleanup.
|
||||
- `detmir-core` - shared status levels, exit codes, and UTC timestamp helpers.
|
||||
- `detmir-state` - DetMir autonomous state models, normalization, and atomic JSON writes.
|
||||
- `detmir-aw-client` - small blocking ActivityWatch HTTP client and event timestamp helpers.
|
||||
- `detmir-check` - read-only DetMir contour check replacement for the legacy Python command.
|
||||
- `detmir-dlp` - SSH wrapper replacement for remote DLP health JSON collection.
|
||||
- `dlp-health-check` - AW server DLP health check replacement.
|
||||
- `aw-db-maintenance` - guarded weekly SQLite maintenance for old allowlisted
|
||||
process-level session events, with backup-before-delete.
|
||||
- `aw-ensure-reliability` - safe dry-run/apply planner for AW service
|
||||
reliability repair actions that were previously immediate Bash mutations.
|
||||
- `aw-linux-install` - safe dry-run/apply planner for Linux ActivityWatch
|
||||
install wrappers; legacy shell install is explicit.
|
||||
- `check-aw-full` - read-only local AW/RDP full check replacement for the
|
||||
legacy shell helper.
|
||||
- `dlp-aggregator` - AW server DLP warehouse aggregator replacement.
|
||||
- `dlp-influx-exporter` - AW server DLP InfluxDB line protocol exporter replacement.
|
||||
- `worktime-autoheal` - AW server worktime autoheal and backfill replacement.
|
||||
- `worktime-influx-exporter` - AW server worktime InfluxDB line protocol exporter replacement.
|
||||
- `worktime-prewarm` - AW server worktime report cache prewarm replacement.
|
||||
- `worktime-ui-bridge` - AW server worktime sessions to AFK/window bridge replacement.
|
||||
- `dlp-syslog-forwarder` - AW server DLP syslog integration replacement.
|
||||
- `dlp-webhook-sender` - AW server DLP webhook integration replacement.
|
||||
- `dlp-cef-exporter` - AW server DLP CEF/syslog exporter replacement.
|
||||
- `extract-ioc-from-sigma` - offline Sigma/Hayabusa IOC export replacement
|
||||
used by the DLP IOC preload wrapper.
|
||||
- `merge-aw-server-dbs` - ActivityWatch SQLite DB merge replacement used by
|
||||
legacy root DB recovery/deploy tooling.
|
||||
- `prod-backup-restore` - safe planner/checker for the destructive production
|
||||
backup-restore flow; apply remains explicit legacy-only at this stage.
|
||||
- `prod-rollout` - safe planner/orchestrator for production AW server/Windows
|
||||
rollout; normal script runs are plan-only and real rollout requires
|
||||
explicit `--apply`.
|
||||
- `rdp-worktime-report` - local RDP worktime CSV/JSON report helper
|
||||
replacement for the legacy shell/Python script.
|
||||
- `aw-contour-smoke` - Rust replacement for the Proxmox-side DetMir contour
|
||||
smoke checks, used through a Rust-first project wrapper.
|
||||
- `diag-and-manual-restart` - Rust replacement for the AW/DLP diagnostic and
|
||||
explicit manual restart helper, with conservative no-restart healthy path.
|
||||
- `aw-browser-smoke` - Rust launcher for the browser smoke test; Playwright
|
||||
remains the execution engine and the legacy Node script remains fallback.
|
||||
- `detmir-status` - read-only DetMir state normalizer with text, JSON, and ADK
|
||||
`Content` output. Also builds `detmir-adk-status` as a compatibility binary.
|
||||
|
||||
## Migration Runbook
|
||||
|
||||
Use `RUNBOOK.md` as the operational plan for replacing Python and shell modules
|
||||
with Rust. It defines migration phases, safety gates, rollback rules, and the
|
||||
order in which DetMir modules should be moved.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
cd adk-rust
|
||||
cargo fmt --all
|
||||
cargo check --workspace
|
||||
cargo build --release --workspace
|
||||
```
|
||||
|
||||
Run the current status module:
|
||||
|
||||
```bash
|
||||
cd adk-rust
|
||||
cargo run -p detmir-status -- --json
|
||||
cargo run -p detmir-status -- --adk-json
|
||||
cargo run -p detmir-status -- status --json
|
||||
```
|
||||
|
||||
## Migration Rules
|
||||
|
||||
- New replacements go under `crates/` and are added to workspace `members`.
|
||||
- Keep modules standalone: config comes from files, environment variables, or
|
||||
explicit CLI flags; no laptop-only assumptions.
|
||||
- Default to read-only behavior first; add mutation/recovery paths only with
|
||||
tests and explicit operator-safe failure modes.
|
||||
- Do not embed secrets in binaries, source files, or examples.
|
||||
- Every module should expose machine-readable JSON output where practical.
|
||||
+1773
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "aw-1c-ingest"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
calamine.workspace = true
|
||||
chrono.workspace = true
|
||||
clap.workspace = true
|
||||
csv.workspace = true
|
||||
filetime.workspace = true
|
||||
fs2.workspace = true
|
||||
regex.workspace = true
|
||||
reqwest.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
serde_yaml.workspace = true
|
||||
sha1.workspace = true
|
||||
tempfile.workspace = true
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "aw-browser-smoke"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
clap.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
@@ -0,0 +1,149 @@
|
||||
use std::ffi::{OsStr, OsString};
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use clap::Parser;
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(
|
||||
about = "Rust launcher for the ActivityWatch-Russian browser smoke test",
|
||||
trailing_var_arg = true
|
||||
)]
|
||||
struct Cli {
|
||||
#[arg(long, default_value = ".")]
|
||||
root: PathBuf,
|
||||
|
||||
#[arg(long)]
|
||||
script: Option<PathBuf>,
|
||||
|
||||
#[arg(long)]
|
||||
node: Option<PathBuf>,
|
||||
|
||||
#[arg(last = true)]
|
||||
args: Vec<OsString>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct LaunchPlan {
|
||||
node: PathBuf,
|
||||
script: PathBuf,
|
||||
args: Vec<OsString>,
|
||||
node_path: Option<OsString>,
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let code = match run() {
|
||||
Ok(code) => code,
|
||||
Err(err) => {
|
||||
eprintln!("{err:#}");
|
||||
1
|
||||
}
|
||||
};
|
||||
std::process::exit(code);
|
||||
}
|
||||
|
||||
fn run() -> Result<i32> {
|
||||
let cli = Cli::parse();
|
||||
let plan = build_launch_plan(&cli)?;
|
||||
run_child(&plan)
|
||||
}
|
||||
|
||||
fn build_launch_plan(cli: &Cli) -> Result<LaunchPlan> {
|
||||
let root = cli.root.clone();
|
||||
let script = cli
|
||||
.script
|
||||
.clone()
|
||||
.unwrap_or_else(|| root.join("scripts").join("aw-webui-browser-smoke.mjs"));
|
||||
if !script.is_file() {
|
||||
bail!("browser smoke script not found: {}", script.display());
|
||||
}
|
||||
|
||||
Ok(LaunchPlan {
|
||||
node: cli.node.clone().unwrap_or_else(|| PathBuf::from("node")),
|
||||
script,
|
||||
args: cli.args.clone(),
|
||||
node_path: default_node_path(std::env::var_os("NODE_PATH").as_deref()),
|
||||
})
|
||||
}
|
||||
|
||||
fn default_node_path(current: Option<&OsStr>) -> Option<OsString> {
|
||||
if current.is_some_and(|value| !value.is_empty()) {
|
||||
return None;
|
||||
}
|
||||
let home = std::env::var_os("HOME")?;
|
||||
let path = PathBuf::from(home)
|
||||
.join(".agents")
|
||||
.join("skills")
|
||||
.join("playwright")
|
||||
.join("node_modules");
|
||||
if path.is_dir() {
|
||||
Some(path.as_os_str().to_os_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn run_child(plan: &LaunchPlan) -> Result<i32> {
|
||||
let mut command = Command::new(&plan.node);
|
||||
command.arg(&plan.script).args(&plan.args);
|
||||
if let Some(node_path) = &plan.node_path {
|
||||
command.env("NODE_PATH", node_path);
|
||||
}
|
||||
|
||||
let status = command
|
||||
.status()
|
||||
.with_context(|| format!("run {}", plan.node.display()))?;
|
||||
Ok(status.code().unwrap_or(1))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::fs;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn keeps_existing_node_path_untouched() {
|
||||
assert_eq!(
|
||||
default_node_path(Some(OsStr::new("/custom/node_modules"))),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launch_plan_uses_default_script_and_passes_args() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let scripts = dir.path().join("scripts");
|
||||
fs::create_dir(&scripts).unwrap();
|
||||
let script = scripts.join("aw-webui-browser-smoke.mjs");
|
||||
fs::write(&script, "console.log('ok')\n").unwrap();
|
||||
|
||||
let cli = Cli {
|
||||
root: dir.path().to_path_buf(),
|
||||
script: None,
|
||||
node: Some(PathBuf::from("/usr/bin/node")),
|
||||
args: vec![OsString::from("--probe"), OsString::from("value")],
|
||||
};
|
||||
|
||||
let plan = build_launch_plan(&cli).unwrap();
|
||||
assert_eq!(plan.node, PathBuf::from("/usr/bin/node"));
|
||||
assert_eq!(plan.script, script);
|
||||
assert_eq!(
|
||||
plan.args,
|
||||
vec![OsString::from("--probe"), OsString::from("value")]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_script_is_an_error() {
|
||||
let cli = Cli {
|
||||
root: PathBuf::from("/tmp/no-such-aw-browser-root"),
|
||||
script: None,
|
||||
node: None,
|
||||
args: vec![],
|
||||
};
|
||||
let err = build_launch_plan(&cli).unwrap_err().to_string();
|
||||
assert!(err.contains("browser smoke script not found"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "aw-contour-smoke"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
chrono.workspace = true
|
||||
clap.workspace = true
|
||||
reqwest.workspace = true
|
||||
serde_json.workspace = true
|
||||
url.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
@@ -0,0 +1,425 @@
|
||||
use std::net::{TcpStream, ToSocketAddrs};
|
||||
use std::process::Command;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use clap::{Parser, ValueEnum};
|
||||
use reqwest::blocking::Client;
|
||||
use reqwest::redirect::Policy;
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(about = "ActivityWatch-Russian contour smoke checks")]
|
||||
struct Cli {
|
||||
#[arg(long, value_enum, default_value_t = Mode::ProxmoxRemote)]
|
||||
mode: Mode,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, ValueEnum)]
|
||||
enum Mode {
|
||||
ProxmoxRemote,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct Counts {
|
||||
ok: usize,
|
||||
warn: usize,
|
||||
fail: usize,
|
||||
skip: usize,
|
||||
}
|
||||
|
||||
impl Counts {
|
||||
fn pass(&mut self, msg: impl AsRef<str>) {
|
||||
self.ok += 1;
|
||||
println!("[OK] {}", msg.as_ref());
|
||||
}
|
||||
|
||||
fn fail(&mut self, msg: impl AsRef<str>) {
|
||||
self.fail += 1;
|
||||
println!("[FAIL] {}", msg.as_ref());
|
||||
}
|
||||
|
||||
fn skip(&mut self, msg: impl AsRef<str>) {
|
||||
self.skip += 1;
|
||||
println!("[SKIP] {}", msg.as_ref());
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let code = match run() {
|
||||
Ok(code) => code,
|
||||
Err(err) => {
|
||||
eprintln!("{err:#}");
|
||||
1
|
||||
}
|
||||
};
|
||||
std::process::exit(code);
|
||||
}
|
||||
|
||||
fn run() -> Result<i32> {
|
||||
let cli = Cli::parse();
|
||||
match cli.mode {
|
||||
Mode::ProxmoxRemote => run_proxmox_remote(),
|
||||
}
|
||||
}
|
||||
|
||||
fn run_proxmox_remote() -> Result<i32> {
|
||||
let mut counts = Counts::default();
|
||||
let http = Client::builder()
|
||||
.no_proxy()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.timeout(Duration::from_secs(15))
|
||||
.build()
|
||||
.context("build HTTP client")?;
|
||||
let no_redirect_http = Client::builder()
|
||||
.no_proxy()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.timeout(Duration::from_secs(15))
|
||||
.redirect(Policy::none())
|
||||
.build()
|
||||
.context("build no-redirect HTTP client")?;
|
||||
|
||||
section("Host");
|
||||
print_command("hostnamectl", &["hostnamectl"]);
|
||||
print_command("date", &["date", "-Is"]);
|
||||
print_command("uptime", &["uptime"]);
|
||||
|
||||
section("Core Services");
|
||||
for unit in [
|
||||
"nginx.service",
|
||||
"pveproxy.service",
|
||||
"pvedaemon.service",
|
||||
"pvestatd.service",
|
||||
"pve-cluster.service",
|
||||
"docker.service",
|
||||
"aw-1c-company-api.service",
|
||||
"aw-pve-webadmin-logger.service",
|
||||
] {
|
||||
check_service(&mut counts, unit);
|
||||
}
|
||||
|
||||
section("Timers");
|
||||
for unit in [
|
||||
"aw-1c-ingest.timer",
|
||||
"aw-1c-proofcheck.timer",
|
||||
"aw-1c-manager-brief.timer",
|
||||
"aw-1c-recovery-brief.timer",
|
||||
"aw-1c-weekly-digest.timer",
|
||||
] {
|
||||
check_timer(&mut counts, unit);
|
||||
}
|
||||
if let Ok(out) = command_output("systemctl", &["list-timers", "--all", "--no-pager"]) {
|
||||
print_filtered_lines(&out, &["aw-1c", "NEXT", "LEFT", "PASSED"], 40);
|
||||
}
|
||||
|
||||
section("Ports");
|
||||
check_tcp(&mut counts, "nginx http", "127.0.0.1", 80);
|
||||
check_tcp(&mut counts, "nginx https", "127.0.0.1", 443);
|
||||
check_tcp(&mut counts, "proxmox web", "127.0.0.1", 8006);
|
||||
check_tcp(&mut counts, "1C company API", "10.10.10.2", 8710);
|
||||
check_tcp(&mut counts, "clickhouse native", "127.0.0.1", 9000);
|
||||
check_tcp(&mut counts, "clickhouse http", "127.0.0.1", 8123);
|
||||
if let Ok(out) = command_output("ss", &["-tulpn"]) {
|
||||
print_filtered_lines(
|
||||
&out,
|
||||
&[":80", ":443", ":8006", ":8710", ":8123", ":9000"],
|
||||
40,
|
||||
);
|
||||
}
|
||||
|
||||
section("Gateway HTTP");
|
||||
check_http_code(
|
||||
&mut counts,
|
||||
&http,
|
||||
"nginx healthz",
|
||||
"https://127.0.0.1/healthz",
|
||||
&[200],
|
||||
);
|
||||
check_http_code(
|
||||
&mut counts,
|
||||
&http,
|
||||
"go proxmox gui protected",
|
||||
"https://127.0.0.1/go/proxmox-gui",
|
||||
&[401],
|
||||
);
|
||||
check_http_code(
|
||||
&mut counts,
|
||||
&http,
|
||||
"go file1c brief protected",
|
||||
"https://127.0.0.1/go/file1c-brief",
|
||||
&[401],
|
||||
);
|
||||
check_http_code(
|
||||
&mut counts,
|
||||
&http,
|
||||
"go file1c actions protected",
|
||||
"https://127.0.0.1/go/file1c-actions",
|
||||
&[401],
|
||||
);
|
||||
|
||||
section("1C Company API");
|
||||
check_http_code(
|
||||
&mut counts,
|
||||
&no_redirect_http,
|
||||
"1C root redirect",
|
||||
"http://10.10.10.2:8710/",
|
||||
&[307],
|
||||
);
|
||||
for (name, url) in [
|
||||
("1C /health", "http://10.10.10.2:8710/health"),
|
||||
("1C /api/health", "http://10.10.10.2:8710/api/health"),
|
||||
("1C manager brief", "http://10.10.10.2:8710/manager/brief"),
|
||||
(
|
||||
"1C manager actions",
|
||||
"http://10.10.10.2:8710/manager/actions",
|
||||
),
|
||||
(
|
||||
"1C manager recovery",
|
||||
"http://10.10.10.2:8710/manager/recovery",
|
||||
),
|
||||
(
|
||||
"1C weekly digest",
|
||||
"http://10.10.10.2:8710/manager/digest/weekly",
|
||||
),
|
||||
] {
|
||||
check_http_code(&mut counts, &http, name, url, &[200]);
|
||||
}
|
||||
|
||||
section("ClickHouse");
|
||||
check_docker_container(&mut counts, "aw-rus-1c-clickhouse");
|
||||
check_http_code(
|
||||
&mut counts,
|
||||
&http,
|
||||
"ClickHouse ping",
|
||||
"http://127.0.0.1:8123/ping",
|
||||
&[200],
|
||||
);
|
||||
if command_exists("docker") && docker_container_running("aw-rus-1c-clickhouse") {
|
||||
check_command(
|
||||
&mut counts,
|
||||
"ClickHouse SELECT 1",
|
||||
"docker",
|
||||
&[
|
||||
"exec",
|
||||
"aw-rus-1c-clickhouse",
|
||||
"clickhouse-client",
|
||||
"--query",
|
||||
"SELECT 1",
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
section("System Capacity");
|
||||
print_command("df", &["df", "-h", "/", "/var", "/opt"]);
|
||||
print_command("free", &["free", "-h"]);
|
||||
|
||||
section("Summary");
|
||||
println!(
|
||||
"OK={} WARN={} FAIL={} SKIP={}",
|
||||
counts.ok, counts.warn, counts.fail, counts.skip
|
||||
);
|
||||
Ok(if counts.fail > 0 { 2 } else { 0 })
|
||||
}
|
||||
|
||||
fn section(name: &str) {
|
||||
println!();
|
||||
println!("== {name} ==");
|
||||
}
|
||||
|
||||
fn command_exists(cmd: &str) -> bool {
|
||||
Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg(format!("command -v {cmd} >/dev/null 2>&1"))
|
||||
.status()
|
||||
.map(|status| status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn command_output(cmd: &str, args: &[&str]) -> Result<String> {
|
||||
let output = Command::new(cmd)
|
||||
.args(args)
|
||||
.output()
|
||||
.with_context(|| format!("run {cmd}"))?;
|
||||
let mut text = String::new();
|
||||
text.push_str(&String::from_utf8_lossy(&output.stdout));
|
||||
text.push_str(&String::from_utf8_lossy(&output.stderr));
|
||||
if output.status.success() {
|
||||
Ok(text)
|
||||
} else {
|
||||
Err(anyhow::anyhow!("{cmd} failed: {text}"))
|
||||
}
|
||||
}
|
||||
|
||||
fn print_command(label: &str, command: &[&str]) {
|
||||
if let Some((cmd, args)) = command.split_first() {
|
||||
match command_output(cmd, args) {
|
||||
Ok(out) => print_indented(&out, 80),
|
||||
Err(err) => println!(" {label}: {err:#}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn print_indented(text: &str, max_lines: usize) {
|
||||
for line in text.lines().take(max_lines) {
|
||||
println!(" {line}");
|
||||
}
|
||||
}
|
||||
|
||||
fn print_filtered_lines(text: &str, patterns: &[&str], max_lines: usize) {
|
||||
for line in text
|
||||
.lines()
|
||||
.filter(|line| patterns.iter().any(|pattern| line.contains(pattern)))
|
||||
.take(max_lines)
|
||||
{
|
||||
println!(" {line}");
|
||||
}
|
||||
}
|
||||
|
||||
fn check_service(counts: &mut Counts, unit: &str) {
|
||||
check_systemd_unit(counts, unit, "service");
|
||||
}
|
||||
|
||||
fn check_timer(counts: &mut Counts, unit: &str) {
|
||||
check_systemd_unit(counts, unit, "timer");
|
||||
}
|
||||
|
||||
fn check_systemd_unit(counts: &mut Counts, unit: &str, kind: &str) {
|
||||
if !Command::new("systemctl")
|
||||
.args(["list-unit-files", unit])
|
||||
.status()
|
||||
.map(|status| status.success())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
counts.skip(format!("{unit} is not installed"));
|
||||
return;
|
||||
}
|
||||
if Command::new("systemctl")
|
||||
.args(["is-active", "--quiet", unit])
|
||||
.status()
|
||||
.map(|status| status.success())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
counts.pass(format!("{unit} active"));
|
||||
} else {
|
||||
counts.fail(format!("{unit} inactive or failed"));
|
||||
if let Ok(out) = command_output("systemctl", &["--no-pager", "--lines=8", "status", unit]) {
|
||||
print_indented(&out, 30);
|
||||
} else {
|
||||
let _ = kind;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn check_tcp(counts: &mut Counts, name: &str, host: &str, port: u16) {
|
||||
let addr = format!("{host}:{port}");
|
||||
let ok = addr
|
||||
.to_socket_addrs()
|
||||
.ok()
|
||||
.and_then(|mut addrs| addrs.next())
|
||||
.and_then(|addr| TcpStream::connect_timeout(&addr, Duration::from_secs(4)).ok())
|
||||
.is_some();
|
||||
if ok {
|
||||
counts.pass(format!("{name} TCP {host}:{port}"));
|
||||
} else {
|
||||
counts.fail(format!("{name} TCP {host}:{port}"));
|
||||
}
|
||||
}
|
||||
|
||||
fn check_http_code(counts: &mut Counts, client: &Client, name: &str, url: &str, expected: &[u16]) {
|
||||
match client.get(url).send() {
|
||||
Ok(response) => {
|
||||
let code = response.status().as_u16();
|
||||
if expected.contains(&code) {
|
||||
counts.pass(format!("{name} HTTP {code} {url}"));
|
||||
} else {
|
||||
counts.fail(format!("{name} HTTP {code} {url}"));
|
||||
if let Ok(text) = response.text() {
|
||||
print_indented(&text, 40);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => counts.fail(format!("{name} HTTP error {url}: {err}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn check_command(counts: &mut Counts, name: &str, cmd: &str, args: &[&str]) {
|
||||
match command_output(cmd, args) {
|
||||
Ok(out) => {
|
||||
counts.pass(name);
|
||||
print_indented(&out, 40);
|
||||
}
|
||||
Err(err) => counts.fail(format!("{name}: {err:#}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn check_docker_container(counts: &mut Counts, name: &str) {
|
||||
if !command_exists("docker") {
|
||||
counts.skip("docker command unavailable");
|
||||
return;
|
||||
}
|
||||
if docker_container_running(name) {
|
||||
counts.pass(format!("docker container {name} running"));
|
||||
if let Ok(out) = command_output(
|
||||
"docker",
|
||||
&[
|
||||
"ps",
|
||||
"--filter",
|
||||
&format!("name=^/{name}$"),
|
||||
"--format",
|
||||
"{{.Names}} {{.Status}} {{.Ports}}",
|
||||
],
|
||||
) {
|
||||
print_indented(&out, 20);
|
||||
}
|
||||
} else {
|
||||
counts.fail(format!("docker container {name} not running"));
|
||||
if let Ok(out) = command_output(
|
||||
"docker",
|
||||
&[
|
||||
"ps",
|
||||
"-a",
|
||||
"--filter",
|
||||
&format!("name=^/{name}$"),
|
||||
"--format",
|
||||
"{{.Names}} {{.Status}} {{.Ports}}",
|
||||
],
|
||||
) {
|
||||
print_indented(&out, 20);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn docker_container_running(name: &str) -> bool {
|
||||
command_output("docker", &["ps", "--format", "{{.Names}}"])
|
||||
.map(|out| out.lines().any(|line| line == name))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn parse_json_key_present(value: &Value, key: &str) -> bool {
|
||||
value.get(key).is_some()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn counts_exit_code_matches_failures() {
|
||||
let counts = Counts {
|
||||
ok: 1,
|
||||
warn: 1,
|
||||
fail: 0,
|
||||
skip: 1,
|
||||
};
|
||||
assert_eq!(if counts.fail > 0 { 2 } else { 0 }, 0);
|
||||
let counts = Counts { fail: 1, ..counts };
|
||||
assert_eq!(if counts.fail > 0 { 2 } else { 0 }, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_counts_are_zero() {
|
||||
let counts = Counts::default();
|
||||
assert_eq!(counts.ok + counts.warn + counts.fail + counts.skip, 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "aw-db-health"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
chrono.workspace = true
|
||||
clap.workspace = true
|
||||
rusqlite.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
@@ -0,0 +1,562 @@
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use chrono::Utc;
|
||||
use clap::Parser;
|
||||
use rusqlite::{Connection, OpenFlags, OptionalExtension, params};
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const DEFAULT_DB_PATH: &str = "/var/lib/activitywatch/aw-server-rust/sqlite.db";
|
||||
const DEFAULT_HOST: &str = "HOST-EXAMPLE";
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(author, version, about = "Read-only ActivityWatch SQLite growth guard")]
|
||||
struct Cli {
|
||||
#[arg(long, default_value = DEFAULT_DB_PATH, env = "AW_DB_HEALTH_DB_PATH")]
|
||||
db_path: PathBuf,
|
||||
|
||||
#[arg(long, env = "AW_DB_HEALTH_SESSION_BUCKET")]
|
||||
session_bucket: Option<String>,
|
||||
|
||||
#[arg(long, env = "AW_WORKTIME_HOST")]
|
||||
host: Option<String>,
|
||||
|
||||
#[arg(long, env = "AW_DB_HEALTH_WINDOWS_CONFIG")]
|
||||
windows_config: Option<PathBuf>,
|
||||
|
||||
#[arg(long, default_value_t = gib(2), env = "AW_DB_HEALTH_DB_WARN_BYTES")]
|
||||
db_warn_bytes: u64,
|
||||
|
||||
#[arg(long, default_value_t = gib(5), env = "AW_DB_HEALTH_DB_FAIL_BYTES")]
|
||||
db_fail_bytes: u64,
|
||||
|
||||
#[arg(long, default_value_t = mib(256), env = "AW_DB_HEALTH_WAL_WARN_BYTES")]
|
||||
wal_warn_bytes: u64,
|
||||
|
||||
#[arg(long, default_value_t = gib(1), env = "AW_DB_HEALTH_WAL_FAIL_BYTES")]
|
||||
wal_fail_bytes: u64,
|
||||
|
||||
#[arg(long, default_value_t = 10_000, env = "AW_DB_HEALTH_SESSION_ROWS_WARN")]
|
||||
session_rows_warn: i64,
|
||||
|
||||
#[arg(
|
||||
long,
|
||||
default_value_t = 100_000,
|
||||
env = "AW_DB_HEALTH_SESSION_ROWS_FAIL"
|
||||
)]
|
||||
session_rows_fail: i64,
|
||||
|
||||
#[arg(
|
||||
long,
|
||||
default_value_t = 600,
|
||||
env = "AW_DB_HEALTH_RECENT_PROCESS_WINDOW_SECONDS"
|
||||
)]
|
||||
recent_process_window_seconds: i64,
|
||||
|
||||
#[arg(long, default_value_t = 1, env = "AW_DB_HEALTH_RECENT_PROCESS_WARN")]
|
||||
recent_process_warn: i64,
|
||||
|
||||
#[arg(long, default_value_t = 100, env = "AW_DB_HEALTH_RECENT_PROCESS_FAIL")]
|
||||
recent_process_fail: i64,
|
||||
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
enum Status {
|
||||
Ok,
|
||||
Warn,
|
||||
Fail,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct CheckResult {
|
||||
name: String,
|
||||
status: Status,
|
||||
summary: String,
|
||||
details: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct Report {
|
||||
ok: bool,
|
||||
generated_at_utc: String,
|
||||
counts: Counts,
|
||||
results: Vec<CheckResult>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize)]
|
||||
struct Counts {
|
||||
ok: usize,
|
||||
warn: usize,
|
||||
fail: usize,
|
||||
}
|
||||
|
||||
const fn mib(value: u64) -> u64 {
|
||||
value * 1024 * 1024
|
||||
}
|
||||
|
||||
const fn gib(value: u64) -> u64 {
|
||||
value * 1024 * 1024 * 1024
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let code = match run() {
|
||||
Ok(code) => code,
|
||||
Err(err) => {
|
||||
eprintln!("{err:#}");
|
||||
1
|
||||
}
|
||||
};
|
||||
std::process::exit(code);
|
||||
}
|
||||
|
||||
fn run() -> Result<i32> {
|
||||
let cli = Cli::parse();
|
||||
let report = build_report(&cli)?;
|
||||
if cli.json {
|
||||
println!("{}", serde_json::to_string_pretty(&report)?);
|
||||
} else {
|
||||
print_text(&report);
|
||||
}
|
||||
Ok(if report.ok { 0 } else { 2 })
|
||||
}
|
||||
|
||||
fn build_report(cli: &Cli) -> Result<Report> {
|
||||
let mut results = Vec::new();
|
||||
let db_path = &cli.db_path;
|
||||
let wal_path = PathBuf::from(format!("{}-wal", db_path.display()));
|
||||
let shm_path = PathBuf::from(format!("{}-shm", db_path.display()));
|
||||
|
||||
let db_size = file_size(db_path)?;
|
||||
results.push(threshold_result(
|
||||
"sqlite:file-size",
|
||||
db_size,
|
||||
cli.db_warn_bytes,
|
||||
cli.db_fail_bytes,
|
||||
"ActivityWatch SQLite DB size",
|
||||
json!({
|
||||
"path": db_path,
|
||||
"size_bytes": db_size,
|
||||
"warn_bytes": cli.db_warn_bytes,
|
||||
"fail_bytes": cli.db_fail_bytes,
|
||||
}),
|
||||
));
|
||||
|
||||
let wal_size = file_size_optional(&wal_path)?;
|
||||
results.push(threshold_result(
|
||||
"sqlite:wal-size",
|
||||
wal_size,
|
||||
cli.wal_warn_bytes,
|
||||
cli.wal_fail_bytes,
|
||||
"ActivityWatch SQLite WAL size",
|
||||
json!({
|
||||
"path": wal_path,
|
||||
"size_bytes": wal_size,
|
||||
"warn_bytes": cli.wal_warn_bytes,
|
||||
"fail_bytes": cli.wal_fail_bytes,
|
||||
}),
|
||||
));
|
||||
|
||||
let shm_size = file_size_optional(&shm_path)?;
|
||||
results.push(CheckResult {
|
||||
name: "sqlite:shm-size".to_string(),
|
||||
status: Status::Ok,
|
||||
summary: format!("SHM size is {}", human_bytes(shm_size)),
|
||||
details: json!({
|
||||
"path": shm_path,
|
||||
"size_bytes": shm_size,
|
||||
}),
|
||||
});
|
||||
|
||||
let conn = open_readonly(db_path)?;
|
||||
let bucket = cli.session_bucket.clone().unwrap_or_else(|| {
|
||||
format!(
|
||||
"aw-session-events_{}",
|
||||
cli.host.as_deref().unwrap_or(DEFAULT_HOST)
|
||||
)
|
||||
});
|
||||
match bucket_row(&conn, &bucket)? {
|
||||
Some(bucketrow) => {
|
||||
let total_rows = count_session_rows(&conn, bucketrow)?;
|
||||
results.push(threshold_result_i64(
|
||||
"aw-session-events:rows",
|
||||
total_rows,
|
||||
cli.session_rows_warn,
|
||||
cli.session_rows_fail,
|
||||
"aw-session-events row count",
|
||||
json!({
|
||||
"bucket": bucket,
|
||||
"bucketrow": bucketrow,
|
||||
"rows": total_rows,
|
||||
"warn_rows": cli.session_rows_warn,
|
||||
"fail_rows": cli.session_rows_fail,
|
||||
}),
|
||||
));
|
||||
|
||||
let cutoff_ns = now_ns()? - cli.recent_process_window_seconds.max(1) * 1_000_000_000;
|
||||
let recent_process = count_recent_process_events(&conn, bucketrow, cutoff_ns)?;
|
||||
results.push(threshold_result_i64(
|
||||
"aw-session-events:recent-process-events",
|
||||
recent_process,
|
||||
cli.recent_process_warn,
|
||||
cli.recent_process_fail,
|
||||
"recent process-level aw-session-events",
|
||||
json!({
|
||||
"bucket": bucket,
|
||||
"bucketrow": bucketrow,
|
||||
"recent_process_events": recent_process,
|
||||
"window_seconds": cli.recent_process_window_seconds,
|
||||
"cutoff_ns": cutoff_ns,
|
||||
"warn_events": cli.recent_process_warn,
|
||||
"fail_events": cli.recent_process_fail,
|
||||
}),
|
||||
));
|
||||
|
||||
let latest = latest_session_event(&conn, bucketrow)?;
|
||||
results.push(CheckResult {
|
||||
name: "aw-session-events:latest".to_string(),
|
||||
status: Status::Ok,
|
||||
summary: latest
|
||||
.as_ref()
|
||||
.map(|event| format!("latest eventType={}", event.event_type))
|
||||
.unwrap_or_else(|| "no session events".to_string()),
|
||||
details: json!({
|
||||
"bucket": bucket,
|
||||
"latest": latest,
|
||||
}),
|
||||
});
|
||||
}
|
||||
None => {
|
||||
results.push(CheckResult {
|
||||
name: "aw-session-events:bucket".to_string(),
|
||||
status: Status::Warn,
|
||||
summary: format!("bucket {bucket} not found"),
|
||||
details: json!({ "bucket": bucket }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(path) = &cli.windows_config {
|
||||
results.push(check_windows_config(path)?);
|
||||
}
|
||||
|
||||
let counts = count_statuses(&results);
|
||||
Ok(Report {
|
||||
ok: counts.fail == 0,
|
||||
generated_at_utc: Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
|
||||
counts,
|
||||
results,
|
||||
})
|
||||
}
|
||||
|
||||
fn open_readonly(path: &Path) -> Result<Connection> {
|
||||
let conn = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)
|
||||
.with_context(|| format!("open SQLite DB read-only: {}", path.display()))?;
|
||||
conn.busy_timeout(std::time::Duration::from_secs(5))?;
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
fn bucket_row(conn: &Connection, bucket: &str) -> Result<Option<i64>> {
|
||||
conn.query_row("SELECT id FROM buckets WHERE name=?", [bucket], |row| {
|
||||
row.get(0)
|
||||
})
|
||||
.optional()
|
||||
.context("lookup bucket row")
|
||||
}
|
||||
|
||||
fn count_session_rows(conn: &Connection, bucketrow: i64) -> Result<i64> {
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM events WHERE bucketrow=?",
|
||||
[bucketrow],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.context("count session event rows")
|
||||
}
|
||||
|
||||
fn count_recent_process_events(conn: &Connection, bucketrow: i64, cutoff_ns: i64) -> Result<i64> {
|
||||
conn.query_row(
|
||||
r#"
|
||||
SELECT COUNT(*)
|
||||
FROM events
|
||||
WHERE bucketrow=?
|
||||
AND endtime >= ?
|
||||
AND (data LIKE ? OR data LIKE ?)
|
||||
"#,
|
||||
params![
|
||||
bucketrow,
|
||||
cutoff_ns,
|
||||
"%\"eventType\":\"process_start\"%",
|
||||
"%\"eventType\":\"process_stop\"%",
|
||||
],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.context("count recent process-level session events")
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct LatestEvent {
|
||||
id: i64,
|
||||
endtime_ns: i64,
|
||||
event_type: String,
|
||||
source: String,
|
||||
}
|
||||
|
||||
fn latest_session_event(conn: &Connection, bucketrow: i64) -> Result<Option<LatestEvent>> {
|
||||
let row = conn
|
||||
.query_row(
|
||||
"SELECT id, endtime, data FROM events WHERE bucketrow=? ORDER BY id DESC LIMIT 1",
|
||||
[bucketrow],
|
||||
|row| {
|
||||
Ok((
|
||||
row.get::<_, i64>(0)?,
|
||||
row.get::<_, i64>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
))
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
.context("read latest session event")?;
|
||||
Ok(row.map(|(id, endtime_ns, data)| LatestEvent {
|
||||
id,
|
||||
endtime_ns,
|
||||
event_type: json_field(&data, "eventType").unwrap_or_else(|| "unknown".to_string()),
|
||||
source: json_field(&data, "source").unwrap_or_else(|| "unknown".to_string()),
|
||||
}))
|
||||
}
|
||||
|
||||
fn check_windows_config(path: &Path) -> Result<CheckResult> {
|
||||
let text = fs::read_to_string(path)
|
||||
.with_context(|| format!("read Windows deployment config {}", path.display()))?;
|
||||
let payload: Value = serde_json::from_str(&text).context("parse Windows deployment config")?;
|
||||
let enabled = payload
|
||||
.pointer("/sessionEvents/processEventsEnabled")
|
||||
.and_then(Value::as_bool);
|
||||
let status = match enabled {
|
||||
Some(false) => Status::Ok,
|
||||
Some(true) => Status::Fail,
|
||||
None => Status::Warn,
|
||||
};
|
||||
let summary = match enabled {
|
||||
Some(false) => "processEventsEnabled=false".to_string(),
|
||||
Some(true) => {
|
||||
"processEventsEnabled=true; high-volume process stream is enabled".to_string()
|
||||
}
|
||||
None => "processEventsEnabled missing".to_string(),
|
||||
};
|
||||
Ok(CheckResult {
|
||||
name: "windows-config:process-events".to_string(),
|
||||
status,
|
||||
summary,
|
||||
details: json!({
|
||||
"path": path,
|
||||
"processEventsEnabled": enabled,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
fn threshold_result(
|
||||
name: &str,
|
||||
value: u64,
|
||||
warn: u64,
|
||||
fail: u64,
|
||||
label: &str,
|
||||
details: Value,
|
||||
) -> CheckResult {
|
||||
let status = status_for_u64(value, warn, fail);
|
||||
CheckResult {
|
||||
name: name.to_string(),
|
||||
status,
|
||||
summary: format!("{label}: {}", human_bytes(value)),
|
||||
details,
|
||||
}
|
||||
}
|
||||
|
||||
fn threshold_result_i64(
|
||||
name: &str,
|
||||
value: i64,
|
||||
warn: i64,
|
||||
fail: i64,
|
||||
label: &str,
|
||||
details: Value,
|
||||
) -> CheckResult {
|
||||
let status = status_for_i64(value, warn, fail);
|
||||
CheckResult {
|
||||
name: name.to_string(),
|
||||
status,
|
||||
summary: format!("{label}: {value}"),
|
||||
details,
|
||||
}
|
||||
}
|
||||
|
||||
fn status_for_u64(value: u64, warn: u64, fail: u64) -> Status {
|
||||
if value >= fail {
|
||||
Status::Fail
|
||||
} else if value >= warn {
|
||||
Status::Warn
|
||||
} else {
|
||||
Status::Ok
|
||||
}
|
||||
}
|
||||
|
||||
fn status_for_i64(value: i64, warn: i64, fail: i64) -> Status {
|
||||
if value >= fail {
|
||||
Status::Fail
|
||||
} else if value >= warn {
|
||||
Status::Warn
|
||||
} else {
|
||||
Status::Ok
|
||||
}
|
||||
}
|
||||
|
||||
fn count_statuses(results: &[CheckResult]) -> Counts {
|
||||
let mut counts = Counts::default();
|
||||
for result in results {
|
||||
match result.status {
|
||||
Status::Ok => counts.ok += 1,
|
||||
Status::Warn => counts.warn += 1,
|
||||
Status::Fail => counts.fail += 1,
|
||||
}
|
||||
}
|
||||
counts
|
||||
}
|
||||
|
||||
fn file_size(path: &Path) -> Result<u64> {
|
||||
Ok(fs::metadata(path)
|
||||
.with_context(|| format!("stat {}", path.display()))?
|
||||
.len())
|
||||
}
|
||||
|
||||
fn file_size_optional(path: &Path) -> Result<u64> {
|
||||
match fs::metadata(path) {
|
||||
Ok(metadata) => Ok(metadata.len()),
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(0),
|
||||
Err(err) => Err(err).with_context(|| format!("stat {}", path.display())),
|
||||
}
|
||||
}
|
||||
|
||||
fn now_ns() -> Result<i64> {
|
||||
Utc::now()
|
||||
.timestamp_nanos_opt()
|
||||
.ok_or_else(|| anyhow!("current timestamp out of range"))
|
||||
}
|
||||
|
||||
fn json_field(data: &str, key: &str) -> Option<String> {
|
||||
let value = serde_json::from_str::<Value>(data).ok()?;
|
||||
value.get(key)?.as_str().map(ToString::to_string)
|
||||
}
|
||||
|
||||
fn human_bytes(value: u64) -> String {
|
||||
const UNITS: &[&str] = &["B", "KiB", "MiB", "GiB", "TiB"];
|
||||
let mut size = value as f64;
|
||||
let mut unit = 0usize;
|
||||
while size >= 1024.0 && unit + 1 < UNITS.len() {
|
||||
size /= 1024.0;
|
||||
unit += 1;
|
||||
}
|
||||
if unit == 0 {
|
||||
format!("{value} {}", UNITS[unit])
|
||||
} else {
|
||||
format!("{size:.1} {}", UNITS[unit])
|
||||
}
|
||||
}
|
||||
|
||||
fn print_text(report: &Report) {
|
||||
println!("=== AW DB Health ===");
|
||||
println!("Timestamp: {}", report.generated_at_utc);
|
||||
for result in &report.results {
|
||||
let mark = match result.status {
|
||||
Status::Ok => "✓",
|
||||
Status::Warn => "⚠",
|
||||
Status::Fail => "✗",
|
||||
};
|
||||
println!("{mark} {}: {}", result.name, result.summary);
|
||||
}
|
||||
println!(
|
||||
"Summary: ok={} warn={} fail={}",
|
||||
report.counts.ok, report.counts.warn, report.counts.fail
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rusqlite::params;
|
||||
|
||||
#[test]
|
||||
fn classifies_thresholds() {
|
||||
assert_eq!(status_for_u64(10, 20, 30), Status::Ok);
|
||||
assert_eq!(status_for_u64(20, 20, 30), Status::Warn);
|
||||
assert_eq!(status_for_u64(30, 20, 30), Status::Fail);
|
||||
assert_eq!(status_for_i64(0, 1, 100), Status::Ok);
|
||||
assert_eq!(status_for_i64(1, 1, 100), Status::Warn);
|
||||
assert_eq!(status_for_i64(101, 0, 100), Status::Fail);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_session_metrics() {
|
||||
let tmp = tempfile::NamedTempFile::new().unwrap();
|
||||
let conn = Connection::open(tmp.path()).unwrap();
|
||||
conn.execute(
|
||||
"CREATE TABLE buckets (id INTEGER PRIMARY KEY, name TEXT)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"CREATE TABLE events (id INTEGER PRIMARY KEY, bucketrow INTEGER, endtime INTEGER, data TEXT)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO buckets (id, name) VALUES (15, 'aw-session-events_TEST')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO events (bucketrow, endtime, data) VALUES (15, ?, ?)",
|
||||
params![
|
||||
now_ns().unwrap(),
|
||||
r#"{"eventType":"process_start","source":"worktime-session-collector"}"#
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO events (bucketrow, endtime, data) VALUES (15, ?, ?)",
|
||||
params![
|
||||
now_ns().unwrap(),
|
||||
r#"{"eventType":"logon","source":"launch-watchers-awatch-rus"}"#
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
drop(conn);
|
||||
|
||||
let conn = open_readonly(tmp.path()).unwrap();
|
||||
let bucketrow = bucket_row(&conn, "aw-session-events_TEST")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(count_session_rows(&conn, bucketrow).unwrap(), 2);
|
||||
assert_eq!(
|
||||
count_recent_process_events(&conn, bucketrow, now_ns().unwrap() - 60_000_000_000)
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
let latest = latest_session_event(&conn, bucketrow).unwrap().unwrap();
|
||||
assert_eq!(latest.event_type, "logon");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checks_windows_config_flag() {
|
||||
let tmp = tempfile::NamedTempFile::new().unwrap();
|
||||
fs::write(
|
||||
tmp.path(),
|
||||
r#"{"sessionEvents":{"processEventsEnabled":false}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let result = check_windows_config(tmp.path()).unwrap();
|
||||
assert_eq!(result.status, Status::Ok);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "aw-db-maintenance"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
chrono.workspace = true
|
||||
clap.workspace = true
|
||||
rusqlite = { workspace = true, features = ["backup"] }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
@@ -0,0 +1,359 @@
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use chrono::{SecondsFormat, Utc};
|
||||
use clap::Parser;
|
||||
use rusqlite::{Connection, DatabaseName, OpenFlags, params};
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
|
||||
const DEFAULT_DB_PATH: &str = "/var/lib/activitywatch/aw-server-rust/sqlite.db";
|
||||
const DEFAULT_BACKUP_DIR: &str = "/var/lib/activitywatch/backups/db";
|
||||
const DEFAULT_HOST: &str = "HOST-EXAMPLE";
|
||||
const ALLOWED_EVENT_TYPES: &[&str] = &["process_start", "process_stop"];
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(about = "Safe ActivityWatch SQLite maintenance for old process-level session events")]
|
||||
struct Cli {
|
||||
#[arg(long, default_value = DEFAULT_DB_PATH, env = "AW_DB_MAINTENANCE_DB_PATH")]
|
||||
db_path: PathBuf,
|
||||
|
||||
#[arg(long, default_value = DEFAULT_BACKUP_DIR, env = "AW_DB_MAINTENANCE_BACKUP_DIR")]
|
||||
backup_dir: PathBuf,
|
||||
|
||||
#[arg(long, env = "AW_DB_MAINTENANCE_SESSION_BUCKET")]
|
||||
session_bucket: Option<String>,
|
||||
|
||||
#[arg(long, env = "AW_WORKTIME_HOST")]
|
||||
host: Option<String>,
|
||||
|
||||
#[arg(long, default_value_t = 7, env = "AW_DB_MAINTENANCE_RETENTION_DAYS")]
|
||||
retention_days: i64,
|
||||
|
||||
#[arg(long, default_value_t = 1000, env = "AW_DB_MAINTENANCE_CHUNK_SIZE")]
|
||||
chunk_size: usize,
|
||||
|
||||
#[arg(long)]
|
||||
apply: bool,
|
||||
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct Report {
|
||||
apply: bool,
|
||||
generated_at_utc: String,
|
||||
db_path: String,
|
||||
backup_path: Option<String>,
|
||||
bucket: String,
|
||||
bucketrow: Option<i64>,
|
||||
retention_days: i64,
|
||||
cutoff_ns: i64,
|
||||
allowed_event_types: Vec<&'static str>,
|
||||
planned_delete_rows: usize,
|
||||
deleted_rows: usize,
|
||||
backup_created: bool,
|
||||
skipped_reason: Option<String>,
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let code = match run() {
|
||||
Ok(code) => code,
|
||||
Err(err) => {
|
||||
eprintln!("{err:#}");
|
||||
1
|
||||
}
|
||||
};
|
||||
std::process::exit(code);
|
||||
}
|
||||
|
||||
fn run() -> Result<i32> {
|
||||
let cli = Cli::parse();
|
||||
let report = build_report(&cli)?;
|
||||
if cli.json {
|
||||
println!("{}", serde_json::to_string_pretty(&report)?);
|
||||
} else {
|
||||
print_text(&report);
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
fn build_report(cli: &Cli) -> Result<Report> {
|
||||
if cli.retention_days < 1 {
|
||||
bail!("retention_days must be >= 1");
|
||||
}
|
||||
if cli.chunk_size == 0 {
|
||||
bail!("chunk_size must be > 0");
|
||||
}
|
||||
let bucket = cli.session_bucket.clone().unwrap_or_else(|| {
|
||||
format!(
|
||||
"aw-session-events_{}",
|
||||
cli.host.as_deref().unwrap_or(DEFAULT_HOST)
|
||||
)
|
||||
});
|
||||
let cutoff_ns = (Utc::now().timestamp() - cli.retention_days * 86_400) * 1_000_000_000;
|
||||
let conn = open_connection(&cli.db_path, cli.apply)?;
|
||||
let bucketrow = bucket_row(&conn, &bucket)?;
|
||||
let Some(bucketrow) = bucketrow else {
|
||||
return Ok(base_report(
|
||||
cli,
|
||||
bucket,
|
||||
None,
|
||||
cutoff_ns,
|
||||
0,
|
||||
0,
|
||||
None,
|
||||
false,
|
||||
Some("session bucket not found".to_string()),
|
||||
));
|
||||
};
|
||||
|
||||
let delete_ids = find_deletable_event_ids(&conn, bucketrow, cutoff_ns)?;
|
||||
let planned = delete_ids.len();
|
||||
let mut backup_file = None;
|
||||
let mut backup_created = false;
|
||||
let mut deleted = 0;
|
||||
if cli.apply && planned > 0 {
|
||||
fs::create_dir_all(&cli.backup_dir)
|
||||
.with_context(|| format!("create backup dir {}", cli.backup_dir.display()))?;
|
||||
let backup = backup_path(&cli.backup_dir);
|
||||
copy_sqlite_via_backup(&cli.db_path, &backup)?;
|
||||
backup_file = Some(backup);
|
||||
backup_created = true;
|
||||
deleted = delete_events(&conn, &delete_ids, cli.chunk_size)?;
|
||||
}
|
||||
|
||||
Ok(base_report(
|
||||
cli,
|
||||
bucket,
|
||||
Some(bucketrow),
|
||||
cutoff_ns,
|
||||
planned,
|
||||
deleted,
|
||||
backup_file,
|
||||
backup_created,
|
||||
None,
|
||||
))
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn base_report(
|
||||
cli: &Cli,
|
||||
bucket: String,
|
||||
bucketrow: Option<i64>,
|
||||
cutoff_ns: i64,
|
||||
planned_delete_rows: usize,
|
||||
deleted_rows: usize,
|
||||
backup_path: Option<PathBuf>,
|
||||
backup_created: bool,
|
||||
skipped_reason: Option<String>,
|
||||
) -> Report {
|
||||
Report {
|
||||
apply: cli.apply,
|
||||
generated_at_utc: Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true),
|
||||
db_path: cli.db_path.display().to_string(),
|
||||
backup_path: backup_path.map(|path| path.display().to_string()),
|
||||
bucket,
|
||||
bucketrow,
|
||||
retention_days: cli.retention_days,
|
||||
cutoff_ns,
|
||||
allowed_event_types: ALLOWED_EVENT_TYPES.to_vec(),
|
||||
planned_delete_rows,
|
||||
deleted_rows,
|
||||
backup_created,
|
||||
skipped_reason,
|
||||
}
|
||||
}
|
||||
|
||||
fn open_connection(path: &Path, writable: bool) -> Result<Connection> {
|
||||
let flags = if writable {
|
||||
OpenFlags::SQLITE_OPEN_READ_WRITE
|
||||
} else {
|
||||
OpenFlags::SQLITE_OPEN_READ_ONLY
|
||||
};
|
||||
let conn = Connection::open_with_flags(path, flags)
|
||||
.with_context(|| format!("open SQLite DB {}", path.display()))?;
|
||||
conn.busy_timeout(Duration::from_secs(10))?;
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
fn bucket_row(conn: &Connection, bucket: &str) -> Result<Option<i64>> {
|
||||
let mut stmt =
|
||||
conn.prepare("select rowid from buckets where name = ?1 order by rowid limit 1")?;
|
||||
let mut rows = stmt.query([bucket])?;
|
||||
Ok(rows.next()?.map(|row| row.get::<_, i64>(0)).transpose()?)
|
||||
}
|
||||
|
||||
fn find_deletable_event_ids(conn: &Connection, bucketrow: i64, cutoff_ns: i64) -> Result<Vec<i64>> {
|
||||
let mut stmt = conn
|
||||
.prepare("select id, data from events where bucketrow = ?1 and endtime < ?2 order by id")?;
|
||||
let rows = stmt.query_map(params![bucketrow, cutoff_ns], |row| {
|
||||
Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
|
||||
})?;
|
||||
let mut ids = Vec::new();
|
||||
for row in rows {
|
||||
let (id, data) = row?;
|
||||
if is_allowed_process_event(&data) {
|
||||
ids.push(id);
|
||||
}
|
||||
}
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
fn is_allowed_process_event(data: &str) -> bool {
|
||||
let Ok(value) = serde_json::from_str::<Value>(data) else {
|
||||
return false;
|
||||
};
|
||||
let event_type = value
|
||||
.pointer("/eventType")
|
||||
.or_else(|| value.pointer("/data/eventType"))
|
||||
.and_then(Value::as_str);
|
||||
event_type.is_some_and(|event_type| ALLOWED_EVENT_TYPES.contains(&event_type))
|
||||
}
|
||||
|
||||
fn copy_sqlite_via_backup(src: &Path, dst: &Path) -> Result<()> {
|
||||
let source =
|
||||
Connection::open(src).with_context(|| format!("open backup source {}", src.display()))?;
|
||||
source
|
||||
.backup(DatabaseName::Main, dst, None)
|
||||
.with_context(|| format!("backup {} to {}", src.display(), dst.display()))
|
||||
}
|
||||
|
||||
fn delete_events(conn: &Connection, ids: &[i64], chunk_size: usize) -> Result<usize> {
|
||||
let mut deleted = 0;
|
||||
for chunk in ids.chunks(chunk_size) {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
{
|
||||
let mut stmt = tx.prepare("delete from events where id = ?1")?;
|
||||
for id in chunk {
|
||||
deleted += stmt.execute([id])?;
|
||||
}
|
||||
}
|
||||
tx.commit()?;
|
||||
}
|
||||
Ok(deleted)
|
||||
}
|
||||
|
||||
fn backup_path(backup_dir: &Path) -> PathBuf {
|
||||
backup_dir.join(format!(
|
||||
"aw-sqlite-before-db-maintenance-{}.db",
|
||||
Utc::now().format("%Y%m%dT%H%M%SZ")
|
||||
))
|
||||
}
|
||||
|
||||
fn print_text(report: &Report) {
|
||||
println!(
|
||||
"aw-db-maintenance: {}",
|
||||
if report.apply { "apply" } else { "dry-run" }
|
||||
);
|
||||
println!("db_path: {}", report.db_path);
|
||||
println!("bucket: {}", report.bucket);
|
||||
println!("retention_days: {}", report.retention_days);
|
||||
println!("planned_delete_rows: {}", report.planned_delete_rows);
|
||||
println!("deleted_rows: {}", report.deleted_rows);
|
||||
println!("backup_created: {}", report.backup_created);
|
||||
if let Some(path) = &report.backup_path {
|
||||
println!("backup_path: {path}");
|
||||
}
|
||||
if let Some(reason) = &report.skipped_reason {
|
||||
println!("skipped_reason: {reason}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn only_allows_process_start_stop_events() {
|
||||
assert!(is_allowed_process_event(r#"{"eventType":"process_start"}"#));
|
||||
assert!(is_allowed_process_event(
|
||||
r#"{"data":{"eventType":"process_stop"}}"#
|
||||
));
|
||||
assert!(!is_allowed_process_event(r#"{"eventType":"logon"}"#));
|
||||
assert!(!is_allowed_process_event(r#"not-json"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dry_run_does_not_delete_or_backup() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let db = dir.path().join("aw.db");
|
||||
create_fixture_db(&db);
|
||||
let cli = Cli {
|
||||
db_path: db.clone(),
|
||||
backup_dir: dir.path().join("backups"),
|
||||
session_bucket: Some("aw-session-events_TEST".to_string()),
|
||||
host: None,
|
||||
retention_days: 7,
|
||||
chunk_size: 100,
|
||||
apply: false,
|
||||
json: true,
|
||||
};
|
||||
let report = build_report(&cli).unwrap();
|
||||
assert_eq!(report.planned_delete_rows, 2);
|
||||
assert_eq!(report.deleted_rows, 0);
|
||||
assert!(!report.backup_created);
|
||||
assert_eq!(count_events(&db), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_deletes_only_old_process_events_and_keeps_logon() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let db = dir.path().join("aw.db");
|
||||
create_fixture_db(&db);
|
||||
let cli = Cli {
|
||||
db_path: db.clone(),
|
||||
backup_dir: dir.path().join("backups"),
|
||||
session_bucket: Some("aw-session-events_TEST".to_string()),
|
||||
host: None,
|
||||
retention_days: 7,
|
||||
chunk_size: 1,
|
||||
apply: true,
|
||||
json: true,
|
||||
};
|
||||
let report = build_report(&cli).unwrap();
|
||||
assert_eq!(report.planned_delete_rows, 2);
|
||||
assert_eq!(report.deleted_rows, 2);
|
||||
assert!(report.backup_created);
|
||||
assert_eq!(count_events(&db), 1);
|
||||
}
|
||||
|
||||
fn create_fixture_db(path: &Path) {
|
||||
let conn = Connection::open(path).unwrap();
|
||||
conn.execute_batch(
|
||||
"create table buckets (name text);
|
||||
create table events (id integer primary key autoincrement, bucketrow integer, starttime integer, endtime integer, data text);",
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"insert into buckets (name) values ('aw-session-events_TEST')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"insert into events (bucketrow,starttime,endtime,data) values (1,1,2,'{\"eventType\":\"process_start\"}')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"insert into events (bucketrow,starttime,endtime,data) values (1,3,4,'{\"eventType\":\"process_stop\"}')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"insert into events (bucketrow,starttime,endtime,data) values (1,5,6,'{\"eventType\":\"logon\"}')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn count_events(path: &Path) -> i64 {
|
||||
Connection::open(path)
|
||||
.unwrap()
|
||||
.query_row("select count(*) from events", [], |row| row.get(0))
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "aw-ensure-reliability"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
clap.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
@@ -0,0 +1,512 @@
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use clap::Parser;
|
||||
use serde::Serialize;
|
||||
|
||||
const LOGROTATE_CONTENT: &str = include_str!("../../../../aw-server/logrotate.conf");
|
||||
const HEALTH_TIMER_CONTENT: &str = r#"[Unit]
|
||||
Description=AW Health Check Timer
|
||||
Requires=aw-health-check.service
|
||||
|
||||
[Timer]
|
||||
OnCalendar=*:0/5:00
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
"#;
|
||||
const HEALTH_SERVICE_CONTENT: &str = r#"[Unit]
|
||||
Description=AW Health Check
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/bin/aw-health-check
|
||||
User=root
|
||||
Group=root
|
||||
"#;
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(about = "Plan or apply AW service reliability hardening")]
|
||||
struct Cli {
|
||||
#[arg(long, default_value = "/etc/activitywatch/aw-server.env")]
|
||||
env_file: PathBuf,
|
||||
|
||||
#[arg(long, default_value = "/var/lib/activitywatch")]
|
||||
data_dir: PathBuf,
|
||||
|
||||
#[arg(long, default_value = "/var/log/activitywatch")]
|
||||
log_dir: PathBuf,
|
||||
|
||||
#[arg(long, default_value = "/opt/activitywatch")]
|
||||
opt_dir: PathBuf,
|
||||
|
||||
#[arg(long, default_value = "/etc/logrotate.d/activitywatch")]
|
||||
logrotate_target: PathBuf,
|
||||
|
||||
#[arg(long, default_value = "/usr/local/bin/aw-health-check")]
|
||||
health_script_target: PathBuf,
|
||||
|
||||
#[arg(long, default_value = "/etc/systemd/system/aw-health-check.timer")]
|
||||
health_timer_target: PathBuf,
|
||||
|
||||
#[arg(long, default_value = "/etc/systemd/system/aw-health-check.service")]
|
||||
health_service_target: PathBuf,
|
||||
|
||||
#[arg(long, default_value_t = false)]
|
||||
apply: bool,
|
||||
|
||||
#[arg(long, default_value_t = false)]
|
||||
json: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum StepKind {
|
||||
Check,
|
||||
Chown,
|
||||
Chmod,
|
||||
Write,
|
||||
Systemd,
|
||||
Sleep,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct Step {
|
||||
order: usize,
|
||||
name: String,
|
||||
kind: StepKind,
|
||||
command: String,
|
||||
mutation: bool,
|
||||
needed: bool,
|
||||
reason: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct Report {
|
||||
apply: bool,
|
||||
ok: bool,
|
||||
env_file: PathBuf,
|
||||
missing_required: Vec<String>,
|
||||
steps: Vec<Step>,
|
||||
executed: Vec<ExecResult>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ExecResult {
|
||||
order: usize,
|
||||
name: String,
|
||||
ok: bool,
|
||||
exit_code: Option<i32>,
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let code = match run() {
|
||||
Ok(code) => code,
|
||||
Err(err) => {
|
||||
eprintln!("{err:#}");
|
||||
1
|
||||
}
|
||||
};
|
||||
std::process::exit(code);
|
||||
}
|
||||
|
||||
fn run() -> Result<i32> {
|
||||
let cli = Cli::parse();
|
||||
let mut report = build_report(&cli);
|
||||
if cli.apply {
|
||||
if !report.missing_required.is_empty() {
|
||||
report.ok = false;
|
||||
print_report(&report, cli.json)?;
|
||||
bail!("refusing --apply because required inputs are missing");
|
||||
}
|
||||
apply_steps(&mut report)?;
|
||||
}
|
||||
print_report(&report, cli.json)?;
|
||||
Ok(if report.ok { 0 } else { 1 })
|
||||
}
|
||||
|
||||
fn build_report(cli: &Cli) -> Report {
|
||||
let mut missing_required = Vec::new();
|
||||
if !cli.env_file.is_file() {
|
||||
missing_required.push(format!("env file missing: {}", cli.env_file.display()));
|
||||
}
|
||||
if !cli.health_script_target.is_file() {
|
||||
missing_required.push(format!(
|
||||
"health script target missing: {}",
|
||||
cli.health_script_target.display()
|
||||
));
|
||||
}
|
||||
|
||||
let mut steps = Vec::new();
|
||||
push_step(
|
||||
&mut steps,
|
||||
"check-env-file",
|
||||
StepKind::Check,
|
||||
format!("test -f {}", shell_quote(&cli.env_file)),
|
||||
false,
|
||||
!cli.env_file.is_file(),
|
||||
"required before reliability actions".to_string(),
|
||||
);
|
||||
for dir in [&cli.data_dir, &cli.log_dir, &cli.opt_dir] {
|
||||
push_step(
|
||||
&mut steps,
|
||||
format!("chown-{}", dir.display()),
|
||||
StepKind::Chown,
|
||||
format!("chown -R activitywatch:activitywatch {}", shell_quote(dir)),
|
||||
true,
|
||||
true,
|
||||
"preserve legacy ownership repair".to_string(),
|
||||
);
|
||||
}
|
||||
for dir in [&cli.data_dir, &cli.log_dir, &cli.opt_dir] {
|
||||
push_step(
|
||||
&mut steps,
|
||||
format!("chmod-{}", dir.display()),
|
||||
StepKind::Chmod,
|
||||
format!("chmod 755 {}", shell_quote(dir)),
|
||||
true,
|
||||
true,
|
||||
"preserve legacy directory mode repair".to_string(),
|
||||
);
|
||||
}
|
||||
push_step(
|
||||
&mut steps,
|
||||
"install-logrotate",
|
||||
StepKind::Write,
|
||||
format!("write {}", shell_quote(&cli.logrotate_target)),
|
||||
true,
|
||||
!cli.logrotate_target.is_file(),
|
||||
if cli.logrotate_target.is_file() {
|
||||
"logrotate already configured".to_string()
|
||||
} else {
|
||||
"logrotate target missing".to_string()
|
||||
},
|
||||
);
|
||||
push_step(
|
||||
&mut steps,
|
||||
"check-health-script",
|
||||
StepKind::Check,
|
||||
format!("test -x {}", shell_quote(&cli.health_script_target)),
|
||||
false,
|
||||
!cli.health_script_target.is_file(),
|
||||
if cli.health_script_target.is_file() {
|
||||
"health script already installed by Ansible".to_string()
|
||||
} else {
|
||||
"health script target missing".to_string()
|
||||
},
|
||||
);
|
||||
for (name, path, content_name) in [
|
||||
(
|
||||
"install-health-timer",
|
||||
&cli.health_timer_target,
|
||||
"aw-health-check.timer",
|
||||
),
|
||||
(
|
||||
"install-health-service",
|
||||
&cli.health_service_target,
|
||||
"aw-health-check.service",
|
||||
),
|
||||
] {
|
||||
push_step(
|
||||
&mut steps,
|
||||
name,
|
||||
StepKind::Write,
|
||||
format!("write {} ({content_name})", shell_quote(path)),
|
||||
true,
|
||||
!path.is_file(),
|
||||
if path.is_file() {
|
||||
format!("{content_name} already installed")
|
||||
} else {
|
||||
format!("{content_name} target missing")
|
||||
},
|
||||
);
|
||||
}
|
||||
for (name, command) in [
|
||||
("daemon-reload-before-restart", "systemctl daemon-reload"),
|
||||
(
|
||||
"stop-services",
|
||||
"systemctl stop aw-worktime-api aw-worktime-ui-bridge activitywatch-server || true",
|
||||
),
|
||||
("sleep-after-stop", "sleep 2"),
|
||||
(
|
||||
"start-activitywatch-server",
|
||||
"systemctl start activitywatch-server",
|
||||
),
|
||||
("sleep-after-server-start", "sleep 3"),
|
||||
("start-worktime-api", "systemctl start aw-worktime-api"),
|
||||
("sleep-after-api-start", "sleep 2"),
|
||||
(
|
||||
"start-worktime-ui-bridge",
|
||||
"systemctl start aw-worktime-ui-bridge",
|
||||
),
|
||||
(
|
||||
"enable-activitywatch-server",
|
||||
"systemctl enable activitywatch-server",
|
||||
),
|
||||
("enable-worktime-api", "systemctl enable aw-worktime-api"),
|
||||
(
|
||||
"enable-worktime-ui-bridge",
|
||||
"systemctl enable aw-worktime-ui-bridge",
|
||||
),
|
||||
("daemon-reload-health", "systemctl daemon-reload"),
|
||||
(
|
||||
"enable-health-timer",
|
||||
"systemctl enable aw-health-check.timer",
|
||||
),
|
||||
(
|
||||
"start-health-timer",
|
||||
"systemctl start aw-health-check.timer",
|
||||
),
|
||||
] {
|
||||
let kind = if command.starts_with("sleep") {
|
||||
StepKind::Sleep
|
||||
} else {
|
||||
StepKind::Systemd
|
||||
};
|
||||
push_step(
|
||||
&mut steps,
|
||||
name,
|
||||
kind,
|
||||
command.to_string(),
|
||||
true,
|
||||
true,
|
||||
"preserve legacy reliability action".to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
Report {
|
||||
apply: cli.apply,
|
||||
ok: missing_required.is_empty(),
|
||||
env_file: cli.env_file.clone(),
|
||||
missing_required,
|
||||
steps,
|
||||
executed: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn push_step(
|
||||
steps: &mut Vec<Step>,
|
||||
name: impl Into<String>,
|
||||
kind: StepKind,
|
||||
command: String,
|
||||
mutation: bool,
|
||||
needed: bool,
|
||||
reason: String,
|
||||
) {
|
||||
steps.push(Step {
|
||||
order: steps.len() + 1,
|
||||
name: name.into(),
|
||||
kind,
|
||||
command,
|
||||
mutation,
|
||||
needed,
|
||||
reason,
|
||||
});
|
||||
}
|
||||
|
||||
fn apply_steps(report: &mut Report) -> Result<()> {
|
||||
let steps = report.steps.clone();
|
||||
for step in steps.iter().filter(|step| step.needed) {
|
||||
let result = match step.name.as_str() {
|
||||
"check-env-file" => ExecResult {
|
||||
order: step.order,
|
||||
name: step.name.clone(),
|
||||
ok: Path::new(&report.env_file).is_file(),
|
||||
exit_code: Some(if Path::new(&report.env_file).is_file() {
|
||||
0
|
||||
} else {
|
||||
1
|
||||
}),
|
||||
},
|
||||
"check-health-script" => {
|
||||
let target = Path::new("/usr/local/bin/aw-health-check");
|
||||
ExecResult {
|
||||
order: step.order,
|
||||
name: step.name.clone(),
|
||||
ok: target.is_file(),
|
||||
exit_code: Some(if target.is_file() { 0 } else { 1 }),
|
||||
}
|
||||
}
|
||||
"install-logrotate" => write_file_result(step, report, LOGROTATE_CONTENT, 0o644)?,
|
||||
"install-health-timer" => write_file_result(step, report, HEALTH_TIMER_CONTENT, 0o644)?,
|
||||
"install-health-service" => {
|
||||
write_file_result(step, report, HEALTH_SERVICE_CONTENT, 0o644)?
|
||||
}
|
||||
_ => run_shell_step(step)?,
|
||||
};
|
||||
let ok = result.ok;
|
||||
report.executed.push(result);
|
||||
if !ok {
|
||||
report.ok = false;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
report.ok = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_file_result(step: &Step, report: &Report, content: &str, mode: u32) -> Result<ExecResult> {
|
||||
let target = match step.name.as_str() {
|
||||
"install-logrotate" => target_from_command(&step.command)?,
|
||||
"install-health-timer" => target_from_command(&step.command)?,
|
||||
"install-health-service" => target_from_command(&step.command)?,
|
||||
_ => bail!("unsupported write step {}", step.name),
|
||||
};
|
||||
let _ = report;
|
||||
if let Some(parent) = target.parent() {
|
||||
fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
|
||||
}
|
||||
fs::write(&target, content).with_context(|| format!("write {}", target.display()))?;
|
||||
set_mode(&target, mode).with_context(|| format!("chmod {:o} {}", mode, target.display()))?;
|
||||
Ok(ExecResult {
|
||||
order: step.order,
|
||||
name: step.name.clone(),
|
||||
ok: true,
|
||||
exit_code: Some(0),
|
||||
})
|
||||
}
|
||||
|
||||
fn run_shell_step(step: &Step) -> Result<ExecResult> {
|
||||
let status = Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg(&step.command)
|
||||
.status()
|
||||
.with_context(|| format!("run {}", step.command))?;
|
||||
Ok(ExecResult {
|
||||
order: step.order,
|
||||
name: step.name.clone(),
|
||||
ok: status.success(),
|
||||
exit_code: status.code(),
|
||||
})
|
||||
}
|
||||
|
||||
fn target_from_command(command: &str) -> Result<PathBuf> {
|
||||
let raw = command
|
||||
.split_whitespace()
|
||||
.nth(1)
|
||||
.or_else(|| command.split_whitespace().nth(2))
|
||||
.context("parse target from command")?;
|
||||
Ok(PathBuf::from(raw.trim_matches('\'')))
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn set_mode(path: &Path, mode: u32) -> Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mut perms = fs::metadata(path)?.permissions();
|
||||
perms.set_mode(mode);
|
||||
fs::set_permissions(path, perms)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn set_mode(_path: &Path, _mode: u32) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_report(report: &Report, json: bool) -> Result<()> {
|
||||
if json {
|
||||
println!("{}", serde_json::to_string_pretty(report)?);
|
||||
return Ok(());
|
||||
}
|
||||
println!(
|
||||
"aw-ensure-reliability: {}",
|
||||
if report.apply { "apply" } else { "dry-run" }
|
||||
);
|
||||
println!("env_file: {}", report.env_file.display());
|
||||
println!("ok: {}", report.ok);
|
||||
if !report.missing_required.is_empty() {
|
||||
println!("missing_required:");
|
||||
for item in &report.missing_required {
|
||||
println!(" - {item}");
|
||||
}
|
||||
}
|
||||
println!("planned steps:");
|
||||
for step in &report.steps {
|
||||
let risk = if step.mutation { "MUTATION" } else { "check" };
|
||||
let needed = if step.needed { "needed" } else { "skip" };
|
||||
println!(
|
||||
" {:02}. {:<28} {:<8} {:<6} {}",
|
||||
step.order, step.name, risk, needed, step.command
|
||||
);
|
||||
}
|
||||
if report.executed.is_empty() {
|
||||
println!("No mutation executed. Use --apply for explicit reliability fix.");
|
||||
} else {
|
||||
println!("executed:");
|
||||
for item in &report.executed {
|
||||
println!(
|
||||
" {:02}. {:<28} ok={} exit={:?}",
|
||||
item.order, item.name, item.ok, item.exit_code
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn shell_quote(path: &Path) -> String {
|
||||
format!("'{}'", path.display().to_string().replace('\'', "'\\''"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn dry_run_marks_legacy_mutations() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let env_file = dir.path().join("aw-server.env");
|
||||
let health_target = dir.path().join("bin/aw-health-check");
|
||||
fs::write(&env_file, "AW_BASE_URL=http://127.0.0.1:5600\n").unwrap();
|
||||
fs::create_dir_all(health_target.parent().unwrap()).unwrap();
|
||||
fs::write(&health_target, "#!/bin/sh\nexit 0\n").unwrap();
|
||||
let cli = Cli {
|
||||
env_file,
|
||||
data_dir: dir.path().join("data"),
|
||||
log_dir: dir.path().join("log"),
|
||||
opt_dir: dir.path().join("opt"),
|
||||
logrotate_target: dir.path().join("logrotate/activitywatch"),
|
||||
health_script_target: health_target,
|
||||
health_timer_target: dir.path().join("systemd/aw-health-check.timer"),
|
||||
health_service_target: dir.path().join("systemd/aw-health-check.service"),
|
||||
apply: false,
|
||||
json: true,
|
||||
};
|
||||
let report = build_report(&cli);
|
||||
assert!(report.ok);
|
||||
assert!(report.steps.iter().any(|step| step.name == "stop-services"));
|
||||
assert!(
|
||||
report
|
||||
.steps
|
||||
.iter()
|
||||
.any(|step| step.name == "install-logrotate")
|
||||
);
|
||||
assert!(report.steps.iter().any(|step| step.mutation));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_env_blocks_apply() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let health_target = dir.path().join("bin/aw-health-check");
|
||||
fs::create_dir_all(health_target.parent().unwrap()).unwrap();
|
||||
fs::write(&health_target, "#!/bin/sh\nexit 0\n").unwrap();
|
||||
let cli = Cli {
|
||||
env_file: dir.path().join("missing.env"),
|
||||
data_dir: dir.path().join("data"),
|
||||
log_dir: dir.path().join("log"),
|
||||
opt_dir: dir.path().join("opt"),
|
||||
logrotate_target: dir.path().join("logrotate/activitywatch"),
|
||||
health_script_target: health_target,
|
||||
health_timer_target: dir.path().join("systemd/aw-health-check.timer"),
|
||||
health_service_target: dir.path().join("systemd/aw-health-check.service"),
|
||||
apply: true,
|
||||
json: true,
|
||||
};
|
||||
let report = build_report(&cli);
|
||||
assert!(!report.ok);
|
||||
assert_eq!(report.missing_required.len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "aw-health-check"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
reqwest.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
@@ -0,0 +1,495 @@
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use reqwest::blocking::Client;
|
||||
use serde_json::Value;
|
||||
|
||||
const ENV_PATH: &str = "/etc/activitywatch/aw-server.env";
|
||||
const SERVICES: &[&str] = &[
|
||||
"activitywatch-server",
|
||||
"aw-worktime-api",
|
||||
"aw-worktime-ui-bridge",
|
||||
];
|
||||
const DLP_TRANSPORT_CHECKS: &[&str] = &[
|
||||
"buckets:endpoint-signals",
|
||||
"buckets:file-operations",
|
||||
"endpoint-self-test-metrics",
|
||||
];
|
||||
const AW_DB_HEALTH_CHECKS: &[&str] = &[
|
||||
"sqlite:file-size",
|
||||
"sqlite:wal-size",
|
||||
"aw-session-events:rows",
|
||||
"aw-session-events:recent-process-events",
|
||||
"windows-config:process-events",
|
||||
];
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct HealthState {
|
||||
unhealthy: Vec<String>,
|
||||
warnings: Vec<String>,
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let code = match run() {
|
||||
Ok(code) => code,
|
||||
Err(err) => {
|
||||
eprintln!("{err:#}");
|
||||
1
|
||||
}
|
||||
};
|
||||
std::process::exit(code);
|
||||
}
|
||||
|
||||
fn run() -> Result<i32> {
|
||||
let env = load_effective_env(Path::new(ENV_PATH))?;
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.no_proxy()
|
||||
.build()
|
||||
.context("build HTTP client")?;
|
||||
let mut state = HealthState::default();
|
||||
|
||||
println!("=== AW Services Health Check ===");
|
||||
println!(
|
||||
"Timestamp: {}",
|
||||
command_stdout("date", &[]).unwrap_or_else(|_| "unknown".to_string())
|
||||
);
|
||||
println!();
|
||||
|
||||
for service in SERVICES {
|
||||
check_service(&mut state, service)?;
|
||||
}
|
||||
println!();
|
||||
|
||||
let worktime_base = env_first(
|
||||
&env,
|
||||
"AW_RUS_HEALTH_WORKTIME_API",
|
||||
&env_first(&env, "AW_WORKTIME_REPORT_BASE", "http://127.0.0.1:5610"),
|
||||
);
|
||||
let worktime_url = format!("{}/health", worktime_base.trim_end_matches('/'));
|
||||
let worktime_timeout = env_i64(&env, "AW_RUS_HEALTH_WORKTIME_TIMEOUT_SECONDS", 15);
|
||||
let aw_timeout = env_i64(&env, "AW_RUS_HEALTH_AW_TIMEOUT_SECONDS", 15);
|
||||
let aw_attempts = env_i64(&env, "AW_RUS_HEALTH_AW_ATTEMPTS", 3);
|
||||
let settings_timeout = env_i64(&env, "AW_RUS_HEALTH_SETTINGS_TIMEOUT_SECONDS", 15);
|
||||
let settings_attempts = env_i64(&env, "AW_RUS_HEALTH_SETTINGS_ATTEMPTS", 3);
|
||||
|
||||
check_api_endpoint(
|
||||
&client,
|
||||
&mut state,
|
||||
"http://127.0.0.1:5600/api/0/info",
|
||||
"activitywatch-server",
|
||||
aw_timeout,
|
||||
aw_attempts,
|
||||
);
|
||||
check_api_endpoint(
|
||||
&client,
|
||||
&mut state,
|
||||
&worktime_url,
|
||||
"aw-worktime-api",
|
||||
worktime_timeout,
|
||||
2,
|
||||
);
|
||||
check_dlp_transport_freshness(&mut state, &env);
|
||||
check_aw_db_health(&mut state, &env);
|
||||
check_expected_setting(
|
||||
&client,
|
||||
&mut state,
|
||||
"startOfDay",
|
||||
env_first(&env, "AW_EXPECT_START_OF_DAY", ""),
|
||||
"startOfDay",
|
||||
settings_timeout,
|
||||
settings_attempts,
|
||||
);
|
||||
check_expected_setting(
|
||||
&client,
|
||||
&mut state,
|
||||
"always_active_pattern",
|
||||
env_first(&env, "AW_EXPECT_ALWAYS_ACTIVE_PATTERN", ""),
|
||||
"always_active_pattern",
|
||||
settings_timeout,
|
||||
settings_attempts,
|
||||
);
|
||||
check_expected_setting(
|
||||
&client,
|
||||
&mut state,
|
||||
"landingpage",
|
||||
env_first(&env, "AW_EXPECT_LANDINGPAGE", ""),
|
||||
"landingpage",
|
||||
settings_timeout,
|
||||
settings_attempts,
|
||||
);
|
||||
|
||||
println!();
|
||||
if state.unhealthy.is_empty() {
|
||||
println!("✓ All services are healthy");
|
||||
if !state.warnings.is_empty() {
|
||||
println!("⚠ Warnings: {}", state.warnings.join(" "));
|
||||
}
|
||||
Ok(0)
|
||||
} else {
|
||||
println!("✗ Unhealthy services: {}", state.unhealthy.join(" "));
|
||||
Ok(1)
|
||||
}
|
||||
}
|
||||
|
||||
fn check_service(state: &mut HealthState, service: &str) -> Result<()> {
|
||||
if service == "aw-worktime-ui-bridge" {
|
||||
let active = systemctl_success(&["is-active", "--quiet", "aw-worktime-ui-bridge.timer"])?;
|
||||
let enabled = systemctl_success(&["is-enabled", "--quiet", "aw-worktime-ui-bridge.timer"])?;
|
||||
if active && enabled {
|
||||
println!("✓ aw-worktime-ui-bridge.timer is running and enabled");
|
||||
} else {
|
||||
println!("✗ aw-worktime-ui-bridge.timer is not active/enabled");
|
||||
state
|
||||
.unhealthy
|
||||
.push("aw-worktime-ui-bridge.timer".to_string());
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if systemctl_success(&["is-active", "--quiet", service])? {
|
||||
println!("✓ {service} is running");
|
||||
} else {
|
||||
println!("✗ {service} is not running");
|
||||
state.unhealthy.push(service.to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn systemctl_success(args: &[&str]) -> Result<bool> {
|
||||
Ok(Command::new("systemctl")
|
||||
.args(args)
|
||||
.status()
|
||||
.with_context(|| format!("run systemctl {}", args.join(" ")))?
|
||||
.success())
|
||||
}
|
||||
|
||||
fn check_api_endpoint(
|
||||
client: &Client,
|
||||
state: &mut HealthState,
|
||||
url: &str,
|
||||
service_name: &str,
|
||||
timeout_seconds: i64,
|
||||
attempts: i64,
|
||||
) {
|
||||
for attempt in 1..=attempts.max(1) {
|
||||
let result = client
|
||||
.get(url)
|
||||
.timeout(Duration::from_secs(timeout_seconds.max(1) as u64))
|
||||
.send()
|
||||
.and_then(|resp| resp.error_for_status())
|
||||
.map(|_| ());
|
||||
if result.is_ok() {
|
||||
println!("✓ {service_name} API endpoint is responding");
|
||||
return;
|
||||
}
|
||||
if attempt < attempts {
|
||||
std::thread::sleep(Duration::from_secs(1));
|
||||
}
|
||||
}
|
||||
println!("✗ {service_name} API endpoint is not responding");
|
||||
state.unhealthy.push(format!("{service_name}-api"));
|
||||
}
|
||||
|
||||
fn check_expected_setting(
|
||||
client: &Client,
|
||||
state: &mut HealthState,
|
||||
key: &str,
|
||||
expected: String,
|
||||
label: &str,
|
||||
timeout_seconds: i64,
|
||||
attempts: i64,
|
||||
) {
|
||||
if expected.is_empty() {
|
||||
println!("⚠ expected value for {label} is not configured, skipping drift check");
|
||||
state.warnings.push(format!("{key}-expected-missing"));
|
||||
return;
|
||||
}
|
||||
|
||||
match read_setting_value(client, key, timeout_seconds, attempts) {
|
||||
Ok(actual) if actual == expected => {
|
||||
println!("✓ {label} matches expected value ({expected})");
|
||||
}
|
||||
Ok(actual) => {
|
||||
println!("✗ {label} drift detected: actual='{actual}' expected='{expected}'");
|
||||
state.unhealthy.push(format!("setting-{key}"));
|
||||
}
|
||||
Err(_) => {
|
||||
println!("✗ failed to read setting {label}");
|
||||
state.unhealthy.push(format!("setting-{key}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read_setting_value(
|
||||
client: &Client,
|
||||
key: &str,
|
||||
timeout_seconds: i64,
|
||||
attempts: i64,
|
||||
) -> Result<String> {
|
||||
let url = format!("http://127.0.0.1:5600/api/0/settings/{key}");
|
||||
for attempt in 1..=attempts.max(1) {
|
||||
let result = client
|
||||
.get(&url)
|
||||
.timeout(Duration::from_secs(timeout_seconds.max(1) as u64))
|
||||
.send()
|
||||
.and_then(|resp| resp.error_for_status())
|
||||
.and_then(|resp| resp.json::<Value>());
|
||||
match result {
|
||||
Ok(value) => return Ok(json_value_to_shell_print(value)),
|
||||
Err(err) if attempt >= attempts => return Err(anyhow!(err)),
|
||||
Err(_) => std::thread::sleep(Duration::from_secs(1)),
|
||||
}
|
||||
}
|
||||
Err(anyhow!("setting read exhausted"))
|
||||
}
|
||||
|
||||
fn check_dlp_transport_freshness(state: &mut HealthState, env: &HashMap<String, String>) {
|
||||
let dlp_health = env_first(env, "DLP_HEALTH_BIN", "/usr/local/bin/dlp-health-check");
|
||||
if !is_executable(Path::new(&dlp_health)) {
|
||||
println!("⚠ dlp-health-check is not available, skipping DLP transport freshness checks");
|
||||
state.warnings.push("dlp-health-check-missing".to_string());
|
||||
return;
|
||||
}
|
||||
|
||||
let output = match Command::new(&dlp_health).arg("--json").output() {
|
||||
Ok(output) => output,
|
||||
Err(_) => {
|
||||
println!(
|
||||
"⚠ dlp-health-check did not return JSON, skipping DLP transport freshness checks"
|
||||
);
|
||||
state.warnings.push("dlp-health-check-empty".to_string());
|
||||
return;
|
||||
}
|
||||
};
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if stdout.is_empty() {
|
||||
println!("⚠ dlp-health-check did not return JSON, skipping DLP transport freshness checks");
|
||||
state.warnings.push("dlp-health-check-empty".to_string());
|
||||
return;
|
||||
}
|
||||
let payload = match serde_json::from_str::<Value>(&stdout) {
|
||||
Ok(payload) => payload,
|
||||
Err(_) => {
|
||||
println!(
|
||||
"⚠ dlp-health-check did not return JSON, skipping DLP transport freshness checks"
|
||||
);
|
||||
state.warnings.push("dlp-health-check-empty".to_string());
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut errors = Vec::new();
|
||||
let mut warnings = Vec::new();
|
||||
for result in payload
|
||||
.get("results")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
let name = result.get("name").and_then(Value::as_str).unwrap_or("");
|
||||
if !DLP_TRANSPORT_CHECKS.contains(&name) {
|
||||
continue;
|
||||
}
|
||||
let status = result.get("status").and_then(Value::as_str).unwrap_or("");
|
||||
let summary = result.get("summary").and_then(Value::as_str).unwrap_or("");
|
||||
if status == "fail" {
|
||||
errors.push(format!("{name}:{summary}"));
|
||||
} else if status == "warn" {
|
||||
warnings.push(format!("{name}:{summary}"));
|
||||
}
|
||||
}
|
||||
|
||||
if errors.is_empty() {
|
||||
println!("✓ DLP transport freshness check passed");
|
||||
} else {
|
||||
println!("✗ DLP transport freshness check failed");
|
||||
state.unhealthy.push("dlp-transport".to_string());
|
||||
}
|
||||
if !errors.is_empty() {
|
||||
println!(" errors: {}", errors.join(", "));
|
||||
}
|
||||
if !warnings.is_empty() {
|
||||
let text = warnings.join(", ");
|
||||
println!(" warnings: {text}");
|
||||
state.warnings.push(text);
|
||||
}
|
||||
}
|
||||
|
||||
fn check_aw_db_health(state: &mut HealthState, env: &HashMap<String, String>) {
|
||||
let aw_db_health = env_first(env, "AW_DB_HEALTH_BIN", "/usr/local/bin/aw-db-health");
|
||||
if !is_executable(Path::new(&aw_db_health)) {
|
||||
println!("⚠ aw-db-health is not available, skipping AW DB growth checks");
|
||||
state.warnings.push("aw-db-health-missing".to_string());
|
||||
return;
|
||||
}
|
||||
|
||||
let output = match Command::new(&aw_db_health).arg("--json").output() {
|
||||
Ok(output) => output,
|
||||
Err(_) => {
|
||||
println!("⚠ aw-db-health did not return JSON, skipping AW DB growth checks");
|
||||
state.warnings.push("aw-db-health-empty".to_string());
|
||||
return;
|
||||
}
|
||||
};
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if stdout.is_empty() {
|
||||
println!("⚠ aw-db-health did not return JSON, skipping AW DB growth checks");
|
||||
state.warnings.push("aw-db-health-empty".to_string());
|
||||
return;
|
||||
}
|
||||
let payload = match serde_json::from_str::<Value>(&stdout) {
|
||||
Ok(payload) => payload,
|
||||
Err(_) => {
|
||||
println!("⚠ aw-db-health did not return JSON, skipping AW DB growth checks");
|
||||
state.warnings.push("aw-db-health-empty".to_string());
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut errors = Vec::new();
|
||||
let mut warnings = Vec::new();
|
||||
for result in payload
|
||||
.get("results")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
let name = result.get("name").and_then(Value::as_str).unwrap_or("");
|
||||
if !AW_DB_HEALTH_CHECKS.contains(&name) {
|
||||
continue;
|
||||
}
|
||||
let status = result.get("status").and_then(Value::as_str).unwrap_or("");
|
||||
let summary = result.get("summary").and_then(Value::as_str).unwrap_or("");
|
||||
if status == "fail" {
|
||||
errors.push(format!("{name}:{summary}"));
|
||||
} else if status == "warn" {
|
||||
warnings.push(format!("{name}:{summary}"));
|
||||
}
|
||||
}
|
||||
|
||||
if errors.is_empty() {
|
||||
println!("✓ AW DB growth guard passed");
|
||||
} else {
|
||||
println!("✗ AW DB growth guard failed");
|
||||
state.unhealthy.push("aw-db-health".to_string());
|
||||
}
|
||||
if !errors.is_empty() {
|
||||
println!(" errors: {}", errors.join(", "));
|
||||
}
|
||||
if !warnings.is_empty() {
|
||||
let text = warnings.join(", ");
|
||||
println!(" warnings: {text}");
|
||||
state.warnings.push(text);
|
||||
}
|
||||
}
|
||||
|
||||
fn command_stdout(program: &str, args: &[&str]) -> Result<String> {
|
||||
let output = Command::new(program).args(args).output()?;
|
||||
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
}
|
||||
|
||||
fn is_executable(path: &Path) -> bool {
|
||||
path.is_file()
|
||||
}
|
||||
|
||||
fn load_effective_env(path: &Path) -> Result<HashMap<String, String>> {
|
||||
let mut env = std::env::vars().collect::<HashMap<_, _>>();
|
||||
if path.is_file() {
|
||||
for (key, value) in parse_env_file(&fs::read_to_string(path)?) {
|
||||
env.insert(key, value);
|
||||
}
|
||||
}
|
||||
Ok(env)
|
||||
}
|
||||
|
||||
fn parse_env_file(text: &str) -> HashMap<String, String> {
|
||||
let mut values = HashMap::new();
|
||||
for raw in text.lines() {
|
||||
let line = raw.trim();
|
||||
if line.is_empty() || line.starts_with('#') || !line.contains('=') {
|
||||
continue;
|
||||
}
|
||||
let Some((key, value)) = line.split_once('=') else {
|
||||
continue;
|
||||
};
|
||||
let key = key.trim();
|
||||
if key.is_empty()
|
||||
|| !key
|
||||
.chars()
|
||||
.all(|ch| ch == '_' || ch.is_ascii_alphanumeric())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
values.insert(key.to_string(), unquote_env_value(value.trim()));
|
||||
}
|
||||
values
|
||||
}
|
||||
|
||||
fn unquote_env_value(value: &str) -> String {
|
||||
if value.len() >= 2 {
|
||||
let bytes = value.as_bytes();
|
||||
if (bytes[0] == b'"' && bytes[value.len() - 1] == b'"')
|
||||
|| (bytes[0] == b'\'' && bytes[value.len() - 1] == b'\'')
|
||||
{
|
||||
return value[1..value.len() - 1].to_string();
|
||||
}
|
||||
}
|
||||
value.to_string()
|
||||
}
|
||||
|
||||
fn env_first(env: &HashMap<String, String>, key: &str, default: &str) -> String {
|
||||
env.get(key)
|
||||
.filter(|value| !value.is_empty())
|
||||
.cloned()
|
||||
.unwrap_or_else(|| default.to_string())
|
||||
}
|
||||
|
||||
fn env_i64(env: &HashMap<String, String>, key: &str, default: i64) -> i64 {
|
||||
env.get(key)
|
||||
.and_then(|value| value.parse::<i64>().ok())
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
fn json_value_to_shell_print(value: Value) -> String {
|
||||
match value {
|
||||
Value::String(value) => value,
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_simple_env_file() {
|
||||
let parsed = parse_env_file(
|
||||
r#"
|
||||
# comment
|
||||
AW_EXPECT_START_OF_DAY="00:00"
|
||||
AW_EXPECT_LANDINGPAGE=/#/activity/HOST-EXAMPLE/view/
|
||||
BAD KEY=value
|
||||
"#,
|
||||
);
|
||||
assert_eq!(parsed.get("AW_EXPECT_START_OF_DAY").unwrap(), "00:00");
|
||||
assert_eq!(
|
||||
parsed.get("AW_EXPECT_LANDINGPAGE").unwrap(),
|
||||
"/#/activity/HOST-EXAMPLE/view/"
|
||||
);
|
||||
assert!(!parsed.contains_key("BAD KEY"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formats_json_setting_like_python_print_json_load() {
|
||||
assert_eq!(
|
||||
json_value_to_shell_print(Value::String("00:00".to_string())),
|
||||
"00:00"
|
||||
);
|
||||
assert_eq!(json_value_to_shell_print(Value::Bool(true)), "true");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "aw-linux-install"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
clap.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
@@ -0,0 +1,290 @@
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use clap::{Parser, ValueEnum};
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, Clone, Copy, ValueEnum, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
enum InstallKind {
|
||||
Client,
|
||||
RemoteWorker,
|
||||
ConsoleSsh,
|
||||
WebCategory,
|
||||
PveWebadmin,
|
||||
}
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(about = "Safe planner/apply wrapper for AW Linux install scripts")]
|
||||
struct Cli {
|
||||
#[arg(long, value_enum)]
|
||||
kind: InstallKind,
|
||||
|
||||
#[arg(long)]
|
||||
legacy_script: PathBuf,
|
||||
|
||||
#[arg(long, default_value = "192.0.2.13")]
|
||||
server_host: String,
|
||||
|
||||
#[arg(long, default_value = "5600")]
|
||||
server_port: String,
|
||||
|
||||
#[arg(long, default_value = "5")]
|
||||
poll_interval: String,
|
||||
|
||||
#[arg(long, default_value = "0.13.2")]
|
||||
version: String,
|
||||
|
||||
#[arg(long)]
|
||||
install_base: Option<PathBuf>,
|
||||
|
||||
#[arg(long, default_value_t = false)]
|
||||
force: bool,
|
||||
|
||||
#[arg(long, default_value_t = false)]
|
||||
apply: bool,
|
||||
|
||||
#[arg(long, default_value_t = false)]
|
||||
json: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct Plan {
|
||||
apply: bool,
|
||||
kind: InstallKind,
|
||||
legacy_script: PathBuf,
|
||||
required_files: Vec<Requirement>,
|
||||
steps: Vec<Step>,
|
||||
missing_count: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct Requirement {
|
||||
name: String,
|
||||
ok: bool,
|
||||
detail: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct Step {
|
||||
order: usize,
|
||||
name: String,
|
||||
mutation: bool,
|
||||
command: Vec<String>,
|
||||
summary: String,
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let code = match run() {
|
||||
Ok(code) => code,
|
||||
Err(err) => {
|
||||
eprintln!("{err:#}");
|
||||
1
|
||||
}
|
||||
};
|
||||
std::process::exit(code);
|
||||
}
|
||||
|
||||
fn run() -> Result<i32> {
|
||||
let cli = Cli::parse();
|
||||
let plan = build_plan(&cli);
|
||||
if !cli.apply {
|
||||
print_plan(&plan, cli.json)?;
|
||||
return Ok(if plan.missing_count == 0 { 0 } else { 2 });
|
||||
}
|
||||
if plan.missing_count > 0 {
|
||||
print_plan(&plan, cli.json)?;
|
||||
bail!("refusing --apply because required files are missing");
|
||||
}
|
||||
let Some(step) = plan.steps.first() else {
|
||||
bail!("empty install plan");
|
||||
};
|
||||
let Some(program) = step.command.first() else {
|
||||
bail!("empty legacy command");
|
||||
};
|
||||
let status = Command::new(program)
|
||||
.args(&step.command[1..])
|
||||
.status()
|
||||
.with_context(|| format!("run {}", shell_join(&step.command)))?;
|
||||
Ok(status.code().unwrap_or(1))
|
||||
}
|
||||
|
||||
fn build_plan(cli: &Cli) -> Plan {
|
||||
let required_files = vec![Requirement {
|
||||
name: "legacy_script".to_string(),
|
||||
ok: cli.legacy_script.is_file(),
|
||||
detail: cli.legacy_script.display().to_string(),
|
||||
}];
|
||||
let command = legacy_command(cli);
|
||||
let steps = vec![Step {
|
||||
order: 1,
|
||||
name: format!("{:?}", cli.kind).to_lowercase(),
|
||||
mutation: true,
|
||||
summary: summary(cli.kind).to_string(),
|
||||
command,
|
||||
}];
|
||||
let missing_count = required_files.iter().filter(|item| !item.ok).count();
|
||||
Plan {
|
||||
apply: cli.apply,
|
||||
kind: cli.kind,
|
||||
legacy_script: cli.legacy_script.clone(),
|
||||
required_files,
|
||||
steps,
|
||||
missing_count,
|
||||
}
|
||||
}
|
||||
|
||||
fn legacy_command(cli: &Cli) -> Vec<String> {
|
||||
let mut command = vec![
|
||||
"sh".to_string(),
|
||||
cli.legacy_script.display().to_string(),
|
||||
"--apply-legacy".to_string(),
|
||||
"--server-host".to_string(),
|
||||
cli.server_host.clone(),
|
||||
"--server-port".to_string(),
|
||||
cli.server_port.clone(),
|
||||
];
|
||||
match cli.kind {
|
||||
InstallKind::Client => {
|
||||
command.extend(["--version".to_string(), cli.version.clone()]);
|
||||
if let Some(path) = &cli.install_base {
|
||||
command.extend(["--install-base".to_string(), path.display().to_string()]);
|
||||
}
|
||||
if cli.force {
|
||||
command.push("--force".to_string());
|
||||
}
|
||||
}
|
||||
InstallKind::RemoteWorker => {
|
||||
command.extend([
|
||||
"--poll-interval".to_string(),
|
||||
cli.poll_interval.clone(),
|
||||
"--version".to_string(),
|
||||
cli.version.clone(),
|
||||
]);
|
||||
}
|
||||
InstallKind::ConsoleSsh | InstallKind::WebCategory | InstallKind::PveWebadmin => {
|
||||
command.extend(["--poll-interval".to_string(), cli.poll_interval.clone()]);
|
||||
}
|
||||
}
|
||||
command
|
||||
}
|
||||
|
||||
fn summary(kind: InstallKind) -> &'static str {
|
||||
match kind {
|
||||
InstallKind::Client => {
|
||||
"Install ActivityWatch Linux GUI watcher bundle and remote server config"
|
||||
}
|
||||
InstallKind::RemoteWorker => {
|
||||
"Install Linux client, console/SSH logger, and web category logger"
|
||||
}
|
||||
InstallKind::ConsoleSsh => "Install console command and SSH session logger",
|
||||
InstallKind::WebCategory => "Install Linux web category logger",
|
||||
InstallKind::PveWebadmin => "Install Proxmox webadmin logger service",
|
||||
}
|
||||
}
|
||||
|
||||
fn print_plan(plan: &Plan, json: bool) -> Result<()> {
|
||||
if json {
|
||||
println!("{}", serde_json::to_string_pretty(plan)?);
|
||||
return Ok(());
|
||||
}
|
||||
println!(
|
||||
"aw-linux-install: {}",
|
||||
if plan.apply { "apply" } else { "dry-run" }
|
||||
);
|
||||
println!("kind: {:?}", plan.kind);
|
||||
println!("legacy_script: {}", plan.legacy_script.display());
|
||||
println!("missing_inputs: {}", plan.missing_count);
|
||||
println!("required files:");
|
||||
for item in &plan.required_files {
|
||||
println!(
|
||||
" [{}] {} - {}",
|
||||
if item.ok { "OK" } else { "MISS" },
|
||||
item.name,
|
||||
item.detail
|
||||
);
|
||||
}
|
||||
println!("planned steps:");
|
||||
for step in &plan.steps {
|
||||
println!(
|
||||
" {:02}. MUTATION {} :: {}",
|
||||
step.order,
|
||||
step.summary,
|
||||
shell_join(&step.command)
|
||||
);
|
||||
}
|
||||
if !plan.apply {
|
||||
println!("No install executed. Use --apply for explicit legacy install execution.");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn shell_join(command: &[String]) -> String {
|
||||
command
|
||||
.iter()
|
||||
.map(|part| {
|
||||
if part
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || "-_./:=,".contains(ch))
|
||||
{
|
||||
part.clone()
|
||||
} else {
|
||||
format!("'{}'", part.replace('\'', "'\\''"))
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn client_plan_preserves_version_and_force() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let script = dir.path().join("install.sh");
|
||||
std::fs::write(&script, "#!/bin/sh\n").unwrap();
|
||||
let cli = Cli {
|
||||
kind: InstallKind::Client,
|
||||
legacy_script: script,
|
||||
server_host: "192.0.2.13".to_string(),
|
||||
server_port: "5600".to_string(),
|
||||
poll_interval: "5".to_string(),
|
||||
version: "0.13.2".to_string(),
|
||||
install_base: Some(PathBuf::from("/tmp/aw")),
|
||||
force: true,
|
||||
apply: false,
|
||||
json: false,
|
||||
};
|
||||
let plan = build_plan(&cli);
|
||||
let cmd = &plan.steps[0].command;
|
||||
assert!(cmd.contains(&"--apply-legacy".to_string()));
|
||||
assert!(cmd.contains(&"--install-base".to_string()));
|
||||
assert!(cmd.contains(&"--force".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_worker_plan_includes_poll_and_version() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let script = dir.path().join("install.sh");
|
||||
std::fs::write(&script, "#!/bin/sh\n").unwrap();
|
||||
let cli = Cli {
|
||||
kind: InstallKind::RemoteWorker,
|
||||
legacy_script: script,
|
||||
server_host: "host".to_string(),
|
||||
server_port: "5600".to_string(),
|
||||
poll_interval: "9".to_string(),
|
||||
version: "0.13.3".to_string(),
|
||||
install_base: None,
|
||||
force: false,
|
||||
apply: false,
|
||||
json: false,
|
||||
};
|
||||
let plan = build_plan(&cli);
|
||||
let joined = shell_join(&plan.steps[0].command);
|
||||
assert!(joined.contains("--poll-interval 9"));
|
||||
assert!(joined.contains("--version 0.13.3"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "aw-prune-local-state"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
clap.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
@@ -0,0 +1,621 @@
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use clap::Parser;
|
||||
use serde::Serialize;
|
||||
|
||||
const DEFAULT_DATA_DIR: &str = "/var/lib/activitywatch";
|
||||
const TMP_ARCHIVE_PATTERNS: &[NamePattern] = &[
|
||||
NamePattern::PrefixSuffix("activitywatch-", ".zip"),
|
||||
NamePattern::PrefixSuffix("hayabusa-", ".zip"),
|
||||
NamePattern::Exact("aw-hayabusa-profiles.txt"),
|
||||
];
|
||||
const TMP_WEBUI_PATTERNS: &[NamePattern] = &[
|
||||
NamePattern::Exact("aw-worktime-ui-bridge.py"),
|
||||
NamePattern::Exact("views-default.json"),
|
||||
NamePattern::Exact("apply_webui_ru_patch.out"),
|
||||
];
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(about = "Safely prune ActivityWatch local app state and temporary artifacts")]
|
||||
struct Cli {
|
||||
#[arg(long)]
|
||||
data_dir: Option<PathBuf>,
|
||||
|
||||
#[arg(long)]
|
||||
backup_dir: Option<PathBuf>,
|
||||
|
||||
#[arg(long)]
|
||||
browser_smoke_dir: Option<PathBuf>,
|
||||
|
||||
#[arg(long)]
|
||||
tmp_dir: Option<PathBuf>,
|
||||
|
||||
#[arg(long, default_value_t = 7)]
|
||||
backup_retention_days: u64,
|
||||
|
||||
#[arg(long, default_value_t = 2)]
|
||||
backup_keep_last_db: usize,
|
||||
|
||||
#[arg(long, default_value_t = 2)]
|
||||
backup_keep_last_json: usize,
|
||||
|
||||
#[arg(long, default_value_t = 24)]
|
||||
browser_smoke_keep_runs: usize,
|
||||
|
||||
#[arg(long, default_value_t = 1)]
|
||||
browser_smoke_retention_days: u64,
|
||||
|
||||
#[arg(long, default_value_t = 1)]
|
||||
tmp_archive_retention_days: u64,
|
||||
|
||||
#[arg(long, default_value_t = 2)]
|
||||
tmp_webui_retention_days: u64,
|
||||
|
||||
#[arg(long, default_value_t = false)]
|
||||
apply: bool,
|
||||
|
||||
#[arg(long, default_value_t = false)]
|
||||
json: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct Config {
|
||||
data_dir: PathBuf,
|
||||
backup_dir: PathBuf,
|
||||
browser_smoke_dir: PathBuf,
|
||||
tmp_dir: PathBuf,
|
||||
backup_retention_days: u64,
|
||||
backup_keep_last_db: usize,
|
||||
backup_keep_last_json: usize,
|
||||
browser_smoke_keep_runs: usize,
|
||||
browser_smoke_retention_days: u64,
|
||||
tmp_archive_retention_days: u64,
|
||||
tmp_webui_retention_days: u64,
|
||||
apply: bool,
|
||||
json: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum ItemKind {
|
||||
File,
|
||||
Directory,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct PruneItem {
|
||||
path: PathBuf,
|
||||
kind: ItemKind,
|
||||
reason: String,
|
||||
age_days: Option<u64>,
|
||||
size_bytes: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct Summary {
|
||||
apply: bool,
|
||||
planned: usize,
|
||||
deleted: usize,
|
||||
failed: usize,
|
||||
bytes: u64,
|
||||
items: Vec<PruneItem>,
|
||||
errors: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum NamePattern {
|
||||
Exact(&'static str),
|
||||
PrefixSuffix(&'static str, &'static str),
|
||||
}
|
||||
|
||||
impl NamePattern {
|
||||
fn matches(self, name: &str) -> bool {
|
||||
match self {
|
||||
Self::Exact(expected) => name == expected,
|
||||
Self::PrefixSuffix(prefix, suffix) => {
|
||||
name.starts_with(prefix) && name.ends_with(suffix)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let code = match run() {
|
||||
Ok(code) => code,
|
||||
Err(err) => {
|
||||
eprintln!("{err:#}");
|
||||
1
|
||||
}
|
||||
};
|
||||
std::process::exit(code);
|
||||
}
|
||||
|
||||
fn run() -> Result<i32> {
|
||||
let cfg = Config::from_cli(Cli::parse());
|
||||
let mut items = Vec::new();
|
||||
fs::create_dir_all(&cfg.backup_dir)
|
||||
.with_context(|| format!("create backup dir {}", cfg.backup_dir.display()))?;
|
||||
|
||||
plan_backup_group(
|
||||
&cfg.backup_dir.join("db"),
|
||||
cfg.backup_keep_last_db,
|
||||
cfg.backup_retention_days,
|
||||
"backup_db",
|
||||
&mut items,
|
||||
)?;
|
||||
plan_backup_group(
|
||||
&cfg.backup_dir,
|
||||
cfg.backup_keep_last_json,
|
||||
cfg.backup_retention_days,
|
||||
"backup_root",
|
||||
&mut items,
|
||||
)?;
|
||||
plan_browser_smoke(&cfg, &mut items)?;
|
||||
plan_tmp(
|
||||
&cfg.tmp_dir,
|
||||
TMP_ARCHIVE_PATTERNS,
|
||||
cfg.tmp_archive_retention_days,
|
||||
"tmp_archive",
|
||||
&mut items,
|
||||
)?;
|
||||
plan_tmp(
|
||||
&cfg.tmp_dir,
|
||||
TMP_WEBUI_PATTERNS,
|
||||
cfg.tmp_webui_retention_days,
|
||||
"tmp_webui",
|
||||
&mut items,
|
||||
)?;
|
||||
|
||||
validate_plan(&cfg, &items)?;
|
||||
let mut summary = Summary {
|
||||
apply: cfg.apply,
|
||||
planned: items.len(),
|
||||
deleted: 0,
|
||||
failed: 0,
|
||||
bytes: items.iter().map(|item| item.size_bytes).sum(),
|
||||
items,
|
||||
errors: Vec::new(),
|
||||
};
|
||||
if cfg.apply {
|
||||
apply_plan(&mut summary);
|
||||
}
|
||||
print_summary(&summary, cfg.json)?;
|
||||
if summary.failed == 0 { Ok(0) } else { Ok(1) }
|
||||
}
|
||||
|
||||
impl Config {
|
||||
fn from_cli(cli: Cli) -> Self {
|
||||
let data_dir = cli
|
||||
.data_dir
|
||||
.or_else(|| env_path("AW_DATA_DIR"))
|
||||
.unwrap_or_else(|| PathBuf::from(DEFAULT_DATA_DIR));
|
||||
let backup_dir = cli
|
||||
.backup_dir
|
||||
.or_else(|| env_path("AW_BACKUP_DIR"))
|
||||
.unwrap_or_else(|| data_dir.join("backups"));
|
||||
let browser_smoke_dir = cli
|
||||
.browser_smoke_dir
|
||||
.or_else(|| env_path("AW_BROWSER_SMOKE_OUTPUT_DIR"))
|
||||
.unwrap_or_else(|| data_dir.join("browser-smoke"));
|
||||
let tmp_dir = cli
|
||||
.tmp_dir
|
||||
.or_else(|| env_path("AW_TMP_DIR"))
|
||||
.unwrap_or_else(|| PathBuf::from("/tmp"));
|
||||
Self {
|
||||
data_dir,
|
||||
backup_dir,
|
||||
browser_smoke_dir,
|
||||
tmp_dir,
|
||||
backup_retention_days: env_u64("AW_BACKUP_RETENTION_DAYS", cli.backup_retention_days),
|
||||
backup_keep_last_db: env_usize("AW_BACKUP_KEEP_LAST_DB", cli.backup_keep_last_db),
|
||||
backup_keep_last_json: env_usize("AW_BACKUP_KEEP_LAST_JSON", cli.backup_keep_last_json),
|
||||
browser_smoke_keep_runs: env_usize(
|
||||
"AW_BROWSER_SMOKE_KEEP_RUNS",
|
||||
cli.browser_smoke_keep_runs,
|
||||
),
|
||||
browser_smoke_retention_days: env_u64(
|
||||
"AW_BROWSER_SMOKE_RETENTION_DAYS",
|
||||
cli.browser_smoke_retention_days,
|
||||
),
|
||||
tmp_archive_retention_days: env_u64(
|
||||
"AW_TMP_ARCHIVE_RETENTION_DAYS",
|
||||
cli.tmp_archive_retention_days,
|
||||
),
|
||||
tmp_webui_retention_days: env_u64(
|
||||
"AW_TMP_WEBUI_RETENTION_DAYS",
|
||||
cli.tmp_webui_retention_days,
|
||||
),
|
||||
apply: cli.apply,
|
||||
json: cli.json,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn plan_backup_group(
|
||||
dir: &Path,
|
||||
keep_last: usize,
|
||||
keep_days: u64,
|
||||
reason: &str,
|
||||
items: &mut Vec<PruneItem>,
|
||||
) -> Result<()> {
|
||||
let mut files = list_files(dir)?;
|
||||
files.sort_by(|a, b| b.modified.cmp(&a.modified));
|
||||
let cutoff = cutoff(keep_days);
|
||||
for (idx, candidate) in files.into_iter().enumerate() {
|
||||
if idx < keep_last || candidate.modified >= cutoff || is_rollback_critical(&candidate.path)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
items.push(candidate.into_item(reason));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn plan_browser_smoke(cfg: &Config, items: &mut Vec<PruneItem>) -> Result<()> {
|
||||
let mut dirs = list_run_dirs(&cfg.browser_smoke_dir)?;
|
||||
dirs.sort_by(|a, b| b.modified.cmp(&a.modified));
|
||||
let cutoff = cutoff(cfg.browser_smoke_retention_days);
|
||||
for (idx, candidate) in dirs.into_iter().enumerate() {
|
||||
if idx < cfg.browser_smoke_keep_runs || candidate.modified >= cutoff {
|
||||
continue;
|
||||
}
|
||||
items.push(candidate.into_item("browser_smoke_run"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn plan_tmp(
|
||||
dir: &Path,
|
||||
patterns: &[NamePattern],
|
||||
keep_days: u64,
|
||||
reason: &str,
|
||||
items: &mut Vec<PruneItem>,
|
||||
) -> Result<()> {
|
||||
if !dir.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
let cutoff = cutoff(keep_days);
|
||||
for entry in fs::read_dir(dir).with_context(|| format!("read {}", dir.display()))? {
|
||||
let entry = entry?;
|
||||
let file_type = entry.file_type()?;
|
||||
if !file_type.is_file() && !file_type.is_symlink() {
|
||||
continue;
|
||||
}
|
||||
let name = entry.file_name();
|
||||
let name = name.to_string_lossy();
|
||||
if !patterns.iter().any(|pattern| pattern.matches(&name)) {
|
||||
continue;
|
||||
}
|
||||
let meta = entry.metadata()?;
|
||||
let modified = meta.modified().unwrap_or(SystemTime::UNIX_EPOCH);
|
||||
if modified >= cutoff {
|
||||
continue;
|
||||
}
|
||||
items.push(
|
||||
Candidate {
|
||||
path: entry.path(),
|
||||
kind: ItemKind::File,
|
||||
modified,
|
||||
size_bytes: meta.len(),
|
||||
}
|
||||
.into_item(reason),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Candidate {
|
||||
path: PathBuf,
|
||||
kind: ItemKind,
|
||||
modified: SystemTime,
|
||||
size_bytes: u64,
|
||||
}
|
||||
|
||||
impl Candidate {
|
||||
fn into_item(self, reason: &str) -> PruneItem {
|
||||
PruneItem {
|
||||
path: self.path,
|
||||
kind: self.kind,
|
||||
reason: reason.to_string(),
|
||||
age_days: age_days(self.modified),
|
||||
size_bytes: self.size_bytes,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn list_files(dir: &Path) -> Result<Vec<Candidate>> {
|
||||
if !dir.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mut files = Vec::new();
|
||||
for entry in fs::read_dir(dir).with_context(|| format!("read {}", dir.display()))? {
|
||||
let entry = entry?;
|
||||
if !entry.file_type()?.is_file() {
|
||||
continue;
|
||||
}
|
||||
let meta = entry.metadata()?;
|
||||
files.push(Candidate {
|
||||
path: entry.path(),
|
||||
kind: ItemKind::File,
|
||||
modified: meta.modified().unwrap_or(SystemTime::UNIX_EPOCH),
|
||||
size_bytes: meta.len(),
|
||||
});
|
||||
}
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
fn list_run_dirs(dir: &Path) -> Result<Vec<Candidate>> {
|
||||
if !dir.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mut dirs = Vec::new();
|
||||
for entry in fs::read_dir(dir).with_context(|| format!("read {}", dir.display()))? {
|
||||
let entry = entry?;
|
||||
if !entry.file_type()?.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let name = entry.file_name();
|
||||
let name = name.to_string_lossy();
|
||||
if !looks_like_browser_smoke_run(&name) {
|
||||
continue;
|
||||
}
|
||||
let meta = entry.metadata()?;
|
||||
dirs.push(Candidate {
|
||||
path: entry.path(),
|
||||
kind: ItemKind::Directory,
|
||||
modified: meta.modified().unwrap_or(SystemTime::UNIX_EPOCH),
|
||||
size_bytes: dir_size(&entry.path()).unwrap_or(0),
|
||||
});
|
||||
}
|
||||
Ok(dirs)
|
||||
}
|
||||
|
||||
fn looks_like_browser_smoke_run(name: &str) -> bool {
|
||||
name.len() >= 20
|
||||
&& name.starts_with("20")
|
||||
&& name.contains('T')
|
||||
&& name
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')
|
||||
}
|
||||
|
||||
fn validate_plan(cfg: &Config, items: &[PruneItem]) -> Result<()> {
|
||||
for item in items {
|
||||
if is_rollback_critical(&item.path) {
|
||||
bail!(
|
||||
"refusing to delete rollback-critical path {}",
|
||||
item.path.display()
|
||||
);
|
||||
}
|
||||
let allowed = match item.reason.as_str() {
|
||||
"backup_db" => is_under_or_equal(&item.path, &cfg.backup_dir.join("db")),
|
||||
"backup_root" => is_under_or_equal(&item.path, &cfg.backup_dir),
|
||||
"browser_smoke_run" => is_under_or_equal(&item.path, &cfg.browser_smoke_dir),
|
||||
"tmp_archive" | "tmp_webui" => is_under_or_equal(&item.path, &cfg.tmp_dir),
|
||||
_ => false,
|
||||
};
|
||||
if !allowed {
|
||||
bail!(
|
||||
"refusing to delete path outside allowlist {}",
|
||||
item.path.display()
|
||||
);
|
||||
}
|
||||
if item.reason == "backup_root" && item.path.parent() != Some(cfg.backup_dir.as_path()) {
|
||||
bail!("refusing nested backup_root delete {}", item.path.display());
|
||||
}
|
||||
if item.path == cfg.data_dir
|
||||
|| item.path == cfg.backup_dir
|
||||
|| item.path == cfg.browser_smoke_dir
|
||||
{
|
||||
bail!("refusing to delete root directory {}", item.path.display());
|
||||
}
|
||||
let name = item
|
||||
.path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or("");
|
||||
if is_sqlite_db_name(name) && item.reason != "backup_db" {
|
||||
bail!(
|
||||
"refusing to delete SQLite DB outside backup_db {}",
|
||||
item.path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_plan(summary: &mut Summary) {
|
||||
for item in summary.items.clone() {
|
||||
let result = match item.kind {
|
||||
ItemKind::File => fs::remove_file(&item.path),
|
||||
ItemKind::Directory => fs::remove_dir_all(&item.path),
|
||||
};
|
||||
match result {
|
||||
Ok(()) => summary.deleted += 1,
|
||||
Err(err) if !item.path.exists() => {
|
||||
summary.deleted += 1;
|
||||
summary
|
||||
.errors
|
||||
.push(format!("already gone: {} ({err})", item.path.display()));
|
||||
}
|
||||
Err(err) => {
|
||||
summary.failed += 1;
|
||||
summary
|
||||
.errors
|
||||
.push(format!("{}: {err}", item.path.display()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn print_summary(summary: &Summary, json: bool) -> Result<()> {
|
||||
if json {
|
||||
println!("{}", serde_json::to_string_pretty(summary)?);
|
||||
return Ok(());
|
||||
}
|
||||
let mode = if summary.apply { "apply" } else { "dry-run" };
|
||||
println!(
|
||||
"aw-prune-local-state: mode={mode} planned={} deleted={} failed={} bytes={}",
|
||||
summary.planned, summary.deleted, summary.failed, summary.bytes
|
||||
);
|
||||
for item in &summary.items {
|
||||
let verb = if summary.apply {
|
||||
"DELETE"
|
||||
} else {
|
||||
"WOULD_DELETE"
|
||||
};
|
||||
println!(
|
||||
"{verb} {:?} {} reason={} age_days={} bytes={}",
|
||||
item.kind,
|
||||
item.path.display(),
|
||||
item.reason,
|
||||
item.age_days
|
||||
.map(|value| value.to_string())
|
||||
.unwrap_or_else(|| "unknown".to_string()),
|
||||
item.size_bytes
|
||||
);
|
||||
}
|
||||
for err in &summary.errors {
|
||||
eprintln!("WARN {err}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cutoff(days: u64) -> SystemTime {
|
||||
SystemTime::now()
|
||||
.checked_sub(Duration::from_secs(days.saturating_mul(86_400)))
|
||||
.unwrap_or(SystemTime::UNIX_EPOCH)
|
||||
}
|
||||
|
||||
fn age_days(modified: SystemTime) -> Option<u64> {
|
||||
SystemTime::now()
|
||||
.duration_since(modified)
|
||||
.ok()
|
||||
.map(|age| age.as_secs() / 86_400)
|
||||
}
|
||||
|
||||
fn dir_size(path: &Path) -> Result<u64> {
|
||||
let mut total = 0;
|
||||
for entry in fs::read_dir(path).with_context(|| format!("read {}", path.display()))? {
|
||||
let entry = entry?;
|
||||
let file_type = entry.file_type()?;
|
||||
let child = entry.path();
|
||||
if file_type.is_dir() {
|
||||
total += dir_size(&child).unwrap_or(0);
|
||||
} else if file_type.is_file() || file_type.is_symlink() {
|
||||
total += entry.metadata().map(|meta| meta.len()).unwrap_or(0);
|
||||
}
|
||||
}
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
fn is_under_or_equal(path: &Path, root: &Path) -> bool {
|
||||
path == root || path.starts_with(root)
|
||||
}
|
||||
|
||||
fn is_rollback_critical(path: &Path) -> bool {
|
||||
let text = path.to_string_lossy().to_ascii_lowercase();
|
||||
text.contains("switch-backups")
|
||||
|| text.contains("before-rust")
|
||||
|| text.contains("rollback")
|
||||
|| text.contains("pre-switch")
|
||||
}
|
||||
|
||||
fn is_sqlite_db_name(name: &str) -> bool {
|
||||
name.ends_with(".sqlite")
|
||||
|| name.ends_with(".sqlite3")
|
||||
|| name.ends_with(".db")
|
||||
|| name.ends_with(".db-shm")
|
||||
|| name.ends_with(".db-wal")
|
||||
}
|
||||
|
||||
fn env_path(name: &str) -> Option<PathBuf> {
|
||||
std::env::var(name)
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(PathBuf::from)
|
||||
}
|
||||
|
||||
fn env_u64(name: &str, default: u64) -> u64 {
|
||||
std::env::var(name)
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<u64>().ok())
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
fn env_usize(name: &str, default: usize) -> usize {
|
||||
std::env::var(name)
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<usize>().ok())
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs::File;
|
||||
|
||||
#[test]
|
||||
fn browser_smoke_run_name_is_narrow() {
|
||||
assert!(looks_like_browser_smoke_run("2026-06-02T03-02-19-530Z"));
|
||||
assert!(!looks_like_browser_smoke_run(".cache"));
|
||||
assert!(!looks_like_browser_smoke_run("latest-result.json"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rollback_critical_paths_are_protected() {
|
||||
assert!(is_rollback_critical(Path::new(
|
||||
"/var/lib/activitywatch/health/switch-backups/file"
|
||||
)));
|
||||
assert!(is_rollback_critical(Path::new(
|
||||
"/var/lib/activitywatch/backups/db/before-rust.sqlite"
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backup_group_keeps_newest_even_with_zero_retention() {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
let old = tmp.path().join("old.json");
|
||||
let new = tmp.path().join("new.json");
|
||||
File::create(&old).expect("old");
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
File::create(&new).expect("new");
|
||||
let mut items = Vec::new();
|
||||
plan_backup_group(tmp.path(), 1, 0, "backup_root", &mut items).expect("plan");
|
||||
assert_eq!(items.len(), 1);
|
||||
assert_eq!(items[0].path, old);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validation_rejects_sqlite_outside_backup_db() {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
let cfg = Config {
|
||||
data_dir: tmp.path().to_path_buf(),
|
||||
backup_dir: tmp.path().join("backups"),
|
||||
browser_smoke_dir: tmp.path().join("browser-smoke"),
|
||||
tmp_dir: tmp.path().join("tmp"),
|
||||
backup_retention_days: 1,
|
||||
backup_keep_last_db: 1,
|
||||
backup_keep_last_json: 1,
|
||||
browser_smoke_keep_runs: 1,
|
||||
browser_smoke_retention_days: 1,
|
||||
tmp_archive_retention_days: 1,
|
||||
tmp_webui_retention_days: 1,
|
||||
apply: false,
|
||||
json: false,
|
||||
};
|
||||
let item = PruneItem {
|
||||
path: cfg.browser_smoke_dir.join("state.db"),
|
||||
kind: ItemKind::File,
|
||||
reason: "browser_smoke_run".to_string(),
|
||||
age_days: Some(2),
|
||||
size_bytes: 1,
|
||||
};
|
||||
assert!(validate_plan(&cfg, &[item]).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "aw-rus-healthd"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
chrono.workspace = true
|
||||
clap.workspace = true
|
||||
detmir-core.workspace = true
|
||||
reqwest.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tempfile.workspace = true
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "aw-slo-monitor"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
chrono.workspace = true
|
||||
clap.workspace = true
|
||||
reqwest.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
urlencoding.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
||||
[package]
|
||||
name = "aw-windows-telemetry"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
base64.workspace = true
|
||||
chrono.workspace = true
|
||||
clap.workspace = true
|
||||
csv.workspace = true
|
||||
notify.workspace = true
|
||||
reqwest.workspace = true
|
||||
regex.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
sha2.workspace = true
|
||||
tempfile.workspace = true
|
||||
url.workspace = true
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows = { version = "0.59", features = ["Win32_Foundation", "Win32_System_Com", "Win32_UI_Accessibility", "Win32_UI_WindowsAndMessaging"] }
|
||||
windows-sys = { workspace = true, features = ["Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_DataExchange", "Win32_System_Diagnostics_Debug", "Win32_System_Diagnostics_ToolHelp", "Win32_System_Memory", "Win32_System_RemoteDesktop", "Win32_System_SystemInformation", "Win32_System_Threading", "Win32_UI_WindowsAndMessaging"] }
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
||||
[package]
|
||||
name = "awatch-agent-rs"
|
||||
version = "0.3.0"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
chrono.workspace = true
|
||||
clap.workspace = true
|
||||
reqwest.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.59", features = ["Win32_System_RemoteDesktop"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
asm-lowlevel = []
|
||||
legacy-powershell = []
|
||||
@@ -0,0 +1,18 @@
|
||||
server_url = "https://awatch.local/api/telemetry"
|
||||
api_key = "change-me"
|
||||
collect_interval_seconds = 60
|
||||
role = "workstation"
|
||||
|
||||
enable_processes = true
|
||||
enable_network = true
|
||||
enable_security_events = true
|
||||
enable_workforce_activity = true
|
||||
|
||||
spool_dir = "/var/lib/awatch-agent/spool"
|
||||
timeout_seconds = 10
|
||||
retry_attempts = 3
|
||||
|
||||
# Optional ActivityWatch compatibility output.
|
||||
# Enables Rust replacement for the PowerShell worktime-session collector path.
|
||||
aw_api_base = "http://awatch.local:5600/api/0"
|
||||
aw_worktime_enabled = false
|
||||
@@ -0,0 +1,127 @@
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
use chrono::Utc;
|
||||
|
||||
use crate::config::AgentRole;
|
||||
use crate::telemetry::{SecurityEventInfo, SessionInfo};
|
||||
|
||||
pub fn command_output(program: &str, args: &[&str]) -> Option<String> {
|
||||
let output = Command::new(program).args(args).output().ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
}
|
||||
|
||||
pub fn hostname() -> String {
|
||||
env::var("HOSTNAME")
|
||||
.ok()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.or_else(|| fs::read_to_string("/etc/hostname").ok())
|
||||
.or_else(|| command_output("hostname", &[]))
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| "HOST-EXAMPLE".to_string())
|
||||
}
|
||||
|
||||
pub fn username() -> String {
|
||||
env::var("USER")
|
||||
.or_else(|_| env::var("USERNAME"))
|
||||
.unwrap_or_else(|_| "unknown".to_string())
|
||||
}
|
||||
|
||||
pub fn domain() -> String {
|
||||
env::var("USERDOMAIN")
|
||||
.or_else(|_| env::var("DOMAIN"))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn agent_id(hostname: &str) -> String {
|
||||
format!("awatch-{hostname}")
|
||||
}
|
||||
|
||||
pub fn role_security_events(role: AgentRole) -> Vec<SecurityEventInfo> {
|
||||
if role == AgentRole::Firewall {
|
||||
vec![SecurityEventInfo {
|
||||
event_id: "pfsense-mode-prototype".to_string(),
|
||||
source: "awatch-agent-rs".to_string(),
|
||||
severity: "INFO".to_string(),
|
||||
summary: "pfSense/firewall mode enabled; counters are collected from platform-specific probes when available".to_string(),
|
||||
timestamp: Utc::now(),
|
||||
evidence: vec!["read-only mode".to_string()],
|
||||
}]
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn current_session(session_type: &str) -> SessionInfo {
|
||||
SessionInfo {
|
||||
session_id: format!("{}-{}", session_type, username()),
|
||||
username: username(),
|
||||
session_type: session_type.to_string(),
|
||||
session_source: Some("env_sessionname_fallback".to_string()),
|
||||
remote_addr: std::env::var("SSH_CLIENT")
|
||||
.ok()
|
||||
.and_then(|value| value.split_whitespace().next().map(str::to_string)),
|
||||
started_at: None,
|
||||
active: true,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read_trimmed(path: impl AsRef<Path>) -> Option<String> {
|
||||
fs::read_to_string(path)
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
pub fn parse_os_release(path: &Path) -> (String, String) {
|
||||
let text = fs::read_to_string(path).unwrap_or_default();
|
||||
let mut name = String::new();
|
||||
let mut version = String::new();
|
||||
for line in text.lines() {
|
||||
if let Some(value) = line.strip_prefix("NAME=") {
|
||||
name = value.trim_matches('"').to_string();
|
||||
}
|
||||
if let Some(value) = line.strip_prefix("VERSION_ID=") {
|
||||
version = value.trim_matches('"').to_string();
|
||||
}
|
||||
}
|
||||
if name.is_empty() {
|
||||
name = "Linux".to_string();
|
||||
}
|
||||
(name, version)
|
||||
}
|
||||
|
||||
pub fn parse_hex_ipv4(value: &str) -> Option<String> {
|
||||
if value.len() != 8 {
|
||||
return None;
|
||||
}
|
||||
let raw = u32::from_str_radix(value, 16).ok()?;
|
||||
let bytes = raw.to_le_bytes();
|
||||
Some(format!(
|
||||
"{}.{}.{}.{}",
|
||||
bytes[0], bytes[1], bytes[2], bytes[3]
|
||||
))
|
||||
}
|
||||
|
||||
pub fn tcp_state(value: &str) -> &'static str {
|
||||
match value {
|
||||
"01" => "ESTABLISHED",
|
||||
"02" => "SYN_SENT",
|
||||
"03" => "SYN_RECV",
|
||||
"04" => "FIN_WAIT1",
|
||||
"05" => "FIN_WAIT2",
|
||||
"06" => "TIME_WAIT",
|
||||
"07" => "CLOSE",
|
||||
"08" => "CLOSE_WAIT",
|
||||
"09" => "LAST_ACK",
|
||||
"0A" => "LISTEN",
|
||||
"0B" => "CLOSING",
|
||||
_ => "UNKNOWN",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
use std::fs;
|
||||
|
||||
use anyhow::Result;
|
||||
use chrono::Utc;
|
||||
|
||||
use crate::collectors::common::{
|
||||
agent_id, command_output, current_session, domain, hostname, role_security_events, username,
|
||||
};
|
||||
use crate::config::AgentRole;
|
||||
use crate::telemetry::{
|
||||
IdentityInfo, NetworkConnectionInfo, NetworkInterfaceInfo, NetworkSnapshot, ProcessInfo,
|
||||
ResourceInfo, SecurityEventInfo, SessionSnapshot, TelemetryCollector, WorkforceActivityInfo,
|
||||
dedupe_sessions, diagnostics_for_sessions, empty_workforce_activity,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FreeBsdCollector {
|
||||
role: AgentRole,
|
||||
}
|
||||
|
||||
impl FreeBsdCollector {
|
||||
pub fn new(role: AgentRole) -> Self {
|
||||
Self { role }
|
||||
}
|
||||
}
|
||||
|
||||
impl TelemetryCollector for FreeBsdCollector {
|
||||
fn collect_identity(&self) -> Result<IdentityInfo> {
|
||||
let host = hostname();
|
||||
Ok(IdentityInfo {
|
||||
agent_id: agent_id(&host),
|
||||
hostname: host,
|
||||
os_name: command_output("uname", &["-s"]).unwrap_or_else(|| "FreeBSD".to_string()),
|
||||
os_version: command_output("uname", &["-r"]).unwrap_or_default(),
|
||||
platform: "freebsd".to_string(),
|
||||
username: username(),
|
||||
domain: domain(),
|
||||
})
|
||||
}
|
||||
|
||||
fn collect_sessions(&self) -> Result<SessionSnapshot> {
|
||||
let mut active = vec![current_session("local")];
|
||||
let mut ssh = Vec::new();
|
||||
if std::env::var("SSH_CLIENT").is_ok() || std::env::var("SSH_TTY").is_ok() {
|
||||
let session = current_session("ssh");
|
||||
ssh.push(session.clone());
|
||||
active.push(session);
|
||||
}
|
||||
let host = hostname();
|
||||
let active = dedupe_sessions(&host, active);
|
||||
let ssh = dedupe_sessions(&host, ssh);
|
||||
let diagnostics = diagnostics_for_sessions(&active, &[], "env_sessionname_fallback", None);
|
||||
Ok(SessionSnapshot {
|
||||
active_sessions: active,
|
||||
rdp_sessions: Vec::new(),
|
||||
ssh_sessions: ssh,
|
||||
diagnostics,
|
||||
})
|
||||
}
|
||||
|
||||
fn collect_processes(&self) -> Result<Vec<ProcessInfo>> {
|
||||
Ok(freebsd_processes(128))
|
||||
}
|
||||
|
||||
fn collect_resources(&self) -> Result<ResourceInfo> {
|
||||
let memory_total = command_output("sysctl", &["-n", "hw.physmem"])
|
||||
.and_then(|value| value.parse::<u64>().ok())
|
||||
.unwrap_or(0);
|
||||
Ok(ResourceInfo {
|
||||
uptime_seconds: 0,
|
||||
cpu_usage_percent: 0.0,
|
||||
memory_total,
|
||||
memory_used: 0,
|
||||
})
|
||||
}
|
||||
|
||||
fn collect_network(&self) -> Result<NetworkSnapshot> {
|
||||
Ok(NetworkSnapshot {
|
||||
interfaces: freebsd_interfaces(),
|
||||
connections: freebsd_connections(256),
|
||||
})
|
||||
}
|
||||
|
||||
fn collect_security_events(&self) -> Result<Vec<SecurityEventInfo>> {
|
||||
let mut events = role_security_events(self.role);
|
||||
if let Some(summary) = freebsd_syslog_summary() {
|
||||
events.push(SecurityEventInfo {
|
||||
event_id: "freebsd-syslog-summary".to_string(),
|
||||
source: "syslog".to_string(),
|
||||
severity: "INFO".to_string(),
|
||||
summary,
|
||||
timestamp: Utc::now(),
|
||||
evidence: vec!["/var/log/messages".to_string()],
|
||||
});
|
||||
}
|
||||
Ok(events)
|
||||
}
|
||||
|
||||
fn collect_workforce_activity(&self) -> Result<WorkforceActivityInfo> {
|
||||
let mut activity = empty_workforce_activity();
|
||||
activity.active_today = true;
|
||||
activity.explanation = vec![
|
||||
"FreeBSD collector reports host/session/process/network context; pfSense mode is read-only".to_string(),
|
||||
];
|
||||
Ok(activity)
|
||||
}
|
||||
}
|
||||
|
||||
fn freebsd_processes(limit: usize) -> Vec<ProcessInfo> {
|
||||
let Some(raw) = command_output("ps", &["-axo", "pid,ppid,comm,rss"]) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut items = raw
|
||||
.lines()
|
||||
.skip(1)
|
||||
.filter_map(|line| {
|
||||
let cols = line.split_whitespace().collect::<Vec<_>>();
|
||||
let pid = cols.first()?.parse::<u32>().ok()?;
|
||||
let ppid = cols.get(1).and_then(|value| value.parse::<u32>().ok());
|
||||
let name = cols.get(2).unwrap_or(&"process").to_string();
|
||||
let memory_bytes = cols
|
||||
.get(3)
|
||||
.and_then(|value| value.parse::<u64>().ok())
|
||||
.map(|value| value.saturating_mul(1024));
|
||||
Some(ProcessInfo {
|
||||
pid,
|
||||
ppid,
|
||||
name,
|
||||
exe: None,
|
||||
username: None,
|
||||
cpu_percent: None,
|
||||
memory_bytes,
|
||||
started_at: None,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
items.truncate(limit);
|
||||
items
|
||||
}
|
||||
|
||||
fn freebsd_interfaces() -> Vec<NetworkInterfaceInfo> {
|
||||
let Some(raw) = command_output("ifconfig", &["-l"]) else {
|
||||
return Vec::new();
|
||||
};
|
||||
raw.split_whitespace()
|
||||
.map(|name| NetworkInterfaceInfo {
|
||||
name: name.to_string(),
|
||||
mac: None,
|
||||
addresses: Vec::new(),
|
||||
up: true,
|
||||
rx_bytes: None,
|
||||
tx_bytes: None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn freebsd_connections(limit: usize) -> Vec<NetworkConnectionInfo> {
|
||||
let Some(raw) = command_output("sockstat", &["-4", "-6"]) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut items = raw
|
||||
.lines()
|
||||
.skip(1)
|
||||
.filter_map(parse_sockstat_line)
|
||||
.collect::<Vec<_>>();
|
||||
items.truncate(limit);
|
||||
items
|
||||
}
|
||||
|
||||
fn parse_sockstat_line(line: &str) -> Option<NetworkConnectionInfo> {
|
||||
let cols = line.split_whitespace().collect::<Vec<_>>();
|
||||
let protocol = cols.get(4)?.to_ascii_lowercase();
|
||||
if protocol != "tcp" && protocol != "udp" {
|
||||
return None;
|
||||
}
|
||||
let (local_addr, local_port) = split_host_port(cols.get(5)?)?;
|
||||
let (remote_addr, remote_port) = cols
|
||||
.get(6)
|
||||
.and_then(|value| split_host_port(value))
|
||||
.unwrap_or_default();
|
||||
Some(NetworkConnectionInfo {
|
||||
protocol: protocol.clone(),
|
||||
local_addr,
|
||||
local_port,
|
||||
remote_addr: Some(remote_addr),
|
||||
remote_port: Some(remote_port),
|
||||
state: if protocol == "tcp" { "OPEN" } else { "UDP" }.to_string(),
|
||||
pid: cols.get(2).and_then(|value| value.parse::<u32>().ok()),
|
||||
})
|
||||
}
|
||||
|
||||
fn split_host_port(value: &str) -> Option<(String, u16)> {
|
||||
let (host, port) = value.rsplit_once(':')?;
|
||||
Some((
|
||||
host.trim_matches(['[', ']']).to_string(),
|
||||
port.parse().ok()?,
|
||||
))
|
||||
}
|
||||
|
||||
fn freebsd_syslog_summary() -> Option<String> {
|
||||
let text = fs::read_to_string("/var/log/messages").ok()?;
|
||||
let count = text
|
||||
.lines()
|
||||
.rev()
|
||||
.take(200)
|
||||
.filter(|line| {
|
||||
let lower = line.to_lowercase();
|
||||
lower.contains("error") || lower.contains("fail") || lower.contains("denied")
|
||||
})
|
||||
.count();
|
||||
Some(format!(
|
||||
"recent FreeBSD syslog warning/error lines: {count}"
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::Result;
|
||||
use chrono::Utc;
|
||||
|
||||
use crate::collectors::common::{
|
||||
agent_id, current_session, domain, hostname, parse_hex_ipv4, parse_os_release, read_trimmed,
|
||||
role_security_events, tcp_state, username,
|
||||
};
|
||||
use crate::config::AgentRole;
|
||||
use crate::telemetry::{
|
||||
IdentityInfo, NetworkConnectionInfo, NetworkInterfaceInfo, NetworkSnapshot, ProcessInfo,
|
||||
ResourceInfo, SecurityEventInfo, SessionSnapshot, TelemetryCollector, WorkforceActivityInfo,
|
||||
dedupe_sessions, diagnostics_for_sessions, empty_workforce_activity,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LinuxCollector {
|
||||
role: AgentRole,
|
||||
}
|
||||
|
||||
impl LinuxCollector {
|
||||
pub fn new(role: AgentRole) -> Self {
|
||||
Self { role }
|
||||
}
|
||||
}
|
||||
|
||||
impl TelemetryCollector for LinuxCollector {
|
||||
fn collect_identity(&self) -> Result<IdentityInfo> {
|
||||
let host = hostname();
|
||||
let (os_name, os_version) = parse_os_release(Path::new("/etc/os-release"));
|
||||
Ok(IdentityInfo {
|
||||
agent_id: agent_id(&host),
|
||||
hostname: host,
|
||||
os_name,
|
||||
os_version,
|
||||
platform: "linux".to_string(),
|
||||
username: username(),
|
||||
domain: domain(),
|
||||
})
|
||||
}
|
||||
|
||||
fn collect_sessions(&self) -> Result<SessionSnapshot> {
|
||||
let mut active = vec![current_session("local")];
|
||||
let mut ssh = Vec::new();
|
||||
if std::env::var("SSH_CLIENT").is_ok() || std::env::var("SSH_TTY").is_ok() {
|
||||
let session = current_session("ssh");
|
||||
ssh.push(session.clone());
|
||||
active.push(session);
|
||||
}
|
||||
let host = hostname();
|
||||
let active = dedupe_sessions(&host, active);
|
||||
let ssh = dedupe_sessions(&host, ssh);
|
||||
let diagnostics = diagnostics_for_sessions(&active, &[], "env_sessionname_fallback", None);
|
||||
Ok(SessionSnapshot {
|
||||
active_sessions: active,
|
||||
rdp_sessions: Vec::new(),
|
||||
ssh_sessions: ssh,
|
||||
diagnostics,
|
||||
})
|
||||
}
|
||||
|
||||
fn collect_processes(&self) -> Result<Vec<ProcessInfo>> {
|
||||
Ok(read_processes(128))
|
||||
}
|
||||
|
||||
fn collect_resources(&self) -> Result<ResourceInfo> {
|
||||
let uptime_seconds = fs::read_to_string("/proc/uptime")
|
||||
.ok()
|
||||
.and_then(|text| text.split_whitespace().next()?.parse::<f64>().ok())
|
||||
.map(|value| value as u64)
|
||||
.unwrap_or(0);
|
||||
let (memory_total, memory_available) = read_meminfo();
|
||||
Ok(ResourceInfo {
|
||||
uptime_seconds,
|
||||
cpu_usage_percent: read_loadavg_percent(),
|
||||
memory_total,
|
||||
memory_used: memory_total.saturating_sub(memory_available),
|
||||
})
|
||||
}
|
||||
|
||||
fn collect_network(&self) -> Result<NetworkSnapshot> {
|
||||
Ok(NetworkSnapshot {
|
||||
interfaces: read_interfaces(),
|
||||
connections: read_connections(),
|
||||
})
|
||||
}
|
||||
|
||||
fn collect_security_events(&self) -> Result<Vec<SecurityEventInfo>> {
|
||||
let mut events = role_security_events(self.role);
|
||||
if let Some(summary) = recent_syslog_summary() {
|
||||
events.push(SecurityEventInfo {
|
||||
event_id: "linux-syslog-summary".to_string(),
|
||||
source: "syslog".to_string(),
|
||||
severity: "INFO".to_string(),
|
||||
summary,
|
||||
timestamp: Utc::now(),
|
||||
evidence: vec!["/var/log/syslog or /var/log/messages".to_string()],
|
||||
});
|
||||
}
|
||||
Ok(events)
|
||||
}
|
||||
|
||||
fn collect_workforce_activity(&self) -> Result<WorkforceActivityInfo> {
|
||||
let mut activity = empty_workforce_activity();
|
||||
activity.active_today = true;
|
||||
activity.explanation = vec![
|
||||
"Linux collector reports presence and process/network context; application weighting is calculated server-side".to_string(),
|
||||
];
|
||||
Ok(activity)
|
||||
}
|
||||
}
|
||||
|
||||
fn read_meminfo() -> (u64, u64) {
|
||||
let mut total = 0;
|
||||
let mut available = 0;
|
||||
let text = fs::read_to_string("/proc/meminfo").unwrap_or_default();
|
||||
for line in text.lines() {
|
||||
if let Some(value) = line.strip_prefix("MemTotal:") {
|
||||
total = parse_kib(value);
|
||||
}
|
||||
if let Some(value) = line.strip_prefix("MemAvailable:") {
|
||||
available = parse_kib(value);
|
||||
}
|
||||
}
|
||||
(total, available)
|
||||
}
|
||||
|
||||
fn parse_kib(value: &str) -> u64 {
|
||||
value
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.and_then(|item| item.parse::<u64>().ok())
|
||||
.unwrap_or(0)
|
||||
* 1024
|
||||
}
|
||||
|
||||
fn read_loadavg_percent() -> f64 {
|
||||
fs::read_to_string("/proc/loadavg")
|
||||
.ok()
|
||||
.and_then(|text| text.split_whitespace().next()?.parse::<f64>().ok())
|
||||
.map(|load| (load * 100.0).clamp(0.0, 100.0))
|
||||
.unwrap_or(0.0)
|
||||
}
|
||||
|
||||
fn read_processes(limit: usize) -> Vec<ProcessInfo> {
|
||||
let mut items = fs::read_dir("/proc")
|
||||
.ok()
|
||||
.into_iter()
|
||||
.flat_map(|entries| entries.filter_map(|entry| entry.ok()))
|
||||
.filter_map(|entry| {
|
||||
let pid = entry.file_name().to_string_lossy().parse::<u32>().ok()?;
|
||||
let stat = fs::read_to_string(entry.path().join("stat")).ok()?;
|
||||
let name = stat.split_once('(')?.1.split_once(')')?.0.to_string();
|
||||
let exe = fs::read_link(entry.path().join("exe"))
|
||||
.ok()
|
||||
.map(|path| path.display().to_string());
|
||||
let status = fs::read_to_string(entry.path().join("status")).unwrap_or_default();
|
||||
let ppid = status
|
||||
.lines()
|
||||
.find_map(|line| line.strip_prefix("PPid:"))
|
||||
.and_then(|value| value.trim().parse::<u32>().ok());
|
||||
let memory_bytes = status
|
||||
.lines()
|
||||
.find_map(|line| line.strip_prefix("VmRSS:"))
|
||||
.map(parse_kib);
|
||||
Some(ProcessInfo {
|
||||
pid,
|
||||
ppid,
|
||||
name,
|
||||
exe,
|
||||
username: None,
|
||||
cpu_percent: None,
|
||||
memory_bytes,
|
||||
started_at: None,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
items.sort_by_key(|item| item.pid);
|
||||
items.truncate(limit);
|
||||
items
|
||||
}
|
||||
|
||||
fn read_interfaces() -> Vec<NetworkInterfaceInfo> {
|
||||
fs::read_dir("/sys/class/net")
|
||||
.ok()
|
||||
.into_iter()
|
||||
.flat_map(|entries| entries.filter_map(|entry| entry.ok()))
|
||||
.map(|entry| {
|
||||
let path = entry.path();
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
let up = read_trimmed(path.join("operstate")).is_some_and(|state| state == "up");
|
||||
let rx_bytes =
|
||||
read_trimmed(path.join("statistics/rx_bytes")).and_then(|v| v.parse().ok());
|
||||
let tx_bytes =
|
||||
read_trimmed(path.join("statistics/tx_bytes")).and_then(|v| v.parse().ok());
|
||||
NetworkInterfaceInfo {
|
||||
name,
|
||||
mac: read_trimmed(path.join("address")),
|
||||
addresses: Vec::new(),
|
||||
up,
|
||||
rx_bytes,
|
||||
tx_bytes,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn read_connections() -> Vec<NetworkConnectionInfo> {
|
||||
let mut items = Vec::new();
|
||||
read_proc_net("/proc/net/tcp", "tcp", &mut items);
|
||||
read_proc_net("/proc/net/udp", "udp", &mut items);
|
||||
items.truncate(256);
|
||||
items
|
||||
}
|
||||
|
||||
fn read_proc_net(path: &str, protocol: &str, items: &mut Vec<NetworkConnectionInfo>) {
|
||||
let text = fs::read_to_string(path).unwrap_or_default();
|
||||
for line in text.lines().skip(1) {
|
||||
let cols = line.split_whitespace().collect::<Vec<_>>();
|
||||
if cols.len() < 4 {
|
||||
continue;
|
||||
}
|
||||
let Some((local_addr, local_port)) = parse_addr(cols[1]) else {
|
||||
continue;
|
||||
};
|
||||
let (remote_addr, remote_port) = parse_addr(cols[2]).unwrap_or_default();
|
||||
items.push(NetworkConnectionInfo {
|
||||
protocol: protocol.to_string(),
|
||||
local_addr,
|
||||
local_port,
|
||||
remote_addr: if remote_addr == "0.0.0.0" {
|
||||
None
|
||||
} else {
|
||||
Some(remote_addr)
|
||||
},
|
||||
remote_port: if remote_port == 0 {
|
||||
None
|
||||
} else {
|
||||
Some(remote_port)
|
||||
},
|
||||
state: if protocol == "tcp" {
|
||||
tcp_state(cols[3]).to_string()
|
||||
} else {
|
||||
"UDP".to_string()
|
||||
},
|
||||
pid: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_addr(value: &str) -> Option<(String, u16)> {
|
||||
let (ip, port) = value.split_once(':')?;
|
||||
Some((parse_hex_ipv4(ip)?, u16::from_str_radix(port, 16).ok()?))
|
||||
}
|
||||
|
||||
fn recent_syslog_summary() -> Option<String> {
|
||||
for path in ["/var/log/syslog", "/var/log/messages"] {
|
||||
let Ok(text) = fs::read_to_string(path) else {
|
||||
continue;
|
||||
};
|
||||
let count = text
|
||||
.lines()
|
||||
.rev()
|
||||
.take(200)
|
||||
.filter(|line| {
|
||||
let lower = line.to_lowercase();
|
||||
lower.contains("error") || lower.contains("fail") || lower.contains("denied")
|
||||
})
|
||||
.count();
|
||||
return Some(format!("recent syslog warning/error lines: {count}"));
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
pub mod common;
|
||||
pub mod freebsd;
|
||||
pub mod linux;
|
||||
pub mod windows;
|
||||
|
||||
use anyhow::{Result, bail};
|
||||
|
||||
use crate::config::AgentRole;
|
||||
use crate::telemetry::TelemetryCollector;
|
||||
|
||||
pub fn platform_collector(role: AgentRole) -> Result<Box<dyn TelemetryCollector>> {
|
||||
if cfg!(target_os = "linux") {
|
||||
return Ok(Box::new(linux::LinuxCollector::new(role)));
|
||||
}
|
||||
if cfg!(target_os = "windows") {
|
||||
return Ok(Box::new(windows::WindowsCollector::new(role)));
|
||||
}
|
||||
if cfg!(target_os = "freebsd") {
|
||||
return Ok(Box::new(freebsd::FreeBsdCollector::new(role)));
|
||||
}
|
||||
bail!("unsupported platform for awatch-agent-rs")
|
||||
}
|
||||
@@ -0,0 +1,536 @@
|
||||
use anyhow::Result;
|
||||
use chrono::Utc;
|
||||
use std::process::Command;
|
||||
|
||||
use crate::collectors::common::{
|
||||
agent_id, command_output, current_session, domain, hostname, role_security_events, username,
|
||||
};
|
||||
use crate::config::AgentRole;
|
||||
use crate::telemetry::{
|
||||
IdentityInfo, NetworkConnectionInfo, NetworkSnapshot, ProcessInfo, ResourceInfo,
|
||||
SecurityEventInfo, SessionSnapshot, TelemetryCollector, WorkforceActivityInfo, dedupe_sessions,
|
||||
diagnostics_for_sessions, empty_workforce_activity,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WindowsCollector {
|
||||
role: AgentRole,
|
||||
}
|
||||
|
||||
impl WindowsCollector {
|
||||
pub fn new(role: AgentRole) -> Self {
|
||||
Self { role }
|
||||
}
|
||||
}
|
||||
|
||||
impl TelemetryCollector for WindowsCollector {
|
||||
fn collect_identity(&self) -> Result<IdentityInfo> {
|
||||
let host = hostname();
|
||||
Ok(IdentityInfo {
|
||||
agent_id: agent_id(&host),
|
||||
hostname: host,
|
||||
os_name: "Windows".to_string(),
|
||||
os_version: windows_version(),
|
||||
platform: "windows".to_string(),
|
||||
username: username(),
|
||||
domain: domain(),
|
||||
})
|
||||
}
|
||||
|
||||
fn collect_sessions(&self) -> Result<SessionSnapshot> {
|
||||
let host = hostname();
|
||||
let mut collection = windows_query_user_sessions();
|
||||
if collection.sessions.is_empty()
|
||||
&& std::env::var("SESSIONNAME")
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase()
|
||||
.contains("rdp")
|
||||
{
|
||||
let mut session = current_session("rdp");
|
||||
session.session_source = Some("env_sessionname_fallback".to_string());
|
||||
collection.sessions.push(session);
|
||||
collection.source = "env_sessionname_fallback".to_string();
|
||||
}
|
||||
if collection.sessions.is_empty() {
|
||||
let mut session = current_session("local");
|
||||
session.session_source = Some("local_fallback".to_string());
|
||||
collection.sessions.push(session);
|
||||
collection.source = "local_fallback".to_string();
|
||||
collection.error = Some("WTS API and quser did not return sessions".to_string());
|
||||
}
|
||||
let mut active = dedupe_sessions(&host, collection.sessions);
|
||||
let mut rdp = active
|
||||
.iter()
|
||||
.filter(|session| session.session_type == "rdp")
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
if std::env::var("SESSIONNAME")
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase()
|
||||
.contains("rdp")
|
||||
{
|
||||
let mut merged = active.clone();
|
||||
merged.push(with_session_source(
|
||||
current_session("rdp"),
|
||||
"env_sessionname_fallback",
|
||||
));
|
||||
active = dedupe_sessions(&host, merged);
|
||||
rdp = active
|
||||
.iter()
|
||||
.filter(|session| session.session_type == "rdp")
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
}
|
||||
rdp = dedupe_sessions(&host, rdp);
|
||||
let diagnostics =
|
||||
diagnostics_for_sessions(&active, &rdp, collection.source, collection.error);
|
||||
Ok(SessionSnapshot {
|
||||
active_sessions: active,
|
||||
rdp_sessions: rdp,
|
||||
ssh_sessions: Vec::new(),
|
||||
diagnostics,
|
||||
})
|
||||
}
|
||||
|
||||
fn collect_processes(&self) -> Result<Vec<ProcessInfo>> {
|
||||
Ok(windows_processes(128))
|
||||
}
|
||||
|
||||
fn collect_resources(&self) -> Result<ResourceInfo> {
|
||||
let (memory_total, memory_used) = windows_memory();
|
||||
Ok(ResourceInfo {
|
||||
uptime_seconds: 0,
|
||||
cpu_usage_percent: 0.0,
|
||||
memory_total,
|
||||
memory_used,
|
||||
})
|
||||
}
|
||||
|
||||
fn collect_network(&self) -> Result<NetworkSnapshot> {
|
||||
Ok(NetworkSnapshot {
|
||||
interfaces: Vec::new(),
|
||||
connections: windows_connections(256),
|
||||
})
|
||||
}
|
||||
|
||||
fn collect_security_events(&self) -> Result<Vec<SecurityEventInfo>> {
|
||||
let mut events = role_security_events(self.role);
|
||||
events.push(SecurityEventInfo {
|
||||
event_id: "windows-collector-v03".to_string(),
|
||||
source: "awatch-agent-rs".to_string(),
|
||||
severity: "INFO".to_string(),
|
||||
summary: "Windows read-only collector is active without PowerShell primary collection; WinAPI/ETW/WMI depth is planned behind the same TelemetryRecord contract".to_string(),
|
||||
timestamp: Utc::now(),
|
||||
evidence: vec!["no PowerShell primary collector".to_string()],
|
||||
});
|
||||
Ok(events)
|
||||
}
|
||||
|
||||
fn collect_workforce_activity(&self) -> Result<WorkforceActivityInfo> {
|
||||
let mut activity = empty_workforce_activity();
|
||||
activity.active_today = true;
|
||||
activity.explanation = vec![
|
||||
"Windows collector reports session/process/network context; ActivityWatch/workforce scoring is calculated server-side".to_string(),
|
||||
];
|
||||
Ok(activity)
|
||||
}
|
||||
}
|
||||
|
||||
fn windows_version() -> String {
|
||||
command_output("cmd", &["/C", "ver"])
|
||||
.or_else(|| std::env::var("OS").ok())
|
||||
.unwrap_or_else(|| "Windows".to_string())
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct SessionCollection {
|
||||
sessions: Vec<crate::telemetry::SessionInfo>,
|
||||
source: String,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
fn with_session_source(
|
||||
mut session: crate::telemetry::SessionInfo,
|
||||
source: &str,
|
||||
) -> crate::telemetry::SessionInfo {
|
||||
session.session_source = Some(source.to_string());
|
||||
session
|
||||
}
|
||||
|
||||
fn windows_query_user_sessions() -> SessionCollection {
|
||||
let native = windows_wts_sessions();
|
||||
if !native.is_empty() {
|
||||
return SessionCollection {
|
||||
sessions: native,
|
||||
source: "wts_api".to_string(),
|
||||
error: None,
|
||||
};
|
||||
}
|
||||
if let Some(raw) = command_output_utf16le("cmd", &["/U", "/C", "query user"])
|
||||
.or_else(|| command_output_utf16le("cmd", &["/U", "/C", "quser"]))
|
||||
{
|
||||
let sessions = parse_query_user_sessions(&raw, "quser_utf16");
|
||||
if !sessions.is_empty() {
|
||||
return SessionCollection {
|
||||
sessions,
|
||||
source: "quser_utf16".to_string(),
|
||||
error: None,
|
||||
};
|
||||
}
|
||||
}
|
||||
if let Some(raw) = command_output_lossy_combined("cmd", &["/C", "query user"])
|
||||
.or_else(|| command_output_lossy_combined("cmd", &["/C", "quser"]))
|
||||
{
|
||||
let sessions = parse_query_user_sessions(&raw, "quser_lossy");
|
||||
if !sessions.is_empty() {
|
||||
return SessionCollection {
|
||||
sessions,
|
||||
source: "quser_lossy".to_string(),
|
||||
error: None,
|
||||
};
|
||||
}
|
||||
}
|
||||
SessionCollection {
|
||||
sessions: Vec::new(),
|
||||
source: "local_fallback".to_string(),
|
||||
error: Some("WTS API and quser returned no sessions".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_query_user_sessions(raw: &str, source: &str) -> Vec<crate::telemetry::SessionInfo> {
|
||||
raw.lines()
|
||||
.skip(1)
|
||||
.filter_map(|line| parse_query_user_line(line, source))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn windows_wts_sessions() -> Vec<crate::telemetry::SessionInfo> {
|
||||
use std::ptr;
|
||||
use windows_sys::Win32::System::RemoteDesktop::{
|
||||
WTS_CURRENT_SERVER_HANDLE, WTS_SESSION_INFOW, WTSActive, WTSEnumerateSessionsW,
|
||||
WTSFreeMemory, WTSUserName,
|
||||
};
|
||||
|
||||
let mut sessions_ptr: *mut WTS_SESSION_INFOW = ptr::null_mut();
|
||||
let mut count = 0_u32;
|
||||
let ok = unsafe {
|
||||
WTSEnumerateSessionsW(
|
||||
WTS_CURRENT_SERVER_HANDLE,
|
||||
0,
|
||||
1,
|
||||
&mut sessions_ptr,
|
||||
&mut count,
|
||||
)
|
||||
};
|
||||
if ok == 0 || sessions_ptr.is_null() || count == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let sessions =
|
||||
unsafe { std::slice::from_raw_parts(sessions_ptr, usize::try_from(count).unwrap_or(0)) };
|
||||
let mut items = Vec::new();
|
||||
for session in sessions {
|
||||
let username = wts_session_string(session.SessionId, WTSUserName);
|
||||
if username.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let station = unsafe { wide_nul_to_string(session.pWinStationName) };
|
||||
let state = session.State;
|
||||
let session_type = if station.to_ascii_lowercase().contains("rdp") {
|
||||
"rdp"
|
||||
} else {
|
||||
"local"
|
||||
};
|
||||
items.push(crate::telemetry::SessionInfo {
|
||||
session_id: session.SessionId.to_string(),
|
||||
username,
|
||||
session_type: session_type.to_string(),
|
||||
session_source: Some("wts_api".to_string()),
|
||||
remote_addr: None,
|
||||
started_at: None,
|
||||
active: state == WTSActive,
|
||||
});
|
||||
}
|
||||
unsafe {
|
||||
WTSFreeMemory(sessions_ptr.cast());
|
||||
}
|
||||
items
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn windows_wts_sessions() -> Vec<crate::telemetry::SessionInfo> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn wts_session_string(session_id: u32, class: i32) -> String {
|
||||
use std::ptr;
|
||||
use windows_sys::Win32::System::RemoteDesktop::{
|
||||
WTS_CURRENT_SERVER_HANDLE, WTSFreeMemory, WTSQuerySessionInformationW,
|
||||
};
|
||||
|
||||
let mut buffer = ptr::null_mut();
|
||||
let mut bytes = 0_u32;
|
||||
let ok = unsafe {
|
||||
WTSQuerySessionInformationW(
|
||||
WTS_CURRENT_SERVER_HANDLE,
|
||||
session_id,
|
||||
class,
|
||||
&mut buffer,
|
||||
&mut bytes,
|
||||
)
|
||||
};
|
||||
if ok == 0 || buffer.is_null() || bytes == 0 {
|
||||
return String::new();
|
||||
}
|
||||
let len = usize::try_from(bytes / 2).unwrap_or(0);
|
||||
let value = unsafe {
|
||||
let slice = std::slice::from_raw_parts(buffer, len);
|
||||
String::from_utf16_lossy(slice)
|
||||
.trim_matches('\0')
|
||||
.trim()
|
||||
.to_string()
|
||||
};
|
||||
unsafe {
|
||||
WTSFreeMemory(buffer.cast());
|
||||
}
|
||||
value
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
unsafe fn wide_nul_to_string(ptr: *const u16) -> String {
|
||||
if ptr.is_null() {
|
||||
return String::new();
|
||||
}
|
||||
let mut len = 0;
|
||||
while unsafe { *ptr.add(len) } != 0 {
|
||||
len += 1;
|
||||
}
|
||||
let slice = unsafe { std::slice::from_raw_parts(ptr, len) };
|
||||
String::from_utf16_lossy(slice)
|
||||
}
|
||||
|
||||
fn command_output_utf16le(program: &str, args: &[&str]) -> Option<String> {
|
||||
let output = Command::new(program).args(args).output().ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
let mut bytes = output.stdout;
|
||||
bytes.extend_from_slice(&output.stderr);
|
||||
if bytes.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut words = Vec::new();
|
||||
for chunk in bytes.chunks_exact(2) {
|
||||
words.push(u16::from_le_bytes([chunk[0], chunk[1]]));
|
||||
}
|
||||
String::from_utf16(&words)
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn command_output_lossy_combined(program: &str, args: &[&str]) -> Option<String> {
|
||||
let output = Command::new(program).args(args).output().ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
let mut bytes = output.stdout;
|
||||
bytes.extend_from_slice(&output.stderr);
|
||||
Some(String::from_utf8_lossy(&bytes).trim().to_string()).filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn parse_query_user_line(line: &str, source: &str) -> Option<crate::telemetry::SessionInfo> {
|
||||
let cleaned = line.trim().trim_start_matches('>').trim();
|
||||
if cleaned.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let parts = cleaned.split_whitespace().collect::<Vec<_>>();
|
||||
if parts.len() < 3 {
|
||||
return None;
|
||||
}
|
||||
let username = parts.first()?.to_string();
|
||||
let (session_name, session_id, state) = if parts.get(1)?.chars().all(|ch| ch.is_ascii_digit()) {
|
||||
("".to_string(), *parts.get(1)?, *parts.get(2)?)
|
||||
} else {
|
||||
(
|
||||
parts.get(1)?.to_string(),
|
||||
*parts.get(2)?,
|
||||
*parts.get(3).unwrap_or(&"Unknown"),
|
||||
)
|
||||
};
|
||||
let active = session_state_active(state);
|
||||
let session_type = if session_name.to_ascii_lowercase().contains("rdp") {
|
||||
"rdp"
|
||||
} else {
|
||||
"local"
|
||||
};
|
||||
Some(crate::telemetry::SessionInfo {
|
||||
session_id: session_id.to_string(),
|
||||
username,
|
||||
session_type: session_type.to_string(),
|
||||
session_source: Some(source.to_string()),
|
||||
remote_addr: None,
|
||||
started_at: None,
|
||||
active,
|
||||
})
|
||||
}
|
||||
|
||||
fn session_state_active(state: &str) -> bool {
|
||||
let lower = state.to_lowercase();
|
||||
lower.contains("active") || lower.contains("актив")
|
||||
}
|
||||
|
||||
fn windows_memory() -> (u64, u64) {
|
||||
let Some(raw) = command_output(
|
||||
"wmic",
|
||||
&[
|
||||
"OS",
|
||||
"get",
|
||||
"FreePhysicalMemory,TotalVisibleMemorySize",
|
||||
"/Value",
|
||||
],
|
||||
) else {
|
||||
return (0, 0);
|
||||
};
|
||||
let mut free_kib = 0;
|
||||
let mut total_kib = 0;
|
||||
for line in raw.lines() {
|
||||
if let Some(value) = line.strip_prefix("FreePhysicalMemory=") {
|
||||
free_kib = value.trim().parse::<u64>().unwrap_or(0);
|
||||
}
|
||||
if let Some(value) = line.strip_prefix("TotalVisibleMemorySize=") {
|
||||
total_kib = value.trim().parse::<u64>().unwrap_or(0);
|
||||
}
|
||||
}
|
||||
let total = total_kib.saturating_mul(1024);
|
||||
let used = total_kib.saturating_sub(free_kib).saturating_mul(1024);
|
||||
(total, used)
|
||||
}
|
||||
|
||||
fn windows_processes(limit: usize) -> Vec<ProcessInfo> {
|
||||
let Some(raw) = command_output("tasklist", &["/FO", "CSV", "/NH"]) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut items = raw
|
||||
.lines()
|
||||
.filter_map(parse_tasklist_line)
|
||||
.collect::<Vec<_>>();
|
||||
items.truncate(limit);
|
||||
items
|
||||
}
|
||||
|
||||
fn parse_tasklist_line(line: &str) -> Option<ProcessInfo> {
|
||||
let cols = parse_csv_line(line);
|
||||
let name = cols.first()?.to_string();
|
||||
let pid = cols.get(1)?.parse::<u32>().ok()?;
|
||||
let memory_bytes = cols.get(4).map(|value| parse_tasklist_memory(value));
|
||||
Some(ProcessInfo {
|
||||
pid,
|
||||
ppid: None,
|
||||
name,
|
||||
exe: None,
|
||||
username: None,
|
||||
cpu_percent: None,
|
||||
memory_bytes,
|
||||
started_at: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_csv_line(line: &str) -> Vec<String> {
|
||||
line.trim_matches('"')
|
||||
.split("\",\"")
|
||||
.map(|value| value.trim().to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn parse_tasklist_memory(value: &str) -> u64 {
|
||||
value
|
||||
.chars()
|
||||
.filter(|ch| ch.is_ascii_digit())
|
||||
.collect::<String>()
|
||||
.parse::<u64>()
|
||||
.unwrap_or(0)
|
||||
.saturating_mul(1024)
|
||||
}
|
||||
|
||||
fn windows_connections(limit: usize) -> Vec<NetworkConnectionInfo> {
|
||||
let Some(raw) = command_output("netstat", &["-ano"]) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut items = raw
|
||||
.lines()
|
||||
.filter_map(parse_netstat_line)
|
||||
.collect::<Vec<_>>();
|
||||
items.truncate(limit);
|
||||
items
|
||||
}
|
||||
|
||||
fn parse_netstat_line(line: &str) -> Option<NetworkConnectionInfo> {
|
||||
let cols = line.split_whitespace().collect::<Vec<_>>();
|
||||
let protocol = cols.first()?.to_ascii_lowercase();
|
||||
if protocol != "tcp" && protocol != "udp" {
|
||||
return None;
|
||||
}
|
||||
let (local_addr, local_port) = split_host_port(cols.get(1)?)?;
|
||||
let (remote_addr, remote_port) = split_host_port(cols.get(2)?).unwrap_or_default();
|
||||
let state = if protocol == "tcp" {
|
||||
cols.get(3).unwrap_or(&"UNKNOWN").to_string()
|
||||
} else {
|
||||
"UDP".to_string()
|
||||
};
|
||||
let pid = cols.last().and_then(|value| value.parse::<u32>().ok());
|
||||
Some(NetworkConnectionInfo {
|
||||
protocol,
|
||||
local_addr,
|
||||
local_port,
|
||||
remote_addr: Some(remote_addr),
|
||||
remote_port: Some(remote_port),
|
||||
state,
|
||||
pid,
|
||||
})
|
||||
}
|
||||
|
||||
fn split_host_port(value: &str) -> Option<(String, u16)> {
|
||||
let (host, port) = value.rsplit_once(':')?;
|
||||
Some((
|
||||
host.trim_matches(['[', ']']).to_string(),
|
||||
port.parse().ok()?,
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_query_user_line_with_rdp_session() {
|
||||
let session = parse_query_user_line(
|
||||
" user1 rdp-tcp#5 3 Active",
|
||||
"quser_utf16",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(session.username, "user1");
|
||||
assert_eq!(session.session_id, "3");
|
||||
assert_eq!(session.session_type, "rdp");
|
||||
assert_eq!(session.session_source.as_deref(), Some("quser_utf16"));
|
||||
assert!(session.active);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_query_user_line_without_session_name() {
|
||||
let session =
|
||||
parse_query_user_line(" user2 4 Disc", "quser_lossy").unwrap();
|
||||
assert_eq!(session.username, "user2");
|
||||
assert_eq!(session.session_id, "4");
|
||||
assert_eq!(session.session_type, "local");
|
||||
assert_eq!(session.session_source.as_deref(), Some("quser_lossy"));
|
||||
assert!(!session.active);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_russian_active_state() {
|
||||
assert!(session_state_active("Активно"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct AgentConfig {
|
||||
pub server_url: String,
|
||||
pub api_key: String,
|
||||
pub collect_interval_seconds: u64,
|
||||
pub role: AgentRole,
|
||||
pub enable_processes: bool,
|
||||
pub enable_network: bool,
|
||||
pub enable_security_events: bool,
|
||||
pub enable_workforce_activity: bool,
|
||||
pub spool_dir: PathBuf,
|
||||
pub timeout_seconds: u64,
|
||||
pub retry_attempts: u32,
|
||||
pub aw_api_base: Option<String>,
|
||||
pub aw_worktime_enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AgentRole {
|
||||
Workstation,
|
||||
Server,
|
||||
Firewall,
|
||||
}
|
||||
|
||||
impl AgentRole {
|
||||
pub fn parse(value: &str) -> Self {
|
||||
match value.trim().to_lowercase().as_str() {
|
||||
"firewall" | "pfsense" => Self::Firewall,
|
||||
"server" => Self::Server,
|
||||
_ => Self::Workstation,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AgentConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
server_url: "https://awatch.local/api/telemetry".to_string(),
|
||||
api_key: "change-me".to_string(),
|
||||
collect_interval_seconds: 60,
|
||||
role: AgentRole::Workstation,
|
||||
enable_processes: true,
|
||||
enable_network: true,
|
||||
enable_security_events: true,
|
||||
enable_workforce_activity: true,
|
||||
spool_dir: default_spool_dir(),
|
||||
timeout_seconds: 10,
|
||||
retry_attempts: 3,
|
||||
aw_api_base: None,
|
||||
aw_worktime_enabled: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn default_config_path() -> PathBuf {
|
||||
if cfg!(windows) {
|
||||
PathBuf::from(r"C:\ProgramData\AWatch\agent\awatch-agent.toml")
|
||||
} else {
|
||||
PathBuf::from("/etc/awatch-agent/awatch-agent.toml")
|
||||
}
|
||||
}
|
||||
|
||||
fn default_spool_dir() -> PathBuf {
|
||||
if cfg!(windows) {
|
||||
PathBuf::from(r"C:\ProgramData\AWatch\agent\spool")
|
||||
} else {
|
||||
PathBuf::from("/var/lib/awatch-agent/spool")
|
||||
}
|
||||
}
|
||||
|
||||
impl AgentConfig {
|
||||
pub fn load(path: &Path) -> Result<Self> {
|
||||
let text = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
|
||||
Self::parse_toml_like(&text)
|
||||
}
|
||||
|
||||
pub fn parse_toml_like(text: &str) -> Result<Self> {
|
||||
let mut config = Self::default();
|
||||
for raw in text.lines() {
|
||||
let line = raw.split('#').next().unwrap_or("").trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some((key, value)) = line.split_once('=') else {
|
||||
continue;
|
||||
};
|
||||
let key = key.trim();
|
||||
let value = value.trim().trim_matches('"');
|
||||
match key {
|
||||
"server_url" => config.server_url = value.to_string(),
|
||||
"api_key" => config.api_key = value.to_string(),
|
||||
"collect_interval_seconds" => {
|
||||
config.collect_interval_seconds = value.parse().unwrap_or(60)
|
||||
}
|
||||
"role" => config.role = AgentRole::parse(value),
|
||||
"enable_processes" => config.enable_processes = parse_bool(value, true),
|
||||
"enable_network" => config.enable_network = parse_bool(value, true),
|
||||
"enable_security_events" => config.enable_security_events = parse_bool(value, true),
|
||||
"enable_workforce_activity" => {
|
||||
config.enable_workforce_activity = parse_bool(value, true)
|
||||
}
|
||||
"spool_dir" => config.spool_dir = PathBuf::from(value),
|
||||
"timeout_seconds" => config.timeout_seconds = value.parse().unwrap_or(10),
|
||||
"retry_attempts" => config.retry_attempts = value.parse().unwrap_or(3),
|
||||
"aw_api_base" => config.aw_api_base = Some(value.trim_end_matches('/').to_string()),
|
||||
"aw_worktime_enabled" => {
|
||||
config.aw_worktime_enabled = parse_bool(value, false);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(config)
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_bool(value: &str, fallback: bool) -> bool {
|
||||
match value.trim().to_lowercase().as_str() {
|
||||
"1" | "true" | "yes" | "on" => true,
|
||||
"0" | "false" | "no" | "off" => false,
|
||||
_ => fallback,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_agent_config() {
|
||||
let config = AgentConfig::parse_toml_like(
|
||||
r#"
|
||||
server_url = "https://awatch.local/api/telemetry"
|
||||
api_key = "change-me"
|
||||
collect_interval_seconds = 30
|
||||
role = "firewall"
|
||||
enable_processes = false
|
||||
aw_api_base = "http://awatch.local:5600/api/0"
|
||||
aw_worktime_enabled = true
|
||||
spool_dir = "/tmp/awatch-spool"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(config.role, AgentRole::Firewall);
|
||||
assert_eq!(config.collect_interval_seconds, 30);
|
||||
assert!(!config.enable_processes);
|
||||
assert!(config.aw_worktime_enabled);
|
||||
assert_eq!(
|
||||
config.aw_api_base.as_deref(),
|
||||
Some("http://awatch.local:5600/api/0")
|
||||
);
|
||||
assert_eq!(config.spool_dir, PathBuf::from("/tmp/awatch-spool"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CpuFeatures {
|
||||
pub architecture: String,
|
||||
pub backend: &'static str,
|
||||
pub features: Vec<String>,
|
||||
}
|
||||
|
||||
pub fn get_cpu_features() -> CpuFeatures {
|
||||
let mut features = Vec::new();
|
||||
collect_cpu_features(&mut features);
|
||||
features.sort();
|
||||
features.dedup();
|
||||
|
||||
CpuFeatures {
|
||||
architecture: std::env::consts::ARCH.to_string(),
|
||||
backend: super::LOWLEVEL_BACKEND,
|
||||
features,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
|
||||
fn collect_cpu_features(features: &mut Vec<String>) {
|
||||
for (name, detected) in [
|
||||
("sse2", std::is_x86_feature_detected!("sse2")),
|
||||
("sse4.2", std::is_x86_feature_detected!("sse4.2")),
|
||||
("avx", std::is_x86_feature_detected!("avx")),
|
||||
("avx2", std::is_x86_feature_detected!("avx2")),
|
||||
("aes", std::is_x86_feature_detected!("aes")),
|
||||
] {
|
||||
if detected {
|
||||
features.push(name.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_arch = "x86", target_arch = "x86_64")))]
|
||||
fn collect_cpu_features(features: &mut Vec<String>) {
|
||||
for name in compile_time_features() {
|
||||
features.push(name.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(
|
||||
not(any(target_arch = "x86", target_arch = "x86_64")),
|
||||
target_arch = "aarch64"
|
||||
))]
|
||||
fn compile_time_features() -> &'static [&'static str] {
|
||||
&[
|
||||
#[cfg(target_feature = "aes")]
|
||||
"aes",
|
||||
#[cfg(target_feature = "neon")]
|
||||
"neon",
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(all(
|
||||
not(any(target_arch = "x86", target_arch = "x86_64")),
|
||||
not(target_arch = "aarch64")
|
||||
))]
|
||||
fn compile_time_features() -> &'static [&'static str] {
|
||||
&[]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cpu_features_have_architecture_and_backend() {
|
||||
let features = get_cpu_features();
|
||||
|
||||
assert!(!features.architecture.trim().is_empty());
|
||||
assert!(!features.backend.trim().is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
//! Safe low-level extension point for future agent optimizations.
|
||||
//!
|
||||
//! The default implementation is pure Rust. The `asm-lowlevel` feature is a
|
||||
//! reserved integration point for future platform-specific CPU and timing
|
||||
//! probes implemented through `core::arch`, inline assembly, or native OS
|
||||
//! calls. Keep any future `unsafe` code isolated inside this module.
|
||||
|
||||
pub mod cpu;
|
||||
pub mod timing;
|
||||
|
||||
pub use cpu::{CpuFeatures, get_cpu_features};
|
||||
pub use timing::{high_precision_time_ns, monotonic_ticks};
|
||||
|
||||
#[cfg(feature = "asm-lowlevel")]
|
||||
pub const LOWLEVEL_BACKEND: &str = "asm-lowlevel-ready-rust-fallback";
|
||||
|
||||
#[cfg(not(feature = "asm-lowlevel"))]
|
||||
pub const LOWLEVEL_BACKEND: &str = "rust";
|
||||
@@ -0,0 +1,36 @@
|
||||
use std::sync::OnceLock;
|
||||
use std::time::{Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
static MONOTONIC_START: OnceLock<Instant> = OnceLock::new();
|
||||
|
||||
pub fn monotonic_ticks() -> u128 {
|
||||
MONOTONIC_START
|
||||
.get_or_init(Instant::now)
|
||||
.elapsed()
|
||||
.as_nanos()
|
||||
}
|
||||
|
||||
pub fn high_precision_time_ns() -> u128 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn monotonic_ticks_do_not_go_backwards() {
|
||||
let first = monotonic_ticks();
|
||||
let second = monotonic_ticks();
|
||||
|
||||
assert!(second >= first);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn high_precision_time_is_epoch_based() {
|
||||
assert!(high_precision_time_ns() > 1_000_000_000);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
mod collectors;
|
||||
mod config;
|
||||
pub mod lowlevel;
|
||||
mod telemetry;
|
||||
mod transport;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use clap::Parser;
|
||||
use config::{AgentConfig, AgentRole, default_config_path};
|
||||
use transport::{AwWorktimePublisher, TelemetryTransport, spool_health};
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(about = "AWatch-rus Rust telemetry agent")]
|
||||
struct Cli {
|
||||
#[arg(long, env = "AWATCH_AGENT_CONFIG")]
|
||||
config: Option<PathBuf>,
|
||||
|
||||
#[arg(long, env = "AWATCH_AGENT_SERVER_URL")]
|
||||
server_url: Option<String>,
|
||||
|
||||
#[arg(long, env = "AWATCH_AGENT_API_KEY")]
|
||||
api_key: Option<String>,
|
||||
|
||||
#[arg(long, env = "AWATCH_AGENT_ROLE")]
|
||||
role: Option<String>,
|
||||
|
||||
#[arg(long, env = "AWATCH_AGENT_AW_API_BASE")]
|
||||
aw_api_base: Option<String>,
|
||||
|
||||
#[arg(long, env = "AWATCH_AGENT_AW_WORKTIME_ENABLED")]
|
||||
aw_worktime_enabled: Option<bool>,
|
||||
|
||||
#[arg(long)]
|
||||
once: bool,
|
||||
|
||||
#[arg(long)]
|
||||
print_json: bool,
|
||||
|
||||
#[arg(long)]
|
||||
flush_spool: bool,
|
||||
|
||||
#[arg(long)]
|
||||
spool_health: bool,
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let code = match run() {
|
||||
Ok(code) => code,
|
||||
Err(err) => {
|
||||
eprintln!("{err:#}");
|
||||
1
|
||||
}
|
||||
};
|
||||
std::process::exit(code);
|
||||
}
|
||||
|
||||
fn run() -> Result<i32> {
|
||||
let cli = Cli::parse();
|
||||
let mut config = load_config(cli.config.as_ref())?;
|
||||
if let Some(server_url) = cli.server_url {
|
||||
config.server_url = server_url;
|
||||
}
|
||||
if let Some(api_key) = cli.api_key {
|
||||
config.api_key = api_key;
|
||||
}
|
||||
if let Some(role) = cli.role {
|
||||
config.role = AgentRole::parse(&role);
|
||||
}
|
||||
if let Some(aw_api_base) = cli.aw_api_base {
|
||||
config.aw_api_base = Some(aw_api_base.trim_end_matches('/').to_string());
|
||||
}
|
||||
if let Some(enabled) = cli.aw_worktime_enabled {
|
||||
config.aw_worktime_enabled = enabled;
|
||||
}
|
||||
if cli.spool_health {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&spool_health(&config.spool_dir))?
|
||||
);
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let transport = TelemetryTransport::new(&config);
|
||||
let aw_worktime = AwWorktimePublisher::new(&config);
|
||||
if cli.flush_spool {
|
||||
let telemetry_flushed = transport.flush_spool()?;
|
||||
let worktime_flushed = match aw_worktime.as_ref() {
|
||||
Some(publisher) => publisher.flush_spool()?,
|
||||
None => 0,
|
||||
};
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::json!({
|
||||
"ok": true,
|
||||
"flushed": telemetry_flushed + worktime_flushed,
|
||||
"telemetry_flushed": telemetry_flushed,
|
||||
"worktime_flushed": worktime_flushed,
|
||||
})
|
||||
);
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
loop {
|
||||
let collector = collectors::platform_collector(config.role)?;
|
||||
let record = collector.collect_all()?;
|
||||
if cli.print_json {
|
||||
println!("{}", serde_json::to_string_pretty(&record)?);
|
||||
} else if let Err(err) = transport.send_or_spool(&record) {
|
||||
eprintln!("{err:#}");
|
||||
}
|
||||
if !cli.print_json {
|
||||
if let Some(publisher) = aw_worktime.as_ref() {
|
||||
if let Err(err) = publisher.publish_or_spool(&record) {
|
||||
eprintln!("{err:#}");
|
||||
}
|
||||
}
|
||||
}
|
||||
if cli.once {
|
||||
break;
|
||||
}
|
||||
thread::sleep(Duration::from_secs(config.collect_interval_seconds));
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
fn load_config(path: Option<&PathBuf>) -> Result<AgentConfig> {
|
||||
let path = path.cloned().unwrap_or_else(default_config_path);
|
||||
if path.exists() {
|
||||
AgentConfig::load(&path)
|
||||
} else {
|
||||
AgentConfig::parse_toml_like("")
|
||||
.with_context(|| format!("load default config because {} is absent", path.display()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
use anyhow::Result;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
pub const COLLECTOR_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct TelemetryRecord {
|
||||
pub agent_id: String,
|
||||
pub hostname: String,
|
||||
pub os_name: String,
|
||||
pub os_version: String,
|
||||
pub platform: String,
|
||||
pub username: String,
|
||||
pub domain: String,
|
||||
pub timestamp: DateTime<Utc>,
|
||||
pub uptime_seconds: u64,
|
||||
pub cpu_usage_percent: f64,
|
||||
pub memory_total: u64,
|
||||
pub memory_used: u64,
|
||||
pub active_sessions: Vec<SessionInfo>,
|
||||
pub rdp_sessions: Vec<SessionInfo>,
|
||||
pub ssh_sessions: Vec<SessionInfo>,
|
||||
pub processes: Vec<ProcessInfo>,
|
||||
pub network_interfaces: Vec<NetworkInterfaceInfo>,
|
||||
pub network_connections: Vec<NetworkConnectionInfo>,
|
||||
pub workforce_activity: WorkforceActivityInfo,
|
||||
pub security_events: Vec<SecurityEventInfo>,
|
||||
pub diagnostics: AgentDiagnostics,
|
||||
pub collector_version: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct IdentityInfo {
|
||||
pub agent_id: String,
|
||||
pub hostname: String,
|
||||
pub os_name: String,
|
||||
pub os_version: String,
|
||||
pub platform: String,
|
||||
pub username: String,
|
||||
pub domain: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ResourceInfo {
|
||||
pub uptime_seconds: u64,
|
||||
pub cpu_usage_percent: f64,
|
||||
pub memory_total: u64,
|
||||
pub memory_used: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct SessionInfo {
|
||||
pub session_id: String,
|
||||
pub username: String,
|
||||
pub session_type: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub session_source: Option<String>,
|
||||
pub remote_addr: Option<String>,
|
||||
pub started_at: Option<DateTime<Utc>>,
|
||||
pub active: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct AgentDiagnostics {
|
||||
pub sessions_collected_total: usize,
|
||||
pub rdp_sessions_total: usize,
|
||||
pub active_sessions_total: usize,
|
||||
pub collector_source: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub collector_error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ProcessInfo {
|
||||
pub pid: u32,
|
||||
pub ppid: Option<u32>,
|
||||
pub name: String,
|
||||
pub exe: Option<String>,
|
||||
pub username: Option<String>,
|
||||
pub cpu_percent: Option<f64>,
|
||||
pub memory_bytes: Option<u64>,
|
||||
pub started_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct NetworkInterfaceInfo {
|
||||
pub name: String,
|
||||
pub mac: Option<String>,
|
||||
pub addresses: Vec<String>,
|
||||
pub up: bool,
|
||||
pub rx_bytes: Option<u64>,
|
||||
pub tx_bytes: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct NetworkConnectionInfo {
|
||||
pub protocol: String,
|
||||
pub local_addr: String,
|
||||
pub local_port: u16,
|
||||
pub remote_addr: Option<String>,
|
||||
pub remote_port: Option<u16>,
|
||||
pub state: String,
|
||||
pub pid: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct WorkforceActivityInfo {
|
||||
pub active_today: bool,
|
||||
pub activity_index: Option<u8>,
|
||||
pub department: Option<String>,
|
||||
pub owner: Option<String>,
|
||||
pub work_applications: Vec<String>,
|
||||
pub idle_seconds: Option<u64>,
|
||||
pub explanation: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct SecurityEventInfo {
|
||||
pub event_id: String,
|
||||
pub source: String,
|
||||
pub severity: String,
|
||||
pub summary: String,
|
||||
pub timestamp: DateTime<Utc>,
|
||||
pub evidence: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct SessionSnapshot {
|
||||
pub active_sessions: Vec<SessionInfo>,
|
||||
pub rdp_sessions: Vec<SessionInfo>,
|
||||
pub ssh_sessions: Vec<SessionInfo>,
|
||||
pub diagnostics: AgentDiagnostics,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct NetworkSnapshot {
|
||||
pub interfaces: Vec<NetworkInterfaceInfo>,
|
||||
pub connections: Vec<NetworkConnectionInfo>,
|
||||
}
|
||||
|
||||
pub trait TelemetryCollector {
|
||||
fn collect_identity(&self) -> Result<IdentityInfo>;
|
||||
fn collect_sessions(&self) -> Result<SessionSnapshot>;
|
||||
fn collect_processes(&self) -> Result<Vec<ProcessInfo>>;
|
||||
fn collect_resources(&self) -> Result<ResourceInfo>;
|
||||
fn collect_network(&self) -> Result<NetworkSnapshot>;
|
||||
fn collect_security_events(&self) -> Result<Vec<SecurityEventInfo>>;
|
||||
fn collect_workforce_activity(&self) -> Result<WorkforceActivityInfo>;
|
||||
|
||||
fn collect_all(&self) -> Result<TelemetryRecord> {
|
||||
let identity = self.collect_identity()?;
|
||||
let sessions = self.collect_sessions()?;
|
||||
let resources = self.collect_resources()?;
|
||||
let network = self.collect_network()?;
|
||||
Ok(TelemetryRecord {
|
||||
agent_id: identity.agent_id,
|
||||
hostname: identity.hostname,
|
||||
os_name: identity.os_name,
|
||||
os_version: identity.os_version,
|
||||
platform: identity.platform,
|
||||
username: identity.username,
|
||||
domain: identity.domain,
|
||||
timestamp: Utc::now(),
|
||||
uptime_seconds: resources.uptime_seconds,
|
||||
cpu_usage_percent: resources.cpu_usage_percent,
|
||||
memory_total: resources.memory_total,
|
||||
memory_used: resources.memory_used,
|
||||
active_sessions: sessions.active_sessions,
|
||||
rdp_sessions: sessions.rdp_sessions,
|
||||
ssh_sessions: sessions.ssh_sessions,
|
||||
processes: self.collect_processes()?,
|
||||
network_interfaces: network.interfaces,
|
||||
network_connections: network.connections,
|
||||
workforce_activity: self.collect_workforce_activity()?,
|
||||
security_events: self.collect_security_events()?,
|
||||
diagnostics: sessions.diagnostics,
|
||||
collector_version: COLLECTOR_VERSION.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn diagnostics_for_sessions(
|
||||
active_sessions: &[SessionInfo],
|
||||
rdp_sessions: &[SessionInfo],
|
||||
collector_source: impl Into<String>,
|
||||
collector_error: Option<String>,
|
||||
) -> AgentDiagnostics {
|
||||
AgentDiagnostics {
|
||||
sessions_collected_total: active_sessions.len(),
|
||||
rdp_sessions_total: rdp_sessions.len(),
|
||||
active_sessions_total: active_sessions
|
||||
.iter()
|
||||
.filter(|session| session.active)
|
||||
.count(),
|
||||
collector_source: collector_source.into(),
|
||||
collector_error,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn dedupe_sessions(hostname: &str, sessions: Vec<SessionInfo>) -> Vec<SessionInfo> {
|
||||
let mut seen = BTreeSet::new();
|
||||
sessions
|
||||
.into_iter()
|
||||
.filter(|session| {
|
||||
seen.insert(format!(
|
||||
"{}\u{1f}{}\u{1f}{}\u{1f}{}",
|
||||
hostname, session.username, session.session_id, session.session_type
|
||||
))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn empty_workforce_activity() -> WorkforceActivityInfo {
|
||||
WorkforceActivityInfo {
|
||||
active_today: false,
|
||||
activity_index: None,
|
||||
department: None,
|
||||
owner: None,
|
||||
work_applications: Vec::new(),
|
||||
idle_seconds: None,
|
||||
explanation: vec!["activity scoring requires workstation activity events".to_string()],
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn telemetry_record_serializes_required_fields() {
|
||||
let record = TelemetryRecord {
|
||||
agent_id: "agent-1".to_string(),
|
||||
hostname: "HOST-EXAMPLE".to_string(),
|
||||
os_name: "Linux".to_string(),
|
||||
os_version: "test".to_string(),
|
||||
platform: "linux".to_string(),
|
||||
username: "user".to_string(),
|
||||
domain: "".to_string(),
|
||||
timestamp: Utc::now(),
|
||||
uptime_seconds: 1,
|
||||
cpu_usage_percent: 0.0,
|
||||
memory_total: 10,
|
||||
memory_used: 5,
|
||||
active_sessions: Vec::new(),
|
||||
rdp_sessions: Vec::new(),
|
||||
ssh_sessions: Vec::new(),
|
||||
processes: Vec::new(),
|
||||
network_interfaces: Vec::new(),
|
||||
network_connections: Vec::new(),
|
||||
workforce_activity: empty_workforce_activity(),
|
||||
security_events: Vec::new(),
|
||||
diagnostics: diagnostics_for_sessions(&[], &[], "test", None),
|
||||
collector_version: COLLECTOR_VERSION.to_string(),
|
||||
};
|
||||
let value = serde_json::to_value(record).unwrap();
|
||||
assert_eq!(value["agent_id"], "agent-1");
|
||||
assert!(value.get("network_connections").unwrap().is_array());
|
||||
assert!(value.get("workforce_activity").is_some());
|
||||
assert_eq!(value["diagnostics"]["collector_source"], "test");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deduplicates_sessions_by_host_user_id_and_type() {
|
||||
let session = SessionInfo {
|
||||
session_id: "2".to_string(),
|
||||
username: "user".to_string(),
|
||||
session_type: "rdp".to_string(),
|
||||
session_source: Some("wts_api".to_string()),
|
||||
remote_addr: None,
|
||||
started_at: None,
|
||||
active: true,
|
||||
};
|
||||
let deduped = dedupe_sessions("HOST-EXAMPLE", vec![session.clone(), session]);
|
||||
assert_eq!(deduped.len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,556 @@
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use chrono::Utc;
|
||||
use reqwest::blocking::Client;
|
||||
use reqwest::header::{HeaderMap, HeaderValue};
|
||||
|
||||
use crate::config::AgentConfig;
|
||||
use crate::telemetry::{SessionInfo, TelemetryRecord};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TelemetryTransport {
|
||||
server_url: String,
|
||||
api_key: String,
|
||||
spool_dir: PathBuf,
|
||||
timeout: Duration,
|
||||
retry_attempts: u32,
|
||||
}
|
||||
|
||||
impl TelemetryTransport {
|
||||
pub fn new(config: &AgentConfig) -> Self {
|
||||
Self {
|
||||
server_url: config.server_url.clone(),
|
||||
api_key: config.api_key.clone(),
|
||||
spool_dir: config.spool_dir.clone(),
|
||||
timeout: Duration::from_secs(config.timeout_seconds),
|
||||
retry_attempts: config.retry_attempts,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn send_or_spool(&self, record: &TelemetryRecord) -> Result<()> {
|
||||
match self.send(record) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(err) => {
|
||||
self.spool(record)?;
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn send(&self, record: &TelemetryRecord) -> Result<()> {
|
||||
let client = Client::builder()
|
||||
.timeout(self.timeout)
|
||||
.build()
|
||||
.context("build telemetry HTTP client")?;
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"x-api-key",
|
||||
HeaderValue::from_str(&self.api_key).context("invalid api key header")?,
|
||||
);
|
||||
let mut last_error = None;
|
||||
for attempt in 0..self.retry_attempts.max(1) {
|
||||
let result = client
|
||||
.post(&self.server_url)
|
||||
.headers(headers.clone())
|
||||
.json(record)
|
||||
.send()
|
||||
.and_then(|response| response.error_for_status())
|
||||
.map(|_| ());
|
||||
match result {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(err) => {
|
||||
last_error = Some(err);
|
||||
let backoff = Duration::from_millis(250 * u64::from(attempt + 1));
|
||||
thread::sleep(backoff);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(anyhow!(
|
||||
"telemetry POST failed: {}",
|
||||
last_error
|
||||
.map(|err| err.to_string())
|
||||
.unwrap_or_else(|| "unknown error".to_string())
|
||||
))
|
||||
}
|
||||
|
||||
pub fn spool(&self, record: &TelemetryRecord) -> Result<PathBuf> {
|
||||
fs::create_dir_all(&self.spool_dir)
|
||||
.with_context(|| format!("create spool {}", self.spool_dir.display()))?;
|
||||
let file_name = format!(
|
||||
"{}-{}.json",
|
||||
record.timestamp.format("%Y%m%dT%H%M%S%.3fZ"),
|
||||
sanitize_file_part(&record.agent_id)
|
||||
);
|
||||
let path = self.spool_dir.join(file_name);
|
||||
fs::write(&path, serde_json::to_vec(record)?)
|
||||
.with_context(|| format!("write spool {}", path.display()))?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
pub fn flush_spool(&self) -> Result<usize> {
|
||||
flush_spool_dir(&self.spool_dir, |record| self.send(record))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AwWorktimePublisher {
|
||||
aw_api_base: String,
|
||||
spool_dir: PathBuf,
|
||||
timeout: Duration,
|
||||
retry_attempts: u32,
|
||||
}
|
||||
|
||||
impl AwWorktimePublisher {
|
||||
pub fn new(config: &AgentConfig) -> Option<Self> {
|
||||
if !config.aw_worktime_enabled {
|
||||
return None;
|
||||
}
|
||||
let aw_api_base = config
|
||||
.aw_api_base
|
||||
.as_ref()?
|
||||
.trim_end_matches('/')
|
||||
.to_string();
|
||||
if aw_api_base.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(Self {
|
||||
aw_api_base,
|
||||
spool_dir: config.spool_dir.join("aw-worktime"),
|
||||
timeout: Duration::from_secs(config.timeout_seconds),
|
||||
retry_attempts: config.retry_attempts,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn publish_or_spool(&self, record: &TelemetryRecord) -> Result<()> {
|
||||
if let Err(err) = self.flush_spool() {
|
||||
eprintln!("ActivityWatch worktime spool flush failed: {err:#}");
|
||||
}
|
||||
match self.publish(record) {
|
||||
Ok(_) => Ok(()),
|
||||
Err(err) => {
|
||||
self.spool(record)?;
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn publish(&self, record: &TelemetryRecord) -> Result<usize> {
|
||||
let client = Client::builder()
|
||||
.timeout(self.timeout)
|
||||
.build()
|
||||
.context("build ActivityWatch HTTP client")?;
|
||||
let bucket_id = format!(
|
||||
"aw-worktime-sessions_{}",
|
||||
sanitize_bucket_part(&record.hostname)
|
||||
);
|
||||
ensure_aw_bucket(
|
||||
&client,
|
||||
&self.aw_api_base,
|
||||
&bucket_id,
|
||||
"aw-worktime-session-collector",
|
||||
"aw.worktime.session",
|
||||
&record.hostname,
|
||||
)?;
|
||||
let sessions = if record.active_sessions.is_empty() {
|
||||
vec![SessionInfo {
|
||||
session_id: "0".to_string(),
|
||||
username: record.username.clone(),
|
||||
session_type: "local".to_string(),
|
||||
session_source: Some("local_fallback".to_string()),
|
||||
remote_addr: None,
|
||||
started_at: None,
|
||||
active: true,
|
||||
}]
|
||||
} else {
|
||||
record.active_sessions.clone()
|
||||
};
|
||||
let mut sent = 0;
|
||||
let sample_seconds = 60_i64;
|
||||
for session in sessions {
|
||||
let ignore_for_kpi = ignored_for_kpi(record, &session);
|
||||
let active_for_kpi = session.active && !ignore_for_kpi;
|
||||
let state = if ignore_for_kpi {
|
||||
"IgnoredForKpi"
|
||||
} else if session.active {
|
||||
"Active"
|
||||
} else {
|
||||
"Disconnected"
|
||||
};
|
||||
let payload = serde_json::json!({
|
||||
"timestamp": record.timestamp,
|
||||
"duration": sample_seconds,
|
||||
"data": {
|
||||
"username": session.username,
|
||||
"userId": format!("{}\\{}", record.hostname, session.username),
|
||||
"sessionId": session_id_number(&session),
|
||||
"sessionName": session.session_type,
|
||||
"sessionSource": session.session_source,
|
||||
"state": state,
|
||||
"active": active_for_kpi,
|
||||
"ignoredForKpi": ignore_for_kpi,
|
||||
"qualityNote": if ignore_for_kpi { Some("local_fallback is diagnostics-only and is not accepted as activity proof") } else { None },
|
||||
"sampleSeconds": sample_seconds,
|
||||
"pollSeconds": sample_seconds,
|
||||
"hostname": record.hostname,
|
||||
"source": "awatch-agent-rs",
|
||||
"collectorSource": record.diagnostics.collector_source,
|
||||
"sessionsCollectedTotal": record.diagnostics.sessions_collected_total,
|
||||
"rdpSessionsTotal": record.diagnostics.rdp_sessions_total,
|
||||
"activeSessionsTotal": record.diagnostics.active_sessions_total,
|
||||
"collectorError": record.diagnostics.collector_error,
|
||||
}
|
||||
});
|
||||
post_json_with_retry(
|
||||
&client,
|
||||
&format!(
|
||||
"{}/buckets/{}/heartbeat?pulsetime=180",
|
||||
self.aw_api_base, bucket_id
|
||||
),
|
||||
&payload,
|
||||
self.retry_attempts,
|
||||
)
|
||||
.context("publish ActivityWatch worktime heartbeat")?;
|
||||
sent += 1;
|
||||
}
|
||||
Ok(sent)
|
||||
}
|
||||
|
||||
pub fn spool(&self, record: &TelemetryRecord) -> Result<PathBuf> {
|
||||
fs::create_dir_all(&self.spool_dir)
|
||||
.with_context(|| format!("create worktime spool {}", self.spool_dir.display()))?;
|
||||
let file_name = format!(
|
||||
"{}-{}.json",
|
||||
record.timestamp.format("%Y%m%dT%H%M%S%.3fZ"),
|
||||
sanitize_file_part(&record.agent_id)
|
||||
);
|
||||
let path = self.spool_dir.join(file_name);
|
||||
fs::write(&path, serde_json::to_vec(record)?)
|
||||
.with_context(|| format!("write worktime spool {}", path.display()))?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
pub fn flush_spool(&self) -> Result<usize> {
|
||||
flush_spool_dir(&self.spool_dir, |record| self.publish(record).map(|_| ()))
|
||||
}
|
||||
}
|
||||
|
||||
fn ignored_for_kpi(record: &TelemetryRecord, session: &SessionInfo) -> bool {
|
||||
record.diagnostics.collector_source == "local_fallback"
|
||||
|| session.session_source.as_deref() == Some("local_fallback")
|
||||
}
|
||||
|
||||
fn ensure_aw_bucket(
|
||||
client: &Client,
|
||||
aw_api_base: &str,
|
||||
bucket_id: &str,
|
||||
client_name: &str,
|
||||
bucket_type: &str,
|
||||
hostname: &str,
|
||||
) -> Result<()> {
|
||||
let bucket_url = format!("{}/buckets/{}", aw_api_base, bucket_id);
|
||||
if client
|
||||
.get(&bucket_url)
|
||||
.send()
|
||||
.and_then(|response| response.error_for_status())
|
||||
.is_ok()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
let body = serde_json::json!({
|
||||
"client": client_name,
|
||||
"type": bucket_type,
|
||||
"hostname": hostname,
|
||||
});
|
||||
post_json_with_retry(client, &bucket_url, &body, 3)
|
||||
.context("create ActivityWatch worktime bucket")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn post_json_with_retry(
|
||||
client: &Client,
|
||||
url: &str,
|
||||
payload: &serde_json::Value,
|
||||
retry_attempts: u32,
|
||||
) -> Result<()> {
|
||||
let mut last_error = None;
|
||||
for attempt in 0..retry_attempts.max(1) {
|
||||
let result = client
|
||||
.post(url)
|
||||
.json(payload)
|
||||
.send()
|
||||
.and_then(|response| response.error_for_status())
|
||||
.map(|_| ());
|
||||
match result {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(err) => {
|
||||
last_error = Some(err);
|
||||
let backoff = Duration::from_millis(250 * u64::from(attempt + 1));
|
||||
thread::sleep(backoff);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(anyhow!(
|
||||
"HTTP POST failed: {}",
|
||||
last_error
|
||||
.map(|err| err.to_string())
|
||||
.unwrap_or_else(|| "unknown error".to_string())
|
||||
))
|
||||
}
|
||||
|
||||
fn session_id_number(session: &SessionInfo) -> i64 {
|
||||
session
|
||||
.session_id
|
||||
.split(|ch: char| !ch.is_ascii_digit())
|
||||
.find(|part| !part.is_empty())
|
||||
.and_then(|part| part.parse::<i64>().ok())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn sanitize_bucket_part(value: &str) -> String {
|
||||
value
|
||||
.chars()
|
||||
.map(|ch| {
|
||||
if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
|
||||
ch
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn flush_spool_dir<F>(spool_dir: &Path, mut sender: F) -> Result<usize>
|
||||
where
|
||||
F: FnMut(&TelemetryRecord) -> Result<()>,
|
||||
{
|
||||
if !spool_dir.exists() {
|
||||
return Ok(0);
|
||||
}
|
||||
let mut sent = 0;
|
||||
let mut entries = fs::read_dir(spool_dir)
|
||||
.with_context(|| format!("read spool {}", spool_dir.display()))?
|
||||
.filter_map(|entry| entry.ok())
|
||||
.map(|entry| entry.path())
|
||||
.filter(|path| path.extension().is_some_and(|ext| ext == "json"))
|
||||
.collect::<Vec<_>>();
|
||||
entries.sort();
|
||||
for path in entries {
|
||||
let data = fs::read(&path).with_context(|| format!("read {}", path.display()))?;
|
||||
let record: TelemetryRecord =
|
||||
serde_json::from_slice(&data).with_context(|| format!("parse {}", path.display()))?;
|
||||
sender(&record)?;
|
||||
fs::remove_file(&path).with_context(|| format!("remove {}", path.display()))?;
|
||||
sent += 1;
|
||||
}
|
||||
Ok(sent)
|
||||
}
|
||||
|
||||
fn sanitize_file_part(value: &str) -> String {
|
||||
value
|
||||
.chars()
|
||||
.map(|ch| {
|
||||
if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
|
||||
ch
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn spool_health(spool_dir: &Path) -> serde_json::Value {
|
||||
let telemetry_queued = count_spool_json_files(spool_dir);
|
||||
let worktime_spool_dir = spool_dir.join("aw-worktime");
|
||||
let worktime_queued = count_spool_json_files(&worktime_spool_dir);
|
||||
serde_json::json!({
|
||||
"generated_at_utc": Utc::now(),
|
||||
"spool_dir": spool_dir.display().to_string(),
|
||||
"worktime_spool_dir": worktime_spool_dir.display().to_string(),
|
||||
"queued": telemetry_queued,
|
||||
"telemetry_queued": telemetry_queued,
|
||||
"worktime_queued": worktime_queued,
|
||||
"total_queued": telemetry_queued + worktime_queued,
|
||||
})
|
||||
}
|
||||
|
||||
fn count_spool_json_files(spool_dir: &Path) -> usize {
|
||||
fs::read_dir(spool_dir)
|
||||
.ok()
|
||||
.into_iter()
|
||||
.flat_map(|entries| entries.filter_map(|entry| entry.ok()))
|
||||
.filter(|entry| entry.path().extension().is_some_and(|ext| ext == "json"))
|
||||
.count()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use tempfile::tempdir;
|
||||
|
||||
use super::*;
|
||||
use crate::telemetry::{TelemetryRecord, diagnostics_for_sessions, empty_workforce_activity};
|
||||
|
||||
fn record() -> TelemetryRecord {
|
||||
TelemetryRecord {
|
||||
agent_id: "agent/1".to_string(),
|
||||
hostname: "HOST-EXAMPLE".to_string(),
|
||||
os_name: "Linux".to_string(),
|
||||
os_version: "test".to_string(),
|
||||
platform: "linux".to_string(),
|
||||
username: "user".to_string(),
|
||||
domain: "".to_string(),
|
||||
timestamp: Utc::now(),
|
||||
uptime_seconds: 1,
|
||||
cpu_usage_percent: 0.0,
|
||||
memory_total: 1,
|
||||
memory_used: 1,
|
||||
active_sessions: Vec::new(),
|
||||
rdp_sessions: Vec::new(),
|
||||
ssh_sessions: Vec::new(),
|
||||
processes: Vec::new(),
|
||||
network_interfaces: Vec::new(),
|
||||
network_connections: Vec::new(),
|
||||
workforce_activity: empty_workforce_activity(),
|
||||
security_events: Vec::new(),
|
||||
diagnostics: diagnostics_for_sessions(&[], &[], "test", None),
|
||||
collector_version: "test".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spools_and_flushes_records() {
|
||||
let dir = tempdir().unwrap();
|
||||
let config = AgentConfig {
|
||||
spool_dir: dir.path().to_path_buf(),
|
||||
..AgentConfig::default()
|
||||
};
|
||||
let transport = TelemetryTransport::new(&config);
|
||||
let path = transport.spool(&record()).unwrap();
|
||||
assert!(path.is_file());
|
||||
let mut seen = 0;
|
||||
let flushed = flush_spool_dir(dir.path(), |_| {
|
||||
seen += 1;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(flushed, 1);
|
||||
assert_eq!(seen, 1);
|
||||
assert!(!path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_or_spool_preserves_record_when_server_is_unavailable() {
|
||||
let dir = tempdir().unwrap();
|
||||
let config = AgentConfig {
|
||||
server_url: "http://127.0.0.1:9/api/telemetry".to_string(),
|
||||
retry_attempts: 1,
|
||||
timeout_seconds: 1,
|
||||
spool_dir: dir.path().to_path_buf(),
|
||||
..AgentConfig::default()
|
||||
};
|
||||
let transport = TelemetryTransport::new(&config);
|
||||
assert!(transport.send_or_spool(&record()).is_err());
|
||||
let queued = fs::read_dir(dir.path())
|
||||
.unwrap()
|
||||
.filter_map(|entry| entry.ok())
|
||||
.filter(|entry| entry.path().extension().is_some_and(|ext| ext == "json"))
|
||||
.count();
|
||||
assert_eq!(queued, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flush_spool_keeps_record_when_sender_fails() {
|
||||
let dir = tempdir().unwrap();
|
||||
let config = AgentConfig {
|
||||
spool_dir: dir.path().to_path_buf(),
|
||||
..AgentConfig::default()
|
||||
};
|
||||
let transport = TelemetryTransport::new(&config);
|
||||
let path = transport.spool(&record()).unwrap();
|
||||
let result = flush_spool_dir(dir.path(), |_| anyhow::bail!("transport down"));
|
||||
assert!(result.is_err());
|
||||
assert!(path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_id_number_extracts_numeric_id() {
|
||||
let session = SessionInfo {
|
||||
session_id: "rdp-12-user".to_string(),
|
||||
username: "user".to_string(),
|
||||
session_type: "rdp".to_string(),
|
||||
session_source: Some("wts_api".to_string()),
|
||||
remote_addr: None,
|
||||
started_at: None,
|
||||
active: true,
|
||||
};
|
||||
assert_eq!(session_id_number(&session), 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_fallback_sessions_are_diagnostics_only_for_kpi() {
|
||||
let mut wts_record = record();
|
||||
wts_record.diagnostics = diagnostics_for_sessions(&[], &[], "wts_api", None);
|
||||
let session = SessionInfo {
|
||||
session_id: "0".to_string(),
|
||||
username: "user".to_string(),
|
||||
session_type: "local".to_string(),
|
||||
session_source: Some("local_fallback".to_string()),
|
||||
remote_addr: None,
|
||||
started_at: None,
|
||||
active: true,
|
||||
};
|
||||
assert!(ignored_for_kpi(&wts_record, &session));
|
||||
|
||||
let mut fallback_record = record();
|
||||
fallback_record.diagnostics = diagnostics_for_sessions(&[], &[], "local_fallback", None);
|
||||
let session = SessionInfo {
|
||||
session_source: Some("wts_api".to_string()),
|
||||
..session
|
||||
};
|
||||
assert!(ignored_for_kpi(&fallback_record, &session));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worktime_publisher_is_disabled_by_default() {
|
||||
assert!(AwWorktimePublisher::new(&AgentConfig::default()).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worktime_publisher_spools_to_separate_dir() {
|
||||
let dir = tempdir().unwrap();
|
||||
let config = AgentConfig {
|
||||
aw_api_base: Some("http://127.0.0.1:9/api/0".to_string()),
|
||||
aw_worktime_enabled: true,
|
||||
spool_dir: dir.path().to_path_buf(),
|
||||
..AgentConfig::default()
|
||||
};
|
||||
let publisher = AwWorktimePublisher::new(&config).unwrap();
|
||||
let path = publisher.spool(&record()).unwrap();
|
||||
assert!(path.starts_with(dir.path().join("aw-worktime")));
|
||||
assert!(path.is_file());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spool_health_reports_telemetry_and_worktime_backlog() {
|
||||
let dir = tempdir().unwrap();
|
||||
let config = AgentConfig {
|
||||
aw_api_base: Some("http://127.0.0.1:9/api/0".to_string()),
|
||||
aw_worktime_enabled: true,
|
||||
spool_dir: dir.path().to_path_buf(),
|
||||
..AgentConfig::default()
|
||||
};
|
||||
let transport = TelemetryTransport::new(&config);
|
||||
let publisher = AwWorktimePublisher::new(&config).unwrap();
|
||||
transport.spool(&record()).unwrap();
|
||||
publisher.spool(&record()).unwrap();
|
||||
|
||||
let health = spool_health(dir.path());
|
||||
assert_eq!(health["queued"].as_u64(), Some(1));
|
||||
assert_eq!(health["telemetry_queued"].as_u64(), Some(1));
|
||||
assert_eq!(health["worktime_queued"].as_u64(), Some(1));
|
||||
assert_eq!(health["total_queued"].as_u64(), Some(2));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "awatch-agent"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
chrono.workspace = true
|
||||
clap.workspace = true
|
||||
reqwest.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
sha2.workspace = true
|
||||
tiny_http.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
@@ -0,0 +1,174 @@
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AgentConfig {
|
||||
pub agent_id: String,
|
||||
pub host_id: String,
|
||||
pub platform: String,
|
||||
pub server_url: String,
|
||||
pub spool_dir: PathBuf,
|
||||
pub health_bind: String,
|
||||
pub request_timeout_seconds: u64,
|
||||
pub retry_max_attempts: u32,
|
||||
pub retry_base_backoff_ms: u64,
|
||||
}
|
||||
|
||||
impl Default for AgentConfig {
|
||||
fn default() -> Self {
|
||||
let hostname = local_hostname();
|
||||
Self {
|
||||
agent_id: uuid_from_seed(&format!("agent:{hostname}")),
|
||||
host_id: uuid_from_seed(&format!("host:{hostname}")),
|
||||
platform: current_platform().to_string(),
|
||||
server_url: "http://127.0.0.1:9/api/agent/telemetry".to_string(),
|
||||
spool_dir: default_spool_dir(),
|
||||
health_bind: "127.0.0.1:8787".to_string(),
|
||||
request_timeout_seconds: 10,
|
||||
retry_max_attempts: 3,
|
||||
retry_base_backoff_ms: 250,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AgentConfig {
|
||||
pub fn load(path: &Path) -> Result<Self> {
|
||||
let text = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
|
||||
Self::parse_toml_like(&text)
|
||||
}
|
||||
|
||||
pub fn parse_toml_like(text: &str) -> Result<Self> {
|
||||
let mut config = Self::default();
|
||||
for raw in text.lines() {
|
||||
let line = raw.split('#').next().unwrap_or("").trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some((key, value)) = line.split_once('=') else {
|
||||
continue;
|
||||
};
|
||||
let key = key.trim();
|
||||
let value = value.trim().trim_matches('"');
|
||||
match key {
|
||||
"agent_id" => config.agent_id = value.to_string(),
|
||||
"host_id" => config.host_id = value.to_string(),
|
||||
"platform" => config.platform = value.to_string(),
|
||||
"server_url" => config.server_url = value.to_string(),
|
||||
"spool_dir" => config.spool_dir = PathBuf::from(value),
|
||||
"health_bind" => config.health_bind = value.to_string(),
|
||||
"request_timeout_seconds" => {
|
||||
config.request_timeout_seconds = value.parse().unwrap_or(10)
|
||||
}
|
||||
"retry_max_attempts" => config.retry_max_attempts = value.parse().unwrap_or(3),
|
||||
"retry_base_backoff_ms" => {
|
||||
config.retry_base_backoff_ms = value.parse().unwrap_or(250)
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
pub fn request_timeout(&self) -> Duration {
|
||||
Duration::from_secs(self.request_timeout_seconds)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn default_config_path() -> PathBuf {
|
||||
if cfg!(windows) {
|
||||
PathBuf::from(r"C:\ProgramData\AWatch-rus\agent\awatch-agent.toml")
|
||||
} else {
|
||||
PathBuf::from("/etc/awatch-agent/awatch-agent.toml")
|
||||
}
|
||||
}
|
||||
|
||||
fn default_spool_dir() -> PathBuf {
|
||||
if cfg!(windows) {
|
||||
PathBuf::from(r"C:\ProgramData\AWatch-rus\agent\spool")
|
||||
} else {
|
||||
PathBuf::from("/var/lib/awatch-agent/spool")
|
||||
}
|
||||
}
|
||||
|
||||
fn current_platform() -> &'static str {
|
||||
if cfg!(windows) {
|
||||
"windows"
|
||||
} else if cfg!(target_os = "macos") {
|
||||
"macos"
|
||||
} else if cfg!(target_os = "freebsd") {
|
||||
"freebsd"
|
||||
} else {
|
||||
"linux"
|
||||
}
|
||||
}
|
||||
|
||||
fn local_hostname() -> String {
|
||||
std::env::var("COMPUTERNAME")
|
||||
.or_else(|_| std::env::var("HOSTNAME"))
|
||||
.unwrap_or_else(|_| "HOST-EXAMPLE".to_string())
|
||||
}
|
||||
|
||||
fn uuid_from_seed(seed: &str) -> String {
|
||||
let digest = Sha256::digest(seed.as_bytes());
|
||||
let mut bytes = [0_u8; 16];
|
||||
bytes.copy_from_slice(&digest[..16]);
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x50;
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||
format!(
|
||||
"{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
|
||||
bytes[0],
|
||||
bytes[1],
|
||||
bytes[2],
|
||||
bytes[3],
|
||||
bytes[4],
|
||||
bytes[5],
|
||||
bytes[6],
|
||||
bytes[7],
|
||||
bytes[8],
|
||||
bytes[9],
|
||||
bytes[10],
|
||||
bytes[11],
|
||||
bytes[12],
|
||||
bytes[13],
|
||||
bytes[14],
|
||||
bytes[15]
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_config_without_external_toml_dependency() {
|
||||
let config = AgentConfig::parse_toml_like(
|
||||
r#"
|
||||
agent_id = "00000000-0000-5000-8000-000000000001"
|
||||
host_id = "00000000-0000-5000-8000-000000000002"
|
||||
platform = "windows"
|
||||
server_url = "https://awatch.example/api/agent/telemetry"
|
||||
spool_dir = "/tmp/awatch-agent-spool"
|
||||
health_bind = "127.0.0.1:8788"
|
||||
request_timeout_seconds = 2
|
||||
retry_max_attempts = 5
|
||||
retry_base_backoff_ms = 50
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(config.platform, "windows");
|
||||
assert_eq!(config.retry_max_attempts, 5);
|
||||
assert_eq!(config.spool_dir, PathBuf::from("/tmp/awatch-agent-spool"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_ids_are_uuid_shaped() {
|
||||
let id = uuid_from_seed("HOST-EXAMPLE");
|
||||
assert_eq!(id.len(), 36);
|
||||
assert_eq!(&id[14..15], "5");
|
||||
assert!(matches!(&id[19..20], "8" | "9" | "a" | "b"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::config::AgentConfig;
|
||||
|
||||
pub const AGENT_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct TelemetryEnvelope {
|
||||
pub agent_id: String,
|
||||
pub host_id: String,
|
||||
pub platform: String,
|
||||
pub timestamp: DateTime<Utc>,
|
||||
pub records: Vec<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct Heartbeat {
|
||||
pub agent_version: String,
|
||||
pub platform: String,
|
||||
pub status: AgentStatus,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AgentStatus {
|
||||
Online,
|
||||
Degraded,
|
||||
Offline,
|
||||
}
|
||||
|
||||
impl TelemetryEnvelope {
|
||||
pub fn empty(config: &AgentConfig) -> Self {
|
||||
Self {
|
||||
agent_id: config.agent_id.clone(),
|
||||
host_id: config.host_id.clone(),
|
||||
platform: config.platform.clone(),
|
||||
timestamp: Utc::now(),
|
||||
records: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn heartbeat(config: &AgentConfig) -> Self {
|
||||
let heartbeat = Heartbeat {
|
||||
agent_version: AGENT_VERSION.to_string(),
|
||||
platform: config.platform.clone(),
|
||||
status: AgentStatus::Online,
|
||||
};
|
||||
Self {
|
||||
records: vec![serde_json::json!({
|
||||
"type": "heartbeat",
|
||||
"payload": heartbeat,
|
||||
})],
|
||||
..Self::empty(config)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn heartbeat_contract_is_stable_and_contains_no_inventory() {
|
||||
let config = AgentConfig::default();
|
||||
let envelope = TelemetryEnvelope::heartbeat(&config);
|
||||
let value = serde_json::to_value(&envelope).unwrap();
|
||||
assert_eq!(value["agent_id"], config.agent_id);
|
||||
assert_eq!(value["host_id"], config.host_id);
|
||||
assert_eq!(value["platform"], config.platform);
|
||||
assert!(value["records"].is_array());
|
||||
assert_eq!(value["records"][0]["type"], "heartbeat");
|
||||
assert_eq!(value["records"][0]["payload"]["status"], "online");
|
||||
assert!(value["records"][0]["payload"].get("hostname").is_none());
|
||||
assert!(value["records"][0]["payload"].get("processes").is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
use std::net::ToSocketAddrs;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use tiny_http::{Header, Response, Server, StatusCode};
|
||||
|
||||
use crate::envelope::AGENT_VERSION;
|
||||
use crate::metrics::AgentMetrics;
|
||||
|
||||
pub fn serve_health(bind: &str, metrics: AgentMetrics, max_requests: Option<usize>) -> Result<()> {
|
||||
bind.to_socket_addrs()
|
||||
.with_context(|| format!("parse health bind address {bind}"))?;
|
||||
let server =
|
||||
Server::http(bind).map_err(|err| anyhow::anyhow!("bind health endpoint: {err}"))?;
|
||||
let mut served = 0_usize;
|
||||
loop {
|
||||
if max_requests.is_some_and(|limit| served >= limit) {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(request) = server
|
||||
.recv_timeout(Duration::from_millis(250))
|
||||
.map_err(|err| anyhow::anyhow!("receive health request: {err}"))?
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
served += 1;
|
||||
let response = match (request.method().as_str(), request.url()) {
|
||||
("GET", "/healthz") => json_response(serde_json::json!({
|
||||
"ok": true,
|
||||
"status": "online",
|
||||
"agent_version": AGENT_VERSION,
|
||||
})),
|
||||
("GET", "/metrics") => text_response(metrics.render_prometheus()),
|
||||
_ => Response::from_string("not found").with_status_code(StatusCode(404)),
|
||||
};
|
||||
request
|
||||
.respond(response)
|
||||
.map_err(|err| anyhow::anyhow!("send health response: {err}"))?;
|
||||
}
|
||||
}
|
||||
|
||||
fn json_response(value: serde_json::Value) -> Response<std::io::Cursor<Vec<u8>>> {
|
||||
let mut response = Response::from_data(serde_json::to_vec(&value).unwrap_or_default());
|
||||
if let Ok(header) = Header::from_bytes("Content-Type", "application/json") {
|
||||
response.add_header(header);
|
||||
}
|
||||
response
|
||||
}
|
||||
|
||||
fn text_response(value: String) -> Response<std::io::Cursor<Vec<u8>>> {
|
||||
let mut response = Response::from_string(value);
|
||||
if let Ok(header) = Header::from_bytes("Content-Type", "text/plain; version=0.0.4") {
|
||||
response.add_header(header);
|
||||
}
|
||||
response
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
use chrono::Utc;
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct LogLine<'a> {
|
||||
timestamp: String,
|
||||
level: &'a str,
|
||||
agent_id: &'a str,
|
||||
component: &'a str,
|
||||
message: &'a str,
|
||||
}
|
||||
|
||||
pub fn log_json(agent_id: &str, level: &str, component: &str, message: &str) {
|
||||
let line = LogLine {
|
||||
timestamp: Utc::now().to_rfc3339(),
|
||||
level,
|
||||
agent_id,
|
||||
component,
|
||||
message,
|
||||
};
|
||||
if let Ok(json) = serde_json::to_string(&line) {
|
||||
eprintln!("{json}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn structured_log_shape_is_serializable() {
|
||||
let line = LogLine {
|
||||
timestamp: "2026-06-07T00:00:00Z".to_string(),
|
||||
level: "INFO",
|
||||
agent_id: "agent-1",
|
||||
component: "spool",
|
||||
message: "queued",
|
||||
};
|
||||
let value = serde_json::to_value(line).unwrap();
|
||||
assert_eq!(value["level"], "INFO");
|
||||
assert_eq!(value["component"], "spool");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
mod config;
|
||||
mod envelope;
|
||||
mod health;
|
||||
mod logging;
|
||||
mod metrics;
|
||||
mod spool;
|
||||
mod transport;
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use clap::Parser;
|
||||
|
||||
use config::{AgentConfig, default_config_path};
|
||||
use envelope::TelemetryEnvelope;
|
||||
use logging::log_json;
|
||||
use spool::LocalSpool;
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(about = "AWatch-rus Rust agent baseline scaffold")]
|
||||
struct Cli {
|
||||
#[arg(long, env = "AWATCH_AGENT_CONFIG")]
|
||||
config: Option<PathBuf>,
|
||||
|
||||
#[arg(long, env = "AWATCH_AGENT_SERVER_URL")]
|
||||
server_url: Option<String>,
|
||||
|
||||
#[arg(long, env = "AWATCH_AGENT_SPOOL_DIR")]
|
||||
spool_dir: Option<PathBuf>,
|
||||
|
||||
#[arg(long)]
|
||||
enqueue_heartbeat: bool,
|
||||
|
||||
#[arg(long)]
|
||||
flush_spool: bool,
|
||||
|
||||
#[arg(long)]
|
||||
metrics: bool,
|
||||
|
||||
#[arg(long)]
|
||||
healthz: bool,
|
||||
|
||||
#[arg(long)]
|
||||
print_envelope: bool,
|
||||
|
||||
#[arg(long)]
|
||||
max_health_requests: Option<usize>,
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let code = match run() {
|
||||
Ok(code) => code,
|
||||
Err(err) => {
|
||||
eprintln!("{err:#}");
|
||||
1
|
||||
}
|
||||
};
|
||||
std::process::exit(code);
|
||||
}
|
||||
|
||||
fn run() -> Result<i32> {
|
||||
let cli = Cli::parse();
|
||||
let mut config = load_config(cli.config.as_ref())?;
|
||||
if let Some(server_url) = cli.server_url {
|
||||
config.server_url = server_url;
|
||||
}
|
||||
if let Some(spool_dir) = cli.spool_dir {
|
||||
config.spool_dir = spool_dir;
|
||||
}
|
||||
let spool = LocalSpool::new(config.spool_dir.clone());
|
||||
let mut metrics = spool.metrics().unwrap_or_default();
|
||||
|
||||
if cli.print_envelope {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&TelemetryEnvelope::heartbeat(&config))?
|
||||
);
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
if cli.enqueue_heartbeat {
|
||||
spool.enqueue(TelemetryEnvelope::heartbeat(&config))?;
|
||||
metrics.heartbeat_sent = metrics.heartbeat_sent.saturating_add(1);
|
||||
log_json(
|
||||
&config.agent_id,
|
||||
"INFO",
|
||||
"heartbeat",
|
||||
"heartbeat envelope queued",
|
||||
);
|
||||
}
|
||||
|
||||
if cli.flush_spool {
|
||||
let summary = transport::flush_with_retry(&config, &spool, &mut metrics)?;
|
||||
println!("{}", serde_json::to_string_pretty(&summary)?);
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
if cli.metrics {
|
||||
let mut current = spool.metrics()?;
|
||||
current.heartbeat_sent = metrics.heartbeat_sent;
|
||||
current.retry_count = metrics.retry_count;
|
||||
print!("{}", current.render_prometheus());
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
if cli.healthz {
|
||||
health::serve_health(
|
||||
&config.health_bind,
|
||||
spool.metrics()?,
|
||||
cli.max_health_requests,
|
||||
)?;
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
if !cli.enqueue_heartbeat {
|
||||
log_json(&config.agent_id, "INFO", "agent", "no action requested");
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
fn load_config(path: Option<&PathBuf>) -> Result<AgentConfig> {
|
||||
let path = path.cloned().unwrap_or_else(default_config_path);
|
||||
if path.exists() {
|
||||
AgentConfig::load(&path)
|
||||
} else {
|
||||
AgentConfig::parse_toml_like("")
|
||||
.with_context(|| format!("load default config because {} is absent", path.display()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_run_has_no_monitoring_side_effect() {
|
||||
let config = AgentConfig::parse_toml_like("").unwrap();
|
||||
let envelope = TelemetryEnvelope::heartbeat(&config);
|
||||
assert_eq!(envelope.records.len(), 1);
|
||||
assert!(envelope.records[0].get("processes").is_none());
|
||||
assert!(envelope.records[0].get("screenshots").is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct AgentMetrics {
|
||||
pub queued_records: usize,
|
||||
pub retry_count: u64,
|
||||
pub heartbeat_sent: u64,
|
||||
pub spool_size: u64,
|
||||
}
|
||||
|
||||
impl AgentMetrics {
|
||||
pub fn render_prometheus(&self) -> String {
|
||||
format!(
|
||||
concat!(
|
||||
"# HELP awatch_agent_queued_records Local spool records waiting for delivery.\n",
|
||||
"# TYPE awatch_agent_queued_records gauge\n",
|
||||
"awatch_agent_queued_records {}\n",
|
||||
"# HELP awatch_agent_retry_count Total retry attempts performed by the agent.\n",
|
||||
"# TYPE awatch_agent_retry_count counter\n",
|
||||
"awatch_agent_retry_count {}\n",
|
||||
"# HELP awatch_agent_heartbeat_sent Heartbeat envelopes generated by the agent.\n",
|
||||
"# TYPE awatch_agent_heartbeat_sent counter\n",
|
||||
"awatch_agent_heartbeat_sent {}\n",
|
||||
"# HELP awatch_agent_spool_size Local spool size in bytes.\n",
|
||||
"# TYPE awatch_agent_spool_size gauge\n",
|
||||
"awatch_agent_spool_size {}\n"
|
||||
),
|
||||
self.queued_records, self.retry_count, self.heartbeat_sent, self.spool_size
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn renders_prometheus_metrics() {
|
||||
let metrics = AgentMetrics {
|
||||
queued_records: 2,
|
||||
retry_count: 3,
|
||||
heartbeat_sent: 1,
|
||||
spool_size: 512,
|
||||
};
|
||||
let rendered = metrics.render_prometheus();
|
||||
assert!(rendered.contains("awatch_agent_queued_records 2"));
|
||||
assert!(rendered.contains("awatch_agent_retry_count 3"));
|
||||
assert!(rendered.contains("awatch_agent_heartbeat_sent 1"));
|
||||
assert!(rendered.contains("awatch_agent_spool_size 512"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::envelope::TelemetryEnvelope;
|
||||
use crate::metrics::AgentMetrics;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LocalSpool {
|
||||
root: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct SpoolItem {
|
||||
pub envelope: TelemetryEnvelope,
|
||||
pub enqueued_at: DateTime<Utc>,
|
||||
pub retry_count: u32,
|
||||
pub last_error: Option<String>,
|
||||
}
|
||||
|
||||
impl LocalSpool {
|
||||
pub fn new(root: impl Into<PathBuf>) -> Self {
|
||||
Self { root: root.into() }
|
||||
}
|
||||
|
||||
pub fn enqueue(&self, envelope: TelemetryEnvelope) -> Result<PathBuf> {
|
||||
self.ensure_dirs()?;
|
||||
let item = SpoolItem {
|
||||
envelope,
|
||||
enqueued_at: Utc::now(),
|
||||
retry_count: 0,
|
||||
last_error: None,
|
||||
};
|
||||
let file_name = format!(
|
||||
"{}-{}.json",
|
||||
item.enqueued_at.format("%Y%m%dT%H%M%S%.3fZ"),
|
||||
sanitize_file_part(&item.envelope.agent_id)
|
||||
);
|
||||
let path = self.pending_dir().join(file_name);
|
||||
write_json_atomic(&path, &item)?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
pub fn pending_paths(&self) -> Result<Vec<PathBuf>> {
|
||||
read_json_paths(&self.pending_dir())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn dead_letter_paths(&self) -> Result<Vec<PathBuf>> {
|
||||
read_json_paths(&self.dead_letter_dir())
|
||||
}
|
||||
|
||||
pub fn metrics(&self) -> Result<AgentMetrics> {
|
||||
let paths = self.pending_paths()?;
|
||||
let spool_size = paths
|
||||
.iter()
|
||||
.filter_map(|path| fs::metadata(path).ok())
|
||||
.map(|metadata| metadata.len())
|
||||
.sum();
|
||||
Ok(AgentMetrics {
|
||||
queued_records: paths.len(),
|
||||
spool_size,
|
||||
..AgentMetrics::default()
|
||||
})
|
||||
}
|
||||
|
||||
pub fn process_pending<F>(&self, max_retry_count: u32, mut sender: F) -> Result<FlushSummary>
|
||||
where
|
||||
F: FnMut(&TelemetryEnvelope) -> Result<()>,
|
||||
{
|
||||
self.ensure_dirs()?;
|
||||
let mut summary = FlushSummary::default();
|
||||
for path in self.pending_paths()? {
|
||||
let bytes = fs::read(&path).with_context(|| format!("read {}", path.display()))?;
|
||||
let mut item = match serde_json::from_slice::<SpoolItem>(&bytes) {
|
||||
Ok(item) => item,
|
||||
Err(err) => {
|
||||
self.move_to_dead_letter(&path, Some(format!("corrupt json: {err}")))?;
|
||||
summary.corrupt += 1;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
match sender(&item.envelope) {
|
||||
Ok(()) => {
|
||||
fs::remove_file(&path)
|
||||
.with_context(|| format!("remove delivered {}", path.display()))?;
|
||||
summary.delivered += 1;
|
||||
}
|
||||
Err(err) => {
|
||||
item.retry_count = item.retry_count.saturating_add(1);
|
||||
item.last_error = Some(err.to_string());
|
||||
summary.retried += 1;
|
||||
if item.retry_count >= max_retry_count {
|
||||
write_json_atomic(&path, &item)?;
|
||||
self.move_to_dead_letter(&path, item.last_error.clone())?;
|
||||
summary.dead_lettered += 1;
|
||||
} else {
|
||||
write_json_atomic(&path, &item)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(summary)
|
||||
}
|
||||
|
||||
fn ensure_dirs(&self) -> Result<()> {
|
||||
fs::create_dir_all(self.pending_dir())
|
||||
.with_context(|| format!("create {}", self.pending_dir().display()))?;
|
||||
fs::create_dir_all(self.dead_letter_dir())
|
||||
.with_context(|| format!("create {}", self.dead_letter_dir().display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn pending_dir(&self) -> PathBuf {
|
||||
self.root.join("pending")
|
||||
}
|
||||
|
||||
fn dead_letter_dir(&self) -> PathBuf {
|
||||
self.root.join("dead-letter")
|
||||
}
|
||||
|
||||
fn move_to_dead_letter(&self, path: &Path, reason: Option<String>) -> Result<()> {
|
||||
self.ensure_dirs()?;
|
||||
let file_name = path
|
||||
.file_name()
|
||||
.map(|value| value.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| format!("{}.json", Utc::now().timestamp_millis()));
|
||||
let target = self.dead_letter_dir().join(file_name);
|
||||
if let Some(reason) = reason {
|
||||
let note_path = target.with_extension("reason.txt");
|
||||
fs::write(note_path, reason)?;
|
||||
}
|
||||
fs::rename(path, target).or_else(|_| {
|
||||
fs::copy(path, self.dead_letter_dir().join("recovered-corrupt.json"))?;
|
||||
fs::remove_file(path)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct FlushSummary {
|
||||
pub delivered: usize,
|
||||
pub retried: usize,
|
||||
pub dead_lettered: usize,
|
||||
pub corrupt: usize,
|
||||
}
|
||||
|
||||
fn read_json_paths(dir: &Path) -> Result<Vec<PathBuf>> {
|
||||
if !dir.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mut paths = fs::read_dir(dir)
|
||||
.with_context(|| format!("read {}", dir.display()))?
|
||||
.filter_map(|entry| entry.ok())
|
||||
.map(|entry| entry.path())
|
||||
.filter(|path| path.extension().is_some_and(|ext| ext == "json"))
|
||||
.collect::<Vec<_>>();
|
||||
paths.sort();
|
||||
Ok(paths)
|
||||
}
|
||||
|
||||
fn write_json_atomic<T: Serialize>(path: &Path, value: &T) -> Result<()> {
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
fs::write(&tmp, serde_json::to_vec_pretty(value)?)
|
||||
.with_context(|| format!("write {}", tmp.display()))?;
|
||||
fs::rename(&tmp, path)
|
||||
.with_context(|| format!("rename {} to {}", tmp.display(), path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sanitize_file_part(value: &str) -> String {
|
||||
value
|
||||
.chars()
|
||||
.map(|ch| {
|
||||
if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
|
||||
ch
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use anyhow::anyhow;
|
||||
use tempfile::tempdir;
|
||||
|
||||
use super::*;
|
||||
use crate::config::AgentConfig;
|
||||
|
||||
fn envelope() -> TelemetryEnvelope {
|
||||
TelemetryEnvelope::heartbeat(&AgentConfig::default())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enqueues_and_delivers_spool_item() {
|
||||
let dir = tempdir().unwrap();
|
||||
let spool = LocalSpool::new(dir.path());
|
||||
spool.enqueue(envelope()).unwrap();
|
||||
assert_eq!(spool.pending_paths().unwrap().len(), 1);
|
||||
|
||||
let summary = spool.process_pending(3, |_| Ok(())).unwrap();
|
||||
assert_eq!(summary.delivered, 1);
|
||||
assert_eq!(spool.pending_paths().unwrap().len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_keeps_item_until_max_retry_then_dead_letters() {
|
||||
let dir = tempdir().unwrap();
|
||||
let spool = LocalSpool::new(dir.path());
|
||||
spool.enqueue(envelope()).unwrap();
|
||||
|
||||
let first = spool
|
||||
.process_pending(2, |_| Err(anyhow!("transport down")))
|
||||
.unwrap();
|
||||
assert_eq!(first.retried, 1);
|
||||
assert_eq!(first.dead_lettered, 0);
|
||||
assert_eq!(spool.pending_paths().unwrap().len(), 1);
|
||||
|
||||
let second = spool
|
||||
.process_pending(2, |_| Err(anyhow!("transport down")))
|
||||
.unwrap();
|
||||
assert_eq!(second.dead_lettered, 1);
|
||||
assert_eq!(spool.pending_paths().unwrap().len(), 0);
|
||||
assert_eq!(spool.dead_letter_paths().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_spool_item_moves_to_dead_letter() {
|
||||
let dir = tempdir().unwrap();
|
||||
let spool = LocalSpool::new(dir.path());
|
||||
fs::create_dir_all(dir.path().join("pending")).unwrap();
|
||||
fs::write(dir.path().join("pending/bad.json"), b"{not-json").unwrap();
|
||||
|
||||
let summary = spool.process_pending(3, |_| Ok(())).unwrap();
|
||||
assert_eq!(summary.corrupt, 1);
|
||||
assert_eq!(spool.pending_paths().unwrap().len(), 0);
|
||||
assert_eq!(spool.dead_letter_paths().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metrics_report_queue_and_size() {
|
||||
let dir = tempdir().unwrap();
|
||||
let spool = LocalSpool::new(dir.path());
|
||||
spool.enqueue(envelope()).unwrap();
|
||||
let metrics = spool.metrics().unwrap();
|
||||
assert_eq!(metrics.queued_records, 1);
|
||||
assert!(metrics.spool_size > 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use reqwest::blocking::Client;
|
||||
|
||||
use crate::config::AgentConfig;
|
||||
use crate::envelope::TelemetryEnvelope;
|
||||
use crate::metrics::AgentMetrics;
|
||||
use crate::spool::{FlushSummary, LocalSpool};
|
||||
|
||||
pub fn send_envelope(config: &AgentConfig, envelope: &TelemetryEnvelope) -> Result<()> {
|
||||
let client = Client::builder()
|
||||
.timeout(config.request_timeout())
|
||||
.build()
|
||||
.context("build agent telemetry HTTP client")?;
|
||||
client
|
||||
.post(&config.server_url)
|
||||
.json(envelope)
|
||||
.send()
|
||||
.and_then(|response| response.error_for_status())
|
||||
.map(|_| ())
|
||||
.map_err(|err| anyhow!("agent telemetry POST failed: {err}"))
|
||||
}
|
||||
|
||||
pub fn flush_with_retry(
|
||||
config: &AgentConfig,
|
||||
spool: &LocalSpool,
|
||||
metrics: &mut AgentMetrics,
|
||||
) -> Result<FlushSummary> {
|
||||
let mut attempt = 0_u32;
|
||||
loop {
|
||||
let summary = spool.process_pending(config.retry_max_attempts, |envelope| {
|
||||
send_envelope(config, envelope)
|
||||
})?;
|
||||
metrics.retry_count = metrics
|
||||
.retry_count
|
||||
.saturating_add(u64::try_from(summary.retried).unwrap_or(u64::MAX));
|
||||
if summary.retried == 0 || attempt + 1 >= config.retry_max_attempts {
|
||||
return Ok(summary);
|
||||
}
|
||||
let backoff = exponential_backoff(config.retry_base_backoff_ms, attempt);
|
||||
thread::sleep(backoff);
|
||||
attempt += 1;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn exponential_backoff(base_ms: u64, attempt: u32) -> Duration {
|
||||
let factor = 1_u64.checked_shl(attempt.min(10)).unwrap_or(1024);
|
||||
Duration::from_millis(base_ms.saturating_mul(factor))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn backoff_is_exponential_and_bounded() {
|
||||
assert_eq!(exponential_backoff(100, 0), Duration::from_millis(100));
|
||||
assert_eq!(exponential_backoff(100, 3), Duration::from_millis(800));
|
||||
assert_eq!(exponential_backoff(100, 99), Duration::from_millis(102400));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "check-aw-data"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
chrono.workspace = true
|
||||
clap.workspace = true
|
||||
reqwest.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
@@ -0,0 +1,555 @@
|
||||
use std::process::Command;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use chrono::{DateTime, Utc};
|
||||
use clap::Parser;
|
||||
use reqwest::blocking::Client;
|
||||
use serde_json::Value;
|
||||
|
||||
const DEFAULT_SERVER: &str = "http://192.0.2.13:5600";
|
||||
const DEFAULT_HOST: &str = "HOST-EXAMPLE";
|
||||
const BUCKETS: &[&str] = &[
|
||||
"aw-dlp-endpoint-signals",
|
||||
"aw-dlp-incidents",
|
||||
"aw-dlp-review",
|
||||
"aw-dlp-rules",
|
||||
"aw-watcher-afk",
|
||||
"aw-watcher-window",
|
||||
"aw-session-events",
|
||||
"aw-worktime-sessions",
|
||||
];
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(about = "Check ActivityWatch data collection freshness for DetMir/AW-RUS")]
|
||||
struct Cli {
|
||||
#[arg(long)]
|
||||
server: Option<String>,
|
||||
|
||||
#[arg(long)]
|
||||
host: Option<String>,
|
||||
|
||||
#[arg(long, default_value_t = 15)]
|
||||
timeout_seconds: u64,
|
||||
|
||||
#[arg(long, default_value_t = 15)]
|
||||
bucket_timeout_seconds: u64,
|
||||
|
||||
#[arg(long, default_value_t = 3)]
|
||||
context_timeout_seconds: u64,
|
||||
|
||||
#[arg(long, default_value_t = false)]
|
||||
with_event_ids: bool,
|
||||
|
||||
#[arg(long, default_value_t = false)]
|
||||
no_color: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct Colors {
|
||||
red: &'static str,
|
||||
green: &'static str,
|
||||
yellow: &'static str,
|
||||
cyan: &'static str,
|
||||
reset: &'static str,
|
||||
}
|
||||
|
||||
impl Colors {
|
||||
fn new(enabled: bool) -> Self {
|
||||
if enabled {
|
||||
Self {
|
||||
red: "\u{1b}[0;31m",
|
||||
green: "\u{1b}[0;32m",
|
||||
yellow: "\u{1b}[1;33m",
|
||||
cyan: "\u{1b}[0;36m",
|
||||
reset: "\u{1b}[0m",
|
||||
}
|
||||
} else {
|
||||
Self {
|
||||
red: "",
|
||||
green: "",
|
||||
yellow: "",
|
||||
cyan: "",
|
||||
reset: "",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn paint(&self, color: &str, text: &str) -> String {
|
||||
format!("{color}{text}{}", self.reset)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct ContextState {
|
||||
host_inactive: bool,
|
||||
guard_healthy: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum BucketStatus {
|
||||
Fresh,
|
||||
Stale,
|
||||
Dead,
|
||||
Empty,
|
||||
EventDriven,
|
||||
Inactive,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let code = match run() {
|
||||
Ok(code) => code,
|
||||
Err(err) => {
|
||||
eprintln!("{err:#}");
|
||||
1
|
||||
}
|
||||
};
|
||||
std::process::exit(code);
|
||||
}
|
||||
|
||||
fn run() -> Result<i32> {
|
||||
let cli = Cli::parse();
|
||||
let server = cli
|
||||
.server
|
||||
.or_else(|| env_nonempty("AW_CHECK_SERVER"))
|
||||
.or_else(|| env_nonempty("AW_SERVER_URL"))
|
||||
.unwrap_or_else(|| DEFAULT_SERVER.to_string())
|
||||
.trim_end_matches('/')
|
||||
.to_string();
|
||||
let host = cli
|
||||
.host
|
||||
.or_else(|| env_nonempty("AW_CHECK_HOST"))
|
||||
.or_else(|| env_nonempty("AW_MONITORED_HOST"))
|
||||
.or_else(|| env_nonempty("AW_MONITORED_WINDOWS_HOSTNAME"))
|
||||
.unwrap_or_else(|| DEFAULT_HOST.to_string());
|
||||
let colors = Colors::new(!cli.no_color && env_nonempty("NO_COLOR").is_none());
|
||||
let client = Client::builder()
|
||||
.no_proxy()
|
||||
.build()
|
||||
.context("build HTTP client")?;
|
||||
let now = Utc::now();
|
||||
|
||||
println!("=== ActivityWatch Data Check: {host} ===");
|
||||
println!();
|
||||
print!("Server connectivity... ");
|
||||
let info_url = format!("{server}/api/0/info");
|
||||
let info = match get_json(&client, &info_url, cli.timeout_seconds) {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
eprintln!("check-aw-data: {err:#}");
|
||||
println!(
|
||||
"{} (cannot reach {server})",
|
||||
colors.paint(colors.red, "FAILED")
|
||||
);
|
||||
return Ok(1);
|
||||
}
|
||||
};
|
||||
let Some(version) = info.get("version").and_then(Value::as_str) else {
|
||||
println!(
|
||||
"{} (cannot reach {server})",
|
||||
colors.paint(colors.red, "FAILED")
|
||||
);
|
||||
return Ok(1);
|
||||
};
|
||||
println!("{} (aw-server {version})", colors.paint(colors.green, "OK"));
|
||||
println!();
|
||||
|
||||
let context = read_context(&server, &host, now, cli.context_timeout_seconds);
|
||||
let buckets_index = get_json(
|
||||
&client,
|
||||
&format!("{server}/api/0/buckets"),
|
||||
cli.timeout_seconds,
|
||||
)
|
||||
.ok();
|
||||
println!("--- Buckets ---");
|
||||
println!(
|
||||
"{:<45} {:<8} {:<22} STATUS",
|
||||
"BUCKET", "EVENTS", "LAST EVENT"
|
||||
);
|
||||
println!(
|
||||
"{:<45} {:<8} {:<22} ------",
|
||||
"---------------------------------------------", "--------", "----------------------"
|
||||
);
|
||||
|
||||
for bucket in BUCKETS {
|
||||
let bucket_full = format!("{bucket}_{host}");
|
||||
let event = bucket_event(
|
||||
&server,
|
||||
&bucket_full,
|
||||
buckets_index.as_ref(),
|
||||
cli.with_event_ids,
|
||||
cli.bucket_timeout_seconds,
|
||||
);
|
||||
let (last_id, last_ts, age, status) = render_bucket(bucket, event.as_ref(), now, &context);
|
||||
println!(
|
||||
"{:<45} {:<8} {:<22} {}",
|
||||
bucket_full,
|
||||
last_id,
|
||||
format!("{last_ts} ({age})"),
|
||||
render_status(&colors, status)
|
||||
);
|
||||
}
|
||||
|
||||
println!();
|
||||
println!("--- CORS Check ---");
|
||||
let cors_status = check_cors(&client, &server);
|
||||
if cors_status == 200 {
|
||||
println!("{} (HTTP 200)", colors.paint(colors.green, "CORS: OK"));
|
||||
} else {
|
||||
println!(
|
||||
"{} (HTTP {cors_status})",
|
||||
colors.paint(colors.red, "CORS: FAIL")
|
||||
);
|
||||
}
|
||||
|
||||
println!();
|
||||
println!("=== Check Complete ===");
|
||||
println!("Timestamp: {}", now.format("%Y-%m-%d %H:%M:%S UTC"));
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
fn read_context(
|
||||
server: &str,
|
||||
host: &str,
|
||||
now: DateTime<Utc>,
|
||||
timeout_seconds: u64,
|
||||
) -> ContextState {
|
||||
let mut state = ContextState::default();
|
||||
if let Ok(Some(event)) = get_latest_event(
|
||||
server,
|
||||
&format!("aw-worktime-sessions_{host}"),
|
||||
timeout_seconds,
|
||||
) {
|
||||
if let Some(ts) = event_timestamp(&event) {
|
||||
let age = (now - ts).num_seconds();
|
||||
let active = event
|
||||
.pointer("/data/active")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
if (0..900).contains(&age) && !active {
|
||||
state.host_inactive = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Ok(Some(event)) = get_latest_event(
|
||||
server,
|
||||
&format!("aw-rus-collector-guard_{host}"),
|
||||
timeout_seconds,
|
||||
) {
|
||||
if let Some(ts) = event_timestamp(&event) {
|
||||
let age = (now - ts).num_seconds();
|
||||
let status = event
|
||||
.pointer("/data/status")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("");
|
||||
let problems = event
|
||||
.pointer("/data/problems")
|
||||
.and_then(Value::as_array)
|
||||
.map(Vec::len)
|
||||
.unwrap_or(0);
|
||||
if (0..300).contains(&age) && status == "ok" && problems == 0 {
|
||||
state.guard_healthy = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
state
|
||||
}
|
||||
|
||||
fn bucket_event(
|
||||
server: &str,
|
||||
bucket: &str,
|
||||
buckets_index: Option<&Value>,
|
||||
with_event_ids: bool,
|
||||
timeout_seconds: u64,
|
||||
) -> Option<Value> {
|
||||
if with_event_ids {
|
||||
get_latest_event(server, bucket, timeout_seconds)
|
||||
.ok()
|
||||
.flatten()
|
||||
.or_else(|| metadata_event(buckets_index, bucket))
|
||||
} else {
|
||||
metadata_event(buckets_index, bucket)
|
||||
}
|
||||
}
|
||||
|
||||
fn render_bucket(
|
||||
bucket: &str,
|
||||
event: Option<&Value>,
|
||||
now: DateTime<Utc>,
|
||||
context: &ContextState,
|
||||
) -> (String, String, String, BucketStatus) {
|
||||
let Some(event) = event else {
|
||||
return (
|
||||
"0".to_string(),
|
||||
"no events".to_string(),
|
||||
"none".to_string(),
|
||||
classify_bucket_no_events(bucket, context),
|
||||
);
|
||||
};
|
||||
let last_id = event
|
||||
.get("id")
|
||||
.map(json_value_to_string)
|
||||
.unwrap_or_else(|| "0".to_string());
|
||||
let Some(ts_raw) = event.get("timestamp").and_then(Value::as_str) else {
|
||||
return (
|
||||
last_id,
|
||||
"no events".to_string(),
|
||||
"none".to_string(),
|
||||
classify_bucket_no_events(bucket, context),
|
||||
);
|
||||
};
|
||||
let Some(ts) = parse_ts(ts_raw) else {
|
||||
return (
|
||||
last_id,
|
||||
ts_raw.to_string(),
|
||||
"unknown".to_string(),
|
||||
BucketStatus::Unknown,
|
||||
);
|
||||
};
|
||||
let age = (now - ts).num_seconds().max(0);
|
||||
(
|
||||
last_id,
|
||||
ts_raw.to_string(),
|
||||
format_age(age),
|
||||
classify_bucket_age(bucket, age, context),
|
||||
)
|
||||
}
|
||||
|
||||
fn classify_bucket_age(bucket: &str, age_sec: i64, context: &ContextState) -> BucketStatus {
|
||||
match bucket {
|
||||
"aw-watcher-window" if context.host_inactive => return BucketStatus::Inactive,
|
||||
"aw-dlp-endpoint-signals" if context.host_inactive && context.guard_healthy => {
|
||||
return BucketStatus::Inactive;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
match bucket {
|
||||
"aw-dlp-incidents" | "aw-dlp-review" | "aw-dlp-rules" | "aw-session-events" => {
|
||||
if age_sec < 86_400 {
|
||||
BucketStatus::Fresh
|
||||
} else {
|
||||
BucketStatus::EventDriven
|
||||
}
|
||||
}
|
||||
_ if age_sec < 3_600 => BucketStatus::Fresh,
|
||||
_ if age_sec < 86_400 => BucketStatus::Stale,
|
||||
_ => BucketStatus::Dead,
|
||||
}
|
||||
}
|
||||
|
||||
fn classify_bucket_no_events(bucket: &str, context: &ContextState) -> BucketStatus {
|
||||
match bucket {
|
||||
"aw-watcher-window" if context.host_inactive => BucketStatus::Inactive,
|
||||
"aw-dlp-endpoint-signals" if context.host_inactive && context.guard_healthy => {
|
||||
BucketStatus::Inactive
|
||||
}
|
||||
"aw-dlp-incidents" | "aw-dlp-review" | "aw-dlp-rules" | "aw-session-events" => {
|
||||
BucketStatus::EventDriven
|
||||
}
|
||||
_ => BucketStatus::Empty,
|
||||
}
|
||||
}
|
||||
|
||||
fn render_status(colors: &Colors, status: BucketStatus) -> String {
|
||||
match status {
|
||||
BucketStatus::Fresh => colors.paint(colors.green, "FRESH"),
|
||||
BucketStatus::Stale => colors.paint(colors.yellow, "STALE"),
|
||||
BucketStatus::Dead => colors.paint(colors.red, "DEAD"),
|
||||
BucketStatus::Empty => colors.paint(colors.red, "EMPTY"),
|
||||
BucketStatus::EventDriven => colors.paint(colors.cyan, "EVENT-DRIVEN"),
|
||||
BucketStatus::Inactive => colors.paint(colors.cyan, "INACTIVE"),
|
||||
BucketStatus::Unknown => colors.paint(colors.red, "?"),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_latest_event(server: &str, bucket: &str, timeout_seconds: u64) -> Result<Option<Value>> {
|
||||
let url = format!("{server}/api/0/buckets/{bucket}/events?limit=1");
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::from_secs(timeout_seconds.max(1)))
|
||||
.no_proxy()
|
||||
.pool_max_idle_per_host(0)
|
||||
.build()
|
||||
.context("build timed HTTP client")?;
|
||||
let value = client
|
||||
.get(&url)
|
||||
.header("Connection", "close")
|
||||
.send()
|
||||
.with_context(|| format!("GET {url}"))?
|
||||
.error_for_status()
|
||||
.with_context(|| format!("GET {url} status"))?
|
||||
.json::<Value>()
|
||||
.with_context(|| format!("decode JSON from {url}"))?;
|
||||
Ok(value.as_array().and_then(|items| items.first()).cloned())
|
||||
}
|
||||
|
||||
fn metadata_event(buckets_index: Option<&Value>, bucket: &str) -> Option<Value> {
|
||||
let bucket_info = buckets_index?.get(bucket)?;
|
||||
let timestamp = bucket_info
|
||||
.pointer("/metadata/end")
|
||||
.or_else(|| bucket_info.get("end"))
|
||||
.and_then(Value::as_str)?;
|
||||
Some(serde_json::json!({
|
||||
"id": 0,
|
||||
"timestamp": timestamp,
|
||||
"data": {},
|
||||
"_source": "bucket_metadata",
|
||||
}))
|
||||
}
|
||||
|
||||
fn get_json(client: &Client, url: &str, _timeout_seconds: u64) -> Result<Value> {
|
||||
client
|
||||
.get(url)
|
||||
.send()
|
||||
.with_context(|| format!("GET {url}"))?
|
||||
.error_for_status()
|
||||
.with_context(|| format!("GET {url} status"))?
|
||||
.json::<Value>()
|
||||
.with_context(|| format!("decode JSON from {url}"))
|
||||
}
|
||||
|
||||
fn check_cors(_client: &Client, server: &str) -> u16 {
|
||||
let origin = "http://192.0.2.13:5600";
|
||||
let first = curl_status(&format!("{server}/api/0/settings/"), origin);
|
||||
if first == 200 {
|
||||
return first;
|
||||
}
|
||||
if !server.contains("127.0.0.1") && !server.contains("localhost") {
|
||||
let fallback = curl_status("http://127.0.0.1:5600/api/0/settings/", origin);
|
||||
if fallback != 0 {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
first
|
||||
}
|
||||
|
||||
fn curl_status(url: &str, origin: &str) -> u16 {
|
||||
let origin_header = format!("Origin: {origin}");
|
||||
let args = [
|
||||
"-s",
|
||||
"--connect-timeout",
|
||||
"3",
|
||||
"--max-time",
|
||||
"5",
|
||||
"-o",
|
||||
"/dev/null",
|
||||
"-w",
|
||||
"%{http_code}",
|
||||
"-H",
|
||||
origin_header.as_str(),
|
||||
url,
|
||||
];
|
||||
let output = Command::new("/usr/bin/curl")
|
||||
.args(args)
|
||||
.output()
|
||||
.or_else(|_| Command::new("curl").args(args).output());
|
||||
let Ok(output) = output else {
|
||||
return 0;
|
||||
};
|
||||
if !output.status.success() {
|
||||
return 0;
|
||||
}
|
||||
String::from_utf8_lossy(&output.stdout)
|
||||
.trim()
|
||||
.parse::<u16>()
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn event_timestamp(event: &Value) -> Option<DateTime<Utc>> {
|
||||
event
|
||||
.get("timestamp")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(parse_ts)
|
||||
}
|
||||
|
||||
fn parse_ts(value: &str) -> Option<DateTime<Utc>> {
|
||||
DateTime::parse_from_rfc3339(&value.replace('Z', "+00:00"))
|
||||
.map(|ts| ts.with_timezone(&Utc))
|
||||
.ok()
|
||||
}
|
||||
|
||||
fn format_age(age_sec: i64) -> String {
|
||||
if age_sec < 3_600 {
|
||||
format!("{}m ago", age_sec / 60)
|
||||
} else if age_sec < 86_400 {
|
||||
format!("{}h ago", age_sec / 3_600)
|
||||
} else {
|
||||
format!("{}d ago", age_sec / 86_400)
|
||||
}
|
||||
}
|
||||
|
||||
fn json_value_to_string(value: &Value) -> String {
|
||||
match value {
|
||||
Value::String(value) => value.clone(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn env_nonempty(name: &str) -> Option<String> {
|
||||
std::env::var(name)
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn event_driven_buckets_do_not_become_dead_when_old() {
|
||||
let context = ContextState::default();
|
||||
assert_eq!(
|
||||
classify_bucket_age("aw-dlp-incidents", 100_000, &context),
|
||||
BucketStatus::EventDriven
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inactive_host_suppresses_window_stale() {
|
||||
let context = ContextState {
|
||||
host_inactive: true,
|
||||
guard_healthy: true,
|
||||
};
|
||||
assert_eq!(
|
||||
classify_bucket_age("aw-watcher-window", 100_000, &context),
|
||||
BucketStatus::Inactive
|
||||
);
|
||||
assert_eq!(
|
||||
classify_bucket_no_events("aw-dlp-endpoint-signals", &context),
|
||||
BucketStatus::Inactive
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formats_age_like_legacy_script() {
|
||||
assert_eq!(format_age(59), "0m ago");
|
||||
assert_eq!(format_age(3_600), "1h ago");
|
||||
assert_eq!(format_age(86_400), "1d ago");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_only_bucket_event_skips_missing_deep_event_read() {
|
||||
let index = serde_json::json!({
|
||||
"aw-watcher-window_HOST-EXAMPLE": {
|
||||
"metadata": {
|
||||
"end": "2026-06-02T00:00:00Z"
|
||||
}
|
||||
}
|
||||
});
|
||||
let event = bucket_event(
|
||||
"http://127.0.0.1:1",
|
||||
"aw-watcher-window_HOST-EXAMPLE",
|
||||
Some(&index),
|
||||
false,
|
||||
1,
|
||||
)
|
||||
.expect("metadata event");
|
||||
assert_eq!(
|
||||
event.get("timestamp").and_then(Value::as_str),
|
||||
Some("2026-06-02T00:00:00Z")
|
||||
);
|
||||
assert_eq!(event.get("id").and_then(Value::as_i64), Some(0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "check-aw-full"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
chrono.workspace = true
|
||||
clap.workspace = true
|
||||
reqwest.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
@@ -0,0 +1,537 @@
|
||||
use std::net::{TcpStream, ToSocketAddrs};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use chrono::{DateTime, Utc};
|
||||
use clap::Parser;
|
||||
use reqwest::blocking::Client;
|
||||
use serde_json::Value;
|
||||
|
||||
const DEFAULT_SERVER: &str = "http://192.0.2.13:5600";
|
||||
const DEFAULT_HOST: &str = "HOST-EXAMPLE";
|
||||
const DEFAULT_RDP_HOST: &str = "198.51.100.18";
|
||||
const BUCKETS: &[(&str, &str)] = &[
|
||||
("aw-watcher-afk", "AFK watcher"),
|
||||
("aw-watcher-window", "Window watcher"),
|
||||
("aw-worktime-sessions", "Worktime sessions"),
|
||||
("aw-session-events", "Session events"),
|
||||
("aw-dlp-endpoint-signals", "DLP signals"),
|
||||
("aw-dlp-incidents", "DLP incidents"),
|
||||
("aw-dlp-review", "DLP review"),
|
||||
("aw-dlp-rules", "DLP rules"),
|
||||
];
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(about = "Full read-only ActivityWatch check for server, buckets, and RDP host")]
|
||||
struct Cli {
|
||||
#[arg(long, default_value = DEFAULT_SERVER)]
|
||||
server: String,
|
||||
|
||||
#[arg(long, default_value = DEFAULT_HOST)]
|
||||
host: String,
|
||||
|
||||
#[arg(long, default_value = DEFAULT_RDP_HOST)]
|
||||
rdp_host: String,
|
||||
|
||||
#[arg(long, default_value_t = 15)]
|
||||
timeout_seconds: u64,
|
||||
|
||||
#[arg(long)]
|
||||
no_color: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct Colors {
|
||||
red: &'static str,
|
||||
green: &'static str,
|
||||
yellow: &'static str,
|
||||
cyan: &'static str,
|
||||
reset: &'static str,
|
||||
}
|
||||
|
||||
impl Colors {
|
||||
fn new(enabled: bool) -> Self {
|
||||
if enabled {
|
||||
Self {
|
||||
red: "\u{1b}[0;31m",
|
||||
green: "\u{1b}[0;32m",
|
||||
yellow: "\u{1b}[1;33m",
|
||||
cyan: "\u{1b}[0;36m",
|
||||
reset: "\u{1b}[0m",
|
||||
}
|
||||
} else {
|
||||
Self {
|
||||
red: "",
|
||||
green: "",
|
||||
yellow: "",
|
||||
cyan: "",
|
||||
reset: "",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn paint(&self, color: &str, text: &str) -> String {
|
||||
format!("{color}{text}{}", self.reset)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct ContextState {
|
||||
host_inactive: bool,
|
||||
guard_healthy: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum BucketStatus {
|
||||
Fresh,
|
||||
Stale,
|
||||
Dead,
|
||||
Empty,
|
||||
EventDriven,
|
||||
Inactive,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct BucketRow {
|
||||
label: &'static str,
|
||||
last_id: String,
|
||||
age: String,
|
||||
status: BucketStatus,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct Summary {
|
||||
fresh: usize,
|
||||
stale: usize,
|
||||
dead: usize,
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let code = match run() {
|
||||
Ok(code) => code,
|
||||
Err(err) => {
|
||||
eprintln!("{err:#}");
|
||||
1
|
||||
}
|
||||
};
|
||||
std::process::exit(code);
|
||||
}
|
||||
|
||||
fn run() -> Result<i32> {
|
||||
let cli = Cli::parse();
|
||||
let server = cli.server.trim_end_matches('/').to_string();
|
||||
let colors = Colors::new(!cli.no_color && std::env::var_os("NO_COLOR").is_none());
|
||||
let timeout = Duration::from_secs(cli.timeout_seconds.max(1));
|
||||
let client = Client::builder()
|
||||
.timeout(timeout)
|
||||
.no_proxy()
|
||||
.pool_max_idle_per_host(0)
|
||||
.build()
|
||||
.context("build HTTP client")?;
|
||||
let now = Utc::now();
|
||||
|
||||
println!(
|
||||
"{}",
|
||||
colors.paint(
|
||||
colors.cyan,
|
||||
&format!("=== ActivityWatch Full Check: {} ===", cli.host)
|
||||
)
|
||||
);
|
||||
println!();
|
||||
|
||||
println!(
|
||||
"{}",
|
||||
colors.paint(colors.cyan, &format!("--- 1. AW Server ({server}) ---"))
|
||||
);
|
||||
print!(" Connectivity... ");
|
||||
let info = match get_json(&client, &format!("{server}/api/0/info")) {
|
||||
Ok(value) => value,
|
||||
Err(_) => {
|
||||
println!(" {}", colors.paint(colors.red, "FAILED"));
|
||||
return Ok(1);
|
||||
}
|
||||
};
|
||||
let Some(version) = info.get("version").and_then(Value::as_str) else {
|
||||
println!(" {}", colors.paint(colors.red, "FAILED"));
|
||||
return Ok(1);
|
||||
};
|
||||
println!(
|
||||
" {} (aw-server {version})",
|
||||
colors.paint(colors.green, "OK")
|
||||
);
|
||||
|
||||
print!(" CORS... ");
|
||||
let cors_code = check_cors(&client, &server);
|
||||
if cors_code == 200 {
|
||||
println!("{}", colors.paint(colors.green, "OK"));
|
||||
} else {
|
||||
println!("{} (HTTP {cors_code})", colors.paint(colors.red, "FAIL"));
|
||||
}
|
||||
println!();
|
||||
|
||||
let context = read_context(&client, &server, &cli.host, now);
|
||||
println!("{}", colors.paint(colors.cyan, "--- 2. Data Buckets ---"));
|
||||
println!(
|
||||
" {:<42} {:<8} {:<20} STATUS",
|
||||
"BUCKET", "EVENTS", "LAST EVENT"
|
||||
);
|
||||
println!(
|
||||
" {:<42} {:<8} {:<20} ------",
|
||||
"------------------------------------------", "--------", "--------------------"
|
||||
);
|
||||
|
||||
let mut rows = Vec::new();
|
||||
for (bucket, label) in BUCKETS {
|
||||
let row = read_bucket_row(&client, &server, &cli.host, bucket, label, now, &context);
|
||||
println!(
|
||||
" {:<42} {:<8} {:<20} {}",
|
||||
row.label,
|
||||
row.last_id,
|
||||
row.age,
|
||||
render_status(&colors, row.status)
|
||||
);
|
||||
rows.push(row);
|
||||
}
|
||||
println!();
|
||||
|
||||
println!(
|
||||
"{}",
|
||||
colors.paint(
|
||||
colors.cyan,
|
||||
&format!("--- 3. RDP Host ({}) ---", cli.rdp_host)
|
||||
)
|
||||
);
|
||||
print!(" WinRM (5985)... ");
|
||||
if tcp_open(&cli.rdp_host, 5985, Duration::from_secs(5)) {
|
||||
println!("{}", colors.paint(colors.green, "OK"));
|
||||
} else {
|
||||
println!("{}", colors.paint(colors.red, "UNREACHABLE"));
|
||||
}
|
||||
print!(" SSH (22)... ");
|
||||
if tcp_open(&cli.rdp_host, 22, Duration::from_secs(5)) {
|
||||
println!("{}", colors.paint(colors.green, "OK"));
|
||||
} else {
|
||||
println!(
|
||||
"{} (normal for Windows)",
|
||||
colors.paint(colors.yellow, "CLOSED")
|
||||
);
|
||||
}
|
||||
println!();
|
||||
|
||||
let summary = summarize(&rows);
|
||||
println!("{}", colors.paint(colors.cyan, "--- 4. Summary ---"));
|
||||
println!(
|
||||
" FRESH: {}",
|
||||
colors.paint(colors.green, &summary.fresh.to_string())
|
||||
);
|
||||
println!(
|
||||
" STALE: {}",
|
||||
colors.paint(colors.yellow, &summary.stale.to_string())
|
||||
);
|
||||
println!(
|
||||
" DEAD: {}",
|
||||
colors.paint(colors.red, &summary.dead.to_string())
|
||||
);
|
||||
|
||||
if summary.dead > 0 || summary.stale > 0 {
|
||||
println!();
|
||||
println!(
|
||||
" {} Some collectors may need restart on RDP host",
|
||||
colors.paint(colors.red, "WARNING:")
|
||||
);
|
||||
println!(
|
||||
" Run: {}",
|
||||
colors.paint(
|
||||
colors.cyan,
|
||||
"ansible -i ansible/inventory.ini rdp-prod -m win_shell -a 'schtasks /Run /TN \"ActivityWatch Recovery\"'"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
println!();
|
||||
println!("{}", colors.paint(colors.cyan, "=== Check Complete ==="));
|
||||
println!(" Timestamp: {}", now.format("%Y-%m-%d %H:%M:%S UTC"));
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
fn read_context(client: &Client, server: &str, host: &str, now: DateTime<Utc>) -> ContextState {
|
||||
let mut state = ContextState::default();
|
||||
if let Ok(Some(event)) = latest_event(client, server, &format!("aw-worktime-sessions_{host}")) {
|
||||
if let Some(ts) = event_timestamp(&event) {
|
||||
let age = (now - ts).num_seconds();
|
||||
let active = event
|
||||
.pointer("/data/active")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
if (0..900).contains(&age) && !active {
|
||||
state.host_inactive = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Ok(Some(event)) = latest_event(client, server, &format!("aw-rus-collector-guard_{host}"))
|
||||
{
|
||||
if let Some(ts) = event_timestamp(&event) {
|
||||
let age = (now - ts).num_seconds();
|
||||
let status = event
|
||||
.pointer("/data/status")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("");
|
||||
let problems = event
|
||||
.pointer("/data/problems")
|
||||
.and_then(Value::as_array)
|
||||
.map(Vec::len)
|
||||
.unwrap_or(0);
|
||||
if (0..300).contains(&age) && status == "ok" && problems == 0 {
|
||||
state.guard_healthy = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
state
|
||||
}
|
||||
|
||||
fn read_bucket_row(
|
||||
client: &Client,
|
||||
server: &str,
|
||||
host: &str,
|
||||
bucket: &str,
|
||||
label: &'static str,
|
||||
now: DateTime<Utc>,
|
||||
context: &ContextState,
|
||||
) -> BucketRow {
|
||||
let bucket_full = format!("{bucket}_{host}");
|
||||
let event = latest_event(client, server, &bucket_full).ok().flatten();
|
||||
let Some(event) = event else {
|
||||
return BucketRow {
|
||||
label,
|
||||
last_id: "0".to_string(),
|
||||
age: "none".to_string(),
|
||||
status: classify_bucket_no_events(bucket, context),
|
||||
};
|
||||
};
|
||||
|
||||
let last_id = event
|
||||
.get("id")
|
||||
.map(json_value_to_string)
|
||||
.unwrap_or_else(|| "0".to_string());
|
||||
let Some(ts_raw) = event.get("timestamp").and_then(Value::as_str) else {
|
||||
return BucketRow {
|
||||
label,
|
||||
last_id,
|
||||
age: "?".to_string(),
|
||||
status: BucketStatus::Unknown,
|
||||
};
|
||||
};
|
||||
let Some(ts) = parse_ts(ts_raw) else {
|
||||
return BucketRow {
|
||||
label,
|
||||
last_id,
|
||||
age: "?".to_string(),
|
||||
status: BucketStatus::Unknown,
|
||||
};
|
||||
};
|
||||
let age_sec = (now - ts).num_seconds().max(0);
|
||||
BucketRow {
|
||||
label,
|
||||
last_id,
|
||||
age: format_age(age_sec),
|
||||
status: classify_bucket_age(bucket, age_sec, context),
|
||||
}
|
||||
}
|
||||
|
||||
fn classify_bucket_age(bucket: &str, age_sec: i64, context: &ContextState) -> BucketStatus {
|
||||
match bucket {
|
||||
"aw-watcher-window" if context.host_inactive => return BucketStatus::Inactive,
|
||||
"aw-dlp-endpoint-signals" if context.host_inactive && context.guard_healthy => {
|
||||
return BucketStatus::Inactive;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
match bucket {
|
||||
"aw-dlp-incidents" | "aw-dlp-review" | "aw-dlp-rules" | "aw-session-events" => {
|
||||
if age_sec < 86_400 {
|
||||
BucketStatus::Fresh
|
||||
} else {
|
||||
BucketStatus::EventDriven
|
||||
}
|
||||
}
|
||||
_ if age_sec < 3_600 => BucketStatus::Fresh,
|
||||
_ if age_sec < 86_400 => BucketStatus::Stale,
|
||||
_ => BucketStatus::Dead,
|
||||
}
|
||||
}
|
||||
|
||||
fn classify_bucket_no_events(bucket: &str, context: &ContextState) -> BucketStatus {
|
||||
match bucket {
|
||||
"aw-watcher-window" if context.host_inactive => BucketStatus::Inactive,
|
||||
"aw-dlp-endpoint-signals" if context.host_inactive && context.guard_healthy => {
|
||||
BucketStatus::Inactive
|
||||
}
|
||||
"aw-dlp-incidents" | "aw-dlp-review" | "aw-dlp-rules" | "aw-session-events" => {
|
||||
BucketStatus::EventDriven
|
||||
}
|
||||
_ => BucketStatus::Empty,
|
||||
}
|
||||
}
|
||||
|
||||
fn summarize(rows: &[BucketRow]) -> Summary {
|
||||
let mut summary = Summary::default();
|
||||
for row in rows {
|
||||
match row.status {
|
||||
BucketStatus::Fresh | BucketStatus::EventDriven | BucketStatus::Inactive => {
|
||||
summary.fresh += 1
|
||||
}
|
||||
BucketStatus::Stale => summary.stale += 1,
|
||||
BucketStatus::Dead | BucketStatus::Empty | BucketStatus::Unknown => summary.dead += 1,
|
||||
}
|
||||
}
|
||||
summary
|
||||
}
|
||||
|
||||
fn render_status(colors: &Colors, status: BucketStatus) -> String {
|
||||
match status {
|
||||
BucketStatus::Fresh => colors.paint(colors.green, "FRESH"),
|
||||
BucketStatus::Stale => colors.paint(colors.yellow, "STALE"),
|
||||
BucketStatus::Dead => colors.paint(colors.red, "DEAD"),
|
||||
BucketStatus::Empty => colors.paint(colors.red, "EMPTY"),
|
||||
BucketStatus::EventDriven => colors.paint(colors.cyan, "EVENT-DRIVEN"),
|
||||
BucketStatus::Inactive => colors.paint(colors.cyan, "INACTIVE"),
|
||||
BucketStatus::Unknown => colors.paint(colors.red, "?"),
|
||||
}
|
||||
}
|
||||
|
||||
fn latest_event(client: &Client, server: &str, bucket: &str) -> Result<Option<Value>> {
|
||||
let url = format!("{server}/api/0/buckets/{bucket}/events?limit=1");
|
||||
let value = get_json(client, &url)?;
|
||||
Ok(value.as_array().and_then(|items| items.first()).cloned())
|
||||
}
|
||||
|
||||
fn get_json(client: &Client, url: &str) -> Result<Value> {
|
||||
client
|
||||
.get(url)
|
||||
.header("Connection", "close")
|
||||
.send()
|
||||
.with_context(|| format!("GET {url}"))?
|
||||
.error_for_status()
|
||||
.with_context(|| format!("GET {url} status"))?
|
||||
.json::<Value>()
|
||||
.with_context(|| format!("decode JSON from {url}"))
|
||||
}
|
||||
|
||||
fn check_cors(client: &Client, server: &str) -> u16 {
|
||||
let url = format!("{server}/api/0/settings/");
|
||||
client
|
||||
.get(&url)
|
||||
.header("Origin", "http://192.0.2.13:5600")
|
||||
.send()
|
||||
.map(|response| response.status().as_u16())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn tcp_open(host: &str, port: u16, timeout: Duration) -> bool {
|
||||
let Ok(mut addrs) = (host, port).to_socket_addrs() else {
|
||||
return false;
|
||||
};
|
||||
addrs.any(|addr| TcpStream::connect_timeout(&addr, timeout).is_ok())
|
||||
}
|
||||
|
||||
fn event_timestamp(event: &Value) -> Option<DateTime<Utc>> {
|
||||
event
|
||||
.get("timestamp")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(parse_ts)
|
||||
}
|
||||
|
||||
fn parse_ts(value: &str) -> Option<DateTime<Utc>> {
|
||||
DateTime::parse_from_rfc3339(&value.replace('Z', "+00:00"))
|
||||
.map(|ts| ts.with_timezone(&Utc))
|
||||
.ok()
|
||||
}
|
||||
|
||||
fn format_age(age_sec: i64) -> String {
|
||||
if age_sec < 3_600 {
|
||||
format!("{}m", age_sec / 60)
|
||||
} else if age_sec < 86_400 {
|
||||
format!("{}h", age_sec / 3_600)
|
||||
} else {
|
||||
format!("{}d", age_sec / 86_400)
|
||||
}
|
||||
}
|
||||
|
||||
fn json_value_to_string(value: &Value) -> String {
|
||||
match value {
|
||||
Value::String(value) => value.clone(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn event_driven_bucket_is_not_dead_when_old() {
|
||||
assert_eq!(
|
||||
classify_bucket_age("aw-session-events", 100_000, &ContextState::default()),
|
||||
BucketStatus::EventDriven
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inactive_context_suppresses_expected_idle_buckets() {
|
||||
let context = ContextState {
|
||||
host_inactive: true,
|
||||
guard_healthy: true,
|
||||
};
|
||||
assert_eq!(
|
||||
classify_bucket_age("aw-watcher-window", 100_000, &context),
|
||||
BucketStatus::Inactive
|
||||
);
|
||||
assert_eq!(
|
||||
classify_bucket_no_events("aw-dlp-endpoint-signals", &context),
|
||||
BucketStatus::Inactive
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summary_treats_event_and_inactive_as_fresh_class() {
|
||||
let rows = vec![
|
||||
BucketRow {
|
||||
label: "a",
|
||||
last_id: "0".to_string(),
|
||||
age: "1m".to_string(),
|
||||
status: BucketStatus::Fresh,
|
||||
},
|
||||
BucketRow {
|
||||
label: "b",
|
||||
last_id: "0".to_string(),
|
||||
age: "none".to_string(),
|
||||
status: BucketStatus::EventDriven,
|
||||
},
|
||||
BucketRow {
|
||||
label: "c",
|
||||
last_id: "0".to_string(),
|
||||
age: "none".to_string(),
|
||||
status: BucketStatus::Inactive,
|
||||
},
|
||||
BucketRow {
|
||||
label: "d",
|
||||
last_id: "0".to_string(),
|
||||
age: "none".to_string(),
|
||||
status: BucketStatus::Empty,
|
||||
},
|
||||
];
|
||||
let summary = summarize(&rows);
|
||||
assert_eq!(summary.fresh, 3);
|
||||
assert_eq!(summary.stale, 0);
|
||||
assert_eq!(summary.dead, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formats_age_like_legacy_full_check() {
|
||||
assert_eq!(format_age(59), "0m");
|
||||
assert_eq!(format_age(3_600), "1h");
|
||||
assert_eq!(format_age(86_400), "1d");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "check-install-kit-vs-repo"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
clap.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
sha2.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
@@ -0,0 +1,284 @@
|
||||
use std::collections::BTreeSet;
|
||||
use std::fs::{self, File};
|
||||
use std::io::{BufReader, Read};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use clap::Parser;
|
||||
use serde::Serialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
const DEFAULT_KIT_DIR: &str = "install-kit-awindows-20260427-211240";
|
||||
const MANIFEST_NAME: &str = "MANIFEST.txt";
|
||||
const ALLOWED_KIT_ONLY_FILES: &[&str] =
|
||||
&["README-INSTALL-KIT.txt", "windows/aw-windows-telemetry.exe"];
|
||||
const ALLOWED_KIT_ONLY_PREFIXES: &[&str] = &["server-configs-"];
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(about = "Compare ActivityWatch-Russian install-kit contents against the repository")]
|
||||
struct Cli {
|
||||
#[arg(long, default_value = ".")]
|
||||
root: PathBuf,
|
||||
|
||||
#[arg(long, default_value = DEFAULT_KIT_DIR)]
|
||||
kit_dir: PathBuf,
|
||||
|
||||
#[arg(long, default_value_t = false)]
|
||||
json: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize)]
|
||||
struct Report {
|
||||
compared_files: usize,
|
||||
missing_in_repo: Vec<String>,
|
||||
mismatches: Vec<String>,
|
||||
powershell_mismatches: Vec<String>,
|
||||
}
|
||||
|
||||
impl Report {
|
||||
fn is_ok(&self) -> bool {
|
||||
self.missing_in_repo.is_empty() && self.mismatches.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let code = match run() {
|
||||
Ok(code) => code,
|
||||
Err(err) => {
|
||||
eprintln!("{err:#}");
|
||||
1
|
||||
}
|
||||
};
|
||||
std::process::exit(code);
|
||||
}
|
||||
|
||||
fn run() -> Result<i32> {
|
||||
let cli = Cli::parse();
|
||||
let report = compare_install_kit(&cli.root, &cli.kit_dir)?;
|
||||
print_report(&report, cli.json)?;
|
||||
Ok(if report.is_ok() { 0 } else { 1 })
|
||||
}
|
||||
|
||||
fn compare_install_kit(root: &Path, kit_dir_arg: &Path) -> Result<Report> {
|
||||
let root = root
|
||||
.canonicalize()
|
||||
.with_context(|| format!("canonicalize root {}", root.display()))?;
|
||||
let kit_dir = if kit_dir_arg.is_absolute() {
|
||||
kit_dir_arg.to_path_buf()
|
||||
} else {
|
||||
root.join(kit_dir_arg)
|
||||
};
|
||||
if !kit_dir.exists() {
|
||||
bail!("Install kit directory not found: {}", kit_dir.display());
|
||||
}
|
||||
if !kit_dir.is_dir() {
|
||||
bail!("Install kit path is not a directory: {}", kit_dir.display());
|
||||
}
|
||||
|
||||
let mut report = Report::default();
|
||||
for kit_file in collect_files(&kit_dir)? {
|
||||
let rel = kit_file
|
||||
.strip_prefix(&kit_dir)
|
||||
.with_context(|| format!("strip kit prefix from {}", kit_file.display()))?;
|
||||
let rel_str = slash_path(rel);
|
||||
if rel.file_name().and_then(|name| name.to_str()) == Some(MANIFEST_NAME) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let repo_file = root.join(rel);
|
||||
if !repo_file.exists() {
|
||||
if is_allowed_kit_only(&rel_str) {
|
||||
continue;
|
||||
}
|
||||
report.missing_in_repo.push(rel_str);
|
||||
continue;
|
||||
}
|
||||
if !repo_file.is_file() {
|
||||
report.missing_in_repo.push(rel_str);
|
||||
continue;
|
||||
}
|
||||
|
||||
report.compared_files += 1;
|
||||
if sha256_file(&kit_file)? != sha256_file(&repo_file)? {
|
||||
report.mismatches.push(rel_str);
|
||||
}
|
||||
}
|
||||
|
||||
report.missing_in_repo.sort();
|
||||
report.mismatches.sort();
|
||||
report.powershell_mismatches = report
|
||||
.mismatches
|
||||
.iter()
|
||||
.filter(|path| is_powershell_path(path))
|
||||
.cloned()
|
||||
.collect();
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
fn print_report(report: &Report, json: bool) -> Result<()> {
|
||||
if json {
|
||||
println!("{}", serde_json::to_string_pretty(report)?);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!("Compared files: {}", report.compared_files);
|
||||
println!("Missing in repo: {}", report.missing_in_repo.len());
|
||||
println!("Mismatched content: {}", report.mismatches.len());
|
||||
if !report.missing_in_repo.is_empty() {
|
||||
println!("--- Missing in repo ---");
|
||||
for path in &report.missing_in_repo {
|
||||
println!("{path}");
|
||||
}
|
||||
}
|
||||
if !report.mismatches.is_empty() {
|
||||
println!("--- Mismatches ---");
|
||||
for path in &report.mismatches {
|
||||
println!("{path}");
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"PowerShell mismatches: {}",
|
||||
report.powershell_mismatches.len()
|
||||
);
|
||||
if !report.powershell_mismatches.is_empty() {
|
||||
println!("--- PowerShell mismatches ---");
|
||||
for path in &report.powershell_mismatches {
|
||||
println!("{path}");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn collect_files(root: &Path) -> Result<Vec<PathBuf>> {
|
||||
let mut out = Vec::new();
|
||||
collect_files_inner(root, &mut out)?;
|
||||
out.sort();
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn collect_files_inner(path: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
|
||||
for entry in fs::read_dir(path).with_context(|| format!("read dir {}", path.display()))? {
|
||||
let entry = entry.with_context(|| format!("read dir entry {}", path.display()))?;
|
||||
let entry_path = entry.path();
|
||||
let file_type = entry
|
||||
.file_type()
|
||||
.with_context(|| format!("read file type {}", entry_path.display()))?;
|
||||
if file_type.is_dir() {
|
||||
collect_files_inner(&entry_path, out)?;
|
||||
} else if (file_type.is_file() || file_type.is_symlink()) && entry_path.is_file() {
|
||||
out.push(entry_path);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sha256_file(path: &Path) -> Result<String> {
|
||||
let file = File::open(path).with_context(|| format!("open {}", path.display()))?;
|
||||
let mut reader = BufReader::new(file);
|
||||
let mut hasher = Sha256::new();
|
||||
let mut buf = [0_u8; 64 * 1024];
|
||||
loop {
|
||||
let read = reader
|
||||
.read(&mut buf)
|
||||
.with_context(|| format!("read {}", path.display()))?;
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
hasher.update(&buf[..read]);
|
||||
}
|
||||
Ok(format!("{:x}", hasher.finalize()))
|
||||
}
|
||||
|
||||
fn is_allowed_kit_only(rel: &str) -> bool {
|
||||
ALLOWED_KIT_ONLY_FILES.contains(&rel)
|
||||
|| ALLOWED_KIT_ONLY_PREFIXES
|
||||
.iter()
|
||||
.any(|prefix| rel.starts_with(prefix))
|
||||
}
|
||||
|
||||
fn is_powershell_path(path: &str) -> bool {
|
||||
path.starts_with("windows/")
|
||||
&& (path.ends_with(".ps1") || path.ends_with(".psm1") || path.ends_with(".psd1"))
|
||||
}
|
||||
|
||||
fn slash_path(path: &Path) -> String {
|
||||
path.components()
|
||||
.map(|component| component.as_os_str().to_string_lossy())
|
||||
.collect::<Vec<_>>()
|
||||
.join("/")
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn sorted_set(paths: &[String]) -> BTreeSet<String> {
|
||||
paths.iter().cloned().collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::fs;
|
||||
|
||||
use tempfile::tempdir;
|
||||
|
||||
use super::{compare_install_kit, sorted_set};
|
||||
|
||||
#[test]
|
||||
fn reports_clean_tree() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let kit = root.join("install-kit-awindows-20260427-211240");
|
||||
fs::create_dir_all(kit.join("windows")).unwrap();
|
||||
fs::write(root.join("common.txt"), "same").unwrap();
|
||||
fs::write(kit.join("common.txt"), "same").unwrap();
|
||||
fs::create_dir_all(root.join("windows")).unwrap();
|
||||
fs::write(root.join("windows/script.ps1"), "same").unwrap();
|
||||
fs::write(kit.join("windows/script.ps1"), "same").unwrap();
|
||||
fs::write(kit.join("README-INSTALL-KIT.txt"), "kit-only").unwrap();
|
||||
fs::write(kit.join("MANIFEST.txt"), "ignored").unwrap();
|
||||
|
||||
let report = compare_install_kit(root, &kit).unwrap();
|
||||
assert!(report.is_ok());
|
||||
assert_eq!(report.compared_files, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reports_mismatches_and_powershell_subset() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let kit = root.join("install-kit-awindows-20260427-211240");
|
||||
fs::create_dir_all(root.join("windows")).unwrap();
|
||||
fs::create_dir_all(kit.join("windows")).unwrap();
|
||||
fs::write(root.join("windows/script.ps1"), "repo").unwrap();
|
||||
fs::write(kit.join("windows/script.ps1"), "kit").unwrap();
|
||||
fs::write(root.join("plain.txt"), "repo").unwrap();
|
||||
fs::write(kit.join("plain.txt"), "kit").unwrap();
|
||||
|
||||
let report = compare_install_kit(root, &kit).unwrap();
|
||||
assert!(!report.is_ok());
|
||||
assert_eq!(
|
||||
sorted_set(&report.mismatches),
|
||||
sorted_set(&["plain.txt".to_string(), "windows/script.ps1".to_string()])
|
||||
);
|
||||
assert_eq!(
|
||||
report.powershell_mismatches,
|
||||
vec!["windows/script.ps1".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reports_unexpected_kit_only_files() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let kit = root.join("install-kit-awindows-20260427-211240");
|
||||
fs::create_dir_all(&kit).unwrap();
|
||||
fs::write(kit.join("unexpected.txt"), "kit").unwrap();
|
||||
fs::create_dir_all(kit.join("server-configs-198.51.100.18")).unwrap();
|
||||
fs::write(
|
||||
kit.join("server-configs-198.51.100.18/config.deployment-config.json"),
|
||||
"{}",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let report = compare_install_kit(root, &kit).unwrap();
|
||||
assert_eq!(report.missing_in_repo, vec!["unexpected.txt"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "detmir-auto"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
chrono.workspace = true
|
||||
clap.workspace = true
|
||||
detmir-core.workspace = true
|
||||
detmir-state.workspace = true
|
||||
fs2.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
@@ -0,0 +1,661 @@
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::io::Write;
|
||||
use std::os::unix::fs::symlink;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Output, Stdio};
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use chrono::{SecondsFormat, Utc};
|
||||
use clap::Parser;
|
||||
use detmir_core::exit_codes;
|
||||
use detmir_state::write_json_atomic;
|
||||
use fs2::FileExt;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
|
||||
const DEFAULT_STATE_DIR: &str = "/var/lib/detmir-ai";
|
||||
const DEFAULT_CHECK_BIN: &str = "detmir-check";
|
||||
const DEFAULT_DLP_BIN: &str = "detmir-dlp";
|
||||
const DEFAULT_HEAL_BIN: &str = "detmir-heal-safe-rust";
|
||||
const DEFAULT_POLLI_BIN: &str = "polli-chat";
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(about = "DetMir autonomous check/report orchestration.")]
|
||||
struct Cli {
|
||||
#[arg(long, default_value = DEFAULT_STATE_DIR)]
|
||||
state_dir: PathBuf,
|
||||
|
||||
#[arg(long)]
|
||||
lock_dir: Option<PathBuf>,
|
||||
|
||||
#[arg(long, default_value = DEFAULT_CHECK_BIN)]
|
||||
check_bin: String,
|
||||
|
||||
#[arg(long, default_value = DEFAULT_DLP_BIN)]
|
||||
dlp_bin: String,
|
||||
|
||||
#[arg(long, default_value = DEFAULT_HEAL_BIN)]
|
||||
heal_bin: String,
|
||||
|
||||
#[arg(long, default_value = DEFAULT_POLLI_BIN)]
|
||||
polli_bin: String,
|
||||
|
||||
#[arg(long, default_value_t = 14)]
|
||||
retain_days: u64,
|
||||
|
||||
#[arg(long, default_value_t = 120)]
|
||||
command_timeout_seconds: u64,
|
||||
|
||||
#[arg(long, default_value_t = 120)]
|
||||
report_timeout_seconds: u64,
|
||||
|
||||
#[arg(long)]
|
||||
no_report: bool,
|
||||
|
||||
#[arg(long)]
|
||||
enable_heal: bool,
|
||||
|
||||
#[arg(long)]
|
||||
no_heal: bool,
|
||||
}
|
||||
|
||||
impl Cli {
|
||||
fn apply_env(mut self) -> Self {
|
||||
self.state_dir = env_path("DETMIR_AI_STATE_DIR").unwrap_or(self.state_dir);
|
||||
self.lock_dir = env_path("DETMIR_AI_RUN_DIR").or(self.lock_dir);
|
||||
self.check_bin = env_string("DETMIR_CHECK_BIN").unwrap_or(self.check_bin);
|
||||
self.dlp_bin = env_string("DETMIR_DLP_BIN").unwrap_or(self.dlp_bin);
|
||||
self.heal_bin = env_string("DETMIR_HEAL_BIN").unwrap_or(self.heal_bin);
|
||||
self.polli_bin = env_string("DETMIR_POLLI_BIN").unwrap_or(self.polli_bin);
|
||||
self.retain_days = env_string("DETMIR_AI_RETAIN_DAYS")
|
||||
.and_then(|value| value.parse().ok())
|
||||
.unwrap_or(self.retain_days);
|
||||
self.report_timeout_seconds = env_string("DETMIR_REPORT_TIMEOUT_SECONDS")
|
||||
.and_then(|value| value.parse().ok())
|
||||
.unwrap_or(self.report_timeout_seconds);
|
||||
if env_string("DETMIR_AUTO_HEAL").is_some_and(|value| value == "1") {
|
||||
self.enable_heal = true;
|
||||
}
|
||||
if self.no_heal {
|
||||
self.enable_heal = false;
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
fn lock_dir(&self) -> PathBuf {
|
||||
self.lock_dir
|
||||
.clone()
|
||||
.unwrap_or_else(|| self.state_dir.join("locks"))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct RunPaths {
|
||||
state_dir: PathBuf,
|
||||
run_dir: PathBuf,
|
||||
reports_dir: PathBuf,
|
||||
check_file: PathBuf,
|
||||
dlp_file: PathBuf,
|
||||
check_rc_file: PathBuf,
|
||||
dlp_rc_file: PathBuf,
|
||||
heal_rc_file: PathBuf,
|
||||
heal_log: PathBuf,
|
||||
bundle_file: PathBuf,
|
||||
report_file: PathBuf,
|
||||
state_file: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct AutoSummary {
|
||||
check_rc: i32,
|
||||
dlp_rc: i32,
|
||||
check_ok: bool,
|
||||
dlp_ok: bool,
|
||||
severity: String,
|
||||
needs_heal: bool,
|
||||
reasons: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
detmir_summary: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
dlp_counts: Option<Value>,
|
||||
}
|
||||
|
||||
fn env_string(name: &str) -> Option<String> {
|
||||
std::env::var(name).ok().filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn env_path(name: &str) -> Option<PathBuf> {
|
||||
env_string(name).map(PathBuf::from)
|
||||
}
|
||||
|
||||
fn utc_stamp() -> String {
|
||||
Utc::now().format("%Y%m%d-%H%M%S").to_string()
|
||||
}
|
||||
|
||||
fn utc_iso() -> String {
|
||||
Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true)
|
||||
}
|
||||
|
||||
fn init_paths(state_dir: PathBuf) -> Result<RunPaths> {
|
||||
let stamp = utc_stamp();
|
||||
let run_dir = state_dir.join("runs").join(&stamp);
|
||||
let reports_dir = state_dir.join("reports");
|
||||
fs::create_dir_all(&run_dir)?;
|
||||
fs::create_dir_all(&reports_dir)?;
|
||||
fs::create_dir_all(state_dir.join("logs"))?;
|
||||
|
||||
Ok(RunPaths {
|
||||
state_dir: state_dir.clone(),
|
||||
run_dir: run_dir.clone(),
|
||||
reports_dir: reports_dir.clone(),
|
||||
check_file: run_dir.join("detmir-check.json"),
|
||||
dlp_file: run_dir.join("detmir-dlp.json"),
|
||||
check_rc_file: run_dir.join("check.rc"),
|
||||
dlp_rc_file: run_dir.join("dlp.rc"),
|
||||
heal_rc_file: run_dir.join("heal.rc"),
|
||||
heal_log: run_dir.join("heal.log"),
|
||||
bundle_file: run_dir.join("bundle.txt"),
|
||||
report_file: reports_dir.join(format!("detmir-report-{stamp}.md")),
|
||||
state_file: state_dir.join(format!("state-{stamp}.json")),
|
||||
})
|
||||
}
|
||||
|
||||
fn acquire_lock(lock_dir: &Path) -> Result<Option<File>> {
|
||||
fs::create_dir_all(lock_dir)?;
|
||||
let lock_path = lock_dir.join("detmir-auto.lock");
|
||||
let file = OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(false)
|
||||
.open(&lock_path)
|
||||
.with_context(|| format!("failed to open lock {}", lock_path.display()))?;
|
||||
match file.try_lock_exclusive() {
|
||||
Ok(()) => Ok(Some(file)),
|
||||
Err(err) => {
|
||||
if err.kind() == std::io::ErrorKind::WouldBlock {
|
||||
println!("detmir-auto: another run is active");
|
||||
Ok(None)
|
||||
} else {
|
||||
Err(err).with_context(|| format!("failed to lock {}", lock_path.display()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_to_file(
|
||||
command: &str,
|
||||
args: &[&str],
|
||||
output_path: &Path,
|
||||
rc_path: &Path,
|
||||
timeout: Duration,
|
||||
) -> Result<i32> {
|
||||
let stdout = File::create(output_path)
|
||||
.with_context(|| format!("failed to create {}", output_path.display()))?;
|
||||
let stderr_path = output_path.with_extension("stderr");
|
||||
let stderr = File::create(&stderr_path)
|
||||
.with_context(|| format!("failed to create {}", stderr_path.display()))?;
|
||||
|
||||
let mut child = Command::new(command)
|
||||
.args(args)
|
||||
.stdout(Stdio::from(stdout))
|
||||
.stderr(Stdio::from(stderr))
|
||||
.spawn()
|
||||
.with_context(|| format!("failed to execute {command}"))?;
|
||||
|
||||
let started = std::time::Instant::now();
|
||||
let rc = loop {
|
||||
if let Some(status) = child.try_wait()? {
|
||||
break status.code().unwrap_or(1);
|
||||
}
|
||||
if started.elapsed() >= timeout {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
let mut stderr = OpenOptions::new().append(true).open(&stderr_path)?;
|
||||
writeln!(
|
||||
stderr,
|
||||
"{command} timed out after {} seconds",
|
||||
timeout.as_secs()
|
||||
)?;
|
||||
break 124;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
};
|
||||
|
||||
if fs::metadata(&stderr_path)
|
||||
.map(|meta| meta.len())
|
||||
.unwrap_or(0)
|
||||
== 0
|
||||
{
|
||||
let _ = fs::remove_file(&stderr_path);
|
||||
}
|
||||
|
||||
fs::write(rc_path, format!("{rc}\n"))?;
|
||||
Ok(rc)
|
||||
}
|
||||
|
||||
fn read_rc(path: &Path) -> i32 {
|
||||
fs::read_to_string(path)
|
||||
.ok()
|
||||
.and_then(|text| text.trim().parse().ok())
|
||||
.unwrap_or(1)
|
||||
}
|
||||
|
||||
fn summarize(paths: &RunPaths) -> AutoSummary {
|
||||
let check_rc = read_rc(&paths.check_rc_file);
|
||||
let dlp_rc = read_rc(&paths.dlp_rc_file);
|
||||
let mut summary = AutoSummary {
|
||||
check_rc,
|
||||
dlp_rc,
|
||||
check_ok: false,
|
||||
dlp_ok: false,
|
||||
severity: if check_rc != 0 || dlp_rc != 0 {
|
||||
"FAIL".to_string()
|
||||
} else {
|
||||
"OK".to_string()
|
||||
},
|
||||
needs_heal: check_rc != 0 || dlp_rc != 0,
|
||||
reasons: Vec::new(),
|
||||
detmir_summary: None,
|
||||
dlp_counts: None,
|
||||
};
|
||||
|
||||
match read_json(&paths.check_file) {
|
||||
Ok(check) => {
|
||||
summary.check_ok = check.get("ok").and_then(Value::as_bool).unwrap_or(false);
|
||||
let check_summary = check.get("summary").cloned().unwrap_or(Value::Null);
|
||||
if check_summary.is_object() {
|
||||
if !summary.check_ok
|
||||
&& (int_field(&check_summary, "bucket_dead") > 0
|
||||
|| int_field(&check_summary, "bucket_stale") > 0
|
||||
|| int_field(&check_summary, "service_failures") > 0)
|
||||
{
|
||||
summary.reasons.push(
|
||||
"detmir-check has stale/dead bucket or required service failure"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
summary.detmir_summary = Some(check_summary);
|
||||
}
|
||||
}
|
||||
Err(err) => summary
|
||||
.reasons
|
||||
.push(format!("detmir-check parse failed: {err}")),
|
||||
}
|
||||
|
||||
match read_json(&paths.dlp_file) {
|
||||
Ok(dlp) => {
|
||||
summary.dlp_ok = dlp.get("ok").and_then(Value::as_bool).unwrap_or(false);
|
||||
let counts = dlp.get("counts").cloned().unwrap_or(Value::Null);
|
||||
if counts.is_object() {
|
||||
if !summary.dlp_ok
|
||||
&& (int_field(&counts, "fail") > 0 || int_field(&counts, "warn") > 0)
|
||||
{
|
||||
summary
|
||||
.reasons
|
||||
.push("dlp-health-check has warn/fail".to_string());
|
||||
}
|
||||
summary.dlp_counts = Some(counts);
|
||||
}
|
||||
}
|
||||
Err(err) => summary
|
||||
.reasons
|
||||
.push(format!("detmir-dlp parse failed: {err}")),
|
||||
}
|
||||
|
||||
if summary.check_ok && summary.dlp_ok {
|
||||
summary.severity = "OK".to_string();
|
||||
summary.needs_heal = false;
|
||||
} else if summary.reasons.is_empty() {
|
||||
summary.severity = "WARN".to_string();
|
||||
} else {
|
||||
summary.severity = "FAIL".to_string();
|
||||
}
|
||||
|
||||
summary
|
||||
}
|
||||
|
||||
fn read_json(path: &Path) -> Result<Value> {
|
||||
let raw = fs::read_to_string(path)?;
|
||||
Ok(serde_json::from_str(&raw)?)
|
||||
}
|
||||
|
||||
fn int_field(value: &Value, key: &str) -> i64 {
|
||||
value.get(key).and_then(Value::as_i64).unwrap_or(0)
|
||||
}
|
||||
|
||||
fn write_bundle(paths: &RunPaths, summary_after: &AutoSummary) -> Result<()> {
|
||||
let mut out = String::new();
|
||||
out.push_str(
|
||||
"Ты операторский AI-помощник DetMir. По фактам ниже дай короткий русский отчет.\n",
|
||||
);
|
||||
out.push_str("Структура ответа:\n");
|
||||
out.push_str("1. Состояние: OK/WARN/FAIL\n");
|
||||
out.push_str("2. Что важно\n");
|
||||
out.push_str("3. Что уже сделал автомат\n");
|
||||
out.push_str("4. Что сделать человеку, если нужно\n\n");
|
||||
out.push_str("Правила:\n");
|
||||
out.push_str("- Не предлагай рестарты, если факты чистые.\n");
|
||||
out.push_str("- Отличай event-driven bucket от dead/stale.\n");
|
||||
out.push_str("- DLP sendFailures важны только при новом sendFailuresDelta или warn/fail.\n");
|
||||
out.push_str(
|
||||
"- Auto-heal умеет только серверные systemd-сервисы AW/DLP; Windows/RDP не трогает.\n\n",
|
||||
);
|
||||
out.push_str("=== summary-before ===\n");
|
||||
out.push_str(
|
||||
&fs::read_to_string(paths.run_dir.join("summary-before.json")).unwrap_or_default(),
|
||||
);
|
||||
out.push_str("\n\n=== summary-after ===\n");
|
||||
out.push_str(&serde_json::to_string_pretty(summary_after)?);
|
||||
out.push_str("\n\n=== heal-log ===\n");
|
||||
out.push_str(&fs::read_to_string(&paths.heal_log).unwrap_or_default());
|
||||
out.push_str("\n\n=== detmir-check ===\n");
|
||||
out.push_str(&truncate_file(&paths.check_file, 1600));
|
||||
out.push_str("\n\n=== detmir-dlp ===\n");
|
||||
out.push_str(&truncate_file(&paths.dlp_file, 1600));
|
||||
fs::write(&paths.bundle_file, out)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn truncate_file(path: &Path, max_lines: usize) -> String {
|
||||
fs::read_to_string(path)
|
||||
.unwrap_or_default()
|
||||
.lines()
|
||||
.take(max_lines)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
fn write_report(
|
||||
paths: &RunPaths,
|
||||
polli_bin: &str,
|
||||
no_report: bool,
|
||||
report_timeout: Duration,
|
||||
summary: &AutoSummary,
|
||||
) -> Result<()> {
|
||||
let mut report = format!(
|
||||
"# DetMir Autonomous Report\n\n- generated_at_utc: {}\n- run_dir: {}\n\n",
|
||||
utc_iso(),
|
||||
paths.run_dir.display()
|
||||
);
|
||||
|
||||
if no_report {
|
||||
report.push_str("Pollinations report skipped; raw summary follows.\n");
|
||||
report.push_str(&serde_json::to_string_pretty(summary)?);
|
||||
} else {
|
||||
let bundle = File::open(&paths.bundle_file)?;
|
||||
match run_report_command(polli_bin, bundle, report_timeout) {
|
||||
Ok(output) if output.status.success() => {
|
||||
report.push_str(&String::from_utf8_lossy(&output.stdout));
|
||||
}
|
||||
Ok(output) => {
|
||||
report.push_str("Pollinations report failed; raw summary follows.\n");
|
||||
if !output.stderr.is_empty() {
|
||||
report.push_str(&String::from_utf8_lossy(&output.stderr));
|
||||
report.push('\n');
|
||||
}
|
||||
report.push_str(&serde_json::to_string_pretty(summary)?);
|
||||
}
|
||||
Err(err) => {
|
||||
report.push_str("Pollinations report failed; raw summary follows.\n");
|
||||
report.push_str(&format!("{err}\n"));
|
||||
report.push_str(&serde_json::to_string_pretty(summary)?);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fs::write(&paths.report_file, report)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_report_command(polli_bin: &str, bundle: File, timeout: Duration) -> Result<Output> {
|
||||
let mut child = Command::new(polli_bin)
|
||||
.args(["--model", "text.daily", "--max-tokens", "900"])
|
||||
.stdin(Stdio::from(bundle))
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.with_context(|| format!("failed to execute {polli_bin}"))?;
|
||||
|
||||
let started = std::time::Instant::now();
|
||||
loop {
|
||||
if child.try_wait()?.is_some() {
|
||||
return child
|
||||
.wait_with_output()
|
||||
.with_context(|| format!("failed to collect {polli_bin} output"));
|
||||
}
|
||||
if started.elapsed() >= timeout {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
anyhow::bail!(
|
||||
"Pollinations report timed out after {} seconds",
|
||||
timeout.as_secs()
|
||||
);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
}
|
||||
}
|
||||
|
||||
fn update_latest(paths: &RunPaths) -> Result<()> {
|
||||
update_symlink(&paths.run_dir, &paths.state_dir.join("latest-run"))?;
|
||||
update_symlink(
|
||||
&paths.report_file,
|
||||
&paths.state_dir.join("latest-report.md"),
|
||||
)?;
|
||||
update_symlink(
|
||||
&paths.state_file,
|
||||
&paths.state_dir.join("latest-state.json"),
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn update_symlink(target: &Path, link: &Path) -> Result<()> {
|
||||
let tmp = link.with_extension(format!("tmp.{}", std::process::id()));
|
||||
let _ = fs::remove_file(&tmp);
|
||||
symlink(target, &tmp)?;
|
||||
fs::rename(&tmp, link)
|
||||
.with_context(|| format!("failed to update symlink {}", link.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cleanup_retention(paths: &RunPaths, retain_days: u64) -> Result<()> {
|
||||
let cutoff = SystemTime::now()
|
||||
.checked_sub(Duration::from_secs(retain_days * 24 * 60 * 60))
|
||||
.unwrap_or(SystemTime::UNIX_EPOCH);
|
||||
cleanup_old_dirs(&paths.state_dir.join("runs"), cutoff)?;
|
||||
cleanup_old_files(&paths.reports_dir, "detmir-report-", Some(".md"), cutoff)?;
|
||||
cleanup_old_files(&paths.state_dir, "state-", Some(".json"), cutoff)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cleanup_old_dirs(dir: &Path, cutoff: SystemTime) -> Result<()> {
|
||||
if !dir.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
for entry in fs::read_dir(dir)? {
|
||||
let entry = entry?;
|
||||
if entry.file_type()?.is_dir() && is_old(&entry.path(), cutoff) {
|
||||
fs::remove_dir_all(entry.path())?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cleanup_old_files(
|
||||
dir: &Path,
|
||||
prefix: &str,
|
||||
suffix: Option<&str>,
|
||||
cutoff: SystemTime,
|
||||
) -> Result<()> {
|
||||
if !dir.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
for entry in fs::read_dir(dir)? {
|
||||
let entry = entry?;
|
||||
let name = entry.file_name();
|
||||
let name = name.to_string_lossy();
|
||||
let suffix_ok = suffix.is_none_or(|suffix| name.ends_with(suffix));
|
||||
if entry.file_type()?.is_file()
|
||||
&& name.starts_with(prefix)
|
||||
&& suffix_ok
|
||||
&& is_old(&entry.path(), cutoff)
|
||||
{
|
||||
fs::remove_file(entry.path())?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_old(path: &Path, cutoff: SystemTime) -> bool {
|
||||
path.metadata()
|
||||
.and_then(|meta| meta.modified())
|
||||
.map(|modified| modified < cutoff)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args = Cli::parse().apply_env();
|
||||
let Some(_lock) = acquire_lock(&args.lock_dir())? else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let paths = init_paths(args.state_dir.clone())?;
|
||||
let command_timeout = Duration::from_secs(args.command_timeout_seconds);
|
||||
let report_timeout = Duration::from_secs(args.report_timeout_seconds);
|
||||
run_to_file(
|
||||
&args.check_bin,
|
||||
&["--json"],
|
||||
&paths.check_file,
|
||||
&paths.check_rc_file,
|
||||
command_timeout,
|
||||
)?;
|
||||
run_to_file(
|
||||
&args.dlp_bin,
|
||||
&[],
|
||||
&paths.dlp_file,
|
||||
&paths.dlp_rc_file,
|
||||
command_timeout,
|
||||
)?;
|
||||
let summary_before = summarize(&paths);
|
||||
write_json_atomic(paths.run_dir.join("summary-before.json"), &summary_before)?;
|
||||
|
||||
if summary_before.needs_heal && args.enable_heal {
|
||||
fs::write(
|
||||
&paths.heal_log,
|
||||
format!("auto-heal started at {}\n", utc_iso()),
|
||||
)?;
|
||||
let heal_rc = run_to_file(
|
||||
&args.heal_bin,
|
||||
&["--apply", "--json"],
|
||||
&paths.heal_log,
|
||||
&paths.heal_rc_file,
|
||||
command_timeout,
|
||||
)?;
|
||||
let mut heal_log = OpenOptions::new().append(true).open(&paths.heal_log)?;
|
||||
writeln!(
|
||||
heal_log,
|
||||
"\nauto-heal finished at {} rc={heal_rc}",
|
||||
utc_iso()
|
||||
)?;
|
||||
std::thread::sleep(Duration::from_secs(10));
|
||||
run_to_file(
|
||||
&args.check_bin,
|
||||
&["--json"],
|
||||
&paths.check_file,
|
||||
&paths.check_rc_file,
|
||||
command_timeout,
|
||||
)?;
|
||||
run_to_file(
|
||||
&args.dlp_bin,
|
||||
&[],
|
||||
&paths.dlp_file,
|
||||
&paths.dlp_rc_file,
|
||||
command_timeout,
|
||||
)?;
|
||||
} else {
|
||||
fs::write(
|
||||
&paths.heal_log,
|
||||
if summary_before.needs_heal {
|
||||
"auto-heal skipped (disabled)\n"
|
||||
} else {
|
||||
"auto-heal skipped\n"
|
||||
},
|
||||
)?;
|
||||
fs::write(&paths.heal_rc_file, "0\n")?;
|
||||
}
|
||||
|
||||
let summary_after = summarize(&paths);
|
||||
write_json_atomic(&paths.state_file, &summary_after)?;
|
||||
write_bundle(&paths, &summary_after)?;
|
||||
write_report(
|
||||
&paths,
|
||||
&args.polli_bin,
|
||||
args.no_report,
|
||||
report_timeout,
|
||||
&summary_after,
|
||||
)?;
|
||||
update_latest(&paths)?;
|
||||
cleanup_retention(&paths, args.retain_days)?;
|
||||
|
||||
print!("{}", fs::read_to_string(&paths.report_file)?);
|
||||
std::io::stdout().flush().ok();
|
||||
|
||||
let final_check_rc = read_rc(&paths.check_rc_file);
|
||||
let final_dlp_rc = read_rc(&paths.dlp_rc_file);
|
||||
std::process::exit(
|
||||
if summary_after.severity == "OK" && final_check_rc == 0 && final_dlp_rc == 0 {
|
||||
exit_codes::OK
|
||||
} else {
|
||||
exit_codes::CHECK_FAILED
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn summarizes_clean_inputs() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let paths = init_paths(dir.path().to_path_buf()).unwrap();
|
||||
fs::write(
|
||||
&paths.check_file,
|
||||
r#"{"ok": true, "summary": {"bucket_ok": 8, "bucket_stale": 0, "bucket_dead": 0, "service_failures": 0, "service_warnings": 0}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
&paths.dlp_file,
|
||||
r#"{"ok": true, "counts": {"ok": 22, "warn": 0, "fail": 0}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(&paths.check_rc_file, "0\n").unwrap();
|
||||
fs::write(&paths.dlp_rc_file, "0\n").unwrap();
|
||||
let summary = summarize(&paths);
|
||||
assert_eq!(summary.severity, "OK");
|
||||
assert!(!summary.needs_heal);
|
||||
assert!(summary.reasons.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_keep_reasons_when_child_reports_ok() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let paths = init_paths(dir.path().to_path_buf()).unwrap();
|
||||
fs::write(
|
||||
&paths.check_file,
|
||||
r#"{"ok": true, "summary": {"bucket_ok": 8, "bucket_stale": 0, "bucket_dead": 0, "service_failures": 0, "service_warnings": 0}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
&paths.dlp_file,
|
||||
r#"{"ok": true, "counts": {"ok": 21, "warn": 1, "fail": 0}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(&paths.check_rc_file, "0\n").unwrap();
|
||||
fs::write(&paths.dlp_rc_file, "0\n").unwrap();
|
||||
let summary = summarize(&paths);
|
||||
assert_eq!(summary.severity, "OK");
|
||||
assert!(!summary.needs_heal);
|
||||
assert!(summary.reasons.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "detmir-aw-client"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
chrono.workspace = true
|
||||
detmir-core.workspace = true
|
||||
reqwest.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
@@ -0,0 +1,119 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use chrono::{DateTime, Utc};
|
||||
use detmir_core::parse_utc_rfc3339;
|
||||
use reqwest::blocking::Client;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const DEFAULT_GET_ATTEMPTS: usize = 6;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ActivityWatchClient {
|
||||
api_base: String,
|
||||
client: Client,
|
||||
}
|
||||
|
||||
impl ActivityWatchClient {
|
||||
pub fn new(api_base: impl Into<String>, timeout: Duration) -> Result<Self> {
|
||||
let client = Client::builder()
|
||||
.timeout(timeout)
|
||||
.no_proxy()
|
||||
.build()
|
||||
.context("failed to build ActivityWatch HTTP client")?;
|
||||
Ok(Self {
|
||||
api_base: api_base.into().trim_end_matches('/').to_string(),
|
||||
client,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_json<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
|
||||
let url = self.url(path);
|
||||
let mut last_error = None;
|
||||
for attempt in 0..DEFAULT_GET_ATTEMPTS {
|
||||
let result = self
|
||||
.client
|
||||
.get(&url)
|
||||
.send()
|
||||
.with_context(|| format!("ActivityWatch request failed: {url}"))
|
||||
.and_then(|response| {
|
||||
response.error_for_status().with_context(|| {
|
||||
format!("ActivityWatch returned non-success status: {url}")
|
||||
})
|
||||
})
|
||||
.and_then(|response| {
|
||||
response
|
||||
.json()
|
||||
.with_context(|| format!("failed to parse ActivityWatch JSON: {url}"))
|
||||
});
|
||||
|
||||
match result {
|
||||
Ok(value) => return Ok(value),
|
||||
Err(err) => last_error = Some(err),
|
||||
}
|
||||
|
||||
if attempt + 1 < DEFAULT_GET_ATTEMPTS {
|
||||
std::thread::sleep(Duration::from_millis(500 * (attempt as u64 + 1)));
|
||||
}
|
||||
}
|
||||
|
||||
Err(last_error.unwrap_or_else(|| anyhow::anyhow!("ActivityWatch request failed: {url}")))
|
||||
}
|
||||
|
||||
pub fn latest_event(&self, bucket_id: &str) -> Result<Option<AwEvent>> {
|
||||
let path = format!("/buckets/{bucket_id}/events?limit=1");
|
||||
let mut events: Vec<AwEvent> = self.get_json(&path)?;
|
||||
events.sort_by(|left, right| right.timestamp.cmp(&left.timestamp));
|
||||
Ok(events.into_iter().next())
|
||||
}
|
||||
|
||||
fn url(&self, path: &str) -> String {
|
||||
if path.starts_with('/') {
|
||||
format!("{}{}", self.api_base, path)
|
||||
} else {
|
||||
format!("{}/{}", self.api_base, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct AwEvent {
|
||||
pub timestamp: String,
|
||||
#[serde(default)]
|
||||
pub duration: f64,
|
||||
#[serde(default)]
|
||||
pub data: serde_json::Value,
|
||||
}
|
||||
|
||||
impl AwEvent {
|
||||
pub fn timestamp_utc(&self) -> Result<DateTime<Utc>> {
|
||||
parse_utc_rfc3339(&self.timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn builds_paths_without_double_slashes() {
|
||||
let client =
|
||||
ActivityWatchClient::new("http://127.0.0.1:5600/api/0/", Duration::from_secs(1))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
client.url("/buckets/x/events?limit=1"),
|
||||
"http://127.0.0.1:5600/api/0/buckets/x/events?limit=1"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_event_timestamp() {
|
||||
let event = AwEvent {
|
||||
timestamp: "2026-05-31T10:20:30Z".to_string(),
|
||||
duration: 0.0,
|
||||
data: serde_json::json!({}),
|
||||
};
|
||||
assert_eq!(event.timestamp_utc().unwrap().timestamp(), 1_780_222_830);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "detmir-check"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
chrono.workspace = true
|
||||
clap.workspace = true
|
||||
detmir-aw-client.workspace = true
|
||||
detmir-core.workspace = true
|
||||
reqwest.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
@@ -0,0 +1,867 @@
|
||||
use std::net::{SocketAddr, TcpStream};
|
||||
use std::process::Command;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use chrono::{DateTime, SecondsFormat, Utc};
|
||||
use clap::Parser;
|
||||
use detmir_aw_client::ActivityWatchClient;
|
||||
use detmir_core::{exit_codes, now_utc_rfc3339};
|
||||
use reqwest::blocking::Client;
|
||||
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
|
||||
const DEFAULT_AW_API: &str = "http://192.0.2.13:5600/api/0";
|
||||
const DEFAULT_WORKTIME_URL: &str = "http://192.0.2.13:5610";
|
||||
const DEFAULT_ONE_C_URL: &str = "http://192.0.2.2:8710";
|
||||
const DEFAULT_RDP_HOST: &str = "198.51.100.18";
|
||||
const DEFAULT_HOSTNAME: &str = "HOST-EXAMPLE";
|
||||
const DEFAULT_GATEWAY_HOST: &str = "detmir.example.local";
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(about = "Autonomous read-only DetMir contour check from Proxmox.")]
|
||||
struct Cli {
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
|
||||
#[arg(long, default_value = DEFAULT_AW_API)]
|
||||
aw_api: String,
|
||||
|
||||
#[arg(long, default_value = DEFAULT_WORKTIME_URL)]
|
||||
worktime_url: String,
|
||||
|
||||
#[arg(long, default_value = DEFAULT_ONE_C_URL)]
|
||||
one_c_url: String,
|
||||
|
||||
#[arg(long, default_value = DEFAULT_RDP_HOST)]
|
||||
rdp_host: String,
|
||||
|
||||
#[arg(long, default_value = DEFAULT_HOSTNAME)]
|
||||
hostname: String,
|
||||
|
||||
#[arg(long, default_value_t = 5)]
|
||||
service_timeout_seconds: u64,
|
||||
|
||||
#[arg(long, default_value_t = 8)]
|
||||
bucket_timeout_seconds: u64,
|
||||
|
||||
#[arg(long, default_value_t = 3.0)]
|
||||
tcp_timeout_seconds: f64,
|
||||
|
||||
#[arg(long, default_value_t = 201)]
|
||||
grafana_ct_id: u32,
|
||||
|
||||
#[arg(long, default_value = "/var/lib/detmir-grafana-check/latest.json")]
|
||||
grafana_check_json: String,
|
||||
|
||||
#[arg(long, default_value_t = 30 * 60)]
|
||||
grafana_check_max_age_seconds: i64,
|
||||
|
||||
#[arg(long, default_value_t = false)]
|
||||
disable_grafana_check: bool,
|
||||
|
||||
#[arg(long, default_value = "disabled")]
|
||||
security_events_backend: String,
|
||||
|
||||
#[arg(long, default_value = "http://127.0.0.1:8123")]
|
||||
clickhouse_url: String,
|
||||
|
||||
#[arg(long, default_value = "analytics_1c")]
|
||||
clickhouse_database: String,
|
||||
|
||||
#[arg(long, default_value = "default")]
|
||||
clickhouse_user: String,
|
||||
|
||||
#[arg(long, default_value = "")]
|
||||
clickhouse_password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum BucketMode {
|
||||
Fresh,
|
||||
InteractiveFresh,
|
||||
InactiveOk,
|
||||
EventDriven,
|
||||
}
|
||||
|
||||
impl BucketMode {
|
||||
fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Fresh => "fresh",
|
||||
Self::InteractiveFresh => "interactive_fresh",
|
||||
Self::InactiveOk => "inactive_ok",
|
||||
Self::EventDriven => "event_driven",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct BucketSpec {
|
||||
label: &'static str,
|
||||
bucket: String,
|
||||
max_age_seconds: Option<i64>,
|
||||
mode: BucketMode,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct CheckReport {
|
||||
ok: bool,
|
||||
#[serde(rename = "generatedAtUtc")]
|
||||
generated_at_utc: String,
|
||||
services: Vec<ServiceCheck>,
|
||||
buckets: Vec<BucketCheck>,
|
||||
summary: CheckSummary,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct CheckSummary {
|
||||
bucket_ok: usize,
|
||||
bucket_stale: usize,
|
||||
bucket_dead: usize,
|
||||
service_failures: usize,
|
||||
service_warnings: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ServiceCheck {
|
||||
name: String,
|
||||
required: bool,
|
||||
ok: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
url: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
payload: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct BucketCheck {
|
||||
label: String,
|
||||
bucket: String,
|
||||
mode: String,
|
||||
status: String,
|
||||
ok: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
event_count_sample: Option<usize>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
latest: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
age_seconds: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
fn env_or_default(name: &str, default: &str) -> String {
|
||||
std::env::var(name)
|
||||
.ok()
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| default.to_string())
|
||||
}
|
||||
|
||||
fn bucket_specs(hostname: &str) -> Vec<BucketSpec> {
|
||||
vec![
|
||||
BucketSpec {
|
||||
label: "AFK watcher",
|
||||
bucket: format!("aw-watcher-afk_{hostname}"),
|
||||
max_age_seconds: Some(15 * 60),
|
||||
mode: BucketMode::InteractiveFresh,
|
||||
},
|
||||
BucketSpec {
|
||||
label: "Window watcher",
|
||||
bucket: format!("aw-watcher-window_{hostname}"),
|
||||
max_age_seconds: Some(2 * 60 * 60),
|
||||
mode: BucketMode::InactiveOk,
|
||||
},
|
||||
BucketSpec {
|
||||
label: "Worktime sessions",
|
||||
bucket: format!("aw-worktime-sessions_{hostname}"),
|
||||
max_age_seconds: Some(5 * 60),
|
||||
mode: BucketMode::Fresh,
|
||||
},
|
||||
BucketSpec {
|
||||
label: "Session events",
|
||||
bucket: format!("aw-session-events_{hostname}"),
|
||||
max_age_seconds: None,
|
||||
mode: BucketMode::EventDriven,
|
||||
},
|
||||
BucketSpec {
|
||||
label: "DLP signals",
|
||||
bucket: format!("aw-dlp-endpoint-signals_{hostname}"),
|
||||
max_age_seconds: Some(10 * 60),
|
||||
mode: BucketMode::InteractiveFresh,
|
||||
},
|
||||
BucketSpec {
|
||||
label: "DLP incidents",
|
||||
bucket: format!("aw-dlp-incidents_{hostname}"),
|
||||
max_age_seconds: None,
|
||||
mode: BucketMode::EventDriven,
|
||||
},
|
||||
BucketSpec {
|
||||
label: "DLP review",
|
||||
bucket: format!("aw-dlp-review_{hostname}"),
|
||||
max_age_seconds: None,
|
||||
mode: BucketMode::EventDriven,
|
||||
},
|
||||
BucketSpec {
|
||||
label: "DLP rules",
|
||||
bucket: format!("aw-dlp-rules_{hostname}"),
|
||||
max_age_seconds: None,
|
||||
mode: BucketMode::EventDriven,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn build_headers(items: &[(&str, &str)]) -> Result<HeaderMap> {
|
||||
let mut headers = HeaderMap::new();
|
||||
for (name, value) in items {
|
||||
headers.insert(
|
||||
HeaderName::from_bytes(name.as_bytes())?,
|
||||
HeaderValue::from_str(value)?,
|
||||
);
|
||||
}
|
||||
Ok(headers)
|
||||
}
|
||||
|
||||
fn fetch_text(
|
||||
url: &str,
|
||||
timeout: Duration,
|
||||
insecure: bool,
|
||||
headers: HeaderMap,
|
||||
attempts: usize,
|
||||
) -> Result<String> {
|
||||
let client = Client::builder()
|
||||
.timeout(timeout)
|
||||
.danger_accept_invalid_certs(insecure)
|
||||
.no_proxy()
|
||||
.build()
|
||||
.context("failed to build HTTP client")?;
|
||||
let mut last_error = None;
|
||||
for attempt in 0..attempts.max(1) {
|
||||
match client.get(url).headers(headers.clone()).send() {
|
||||
Ok(response) => match response.error_for_status() {
|
||||
Ok(response) => {
|
||||
return response.text().context("failed to read HTTP response body");
|
||||
}
|
||||
Err(err) => last_error = Some(err.into()),
|
||||
},
|
||||
Err(err) => last_error = Some(anyhow::anyhow!("{err:#}")),
|
||||
}
|
||||
if attempt + 1 < attempts.max(1) {
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
}
|
||||
}
|
||||
Err(last_error.unwrap_or_else(|| anyhow::anyhow!("HTTP request failed")))
|
||||
}
|
||||
|
||||
fn service_checks(args: &Cli) -> Vec<ServiceCheck> {
|
||||
let timeout = Duration::from_secs(args.service_timeout_seconds);
|
||||
let one_c_url = args.one_c_url.trim_end_matches('/');
|
||||
let services = [
|
||||
(
|
||||
"aw-info",
|
||||
format!("{}/info", args.aw_api.trim_end_matches('/')),
|
||||
false,
|
||||
false,
|
||||
HeaderMap::new(),
|
||||
),
|
||||
(
|
||||
"worktime-today",
|
||||
format!(
|
||||
"{}/reports/worktime/today",
|
||||
args.worktime_url.trim_end_matches('/')
|
||||
),
|
||||
false,
|
||||
false,
|
||||
HeaderMap::new(),
|
||||
),
|
||||
(
|
||||
"1c-api-health",
|
||||
format!("{one_c_url}/api/health"),
|
||||
false,
|
||||
false,
|
||||
HeaderMap::new(),
|
||||
),
|
||||
(
|
||||
"gateway-healthz",
|
||||
"https://127.0.0.1/healthz".to_string(),
|
||||
true,
|
||||
true,
|
||||
build_headers(&[("Host", DEFAULT_GATEWAY_HOST)]).unwrap_or_default(),
|
||||
),
|
||||
];
|
||||
|
||||
let mut checks = Vec::new();
|
||||
for (name, url, insecure, required, headers) in services {
|
||||
match fetch_text(&url, timeout, insecure, headers, 2) {
|
||||
Ok(raw) => {
|
||||
let payload = serde_json::from_str::<Value>(&raw)
|
||||
.unwrap_or_else(|_| Value::String(raw.trim().to_string()));
|
||||
checks.push(ServiceCheck {
|
||||
name: name.to_string(),
|
||||
required,
|
||||
ok: true,
|
||||
url: Some(url),
|
||||
payload: Some(payload),
|
||||
error: None,
|
||||
});
|
||||
}
|
||||
Err(err) => checks.push(ServiceCheck {
|
||||
name: name.to_string(),
|
||||
required,
|
||||
ok: false,
|
||||
url: Some(url),
|
||||
payload: None,
|
||||
error: Some(err.to_string()),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
checks.push(tcp_check(
|
||||
&args.rdp_host,
|
||||
5985,
|
||||
args.tcp_timeout_seconds,
|
||||
true,
|
||||
));
|
||||
checks.push(tcp_check(
|
||||
&args.rdp_host,
|
||||
22,
|
||||
args.tcp_timeout_seconds,
|
||||
true,
|
||||
));
|
||||
if !args.disable_grafana_check {
|
||||
checks.push(grafana_data_check(args));
|
||||
}
|
||||
if security_events_clickhouse_enabled(args) {
|
||||
checks.push(clickhouse_security_events_check(args));
|
||||
}
|
||||
checks
|
||||
}
|
||||
|
||||
fn security_events_clickhouse_enabled(args: &Cli) -> bool {
|
||||
args.security_events_backend
|
||||
.trim()
|
||||
.eq_ignore_ascii_case("clickhouse")
|
||||
}
|
||||
|
||||
fn clickhouse_security_events_check(args: &Cli) -> ServiceCheck {
|
||||
let name = "security-events-clickhouse".to_string();
|
||||
let Some(database) = clickhouse_identifier(&args.clickhouse_database) else {
|
||||
return ServiceCheck {
|
||||
name,
|
||||
required: false,
|
||||
ok: false,
|
||||
url: None,
|
||||
payload: None,
|
||||
error: Some("invalid ClickHouse database identifier".to_string()),
|
||||
};
|
||||
};
|
||||
let timeout = Duration::from_secs(args.service_timeout_seconds.min(5));
|
||||
let sql = format!(
|
||||
"SELECT toUInt64(count()) AS events_24h FROM {database}.entity_timeline WHERE ts >= now() - INTERVAL 24 HOUR FORMAT JSONEachRow"
|
||||
);
|
||||
match clickhouse_query_first(args, &sql, timeout) {
|
||||
Ok(payload) => ServiceCheck {
|
||||
name,
|
||||
required: false,
|
||||
ok: true,
|
||||
url: Some(args.clickhouse_url.trim_end_matches('/').to_string()),
|
||||
payload: Some(payload.unwrap_or_else(|| serde_json::json!({"events_24h": 0}))),
|
||||
error: None,
|
||||
},
|
||||
Err(err) => ServiceCheck {
|
||||
name,
|
||||
required: false,
|
||||
ok: false,
|
||||
url: Some(args.clickhouse_url.trim_end_matches('/').to_string()),
|
||||
payload: None,
|
||||
error: Some(err.to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn clickhouse_identifier(value: &str) -> Option<String> {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty()
|
||||
|| !trimmed
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(trimmed.to_string())
|
||||
}
|
||||
|
||||
fn clickhouse_query_first(args: &Cli, sql: &str, timeout: Duration) -> Result<Option<Value>> {
|
||||
let client = Client::builder()
|
||||
.timeout(timeout)
|
||||
.no_proxy()
|
||||
.build()
|
||||
.context("ClickHouse HTTP client")?;
|
||||
let mut request = client
|
||||
.post(args.clickhouse_url.trim_end_matches('/'))
|
||||
.query(&[("database", args.clickhouse_database.trim())])
|
||||
.body(sql.to_string());
|
||||
if !args.clickhouse_user.trim().is_empty() {
|
||||
request = request.basic_auth(
|
||||
args.clickhouse_user.trim().to_string(),
|
||||
Some(args.clickhouse_password.clone()),
|
||||
);
|
||||
}
|
||||
let text = request
|
||||
.send()
|
||||
.context("ClickHouse request")?
|
||||
.error_for_status()
|
||||
.context("ClickHouse HTTP status")?
|
||||
.text()
|
||||
.context("ClickHouse response body")?;
|
||||
let Some(line) = text.lines().map(str::trim).find(|line| !line.is_empty()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(Some(
|
||||
serde_json::from_str::<Value>(line).context("ClickHouse JSONEachRow")?,
|
||||
))
|
||||
}
|
||||
|
||||
fn grafana_data_check(args: &Cli) -> ServiceCheck {
|
||||
let name = "grafana-data".to_string();
|
||||
let output = read_grafana_check_json_from_ct(args);
|
||||
let output = match output {
|
||||
Ok(output) => output,
|
||||
Err(err) => {
|
||||
return ServiceCheck {
|
||||
name,
|
||||
required: true,
|
||||
ok: false,
|
||||
url: None,
|
||||
payload: None,
|
||||
error: Some(format!("cannot execute pct for Grafana check: {err}")),
|
||||
};
|
||||
}
|
||||
};
|
||||
if !output.status.success() {
|
||||
return ServiceCheck {
|
||||
name,
|
||||
required: true,
|
||||
ok: false,
|
||||
url: None,
|
||||
payload: None,
|
||||
error: Some(format!(
|
||||
"pct grafana check read failed with status {:?}: {}",
|
||||
output.status.code(),
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
)),
|
||||
};
|
||||
}
|
||||
let raw = String::from_utf8_lossy(&output.stdout);
|
||||
let payload = match serde_json::from_str::<Value>(&raw) {
|
||||
Ok(payload) => payload,
|
||||
Err(err) => {
|
||||
return ServiceCheck {
|
||||
name,
|
||||
required: true,
|
||||
ok: false,
|
||||
url: None,
|
||||
payload: None,
|
||||
error: Some(format!("cannot parse Grafana check JSON: {err}")),
|
||||
};
|
||||
}
|
||||
};
|
||||
let check_ok = payload.get("ok").and_then(Value::as_bool).unwrap_or(false);
|
||||
let fail_count = payload
|
||||
.pointer("/counts/fail")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(1);
|
||||
let generated_at = payload
|
||||
.get("generated_at_utc")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("");
|
||||
let age_seconds = DateTime::parse_from_rfc3339(generated_at)
|
||||
.map(|ts| (Utc::now() - ts.with_timezone(&Utc)).num_seconds())
|
||||
.unwrap_or(i64::MAX);
|
||||
let fresh = (0..=args.grafana_check_max_age_seconds).contains(&age_seconds);
|
||||
let ok = check_ok && fail_count == 0 && fresh;
|
||||
ServiceCheck {
|
||||
name,
|
||||
required: true,
|
||||
ok,
|
||||
url: None,
|
||||
payload: Some(serde_json::json!({
|
||||
"generated_at_utc": generated_at,
|
||||
"age_seconds": age_seconds,
|
||||
"max_age_seconds": args.grafana_check_max_age_seconds,
|
||||
"check_ok": check_ok,
|
||||
"fail_count": fail_count,
|
||||
"dashboard_uid": payload.get("dashboard_uid"),
|
||||
"counts": payload.get("counts"),
|
||||
})),
|
||||
error: if ok {
|
||||
None
|
||||
} else {
|
||||
Some(format!(
|
||||
"Grafana check unhealthy: ok={check_ok} fail_count={fail_count} age_seconds={age_seconds}"
|
||||
))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn read_grafana_check_json_from_ct(args: &Cli) -> std::io::Result<std::process::Output> {
|
||||
let ct_id = args.grafana_ct_id.to_string();
|
||||
let pct_args = [
|
||||
"exec",
|
||||
ct_id.as_str(),
|
||||
"--",
|
||||
"cat",
|
||||
args.grafana_check_json.as_str(),
|
||||
];
|
||||
if let Some(custom_pct) = std::env::var("DETMIR_PCT_BIN")
|
||||
.ok()
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
return Command::new(custom_pct).args(pct_args).output();
|
||||
}
|
||||
let sudo_output = Command::new("/usr/bin/sudo")
|
||||
.args(["-n", "/usr/sbin/pct"])
|
||||
.args(pct_args)
|
||||
.output();
|
||||
match sudo_output {
|
||||
Ok(output) if output.status.success() => Ok(output),
|
||||
Ok(output) if output.status.code() != Some(127) => Ok(output),
|
||||
_ => Command::new("/usr/sbin/pct").args(pct_args).output(),
|
||||
}
|
||||
}
|
||||
|
||||
fn tcp_check(host: &str, port: u16, timeout_seconds: f64, required: bool) -> ServiceCheck {
|
||||
let name = format!("tcp:{host}:{port}");
|
||||
let timeout = Duration::from_secs_f64(timeout_seconds);
|
||||
let addr = format!("{host}:{port}");
|
||||
match addr.parse::<SocketAddr>() {
|
||||
Ok(addr) => match TcpStream::connect_timeout(&addr, timeout) {
|
||||
Ok(_) => ServiceCheck {
|
||||
name,
|
||||
required,
|
||||
ok: true,
|
||||
url: None,
|
||||
payload: None,
|
||||
error: None,
|
||||
},
|
||||
Err(err) => ServiceCheck {
|
||||
name,
|
||||
required,
|
||||
ok: false,
|
||||
url: None,
|
||||
payload: None,
|
||||
error: Some(err.to_string()),
|
||||
},
|
||||
},
|
||||
Err(err) => ServiceCheck {
|
||||
name,
|
||||
required,
|
||||
ok: false,
|
||||
url: None,
|
||||
payload: None,
|
||||
error: Some(err.to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn bucket_health(args: &Cli) -> Result<Vec<BucketCheck>> {
|
||||
let client = ActivityWatchClient::new(
|
||||
args.aw_api.trim_end_matches('/'),
|
||||
Duration::from_secs(args.bucket_timeout_seconds),
|
||||
)?;
|
||||
let now = Utc::now();
|
||||
let interactive_required = interactive_required(&client, &args.hostname, now);
|
||||
let mut out = Vec::new();
|
||||
|
||||
for spec in bucket_specs(&args.hostname) {
|
||||
if matches!(spec.mode, BucketMode::EventDriven) {
|
||||
out.push(BucketCheck {
|
||||
label: spec.label.to_string(),
|
||||
bucket: spec.bucket,
|
||||
mode: spec.mode.as_str().to_string(),
|
||||
status: "EVENT-DRIVEN".to_string(),
|
||||
ok: true,
|
||||
event_count_sample: None,
|
||||
latest: None,
|
||||
age_seconds: None,
|
||||
error: None,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
match client.latest_event(&spec.bucket) {
|
||||
Ok(Some(event)) => {
|
||||
let ts = event.timestamp_utc()?;
|
||||
let age = (now - ts).num_seconds();
|
||||
let (status, ok) =
|
||||
classify_bucket(spec.mode, spec.max_age_seconds, age, interactive_required);
|
||||
out.push(BucketCheck {
|
||||
label: spec.label.to_string(),
|
||||
bucket: spec.bucket,
|
||||
mode: spec.mode.as_str().to_string(),
|
||||
status: status.to_string(),
|
||||
ok,
|
||||
event_count_sample: Some(1),
|
||||
latest: Some(ts.to_rfc3339_opts(SecondsFormat::Secs, true)),
|
||||
age_seconds: Some(age),
|
||||
error: None,
|
||||
});
|
||||
}
|
||||
Ok(None) => {
|
||||
let (status, ok) = classify_missing_bucket(spec.mode, interactive_required);
|
||||
out.push(BucketCheck {
|
||||
label: spec.label.to_string(),
|
||||
bucket: spec.bucket,
|
||||
mode: spec.mode.as_str().to_string(),
|
||||
status: status.to_string(),
|
||||
ok,
|
||||
event_count_sample: Some(0),
|
||||
latest: None,
|
||||
age_seconds: None,
|
||||
error: None,
|
||||
});
|
||||
}
|
||||
Err(err) => out.push(BucketCheck {
|
||||
label: spec.label.to_string(),
|
||||
bucket: spec.bucket,
|
||||
mode: spec.mode.as_str().to_string(),
|
||||
status: "DEAD".to_string(),
|
||||
ok: false,
|
||||
event_count_sample: None,
|
||||
latest: None,
|
||||
age_seconds: None,
|
||||
error: Some(err.to_string()),
|
||||
}),
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn interactive_required(client: &ActivityWatchClient, hostname: &str, now: DateTime<Utc>) -> bool {
|
||||
let bucket = format!("aw-worktime-sessions_{hostname}");
|
||||
let Ok(Some(event)) = client.latest_event(&bucket) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(ts) = event.timestamp_utc() else {
|
||||
return false;
|
||||
};
|
||||
let age = (now - ts).num_seconds();
|
||||
let fresh = (0..=5 * 60).contains(&age);
|
||||
let active = event
|
||||
.data
|
||||
.get("active")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
fresh && active
|
||||
}
|
||||
|
||||
fn classify_bucket(
|
||||
mode: BucketMode,
|
||||
max_age_seconds: Option<i64>,
|
||||
age_seconds: i64,
|
||||
interactive_required: bool,
|
||||
) -> (&'static str, bool) {
|
||||
match mode {
|
||||
BucketMode::InactiveOk => {
|
||||
if max_age_seconds.is_some_and(|max_age| age_seconds <= max_age) {
|
||||
("FRESH", true)
|
||||
} else {
|
||||
("INACTIVE", true)
|
||||
}
|
||||
}
|
||||
BucketMode::InteractiveFresh if !interactive_required => ("INACTIVE", true),
|
||||
BucketMode::InteractiveFresh | BucketMode::Fresh => {
|
||||
if max_age_seconds.is_some_and(|max_age| age_seconds <= max_age) {
|
||||
("FRESH", true)
|
||||
} else {
|
||||
("STALE", false)
|
||||
}
|
||||
}
|
||||
BucketMode::EventDriven => ("EVENT-DRIVEN", true),
|
||||
}
|
||||
}
|
||||
|
||||
fn classify_missing_bucket(mode: BucketMode, interactive_required: bool) -> (&'static str, bool) {
|
||||
match mode {
|
||||
BucketMode::InteractiveFresh if !interactive_required => ("INACTIVE", true),
|
||||
BucketMode::EventDriven => ("EVENT-DRIVEN", true),
|
||||
_ => ("DEAD", false),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_report(args: &Cli) -> Result<CheckReport> {
|
||||
let services = service_checks(args);
|
||||
let buckets = bucket_health(args)?;
|
||||
let bucket_ok = buckets.iter().filter(|bucket| bucket.ok).count();
|
||||
let bucket_stale = buckets
|
||||
.iter()
|
||||
.filter(|bucket| !bucket.ok && bucket.status == "STALE")
|
||||
.count();
|
||||
let bucket_dead = buckets
|
||||
.iter()
|
||||
.filter(|bucket| !bucket.ok && bucket.status == "DEAD")
|
||||
.count();
|
||||
let service_failures = services
|
||||
.iter()
|
||||
.filter(|service| service.required && !service.ok)
|
||||
.count();
|
||||
let service_warnings = services
|
||||
.iter()
|
||||
.filter(|service| !service.required && service.error.is_some())
|
||||
.count();
|
||||
let ok = service_failures == 0 && bucket_stale == 0 && bucket_dead == 0;
|
||||
|
||||
Ok(CheckReport {
|
||||
ok,
|
||||
generated_at_utc: now_utc_rfc3339(),
|
||||
services,
|
||||
buckets,
|
||||
summary: CheckSummary {
|
||||
bucket_ok,
|
||||
bucket_stale,
|
||||
bucket_dead,
|
||||
service_failures,
|
||||
service_warnings,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn render_text(report: &CheckReport) -> String {
|
||||
let mut lines = vec![
|
||||
"=== DetMir Autonomous Check ===".to_string(),
|
||||
format!("OK: {}", if report.ok { "True" } else { "False" }),
|
||||
String::new(),
|
||||
"Services:".to_string(),
|
||||
];
|
||||
for service in &report.services {
|
||||
let mark = if service.ok && service.error.is_none() {
|
||||
"OK"
|
||||
} else if service.required {
|
||||
"FAIL"
|
||||
} else {
|
||||
"WARN"
|
||||
};
|
||||
lines.push(format!(" {:<18} {}", service.name, mark));
|
||||
}
|
||||
lines.push(String::new());
|
||||
lines.push(format!("{:<20} {:<13} {:>8}", "Bucket", "Status", "Age(s)"));
|
||||
lines.push("-".repeat(44));
|
||||
for bucket in &report.buckets {
|
||||
let age = bucket
|
||||
.age_seconds
|
||||
.map(|value| value.to_string())
|
||||
.unwrap_or_else(|| "-".to_string());
|
||||
lines.push(format!(
|
||||
"{:<20} {:<13} {:>8}",
|
||||
bucket.label, bucket.status, age
|
||||
));
|
||||
}
|
||||
lines.push(String::new());
|
||||
lines.push(format!(
|
||||
"Summary: OK={} STALE={} DEAD={} SERVICE_FAIL={} SERVICE_WARN={}",
|
||||
report.summary.bucket_ok,
|
||||
report.summary.bucket_stale,
|
||||
report.summary.bucket_dead,
|
||||
report.summary.service_failures,
|
||||
report.summary.service_warnings,
|
||||
));
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let mut args = Cli::parse();
|
||||
args.aw_api = env_or_default("DETMIR_AW_API", &args.aw_api);
|
||||
args.worktime_url = env_or_default("DETMIR_WORKTIME_URL", &args.worktime_url);
|
||||
args.one_c_url = env_or_default("DETMIR_ONE_C_URL", &args.one_c_url);
|
||||
args.rdp_host = env_or_default("DETMIR_RDP_HOST", &args.rdp_host);
|
||||
args.hostname = env_or_default("DETMIR_HOSTNAME", &args.hostname);
|
||||
args.grafana_check_json = env_or_default("DETMIR_GRAFANA_CHECK_JSON", &args.grafana_check_json);
|
||||
args.security_events_backend =
|
||||
env_or_default("SECURITY_EVENTS_BACKEND", &args.security_events_backend);
|
||||
args.clickhouse_url = env_or_default("CLICKHOUSE_URL", &args.clickhouse_url);
|
||||
args.clickhouse_database = env_or_default("CLICKHOUSE_DATABASE", &args.clickhouse_database);
|
||||
args.clickhouse_user = env_or_default("CLICKHOUSE_USER", &args.clickhouse_user);
|
||||
args.clickhouse_password = env_or_default("CLICKHOUSE_PASSWORD", &args.clickhouse_password);
|
||||
|
||||
let report = build_report(&args)?;
|
||||
if args.json {
|
||||
println!("{}", serde_json::to_string_pretty(&report)?);
|
||||
} else {
|
||||
println!("{}", render_text(&report));
|
||||
}
|
||||
std::process::exit(if report.ok {
|
||||
exit_codes::OK
|
||||
} else {
|
||||
exit_codes::CHECK_FAILED
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn interactive_bucket_is_inactive_when_no_interactive_session() {
|
||||
let (status, ok) = classify_bucket(
|
||||
BucketMode::InteractiveFresh,
|
||||
Some(15 * 60),
|
||||
3 * 60 * 60,
|
||||
false,
|
||||
);
|
||||
assert_eq!(status, "INACTIVE");
|
||||
assert!(ok);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interactive_bucket_is_stale_when_session_is_active() {
|
||||
let (status, ok) = classify_bucket(
|
||||
BucketMode::InteractiveFresh,
|
||||
Some(15 * 60),
|
||||
3 * 60 * 60,
|
||||
true,
|
||||
);
|
||||
assert_eq!(status, "STALE");
|
||||
assert!(!ok);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_interactive_bucket_is_inactive_when_no_interactive_session() {
|
||||
let (status, ok) = classify_missing_bucket(BucketMode::InteractiveFresh, false);
|
||||
assert_eq!(status, "INACTIVE");
|
||||
assert!(ok);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn security_events_backend_disabled_by_default() {
|
||||
let args = Cli::parse_from(["detmir-check"]);
|
||||
assert!(!security_events_clickhouse_enabled(&args));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn security_events_backend_clickhouse_is_optional() {
|
||||
let args = Cli::parse_from([
|
||||
"detmir-check",
|
||||
"--security-events-backend",
|
||||
"clickhouse",
|
||||
"--clickhouse-database",
|
||||
"analytics_1c",
|
||||
]);
|
||||
assert!(security_events_clickhouse_enabled(&args));
|
||||
assert_eq!(
|
||||
clickhouse_identifier(&args.clickhouse_database).as_deref(),
|
||||
Some("analytics_1c")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clickhouse_database_identifier_rejects_injection() {
|
||||
assert_eq!(
|
||||
clickhouse_identifier("analytics_1c").as_deref(),
|
||||
Some("analytics_1c")
|
||||
);
|
||||
assert!(clickhouse_identifier("analytics_1c;DROP TABLE x").is_none());
|
||||
assert!(clickhouse_identifier("").is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "detmir-core"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
chrono.workspace = true
|
||||
serde.workspace = true
|
||||
@@ -0,0 +1,226 @@
|
||||
use std::fmt;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use chrono::{DateTime, SecondsFormat, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "UPPERCASE")]
|
||||
pub enum StatusLevel {
|
||||
Ok,
|
||||
Warn,
|
||||
Fail,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl StatusLevel {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Ok => "OK",
|
||||
Self::Warn => "WARN",
|
||||
Self::Fail => "FAIL",
|
||||
Self::Unknown => "UNKNOWN",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn exit_code(self) -> i32 {
|
||||
match self {
|
||||
Self::Ok => exit_codes::OK,
|
||||
Self::Warn | Self::Fail | Self::Unknown => exit_codes::CHECK_FAILED,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for StatusLevel {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for StatusLevel {
|
||||
fn from(value: &str) -> Self {
|
||||
match value.trim().to_ascii_uppercase().as_str() {
|
||||
"OK" => Self::Ok,
|
||||
"WARN" | "WARNING" => Self::Warn,
|
||||
"FAIL" | "FAILED" | "ERROR" => Self::Fail,
|
||||
_ => Self::Unknown,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub mod exit_codes {
|
||||
pub const OK: i32 = 0;
|
||||
pub const ERROR: i32 = 1;
|
||||
pub const CHECK_FAILED: i32 = 2;
|
||||
pub const POLICY_DENIED: i32 = 3;
|
||||
}
|
||||
|
||||
pub fn now_utc_rfc3339() -> String {
|
||||
Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true)
|
||||
}
|
||||
|
||||
pub fn parse_utc_rfc3339(value: &str) -> Result<DateTime<Utc>> {
|
||||
DateTime::parse_from_rfc3339(value)
|
||||
.with_context(|| format!("invalid RFC3339 timestamp: {value}"))
|
||||
.map(|ts| ts.with_timezone(&Utc))
|
||||
}
|
||||
|
||||
pub mod runtime_guard {
|
||||
use anyhow::{Result, bail};
|
||||
|
||||
const TEST_NET_MARKERS: [&str; 3] = ["192.0.2.", "198.51.100.", "203.0.113."];
|
||||
const CONTAINS_PLACEHOLDERS: [&str; 2] = ["HOST-EXAMPLE", "WINDOWS_USER_EXAMPLE"];
|
||||
const EXACT_PLACEHOLDERS: [&str; 9] = [
|
||||
"CHANGE_ME",
|
||||
"CHANGEME",
|
||||
"REPLACE_ME",
|
||||
"REPLACE-ME",
|
||||
"YOUR_TOKEN",
|
||||
"YOUR_API_KEY",
|
||||
"TOKEN",
|
||||
"SECRET",
|
||||
"PASSWORD",
|
||||
];
|
||||
|
||||
pub fn is_runtime_placeholder(value: &str) -> bool {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
return true;
|
||||
}
|
||||
let normalized = trimmed.to_ascii_uppercase();
|
||||
TEST_NET_MARKERS
|
||||
.iter()
|
||||
.any(|marker| normalized.contains(marker))
|
||||
|| CONTAINS_PLACEHOLDERS
|
||||
.iter()
|
||||
.any(|placeholder| normalized.contains(placeholder))
|
||||
|| EXACT_PLACEHOLDERS
|
||||
.iter()
|
||||
.any(|placeholder| normalized == *placeholder)
|
||||
|| normalized == "EXAMPLE"
|
||||
|| normalized.contains("-EXAMPLE")
|
||||
|| normalized.contains("_EXAMPLE")
|
||||
|| normalized.contains("EXAMPLE.")
|
||||
|| normalized.contains(".EXAMPLE")
|
||||
|| normalized.starts_with("YOUR_")
|
||||
|| (normalized.starts_with('<') && normalized.ends_with('>'))
|
||||
}
|
||||
|
||||
pub fn is_secret_placeholder(value: &str) -> bool {
|
||||
is_runtime_placeholder(value)
|
||||
|| matches!(
|
||||
value.trim().to_ascii_uppercase().as_str(),
|
||||
"API_KEY" | "INFLUX_TOKEN" | "WRITE_TOKEN" | "BEARER_TOKEN"
|
||||
)
|
||||
}
|
||||
|
||||
pub fn ensure_runtime_value(name: &str, value: &str, context: &str) -> Result<()> {
|
||||
if is_runtime_placeholder(value) {
|
||||
bail!("{name} contains an empty/example/TEST-NET value while {context}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn ensure_secret_value(name: &str, value: &str, context: &str) -> Result<()> {
|
||||
if is_secret_placeholder(value) {
|
||||
bail!("{name} contains an empty/example secret value while {context}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn ensure_runtime_values<'a>(
|
||||
name: &str,
|
||||
values: impl IntoIterator<Item = &'a String>,
|
||||
context: &str,
|
||||
) -> Result<()> {
|
||||
let mut empty = true;
|
||||
for value in values {
|
||||
empty = false;
|
||||
ensure_runtime_value(name, value, context)?;
|
||||
}
|
||||
if empty {
|
||||
bail!("{name} contains an empty/example value while {context}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn ensure_influx_runtime_config(
|
||||
prefix: &str,
|
||||
url: &str,
|
||||
org: &str,
|
||||
bucket: &str,
|
||||
token: &str,
|
||||
hosts: &[String],
|
||||
) -> Result<()> {
|
||||
let context = format!("{prefix}_ENABLED=true");
|
||||
ensure_runtime_value(&format!("{prefix}_URL"), url, &context)?;
|
||||
ensure_runtime_value(&format!("{prefix}_ORG"), org, &context)?;
|
||||
ensure_runtime_value(&format!("{prefix}_BUCKET"), bucket, &context)?;
|
||||
ensure_secret_value(&format!("{prefix}_TOKEN"), token, &context)?;
|
||||
ensure_runtime_values(&format!("{prefix}_HOSTS"), hosts, &context)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn status_level_maps_known_values() {
|
||||
assert_eq!(StatusLevel::from("OK"), StatusLevel::Ok);
|
||||
assert_eq!(StatusLevel::from("warn"), StatusLevel::Warn);
|
||||
assert_eq!(StatusLevel::from("ERROR"), StatusLevel::Fail);
|
||||
assert_eq!(StatusLevel::from("other"), StatusLevel::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_zulu_timestamp() {
|
||||
let ts = parse_utc_rfc3339("2026-05-31T10:20:30Z").unwrap();
|
||||
assert_eq!(
|
||||
ts.to_rfc3339_opts(SecondsFormat::Secs, true),
|
||||
"2026-05-31T10:20:30Z"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_guard_detects_public_placeholders() {
|
||||
assert!(runtime_guard::is_runtime_placeholder("HOST-EXAMPLE"));
|
||||
assert!(runtime_guard::is_runtime_placeholder(
|
||||
"http://192.0.2.10:8086"
|
||||
));
|
||||
assert!(runtime_guard::is_runtime_placeholder("<TOKEN>"));
|
||||
assert!(runtime_guard::is_secret_placeholder("CHANGE_ME"));
|
||||
assert!(!runtime_guard::is_runtime_placeholder("aw_metrics"));
|
||||
assert!(!runtime_guard::is_runtime_placeholder("proxmox"));
|
||||
assert!(!runtime_guard::is_secret_placeholder(
|
||||
"prod-write-token-value"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_guard_validates_full_influx_config() {
|
||||
let hosts = vec!["WINDOWS-HOST".to_string()];
|
||||
runtime_guard::ensure_influx_runtime_config(
|
||||
"AW_WORKTIME_INFLUX",
|
||||
"http://influxdb.internal:8086",
|
||||
"proxmox",
|
||||
"aw_metrics",
|
||||
"prod-write-token-value",
|
||||
&hosts,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let err = runtime_guard::ensure_influx_runtime_config(
|
||||
"AW_WORKTIME_INFLUX",
|
||||
"http://influxdb.internal:8086",
|
||||
"proxmox",
|
||||
"aw_metrics",
|
||||
"CHANGE_ME",
|
||||
&hosts,
|
||||
)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("AW_WORKTIME_INFLUX_TOKEN"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "detmir-dlp"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
clap.workspace = true
|
||||
@@ -0,0 +1,107 @@
|
||||
use std::io::{self, Write};
|
||||
use std::process::Command;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use clap::Parser;
|
||||
|
||||
const DEFAULT_SSH_TARGET: &str = "igor@192.0.2.13";
|
||||
const DEFAULT_REMOTE_COMMAND: &str = "sudo -n env AW_DLP_HEALTH_ENDPOINT_SEND_FAILURE_WARN_COUNT=10 AW_DLP_HEALTH_FILEOPS_SEND_FAILURE_WARN_COUNT=10 /usr/local/bin/dlp-health-check --json";
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(about = "Run the DetMir DLP health check on the AW server over SSH.")]
|
||||
struct Cli {
|
||||
#[arg(long, default_value = "ssh")]
|
||||
ssh_bin: String,
|
||||
|
||||
#[arg(long, default_value = DEFAULT_SSH_TARGET)]
|
||||
ssh_target: String,
|
||||
|
||||
#[arg(long, default_value_t = 10)]
|
||||
connect_timeout_seconds: u64,
|
||||
|
||||
#[arg(long, default_value = DEFAULT_REMOTE_COMMAND)]
|
||||
remote_command: String,
|
||||
}
|
||||
|
||||
impl Cli {
|
||||
fn apply_env(mut self) -> Self {
|
||||
self.ssh_target = env_first(
|
||||
&["DETMIR_DLP_SSH_TARGET", "DETMIR_AW_SSH_HOST"],
|
||||
&self.ssh_target,
|
||||
);
|
||||
self.remote_command = env_first(&["DETMIR_DLP_REMOTE_COMMAND"], &self.remote_command);
|
||||
self.ssh_bin = env_first(&["DETMIR_SSH_BIN"], &self.ssh_bin);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
fn env_first(names: &[&str], fallback: &str) -> String {
|
||||
names
|
||||
.iter()
|
||||
.find_map(|name| std::env::var(name).ok().filter(|value| !value.is_empty()))
|
||||
.unwrap_or_else(|| fallback.to_string())
|
||||
}
|
||||
|
||||
fn ssh_args(cli: &Cli) -> Vec<String> {
|
||||
vec![
|
||||
"-o".to_string(),
|
||||
"BatchMode=yes".to_string(),
|
||||
"-o".to_string(),
|
||||
format!("ConnectTimeout={}", cli.connect_timeout_seconds),
|
||||
"-o".to_string(),
|
||||
"StrictHostKeyChecking=accept-new".to_string(),
|
||||
cli.ssh_target.clone(),
|
||||
cli.remote_command.clone(),
|
||||
]
|
||||
}
|
||||
|
||||
fn run(cli: Cli) -> Result<i32> {
|
||||
let args = ssh_args(&cli);
|
||||
let output = Command::new(&cli.ssh_bin)
|
||||
.args(&args)
|
||||
.output()
|
||||
.with_context(|| format!("failed to execute {}", cli.ssh_bin))?;
|
||||
|
||||
io::stdout()
|
||||
.write_all(&output.stdout)
|
||||
.context("failed to write DLP stdout")?;
|
||||
io::stderr()
|
||||
.write_all(&output.stderr)
|
||||
.context("failed to write DLP stderr")?;
|
||||
|
||||
Ok(output.status.code().unwrap_or(1))
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let cli = Cli::parse().apply_env();
|
||||
let code = run(cli)?;
|
||||
std::process::exit(code);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn builds_legacy_ssh_args() {
|
||||
let cli = Cli {
|
||||
ssh_bin: "ssh".to_string(),
|
||||
ssh_target: DEFAULT_SSH_TARGET.to_string(),
|
||||
connect_timeout_seconds: 10,
|
||||
remote_command: DEFAULT_REMOTE_COMMAND.to_string(),
|
||||
};
|
||||
assert_eq!(
|
||||
ssh_args(&cli),
|
||||
vec![
|
||||
"-o",
|
||||
"BatchMode=yes",
|
||||
"-o",
|
||||
"ConnectTimeout=10",
|
||||
"-o",
|
||||
"StrictHostKeyChecking=accept-new",
|
||||
DEFAULT_SSH_TARGET,
|
||||
DEFAULT_REMOTE_COMMAND,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "detmir-grafana-check"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
chrono.workspace = true
|
||||
clap.workspace = true
|
||||
reqwest.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
@@ -0,0 +1,737 @@
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use chrono::{SecondsFormat, Utc};
|
||||
use clap::Parser;
|
||||
use reqwest::blocking::Client;
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const DEFAULT_GRAFANA_URL: &str = "http://127.0.0.1:3000";
|
||||
const DEFAULT_DASHBOARD_UID: &str = "detmir-aw-main";
|
||||
const DEFAULT_DASHBOARD_FILE: &str = "/etc/grafana/provisioning/dashboards/aw/detmir-aw-main.json";
|
||||
const DEFAULT_HOST: &str = "HOST-EXAMPLE";
|
||||
const OLD_MEASUREMENTS: &[&str] = &["aw_window_event", "aw_afk_event"];
|
||||
const REQUIRED_MEASUREMENTS: &[&str] = &[
|
||||
"aw_rdp_worktime_hourly",
|
||||
"aw_rdp_worktime_daily",
|
||||
"aw_rdp_worktime_summary_daily",
|
||||
"aw_true_active_app_daily",
|
||||
"aw_worktime_exporter_heartbeat",
|
||||
];
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(about = "Read-only DetMir Grafana dashboard freshness and correctness check")]
|
||||
struct Cli {
|
||||
#[arg(long, env = "DETMIR_GRAFANA_URL")]
|
||||
grafana_url: Option<String>,
|
||||
|
||||
#[arg(long, env = "DETMIR_GRAFANA_USER")]
|
||||
user: Option<String>,
|
||||
|
||||
#[arg(long, env = "DETMIR_GRAFANA_PASSWORD")]
|
||||
password: Option<String>,
|
||||
|
||||
#[arg(long, default_value = DEFAULT_DASHBOARD_UID, env = "DETMIR_GRAFANA_DASHBOARD_UID")]
|
||||
dashboard_uid: String,
|
||||
|
||||
#[arg(long, default_value = DEFAULT_DASHBOARD_FILE, env = "DETMIR_GRAFANA_DASHBOARD_FILE")]
|
||||
dashboard_file: PathBuf,
|
||||
|
||||
#[arg(long, default_value = DEFAULT_HOST, env = "DETMIR_GRAFANA_HOST")]
|
||||
host: String,
|
||||
|
||||
#[arg(long, default_value_t = 15, env = "DETMIR_GRAFANA_TIMEOUT_SECONDS")]
|
||||
timeout_seconds: u64,
|
||||
|
||||
#[arg(
|
||||
long,
|
||||
default_value_t = 360.0,
|
||||
env = "DETMIR_GRAFANA_MAX_FRESHNESS_MINUTES"
|
||||
)]
|
||||
max_freshness_minutes: f64,
|
||||
|
||||
#[arg(long, default_value_t = 1, env = "DETMIR_GRAFANA_MIN_PANEL_ROWS")]
|
||||
min_panel_rows: usize,
|
||||
|
||||
#[arg(long, default_value_t = 4, env = "DETMIR_GRAFANA_MIN_PANELS")]
|
||||
min_panels: usize,
|
||||
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
|
||||
#[arg(long, env = "DETMIR_GRAFANA_OUTPUT_JSON")]
|
||||
output_json: Option<PathBuf>,
|
||||
|
||||
#[arg(long, env = "DETMIR_GRAFANA_OUTPUT_TEXT")]
|
||||
output_text: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
enum Status {
|
||||
Ok,
|
||||
Warn,
|
||||
Fail,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct CheckResult {
|
||||
name: String,
|
||||
status: Status,
|
||||
summary: String,
|
||||
details: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize)]
|
||||
struct Counts {
|
||||
ok: usize,
|
||||
warn: usize,
|
||||
fail: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct Report {
|
||||
ok: bool,
|
||||
generated_at_utc: String,
|
||||
dashboard_uid: String,
|
||||
grafana_url: String,
|
||||
counts: Counts,
|
||||
results: Vec<CheckResult>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct PanelTarget {
|
||||
panel_id: i64,
|
||||
panel_title: String,
|
||||
ref_id: String,
|
||||
datasource_type: String,
|
||||
datasource_uid: String,
|
||||
query: String,
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let code = match run() {
|
||||
Ok(code) => code,
|
||||
Err(err) => {
|
||||
eprintln!("{err:#}");
|
||||
1
|
||||
}
|
||||
};
|
||||
std::process::exit(code);
|
||||
}
|
||||
|
||||
fn run() -> Result<i32> {
|
||||
let cli = Cli::parse();
|
||||
let report = build_report(&cli)?;
|
||||
let text = render_text(&report);
|
||||
if let Some(path) = &cli.output_json {
|
||||
write_report_file(path, &serde_json::to_string_pretty(&report)?)?;
|
||||
}
|
||||
if let Some(path) = &cli.output_text {
|
||||
write_report_file(path, &text)?;
|
||||
}
|
||||
if cli.json {
|
||||
println!("{}", serde_json::to_string_pretty(&report)?);
|
||||
} else {
|
||||
print!("{text}");
|
||||
}
|
||||
Ok(if report.ok { 0 } else { 2 })
|
||||
}
|
||||
|
||||
fn build_report(cli: &Cli) -> Result<Report> {
|
||||
let grafana_url = cli
|
||||
.grafana_url
|
||||
.clone()
|
||||
.or_else(|| env_nonempty("GRAFANA_URL"))
|
||||
.unwrap_or_else(|| DEFAULT_GRAFANA_URL.to_string())
|
||||
.trim_end_matches('/')
|
||||
.to_string();
|
||||
let user = cli.user.clone().or_else(|| env_nonempty("GRAFANA_USER"));
|
||||
let password = cli
|
||||
.password
|
||||
.clone()
|
||||
.or_else(|| env_nonempty("GRAFANA_PASSWORD"));
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::from_secs(cli.timeout_seconds))
|
||||
.no_proxy()
|
||||
.build()
|
||||
.context("build HTTP client")?;
|
||||
let mut results = Vec::new();
|
||||
|
||||
results.push(check_health(&client, &grafana_url));
|
||||
let dashboard_api = match get_json_auth(
|
||||
&client,
|
||||
&format!("{grafana_url}/api/dashboards/uid/{}", cli.dashboard_uid),
|
||||
user.as_deref(),
|
||||
password.as_deref(),
|
||||
) {
|
||||
Ok(value) => {
|
||||
results.push(CheckResult {
|
||||
name: "grafana:dashboard-api".to_string(),
|
||||
status: Status::Ok,
|
||||
summary: format!("dashboard {} loaded through Grafana API", cli.dashboard_uid),
|
||||
details: json!({
|
||||
"uid": cli.dashboard_uid,
|
||||
"title": value.pointer("/dashboard/title").and_then(Value::as_str),
|
||||
"version": value.pointer("/dashboard/version").and_then(Value::as_i64),
|
||||
}),
|
||||
});
|
||||
Some(value)
|
||||
}
|
||||
Err(err) => {
|
||||
results.push(CheckResult {
|
||||
name: "grafana:dashboard-api".to_string(),
|
||||
status: Status::Fail,
|
||||
summary: format!("dashboard API request failed: {err:#}"),
|
||||
details: json!({ "uid": cli.dashboard_uid }),
|
||||
});
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(api_value) = &dashboard_api {
|
||||
let dashboard = api_value.get("dashboard").unwrap_or(api_value);
|
||||
results.push(check_dashboard_shape(dashboard, cli.min_panels));
|
||||
results.push(check_measurements(
|
||||
"grafana:dashboard-api-measurements",
|
||||
dashboard,
|
||||
));
|
||||
let targets = collect_panel_targets(dashboard);
|
||||
results.push(CheckResult {
|
||||
name: "grafana:panel-targets".to_string(),
|
||||
status: if targets.is_empty() {
|
||||
Status::Fail
|
||||
} else {
|
||||
Status::Ok
|
||||
},
|
||||
summary: format!("{} query targets found", targets.len()),
|
||||
details: json!({
|
||||
"targets": targets.iter().map(|target| json!({
|
||||
"panel_id": target.panel_id,
|
||||
"panel_title": target.panel_title,
|
||||
"ref_id": target.ref_id,
|
||||
"datasource_uid": target.datasource_uid,
|
||||
})).collect::<Vec<_>>()
|
||||
}),
|
||||
});
|
||||
for target in &targets {
|
||||
let rendered_query = render_query_vars(&target.query, &cli.host);
|
||||
let query_result = query_panel_target(
|
||||
&client,
|
||||
&grafana_url,
|
||||
user.as_deref(),
|
||||
password.as_deref(),
|
||||
target,
|
||||
&rendered_query,
|
||||
);
|
||||
match query_result {
|
||||
Ok((rows, first_number)) => {
|
||||
let freshness_failed = is_freshness_panel(&target.panel_title)
|
||||
&& first_number
|
||||
.map(|value| value > cli.max_freshness_minutes)
|
||||
.unwrap_or(true);
|
||||
let empty_optional_panel =
|
||||
rows < cli.min_panel_rows && is_optional_empty_panel(&target.panel_title);
|
||||
let status = if freshness_failed
|
||||
|| (rows < cli.min_panel_rows && !empty_optional_panel)
|
||||
{
|
||||
Status::Fail
|
||||
} else if empty_optional_panel {
|
||||
Status::Warn
|
||||
} else {
|
||||
Status::Ok
|
||||
};
|
||||
let summary = if is_freshness_panel(&target.panel_title) {
|
||||
match first_number {
|
||||
Some(value) => format!(
|
||||
"panel '{}' returned {rows} rows, freshness {:.1} min",
|
||||
target.panel_title, value
|
||||
),
|
||||
None => format!(
|
||||
"panel '{}' returned {rows} rows, but freshness value was absent",
|
||||
target.panel_title
|
||||
),
|
||||
}
|
||||
} else {
|
||||
format!("panel '{}' returned {rows} rows", target.panel_title)
|
||||
};
|
||||
results.push(CheckResult {
|
||||
name: format!("grafana:panel-query:{}:{}", target.panel_id, target.ref_id),
|
||||
status,
|
||||
summary,
|
||||
details: json!({
|
||||
"panel_id": target.panel_id,
|
||||
"panel_title": target.panel_title,
|
||||
"ref_id": target.ref_id,
|
||||
"rows": rows,
|
||||
"first_number": first_number,
|
||||
"min_panel_rows": cli.min_panel_rows,
|
||||
"max_freshness_minutes": cli.max_freshness_minutes,
|
||||
}),
|
||||
});
|
||||
}
|
||||
Err(err) => results.push(CheckResult {
|
||||
name: format!("grafana:panel-query:{}:{}", target.panel_id, target.ref_id),
|
||||
status: Status::Fail,
|
||||
summary: format!("panel '{}' query failed: {err:#}", target.panel_title),
|
||||
details: json!({
|
||||
"panel_id": target.panel_id,
|
||||
"panel_title": target.panel_title,
|
||||
"ref_id": target.ref_id,
|
||||
"datasource_uid": target.datasource_uid,
|
||||
}),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if cli.dashboard_file.exists() {
|
||||
match read_json_file(&cli.dashboard_file) {
|
||||
Ok(file_dashboard) => {
|
||||
results.push(check_measurements(
|
||||
"grafana:provisioned-file-measurements",
|
||||
&file_dashboard,
|
||||
));
|
||||
}
|
||||
Err(err) => results.push(CheckResult {
|
||||
name: "grafana:provisioned-file".to_string(),
|
||||
status: Status::Warn,
|
||||
summary: format!(
|
||||
"cannot read provisioned dashboard file {}: {err:#}",
|
||||
cli.dashboard_file.display()
|
||||
),
|
||||
details: json!({ "path": cli.dashboard_file }),
|
||||
}),
|
||||
}
|
||||
} else {
|
||||
results.push(CheckResult {
|
||||
name: "grafana:provisioned-file".to_string(),
|
||||
status: Status::Warn,
|
||||
summary: format!("dashboard file not found: {}", cli.dashboard_file.display()),
|
||||
details: json!({ "path": cli.dashboard_file }),
|
||||
});
|
||||
}
|
||||
|
||||
let counts = count_statuses(&results);
|
||||
Ok(Report {
|
||||
ok: counts.fail == 0,
|
||||
generated_at_utc: Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true),
|
||||
dashboard_uid: cli.dashboard_uid.clone(),
|
||||
grafana_url,
|
||||
counts,
|
||||
results,
|
||||
})
|
||||
}
|
||||
|
||||
fn check_health(client: &Client, grafana_url: &str) -> CheckResult {
|
||||
match get_json_auth(client, &format!("{grafana_url}/api/health"), None, None) {
|
||||
Ok(value) => {
|
||||
let database = value
|
||||
.get("database")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("unknown");
|
||||
CheckResult {
|
||||
name: "grafana:health".to_string(),
|
||||
status: if database.eq_ignore_ascii_case("ok") {
|
||||
Status::Ok
|
||||
} else {
|
||||
Status::Warn
|
||||
},
|
||||
summary: format!("Grafana health database={database}"),
|
||||
details: value,
|
||||
}
|
||||
}
|
||||
Err(err) => CheckResult {
|
||||
name: "grafana:health".to_string(),
|
||||
status: Status::Fail,
|
||||
summary: format!("Grafana health request failed: {err:#}"),
|
||||
details: json!({ "url": grafana_url }),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn check_dashboard_shape(dashboard: &Value, min_panels: usize) -> CheckResult {
|
||||
let title = dashboard
|
||||
.get("title")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("unknown");
|
||||
let panels = collect_panels(dashboard);
|
||||
CheckResult {
|
||||
name: "grafana:dashboard-shape".to_string(),
|
||||
status: if panels.len() >= min_panels {
|
||||
Status::Ok
|
||||
} else {
|
||||
Status::Fail
|
||||
},
|
||||
summary: format!("dashboard '{title}' has {} panels", panels.len()),
|
||||
details: json!({
|
||||
"title": title,
|
||||
"panel_count": panels.len(),
|
||||
"min_panels": min_panels,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn check_measurements(name: &str, dashboard: &Value) -> CheckResult {
|
||||
let text = dashboard.to_string();
|
||||
let old = contains_any(&text, OLD_MEASUREMENTS);
|
||||
let missing_required = REQUIRED_MEASUREMENTS
|
||||
.iter()
|
||||
.filter(|measurement| !text.contains(**measurement))
|
||||
.copied()
|
||||
.collect::<Vec<_>>();
|
||||
let status = if old || !missing_required.is_empty() {
|
||||
Status::Fail
|
||||
} else {
|
||||
Status::Ok
|
||||
};
|
||||
CheckResult {
|
||||
name: name.to_string(),
|
||||
status,
|
||||
summary: if status == Status::Ok {
|
||||
"dashboard uses current DetMir worktime measurements".to_string()
|
||||
} else {
|
||||
"dashboard measurement set is stale or incomplete".to_string()
|
||||
},
|
||||
details: json!({
|
||||
"old_measurements_present": OLD_MEASUREMENTS
|
||||
.iter()
|
||||
.filter(|measurement| text.contains(**measurement))
|
||||
.copied()
|
||||
.collect::<Vec<_>>(),
|
||||
"required_measurements_missing": missing_required,
|
||||
"required_measurements": REQUIRED_MEASUREMENTS,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn query_panel_target(
|
||||
client: &Client,
|
||||
grafana_url: &str,
|
||||
user: Option<&str>,
|
||||
password: Option<&str>,
|
||||
target: &PanelTarget,
|
||||
query: &str,
|
||||
) -> Result<(usize, Option<f64>)> {
|
||||
let body = json!({
|
||||
"from": "now-48h",
|
||||
"to": "now",
|
||||
"queries": [{
|
||||
"refId": target.ref_id,
|
||||
"datasource": {
|
||||
"type": target.datasource_type,
|
||||
"uid": target.datasource_uid,
|
||||
},
|
||||
"query": query,
|
||||
"rawQuery": true,
|
||||
"format": "table",
|
||||
"intervalMs": 60000,
|
||||
"maxDataPoints": 1000,
|
||||
}]
|
||||
});
|
||||
let response = post_json_auth(
|
||||
client,
|
||||
&format!("{grafana_url}/api/ds/query"),
|
||||
user,
|
||||
password,
|
||||
&body,
|
||||
)?;
|
||||
let result = response
|
||||
.pointer(&format!("/results/{}", target.ref_id))
|
||||
.ok_or_else(|| anyhow!("missing result for refId {}", target.ref_id))?;
|
||||
if let Some(error) = result.get("error").and_then(Value::as_str) {
|
||||
return Err(anyhow!("{error}"));
|
||||
}
|
||||
if let Some(status) = result.get("status").and_then(Value::as_i64) {
|
||||
if status >= 400 {
|
||||
return Err(anyhow!("Grafana datasource status {status}"));
|
||||
}
|
||||
}
|
||||
Ok((frame_row_count(result), first_numeric_value(result)))
|
||||
}
|
||||
|
||||
fn get_json_auth(
|
||||
client: &Client,
|
||||
url: &str,
|
||||
user: Option<&str>,
|
||||
password: Option<&str>,
|
||||
) -> Result<Value> {
|
||||
let mut request = client.get(url);
|
||||
if let Some(user) = user {
|
||||
request = request.basic_auth(user, password);
|
||||
}
|
||||
let response = request
|
||||
.send()
|
||||
.with_context(|| format!("GET {url}"))?
|
||||
.error_for_status()
|
||||
.with_context(|| format!("GET {url} returned non-success status"))?;
|
||||
response
|
||||
.json::<Value>()
|
||||
.with_context(|| format!("decode JSON from {url}"))
|
||||
}
|
||||
|
||||
fn post_json_auth(
|
||||
client: &Client,
|
||||
url: &str,
|
||||
user: Option<&str>,
|
||||
password: Option<&str>,
|
||||
body: &Value,
|
||||
) -> Result<Value> {
|
||||
let mut request = client.post(url).json(body);
|
||||
if let Some(user) = user {
|
||||
request = request.basic_auth(user, password);
|
||||
}
|
||||
let response = request
|
||||
.send()
|
||||
.with_context(|| format!("POST {url}"))?
|
||||
.error_for_status()
|
||||
.with_context(|| format!("POST {url} returned non-success status"))?;
|
||||
response
|
||||
.json::<Value>()
|
||||
.with_context(|| format!("decode JSON from {url}"))
|
||||
}
|
||||
|
||||
fn read_json_file(path: &PathBuf) -> Result<Value> {
|
||||
let text = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
|
||||
serde_json::from_str(&text).with_context(|| format!("parse {}", path.display()))
|
||||
}
|
||||
|
||||
fn collect_panel_targets(dashboard: &Value) -> Vec<PanelTarget> {
|
||||
let mut targets = Vec::new();
|
||||
for panel in collect_panels(dashboard) {
|
||||
let panel_id = panel.get("id").and_then(Value::as_i64).unwrap_or(0);
|
||||
let panel_title = panel
|
||||
.get("title")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("untitled")
|
||||
.to_string();
|
||||
let datasource_type = panel
|
||||
.pointer("/datasource/type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("influxdb")
|
||||
.to_string();
|
||||
let datasource_uid = panel
|
||||
.pointer("/datasource/uid")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let Some(items) = panel.get("targets").and_then(Value::as_array) else {
|
||||
continue;
|
||||
};
|
||||
for item in items {
|
||||
let Some(query) = item.get("query").and_then(Value::as_str) else {
|
||||
continue;
|
||||
};
|
||||
targets.push(PanelTarget {
|
||||
panel_id,
|
||||
panel_title: panel_title.clone(),
|
||||
ref_id: item
|
||||
.get("refId")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("A")
|
||||
.to_string(),
|
||||
datasource_type: item
|
||||
.pointer("/datasource/type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or(&datasource_type)
|
||||
.to_string(),
|
||||
datasource_uid: item
|
||||
.pointer("/datasource/uid")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or(&datasource_uid)
|
||||
.to_string(),
|
||||
query: query.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
targets
|
||||
}
|
||||
|
||||
fn collect_panels(dashboard: &Value) -> Vec<&Value> {
|
||||
let mut panels = Vec::new();
|
||||
collect_panels_inner(dashboard, &mut panels);
|
||||
panels
|
||||
}
|
||||
|
||||
fn collect_panels_inner<'a>(value: &'a Value, panels: &mut Vec<&'a Value>) {
|
||||
if value.get("targets").is_some() && value.get("type").is_some() {
|
||||
panels.push(value);
|
||||
}
|
||||
if let Some(children) = value.get("panels").and_then(Value::as_array) {
|
||||
for child in children {
|
||||
collect_panels_inner(child, panels);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_query_vars(query: &str, host: &str) -> String {
|
||||
query.replace("${host}", host).replace("$host", host)
|
||||
}
|
||||
|
||||
fn contains_any(text: &str, needles: &[&str]) -> bool {
|
||||
needles.iter().any(|needle| text.contains(needle))
|
||||
}
|
||||
|
||||
fn is_freshness_panel(title: &str) -> bool {
|
||||
title.to_lowercase().contains("свеж")
|
||||
}
|
||||
|
||||
fn is_optional_empty_panel(title: &str) -> bool {
|
||||
let title = title.to_lowercase();
|
||||
title.contains("прилож") || title.contains("сотрудник")
|
||||
}
|
||||
|
||||
fn frame_row_count(value: &Value) -> usize {
|
||||
value
|
||||
.get("frames")
|
||||
.and_then(Value::as_array)
|
||||
.map(|frames| {
|
||||
frames
|
||||
.iter()
|
||||
.filter_map(|frame| frame.pointer("/data/values").and_then(Value::as_array))
|
||||
.map(|columns| {
|
||||
columns
|
||||
.iter()
|
||||
.filter_map(Value::as_array)
|
||||
.map(Vec::len)
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
})
|
||||
.sum()
|
||||
})
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn first_numeric_value(value: &Value) -> Option<f64> {
|
||||
match value {
|
||||
Value::Number(number) => number.as_f64(),
|
||||
Value::Array(items) => items.iter().find_map(first_numeric_value),
|
||||
Value::Object(map) => map.values().find_map(first_numeric_value),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn count_statuses(results: &[CheckResult]) -> Counts {
|
||||
let mut counts = Counts::default();
|
||||
for result in results {
|
||||
match result.status {
|
||||
Status::Ok => counts.ok += 1,
|
||||
Status::Warn => counts.warn += 1,
|
||||
Status::Fail => counts.fail += 1,
|
||||
}
|
||||
}
|
||||
counts
|
||||
}
|
||||
|
||||
fn env_nonempty(name: &str) -> Option<String> {
|
||||
std::env::var(name).ok().filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn write_report_file(path: &PathBuf, content: &str) -> Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
|
||||
}
|
||||
fs::write(path, content).with_context(|| format!("write {}", path.display()))
|
||||
}
|
||||
|
||||
fn render_text(report: &Report) -> String {
|
||||
let mut out = String::new();
|
||||
out.push_str("=== DetMir Grafana Check ===\n");
|
||||
out.push_str(&format!("dashboard_uid={}\n", report.dashboard_uid));
|
||||
out.push_str(&format!("grafana_url={}\n", report.grafana_url));
|
||||
out.push_str(&format!("generated_at_utc={}\n\n", report.generated_at_utc));
|
||||
for result in &report.results {
|
||||
out.push_str(&format!(
|
||||
"{:<5} {:<42} {}\n",
|
||||
format!("{:?}", result.status).to_uppercase(),
|
||||
result.name,
|
||||
result.summary
|
||||
));
|
||||
}
|
||||
out.push('\n');
|
||||
out.push_str(&format!(
|
||||
"counts: ok={} warn={} fail={}",
|
||||
report.counts.ok, report.counts.warn, report.counts.fail
|
||||
));
|
||||
out.push('\n');
|
||||
out.push_str(&format!("ok={}\n", report.ok));
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn query_vars_render_host_forms() {
|
||||
assert_eq!(
|
||||
render_query_vars(
|
||||
"r.host == \"${host}\" or r.host == \"$host\"",
|
||||
"HOST-EXAMPLE"
|
||||
),
|
||||
"r.host == \"HOST-EXAMPLE\" or r.host == \"HOST-EXAMPLE\""
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_row_count_reads_grafana_frames() {
|
||||
let value = json!({
|
||||
"frames": [{
|
||||
"data": {
|
||||
"values": [
|
||||
["2026-06-02T10:00:00Z", "2026-06-02T11:00:00Z"],
|
||||
[1.0, 2.0]
|
||||
]
|
||||
}
|
||||
}]
|
||||
});
|
||||
assert_eq!(frame_row_count(&value), 2);
|
||||
assert_eq!(first_numeric_value(&value), Some(1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn measurement_check_catches_old_and_missing() {
|
||||
let dashboard = json!({
|
||||
"panels": [{
|
||||
"type": "stat",
|
||||
"targets": [{"query": "from(bucket:\"aw_metrics\") |> filter(fn:(r)=>r._measurement == \"aw_window_event\")"}]
|
||||
}]
|
||||
});
|
||||
let result = check_measurements("test", &dashboard);
|
||||
assert_eq!(result.status, Status::Fail);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_targets_from_dashboard_panels() {
|
||||
let dashboard = json!({
|
||||
"panels": [{
|
||||
"id": 5,
|
||||
"type": "stat",
|
||||
"title": "Свежесть worktime данных",
|
||||
"datasource": {"type": "influxdb", "uid": "influxdb_aw"},
|
||||
"targets": [{"refId": "A", "query": "from(bucket:\"aw_metrics\")"}]
|
||||
}]
|
||||
});
|
||||
let targets = collect_panel_targets(&dashboard);
|
||||
assert_eq!(targets.len(), 1);
|
||||
assert_eq!(targets[0].panel_id, 5);
|
||||
assert_eq!(targets[0].datasource_uid, "influxdb_aw");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn application_panels_are_optional_when_empty() {
|
||||
assert!(is_optional_empty_panel(
|
||||
"Сегодня: доказанная работа по приложениям"
|
||||
));
|
||||
assert!(is_optional_empty_panel(
|
||||
"Сегодня: приложения и подтверждения"
|
||||
));
|
||||
assert!(is_optional_empty_panel(
|
||||
"Сегодня: активность по сотрудникам"
|
||||
));
|
||||
assert!(!is_optional_empty_panel("Сегодня: активное время"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "detmir-heal-safe"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
clap.workspace = true
|
||||
detmir-core.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
@@ -0,0 +1,321 @@
|
||||
use std::io::Write;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use clap::Parser;
|
||||
use detmir_core::{exit_codes, now_utc_rfc3339};
|
||||
use serde::Serialize;
|
||||
|
||||
const DEFAULT_SSH_TARGET: &str = "igor@192.0.2.13";
|
||||
const REQUIRED_UNITS: &[&str] = &[
|
||||
"activitywatch-server.service",
|
||||
"aw-worktime-api.service",
|
||||
"aw-worktime-ui-bridge.timer",
|
||||
];
|
||||
const OPTIONAL_UNITS: &[&str] = &["activitywatch-dlp-aggregator.timer"];
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(about = "Safely heal allowlisted DetMir server-side services over SSH.")]
|
||||
struct Cli {
|
||||
#[arg(long, default_value = "ssh")]
|
||||
ssh_bin: String,
|
||||
|
||||
#[arg(long, default_value = DEFAULT_SSH_TARGET)]
|
||||
ssh_target: String,
|
||||
|
||||
#[arg(long, default_value_t = 10)]
|
||||
connect_timeout_seconds: u64,
|
||||
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
|
||||
#[arg(long)]
|
||||
apply: bool,
|
||||
|
||||
#[arg(long)]
|
||||
start_optional: bool,
|
||||
}
|
||||
|
||||
impl Cli {
|
||||
fn apply_env(mut self) -> Self {
|
||||
self.ssh_target = env_first(
|
||||
&["DETMIR_HEAL_SSH_TARGET", "DETMIR_AW_SSH_HOST"],
|
||||
&self.ssh_target,
|
||||
);
|
||||
self.ssh_bin = env_first(&["DETMIR_SSH_BIN"], &self.ssh_bin);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||
struct HealAction {
|
||||
action: String,
|
||||
unit: Option<String>,
|
||||
required: bool,
|
||||
status: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct HealReport {
|
||||
ok: bool,
|
||||
dry_run: bool,
|
||||
applied: bool,
|
||||
generated_at_utc: String,
|
||||
ssh_target: String,
|
||||
actions: Vec<HealAction>,
|
||||
raw_stdout: String,
|
||||
raw_stderr: String,
|
||||
}
|
||||
|
||||
fn env_first(names: &[&str], fallback: &str) -> String {
|
||||
names
|
||||
.iter()
|
||||
.find_map(|name| std::env::var(name).ok().filter(|value| !value.is_empty()))
|
||||
.unwrap_or_else(|| fallback.to_string())
|
||||
}
|
||||
|
||||
fn ssh_args(cli: &Cli) -> Vec<String> {
|
||||
vec![
|
||||
"-o".to_string(),
|
||||
"BatchMode=yes".to_string(),
|
||||
"-o".to_string(),
|
||||
format!("ConnectTimeout={}", cli.connect_timeout_seconds),
|
||||
"-o".to_string(),
|
||||
"StrictHostKeyChecking=accept-new".to_string(),
|
||||
cli.ssh_target.clone(),
|
||||
"bash".to_string(),
|
||||
"-s".to_string(),
|
||||
"--".to_string(),
|
||||
if cli.apply { "apply" } else { "dry-run" }.to_string(),
|
||||
if cli.start_optional {
|
||||
"start-optional"
|
||||
} else {
|
||||
"skip-optional"
|
||||
}
|
||||
.to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
fn remote_script() -> String {
|
||||
let required = REQUIRED_UNITS.join(" ");
|
||||
let optional = OPTIONAL_UNITS.join(" ");
|
||||
format!(
|
||||
r#"set -euo pipefail
|
||||
mode="${{1:-dry-run}}"
|
||||
optional_mode="${{2:-skip-optional}}"
|
||||
required_units="{required}"
|
||||
optional_units="{optional}"
|
||||
|
||||
emit() {{
|
||||
action="$1"
|
||||
unit="${{2:-}}"
|
||||
required="${{3:-false}}"
|
||||
status="${{4:-ok}}"
|
||||
printf '%s\t%s\t%s\t%s\n' "$action" "$unit" "$required" "$status"
|
||||
}}
|
||||
|
||||
if [ "$mode" = "apply" ]; then
|
||||
sudo -n systemctl reset-failed $required_units $optional_units >/dev/null 2>&1 || true
|
||||
emit reset-failed "" false applied
|
||||
else
|
||||
emit reset-failed "" false planned
|
||||
fi
|
||||
|
||||
for service in $required_units; do
|
||||
if systemctl is-active --quiet "$service"; then
|
||||
emit active "$service" true ok
|
||||
elif [ "$mode" = "apply" ]; then
|
||||
emit restart "$service" true started
|
||||
sudo -n systemctl restart "$service"
|
||||
emit active "$service" true ok
|
||||
else
|
||||
emit restart "$service" true planned
|
||||
fi
|
||||
done
|
||||
|
||||
for service in $optional_units; do
|
||||
if ! systemctl list-unit-files "$service" >/dev/null 2>&1; then
|
||||
emit absent "$service" false skipped
|
||||
elif systemctl is-active --quiet "$service"; then
|
||||
emit active "$service" false ok
|
||||
elif [ "$mode" = "apply" ]; then
|
||||
if [ "$optional_mode" = "start-optional" ]; then
|
||||
emit start "$service" false started
|
||||
sudo -n systemctl start "$service" || true
|
||||
else
|
||||
emit inactive "$service" false skipped
|
||||
fi
|
||||
else
|
||||
if [ "$optional_mode" = "start-optional" ]; then
|
||||
emit start "$service" false planned
|
||||
else
|
||||
emit inactive "$service" false skipped
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$mode" = "apply" ]; then
|
||||
sudo -n /usr/local/bin/dlp-health-check --json >/tmp/detmir-heal-dlp-health.json || true
|
||||
emit dlp-health-check "/tmp/detmir-heal-dlp-health.json" false written
|
||||
else
|
||||
emit dlp-health-check "/tmp/detmir-heal-dlp-health.json" false planned
|
||||
fi
|
||||
"#
|
||||
)
|
||||
}
|
||||
|
||||
fn parse_actions(raw: &str) -> Vec<HealAction> {
|
||||
raw.lines()
|
||||
.filter_map(|line| {
|
||||
let mut parts = line.split('\t');
|
||||
let action = parts.next()?;
|
||||
let unit = parts.next().unwrap_or_default();
|
||||
let required = parts.next().unwrap_or("false") == "true";
|
||||
let status = parts.next().unwrap_or("unknown");
|
||||
Some(HealAction {
|
||||
action: action.to_string(),
|
||||
unit: if unit.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(unit.to_string())
|
||||
},
|
||||
required,
|
||||
status: status.to_string(),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn run(cli: Cli) -> Result<i32> {
|
||||
let args = ssh_args(&cli);
|
||||
let mut child = Command::new(&cli.ssh_bin)
|
||||
.args(&args)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.with_context(|| format!("failed to execute {}", cli.ssh_bin))?;
|
||||
|
||||
child
|
||||
.stdin
|
||||
.as_mut()
|
||||
.context("failed to open SSH stdin")?
|
||||
.write_all(remote_script().as_bytes())
|
||||
.context("failed to write remote heal script")?;
|
||||
|
||||
let output = child
|
||||
.wait_with_output()
|
||||
.context("SSH heal command failed")?;
|
||||
let raw_stdout = String::from_utf8_lossy(&output.stdout).to_string();
|
||||
let raw_stderr = String::from_utf8_lossy(&output.stderr).to_string();
|
||||
let code = output.status.code().unwrap_or(exit_codes::ERROR);
|
||||
let actions = parse_actions(&raw_stdout);
|
||||
let report = HealReport {
|
||||
ok: code == exit_codes::OK,
|
||||
dry_run: !cli.apply,
|
||||
applied: cli.apply && code == exit_codes::OK,
|
||||
generated_at_utc: now_utc_rfc3339(),
|
||||
ssh_target: cli.ssh_target.clone(),
|
||||
actions,
|
||||
raw_stdout,
|
||||
raw_stderr,
|
||||
};
|
||||
|
||||
if cli.json {
|
||||
println!("{}", serde_json::to_string_pretty(&report)?);
|
||||
} else {
|
||||
if report.dry_run {
|
||||
println!("detmir-heal-safe: dry-run");
|
||||
} else {
|
||||
println!("detmir-heal-safe: apply");
|
||||
}
|
||||
for action in &report.actions {
|
||||
match &action.unit {
|
||||
Some(unit) => println!(
|
||||
"{} {} required={} status={}",
|
||||
action.action, unit, action.required, action.status
|
||||
),
|
||||
None => println!("{} status={}", action.action, action.status),
|
||||
}
|
||||
}
|
||||
if !report.raw_stderr.trim().is_empty() {
|
||||
eprint!("{}", report.raw_stderr);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(code)
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let cli = Cli::parse().apply_env();
|
||||
let code = run(cli)?;
|
||||
std::process::exit(code);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn builds_dry_run_ssh_args() {
|
||||
let cli = Cli {
|
||||
ssh_bin: "ssh".to_string(),
|
||||
ssh_target: DEFAULT_SSH_TARGET.to_string(),
|
||||
connect_timeout_seconds: 10,
|
||||
json: false,
|
||||
apply: false,
|
||||
start_optional: false,
|
||||
};
|
||||
assert_eq!(
|
||||
ssh_args(&cli),
|
||||
vec![
|
||||
"-o",
|
||||
"BatchMode=yes",
|
||||
"-o",
|
||||
"ConnectTimeout=10",
|
||||
"-o",
|
||||
"StrictHostKeyChecking=accept-new",
|
||||
DEFAULT_SSH_TARGET,
|
||||
"bash",
|
||||
"-s",
|
||||
"--",
|
||||
"dry-run",
|
||||
"skip-optional",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_remote_actions() {
|
||||
let actions = parse_actions(
|
||||
"reset-failed\t\tfalse\tplanned\nactive\tactivitywatch-server.service\ttrue\tok\n",
|
||||
);
|
||||
assert_eq!(
|
||||
actions,
|
||||
vec![
|
||||
HealAction {
|
||||
action: "reset-failed".to_string(),
|
||||
unit: None,
|
||||
required: false,
|
||||
status: "planned".to_string(),
|
||||
},
|
||||
HealAction {
|
||||
action: "active".to_string(),
|
||||
unit: Some("activitywatch-server.service".to_string()),
|
||||
required: true,
|
||||
status: "ok".to_string(),
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_script_uses_allowlisted_units() {
|
||||
let script = remote_script();
|
||||
assert!(script.contains("activitywatch-server.service"));
|
||||
assert!(script.contains("aw-worktime-api.service"));
|
||||
assert!(script.contains("aw-worktime-ui-bridge.timer"));
|
||||
assert!(script.contains("activitywatch-dlp-aggregator.timer"));
|
||||
assert!(!script.contains("*"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "detmir-portal"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
base64.workspace = true
|
||||
chrono.workspace = true
|
||||
clap.workspace = true
|
||||
reqwest.workspace = true
|
||||
rusqlite.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
serde_yaml.workspace = true
|
||||
sha2.workspace = true
|
||||
tiny_http.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,362 @@
|
||||
export type ISODateTime = string;
|
||||
|
||||
export type PortalRole = "executive" | "manager" | "security" | "forensics" | "admin";
|
||||
export type RiskLevel = "LOW" | "MEDIUM" | "HIGH" | "CRITICAL" | "UNKNOWN";
|
||||
export type ReviewStatus =
|
||||
| "NEW"
|
||||
| "IN_REVIEW"
|
||||
| "CONFIRMED"
|
||||
| "FALSE_POSITIVE"
|
||||
| "POSTPONED";
|
||||
export type CaseStatus = "OPEN" | "IN_PROGRESS" | "RESOLVED" | "REJECTED" | "ARCHIVED";
|
||||
|
||||
export interface JsonObject {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface EndpointDescriptor {
|
||||
method: string;
|
||||
path: string;
|
||||
purpose?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ContractIndex {
|
||||
ok: boolean;
|
||||
contract_version: string;
|
||||
generated_by?: string;
|
||||
api_base: "/api" | string;
|
||||
compatibility?: JsonObject;
|
||||
targets?: string[];
|
||||
artifacts: {
|
||||
openapi: string;
|
||||
typescript: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
stable_endpoints: EndpointDescriptor[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface RoleContext {
|
||||
role: PortalRole;
|
||||
role_label?: string;
|
||||
scope: string;
|
||||
allowed_scopes?: string[];
|
||||
server_enforced: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ExecutiveDashboard {
|
||||
trust_kpi_score?: number;
|
||||
agent_coverage_pct?: number;
|
||||
high_risk_departments?: number;
|
||||
critical_candidates?: number;
|
||||
open_cases?: number;
|
||||
resolved_cases_30d?: number;
|
||||
forensics_readiness?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface RiskNarrative {
|
||||
ok: boolean;
|
||||
role_context?: RoleContext;
|
||||
scope?: "aggregate" | "department" | string;
|
||||
query?: {
|
||||
date?: string | null;
|
||||
department?: string | null;
|
||||
module?: string | null;
|
||||
employee_id_supported: false;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
risk_level: "low" | "guarded" | "medium" | "high" | "critical" | string;
|
||||
risk_score: number;
|
||||
confidence?: "high" | "medium" | "low" | "unknown" | string;
|
||||
classification?: "confirmed_risk" | "likely_risk" | "needs_investigation" | "insufficient_data" | string;
|
||||
title: string;
|
||||
summary: string;
|
||||
why: string[];
|
||||
evidence: Array<{
|
||||
source: string;
|
||||
label: string;
|
||||
value: string;
|
||||
severity: "low" | "medium" | "high" | "critical" | string;
|
||||
[key: string]: unknown;
|
||||
}>;
|
||||
recommended_actions: string[];
|
||||
limitations: string[];
|
||||
model?: JsonObject;
|
||||
generated_at_utc?: ISODateTime;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export type ActionPriority = "low" | "medium" | "high" | "critical";
|
||||
export type ActionOwnerRole = "executive" | "manager" | "security" | "forensics" | "admin";
|
||||
|
||||
export interface ActionItem {
|
||||
priority: ActionPriority | string;
|
||||
title: string;
|
||||
summary: string;
|
||||
owner_role: ActionOwnerRole | string;
|
||||
recommended_deadline: string;
|
||||
reason_codes: string[];
|
||||
evidence: string[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ActionCenterResponse {
|
||||
ok: boolean;
|
||||
role_context?: RoleContext;
|
||||
actions: ActionItem[];
|
||||
model?: JsonObject;
|
||||
generated_at_utc?: ISODateTime;
|
||||
limitations?: string[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface AgentQuality {
|
||||
collector_source?: string;
|
||||
collector_error?: string | null;
|
||||
sessions_collected_total?: number;
|
||||
active_sessions_total?: number;
|
||||
rdp_sessions_total?: number;
|
||||
quality_status?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface AgentCoverageSla {
|
||||
expected_nodes?: number;
|
||||
reporting_nodes_24h?: number;
|
||||
stale_nodes?: number;
|
||||
missing_nodes?: number;
|
||||
coverage_pct?: number;
|
||||
freshness_pct?: number;
|
||||
sla_status?: "OK" | "WARNING" | "CRITICAL" | "UNKNOWN" | string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface WorkforceKpiExplainFactor {
|
||||
name: string;
|
||||
label: string;
|
||||
impact: string;
|
||||
explanation: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface WorkforceKpiExplainResponse {
|
||||
ok: boolean;
|
||||
role_context?: RoleContext;
|
||||
scope?: "aggregate" | string;
|
||||
kpi_score: number;
|
||||
kpi_status?: string;
|
||||
confidence: "high" | "medium" | "low" | string;
|
||||
coverage: {
|
||||
agent_coverage_percent: number;
|
||||
data_freshness: "fresh" | "stale" | "missing" | string;
|
||||
missing_sources: string[];
|
||||
[key: string]: unknown;
|
||||
};
|
||||
factors: WorkforceKpiExplainFactor[];
|
||||
top_applications: JsonObject[];
|
||||
warnings: string[];
|
||||
recommendations: string[];
|
||||
model?: JsonObject;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface BusinessRiskItem {
|
||||
department?: string;
|
||||
trust_score?: number;
|
||||
activity_score?: number;
|
||||
trend?: string;
|
||||
risk_level?: RiskLevel;
|
||||
reasons?: string[];
|
||||
recommendation?: string;
|
||||
problem_nodes_count?: number;
|
||||
missing_nodes_count?: number;
|
||||
stale_nodes_count?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface IncidentCandidate {
|
||||
id?: string;
|
||||
department?: string;
|
||||
owner?: string;
|
||||
hostname?: string;
|
||||
risk_level?: RiskLevel;
|
||||
reason?: string;
|
||||
evidence?: unknown;
|
||||
first_seen_utc?: ISODateTime;
|
||||
last_seen_utc?: ISODateTime;
|
||||
recommendation?: string;
|
||||
review?: IncidentReview;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface IncidentReview {
|
||||
candidate_id: string;
|
||||
status: ReviewStatus;
|
||||
reviewer?: string;
|
||||
comment?: string;
|
||||
updated_at?: ISODateTime;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface IncidentReviewRequest {
|
||||
candidate_id: string;
|
||||
status: ReviewStatus;
|
||||
reviewer?: string;
|
||||
comment?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface CaseItem {
|
||||
case_id?: string;
|
||||
candidate_id?: string;
|
||||
title?: string;
|
||||
status?: CaseStatus;
|
||||
owner?: string;
|
||||
created_at_utc?: ISODateTime;
|
||||
updated_at_utc?: ISODateTime;
|
||||
summary?: string;
|
||||
decision?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface CreateCaseRequest {
|
||||
candidate_id: string;
|
||||
title?: string;
|
||||
owner?: string;
|
||||
summary?: string;
|
||||
decision?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface CaseStatusRequest {
|
||||
status: CaseStatus;
|
||||
decision?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ReportsResponse {
|
||||
ok: boolean;
|
||||
role_context?: RoleContext;
|
||||
generated_at_utc?: ISODateTime;
|
||||
executive_points?: string[];
|
||||
executive_dashboard?: ExecutiveDashboard;
|
||||
risk_narrative?: RiskNarrative;
|
||||
recommended_actions?: ActionItem[];
|
||||
agent_quality?: AgentQuality;
|
||||
agent_coverage_sla?: AgentCoverageSla;
|
||||
business_risk?: BusinessRiskItem[];
|
||||
risk_incident_candidates?: IncidentCandidate[];
|
||||
cases?: CaseItem[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface UebaResponse {
|
||||
ok: boolean;
|
||||
role_context?: RoleContext;
|
||||
score: number | null;
|
||||
severity: "normal" | "low" | "medium" | "high" | "critical" | string;
|
||||
status?: string;
|
||||
confidence: "high" | "medium" | "low" | "unknown" | string;
|
||||
confidence_score?: number | null;
|
||||
classification: "confirmed_risk" | "likely_risk" | "needs_investigation" | "insufficient_data" | string;
|
||||
classification_reason?: string;
|
||||
confidence_reasons: string[];
|
||||
confidence_contributors?: JsonObject[];
|
||||
evidence_status?: "available" | "not_available" | string;
|
||||
score_components: {
|
||||
activity_anomaly: number;
|
||||
time_anomaly: number;
|
||||
application_anomaly: number;
|
||||
network_anomaly: number;
|
||||
history_anomaly: number;
|
||||
};
|
||||
reason_codes: string[];
|
||||
explanation: string;
|
||||
model: JsonObject;
|
||||
risk: JsonObject;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface PfsenseFirewallEvent {
|
||||
timestamp: ISODateTime;
|
||||
source_host: string;
|
||||
destination: string;
|
||||
action: string;
|
||||
rule_id?: string;
|
||||
protocol?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface PfsenseVpnEvent {
|
||||
timestamp: ISODateTime;
|
||||
source_host: string;
|
||||
user_ref?: string;
|
||||
action: string;
|
||||
tunnel?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface PfsenseReadinessResponse {
|
||||
ok: boolean;
|
||||
role_context?: RoleContext;
|
||||
contract_version: string;
|
||||
status: "contract_only" | "available" | string;
|
||||
siem: boolean;
|
||||
ingestion_available: boolean;
|
||||
firewall_events: PfsenseFirewallEvent[];
|
||||
vpn_events: PfsenseVpnEvent[];
|
||||
traffic_summary: JsonObject;
|
||||
top_destinations: JsonObject[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface CaseListResponse {
|
||||
ok: boolean;
|
||||
cases: CaseItem[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface DetMirPortalApi {
|
||||
getContracts(): Promise<ContractIndex>;
|
||||
getHealth(): Promise<JsonObject>;
|
||||
getOperator(): Promise<JsonObject>;
|
||||
getManager(): Promise<JsonObject>;
|
||||
getOwner(): Promise<JsonObject>;
|
||||
getReports(options?: { anonymize?: boolean; role?: PortalRole }): Promise<ReportsResponse>;
|
||||
getExecutive(options?: { role?: PortalRole }): Promise<ReportsResponse>;
|
||||
getWorkforce(options?: { role?: PortalRole }): Promise<ReportsResponse>;
|
||||
getSecurity(options?: { role?: PortalRole }): Promise<ReportsResponse>;
|
||||
getForensics(options?: { role?: PortalRole }): Promise<ReportsResponse>;
|
||||
getUeba(options?: { role?: PortalRole }): Promise<UebaResponse>;
|
||||
getRiskNarrative(options?: {
|
||||
date?: string;
|
||||
department?: string;
|
||||
module?: string;
|
||||
role?: PortalRole;
|
||||
}): Promise<RiskNarrative>;
|
||||
getActions(options?: { role?: PortalRole }): Promise<ActionCenterResponse>;
|
||||
getPfsense(options?: { role?: PortalRole }): Promise<PfsenseReadinessResponse>;
|
||||
getIncidents(): Promise<JsonObject>;
|
||||
getCases(): Promise<CaseListResponse>;
|
||||
createCase(request: CreateCaseRequest): Promise<JsonObject>;
|
||||
setCaseStatus(caseId: string, request: CaseStatusRequest): Promise<JsonObject>;
|
||||
setIncidentReview(request: IncidentReviewRequest): Promise<JsonObject>;
|
||||
getInvestigationPack(
|
||||
candidateId: string,
|
||||
options?: { format?: "json" | "markdown" },
|
||||
): Promise<JsonObject | string>;
|
||||
getDlpEvidence(): Promise<JsonObject>;
|
||||
getReadinessLatest(): Promise<JsonObject>;
|
||||
getReadinessBundle(): Promise<JsonObject>;
|
||||
verifyReadiness(): Promise<JsonObject>;
|
||||
getWorkforcePolicyExplain(options?: { anonymize?: boolean }): Promise<JsonObject>;
|
||||
getWorkforceKpiExplain(options?: {
|
||||
date?: string;
|
||||
department?: string;
|
||||
owner?: string;
|
||||
role?: PortalRole;
|
||||
}): Promise<WorkforceKpiExplainResponse>;
|
||||
}
|
||||
@@ -0,0 +1,494 @@
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{PortalRole, role_envelope};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub(crate) struct ExecutiveAction {
|
||||
pub priority: ActionPriority,
|
||||
pub title: String,
|
||||
pub summary: String,
|
||||
pub owner_role: ActionOwnerRole,
|
||||
pub recommended_deadline: String,
|
||||
pub reason_codes: Vec<String>,
|
||||
pub evidence: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub(crate) enum ActionPriority {
|
||||
Low,
|
||||
Medium,
|
||||
High,
|
||||
Critical,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub(crate) enum ActionOwnerRole {
|
||||
Executive,
|
||||
Manager,
|
||||
Security,
|
||||
Forensics,
|
||||
Admin,
|
||||
}
|
||||
|
||||
pub(crate) fn build_action_center_from_report(report: &Value, role: PortalRole) -> Value {
|
||||
let actions = generate_actions(report)
|
||||
.into_iter()
|
||||
.filter(|action| action_visible_for_role(action.owner_role, role))
|
||||
.collect::<Vec<_>>();
|
||||
json!({
|
||||
"ok": true,
|
||||
"role_context": role_envelope(role, "actions"),
|
||||
"actions": actions,
|
||||
"model": {
|
||||
"type": "rule_based",
|
||||
"version": "executive-action-center-v1",
|
||||
"ml": false,
|
||||
"llm": false,
|
||||
"auto_remediation": false
|
||||
},
|
||||
"generated_at_utc": Utc::now().to_rfc3339(),
|
||||
"limitations": [
|
||||
"Рекомендуемые действия не выполняются автоматически",
|
||||
"Action Center не блокирует пользователей и не меняет политики",
|
||||
"Все действия требуют ручного подтверждения ответственным контуром"
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn filter_actions_for_role(actions: &Value, role: PortalRole) -> Value {
|
||||
let filtered = actions
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|item| {
|
||||
item.get("owner_role")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(parse_owner_role)
|
||||
.is_some_and(|owner| action_visible_for_role(owner, role))
|
||||
})
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
Value::Array(filtered)
|
||||
}
|
||||
|
||||
pub(crate) fn actions_from_center(center: &Value) -> Value {
|
||||
center
|
||||
.get("actions")
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.map(Value::Array)
|
||||
.unwrap_or_else(|| Value::Array(Vec::new()))
|
||||
}
|
||||
|
||||
fn generate_actions(report: &Value) -> Vec<ExecutiveAction> {
|
||||
let mut actions = Vec::new();
|
||||
add_workforce_kpi_action(report, &mut actions);
|
||||
add_coverage_action(report, &mut actions);
|
||||
add_ueba_confidence_action(report, &mut actions);
|
||||
add_ueba_action(report, &mut actions);
|
||||
add_security_correlation_action(report, &mut actions);
|
||||
add_incident_candidate_action(report, &mut actions);
|
||||
add_risk_narrative_action(report, &mut actions);
|
||||
if actions.is_empty() {
|
||||
actions.push(ExecutiveAction {
|
||||
priority: ActionPriority::Low,
|
||||
title: "Продолжить наблюдение".to_string(),
|
||||
summary: "Критичных управленческих действий по текущему срезу не требуется".to_string(),
|
||||
owner_role: ActionOwnerRole::Manager,
|
||||
recommended_deadline: "72h".to_string(),
|
||||
reason_codes: vec!["NORMAL_OBSERVATION".to_string()],
|
||||
evidence: vec!["Критичные сигналы не выявлены".to_string()],
|
||||
});
|
||||
}
|
||||
actions.sort_by(|left, right| {
|
||||
right
|
||||
.priority
|
||||
.cmp(&left.priority)
|
||||
.then_with(|| left.owner_role.as_str().cmp(right.owner_role.as_str()))
|
||||
.then_with(|| left.title.cmp(&right.title))
|
||||
});
|
||||
actions
|
||||
}
|
||||
|
||||
fn add_workforce_kpi_action(report: &Value, actions: &mut Vec<ExecutiveAction>) {
|
||||
let Some(score) = report
|
||||
.pointer("/workforce_kpi_explain/kpi_score")
|
||||
.and_then(Value::as_u64)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if score >= 70 {
|
||||
return;
|
||||
}
|
||||
let mut reason_codes = vec!["LOW_WORKFORCE_KPI".to_string()];
|
||||
let mut evidence = vec![format!("Workforce KPI ниже целевого уровня: {score}%")];
|
||||
if has_kpi_factor(report, "remote_session_activity") {
|
||||
reason_codes.push("HIGH_REMOTE_ACTIVITY".to_string());
|
||||
evidence.push("Рост удаленных сессий влияет на управленческий риск".to_string());
|
||||
}
|
||||
if let Some(confidence) = report
|
||||
.pointer("/workforce_kpi_explain/confidence")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| *value == "low")
|
||||
{
|
||||
reason_codes.push("LOW_KPI_CONFIDENCE".to_string());
|
||||
evidence.push(format!("Доверие к KPI: {confidence}"));
|
||||
}
|
||||
actions.push(ExecutiveAction {
|
||||
priority: if score < 50 {
|
||||
ActionPriority::Critical
|
||||
} else {
|
||||
ActionPriority::High
|
||||
},
|
||||
title: "Проверить подразделение с низким индексом активности".to_string(),
|
||||
summary:
|
||||
"Индекс активности ниже управленческого порога; требуется проверка причины просадки"
|
||||
.to_string(),
|
||||
owner_role: ActionOwnerRole::Manager,
|
||||
recommended_deadline: if score < 50 { "4h" } else { "24h" }.to_string(),
|
||||
reason_codes,
|
||||
evidence,
|
||||
});
|
||||
}
|
||||
|
||||
fn add_coverage_action(report: &Value, actions: &mut Vec<ExecutiveAction>) {
|
||||
let coverage = report
|
||||
.pointer("/agent_coverage_sla/coverage_pct")
|
||||
.or_else(|| report.pointer("/workforce_kpi_explain/coverage/agent_coverage_percent"))
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(100);
|
||||
let sla_status = report
|
||||
.pointer("/agent_coverage_sla/sla_status")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("OK");
|
||||
if coverage >= 80 && !matches!(sla_status, "WARNING" | "CRITICAL") {
|
||||
return;
|
||||
}
|
||||
actions.push(ExecutiveAction {
|
||||
priority: if coverage < 60 || sla_status == "CRITICAL" {
|
||||
ActionPriority::Critical
|
||||
} else {
|
||||
ActionPriority::High
|
||||
},
|
||||
title: "Проверить состояние агентов".to_string(),
|
||||
summary: "Полнота данных ниже целевого уровня; показатели могут быть нерепрезентативны"
|
||||
.to_string(),
|
||||
owner_role: ActionOwnerRole::Admin,
|
||||
recommended_deadline: if coverage < 60 { "4h" } else { "24h" }.to_string(),
|
||||
reason_codes: vec!["LOW_COVERAGE".to_string()],
|
||||
evidence: vec![
|
||||
format!("Покрытие агентов: {coverage}%"),
|
||||
format!("SLA полноты данных: {sla_status}"),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
fn add_ueba_confidence_action(report: &Value, actions: &mut Vec<ExecutiveAction>) {
|
||||
let score = report
|
||||
.pointer("/ueba_risk/score")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
let explicit_confidence = report
|
||||
.pointer("/ueba_risk/confidence_level")
|
||||
.and_then(Value::as_str);
|
||||
let explicit_classification = report
|
||||
.pointer("/ueba_risk/classification")
|
||||
.and_then(Value::as_str);
|
||||
if score < 70 && explicit_confidence.is_none() && explicit_classification.is_none() {
|
||||
return;
|
||||
}
|
||||
let confidence = report
|
||||
.pointer("/ueba_risk/confidence_level")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("unknown");
|
||||
let classification = report
|
||||
.pointer("/ueba_risk/classification")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("insufficient_data");
|
||||
if score < 70 && !matches!(classification, "needs_investigation" | "insufficient_data") {
|
||||
return;
|
||||
}
|
||||
if !matches!(confidence, "low" | "unknown")
|
||||
&& !matches!(classification, "needs_investigation" | "insufficient_data")
|
||||
{
|
||||
return;
|
||||
}
|
||||
let reasons = report
|
||||
.pointer("/ueba_risk/confidence_reasons")
|
||||
.and_then(Value::as_array)
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.take(3)
|
||||
.map(ToString::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let mut evidence = vec![
|
||||
format!("UEBA confidence: {confidence}"),
|
||||
format!("UEBA classification: {classification}"),
|
||||
];
|
||||
evidence.extend(reasons);
|
||||
actions.push(ExecutiveAction {
|
||||
priority: ActionPriority::Critical,
|
||||
title: "Проверить полноту данных".to_string(),
|
||||
summary: "Перед жестким выводом по UEBA нужно подтвердить покрытие, свежесть и полноту телеметрии"
|
||||
.to_string(),
|
||||
owner_role: ActionOwnerRole::Admin,
|
||||
recommended_deadline: "4h".to_string(),
|
||||
reason_codes: vec![
|
||||
"LOW_UEBA_CONFIDENCE".to_string(),
|
||||
"CHECK_DATA_COMPLETENESS".to_string(),
|
||||
],
|
||||
evidence,
|
||||
});
|
||||
}
|
||||
|
||||
fn add_ueba_action(report: &Value, actions: &mut Vec<ExecutiveAction>) {
|
||||
let score = report
|
||||
.pointer("/ueba_risk/score")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
let level = report
|
||||
.pointer("/ueba_risk/level")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("low");
|
||||
if score < 70 && !matches!(level, "high" | "critical") {
|
||||
return;
|
||||
}
|
||||
actions.push(ExecutiveAction {
|
||||
priority: if score >= 90 || level == "critical" {
|
||||
ActionPriority::Critical
|
||||
} else {
|
||||
ActionPriority::High
|
||||
},
|
||||
title: "Передать данные в контур ИБ".to_string(),
|
||||
summary: "UEBA score повышен; требуется ручная проверка безопасности".to_string(),
|
||||
owner_role: ActionOwnerRole::Security,
|
||||
recommended_deadline: if score >= 90 { "4h" } else { "24h" }.to_string(),
|
||||
reason_codes: vec!["HIGH_UEBA".to_string()],
|
||||
evidence: vec![format!("UEBA score: {score}, уровень: {level}")],
|
||||
});
|
||||
}
|
||||
|
||||
fn add_security_correlation_action(report: &Value, actions: &mut Vec<ExecutiveAction>) {
|
||||
let max_score = report
|
||||
.get("security_correlation")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|item| item.get("correlation_score").and_then(Value::as_u64))
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
if max_score < 60 {
|
||||
return;
|
||||
}
|
||||
actions.push(ExecutiveAction {
|
||||
priority: if max_score >= 80 {
|
||||
ActionPriority::Critical
|
||||
} else {
|
||||
ActionPriority::High
|
||||
},
|
||||
title: "Проверить связь активности и ИБ-событий".to_string(),
|
||||
summary: "Есть корреляция между операционным риском и событиями безопасности".to_string(),
|
||||
owner_role: ActionOwnerRole::Security,
|
||||
recommended_deadline: "24h".to_string(),
|
||||
reason_codes: vec!["HIGH_SECURITY_CORRELATION".to_string()],
|
||||
evidence: vec![format!("Security correlation score: {max_score}")],
|
||||
});
|
||||
}
|
||||
|
||||
fn add_incident_candidate_action(report: &Value, actions: &mut Vec<ExecutiveAction>) {
|
||||
let Some(candidates) = report
|
||||
.get("risk_incident_candidates")
|
||||
.and_then(Value::as_array)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if candidates.is_empty() {
|
||||
return;
|
||||
}
|
||||
let critical = candidates.iter().any(|item| {
|
||||
item.get("risk_level")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|level| level.eq_ignore_ascii_case("critical"))
|
||||
});
|
||||
actions.push(ExecutiveAction {
|
||||
priority: if critical {
|
||||
ActionPriority::Critical
|
||||
} else {
|
||||
ActionPriority::High
|
||||
},
|
||||
title: "Провести расследование кандидатов".to_string(),
|
||||
summary: "В очереди есть кандидаты на проверку; требуется ручной разбор и фиксация решения"
|
||||
.to_string(),
|
||||
owner_role: ActionOwnerRole::Forensics,
|
||||
recommended_deadline: if critical { "4h" } else { "24h" }.to_string(),
|
||||
reason_codes: vec!["INCIDENT_CANDIDATE".to_string()],
|
||||
evidence: vec![format!("Кандидатов на проверку: {}", candidates.len())],
|
||||
});
|
||||
}
|
||||
|
||||
fn add_risk_narrative_action(report: &Value, actions: &mut Vec<ExecutiveAction>) {
|
||||
let score = report
|
||||
.pointer("/risk_narrative/risk_score")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
if score < 75 {
|
||||
return;
|
||||
}
|
||||
let level = report
|
||||
.pointer("/risk_narrative/risk_level")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("high");
|
||||
actions.push(ExecutiveAction {
|
||||
priority: if score >= 90 {
|
||||
ActionPriority::Critical
|
||||
} else {
|
||||
ActionPriority::High
|
||||
},
|
||||
title: "Назначить владельца корректирующих действий".to_string(),
|
||||
summary: "Риск-нарратив показывает высокий управленческий риск; нужен ответственный и срок контроля"
|
||||
.to_string(),
|
||||
owner_role: ActionOwnerRole::Executive,
|
||||
recommended_deadline: if score >= 90 { "4h" } else { "24h" }.to_string(),
|
||||
reason_codes: vec!["RISK_NARRATIVE_HIGH".to_string()],
|
||||
evidence: vec![format!("Risk Narrative: {score}/100, уровень: {level}")],
|
||||
});
|
||||
}
|
||||
|
||||
fn has_kpi_factor(report: &Value, factor_name: &str) -> bool {
|
||||
report
|
||||
.pointer("/workforce_kpi_explain/factors")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.any(|item| item.get("name").and_then(Value::as_str) == Some(factor_name))
|
||||
}
|
||||
|
||||
fn action_visible_for_role(owner_role: ActionOwnerRole, role: PortalRole) -> bool {
|
||||
match role {
|
||||
PortalRole::Admin => true,
|
||||
PortalRole::Executive => matches!(
|
||||
owner_role,
|
||||
ActionOwnerRole::Executive | ActionOwnerRole::Manager | ActionOwnerRole::Admin
|
||||
),
|
||||
PortalRole::Manager => matches!(owner_role, ActionOwnerRole::Manager),
|
||||
PortalRole::Security => {
|
||||
matches!(
|
||||
owner_role,
|
||||
ActionOwnerRole::Security | ActionOwnerRole::Forensics
|
||||
)
|
||||
}
|
||||
PortalRole::Forensics => {
|
||||
matches!(
|
||||
owner_role,
|
||||
ActionOwnerRole::Forensics | ActionOwnerRole::Security
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_owner_role(value: &str) -> Option<ActionOwnerRole> {
|
||||
match value {
|
||||
"executive" => Some(ActionOwnerRole::Executive),
|
||||
"manager" => Some(ActionOwnerRole::Manager),
|
||||
"security" => Some(ActionOwnerRole::Security),
|
||||
"forensics" => Some(ActionOwnerRole::Forensics),
|
||||
"admin" => Some(ActionOwnerRole::Admin),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
impl ActionOwnerRole {
|
||||
fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Executive => "executive",
|
||||
Self::Manager => "manager",
|
||||
Self::Security => "security",
|
||||
Self::Forensics => "forensics",
|
||||
Self::Admin => "admin",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample_report() -> Value {
|
||||
json!({
|
||||
"workforce_kpi_explain": {
|
||||
"kpi_score": 48,
|
||||
"confidence": "low",
|
||||
"coverage": {"agent_coverage_percent": 58},
|
||||
"factors": [{"name": "remote_session_activity"}]
|
||||
},
|
||||
"ueba_risk": {"score": 91, "level": "critical"},
|
||||
"agent_coverage_sla": {"coverage_pct": 58, "sla_status": "CRITICAL"},
|
||||
"security_correlation": [{"correlation_score": 81}],
|
||||
"risk_incident_candidates": [{"risk_level": "CRITICAL"}],
|
||||
"risk_narrative": {"risk_score": 92, "risk_level": "critical"}
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generates_rule_based_actions_with_priorities() {
|
||||
let payload = build_action_center_from_report(&sample_report(), PortalRole::Admin);
|
||||
let actions = payload["actions"].as_array().unwrap();
|
||||
assert!(actions.len() >= 5);
|
||||
assert_eq!(payload["model"]["type"], "rule_based");
|
||||
assert_eq!(payload["model"]["ml"], false);
|
||||
assert_eq!(payload["model"]["llm"], false);
|
||||
assert!(actions.iter().any(|item| {
|
||||
item["reason_codes"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|code| code == "LOW_WORKFORCE_KPI")
|
||||
}));
|
||||
assert!(actions.iter().any(|item| {
|
||||
item["reason_codes"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|code| code == "LOW_UEBA_CONFIDENCE")
|
||||
}));
|
||||
assert!(actions.iter().any(|item| item["priority"] == "critical"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filters_actions_by_role() {
|
||||
let admin = build_action_center_from_report(&sample_report(), PortalRole::Admin);
|
||||
let security = filter_actions_for_role(&actions_from_center(&admin), PortalRole::Security);
|
||||
let security_actions = security.as_array().unwrap();
|
||||
assert!(!security_actions.is_empty());
|
||||
assert!(
|
||||
security_actions
|
||||
.iter()
|
||||
.all(|item| item["owner_role"] == "security" || item["owner_role"] == "forensics")
|
||||
);
|
||||
|
||||
let manager = filter_actions_for_role(&actions_from_center(&admin), PortalRole::Manager);
|
||||
let manager_actions = manager.as_array().unwrap();
|
||||
assert!(
|
||||
manager_actions
|
||||
.iter()
|
||||
.all(|item| item["owner_role"] == "manager")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emits_observation_when_no_rule_matches() {
|
||||
let payload = build_action_center_from_report(&json!({}), PortalRole::Executive);
|
||||
let actions = payload["actions"].as_array().unwrap();
|
||||
assert_eq!(actions.len(), 1);
|
||||
assert_eq!(actions[0]["priority"], "low");
|
||||
assert_eq!(actions[0]["reason_codes"][0], "NORMAL_OBSERVATION");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user