Compare commits

..
Author SHA1 Message Date
dependabot[bot]andGitHub 6ed568870b chore(deps): bump python-multipart from 0.0.29 to 0.0.31 in /detmir-mcp
CI / Rust checks (push) Canceled after 0s
CI / Docs and registry checks (push) Canceled after 0s
CI / Smoke checks (push) Canceled after 0s
Coverage / Coverage baseline (push) Canceled after 0s
Security / Cargo audit (push) Canceled after 0s
Security / Cargo deny (push) Canceled after 0s
Security / Secret pattern check (push) Canceled after 0s
Security / Dependency review (push) Canceled after 0s
Bumps [python-multipart](https://github.com/Kludex/python-multipart) from 0.0.29 to 0.0.31.
- [Release notes](https://github.com/Kludex/python-multipart/releases)
- [Changelog](https://github.com/Kludex/python-multipart/blob/main/CHANGELOG.md)
- [Commits](https://github.com/Kludex/python-multipart/compare/0.0.29...0.0.31)

---
updated-dependencies:
- dependency-name: python-multipart
  dependency-version: 0.0.31
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-01 13:35:46 +00:00
IgorRachkovandGitHub 5ac7b694c6 Merge pull request #51 from igor04091968/docs/detmir-production-baseline-20260629
release-assets / sbom-and-release-assets (push) Canceled after 0s
rust-workspace / rust-workspace (push) Canceled after 0s
CI / Rust checks (push) Canceled after 0s
CI / Docs and registry checks (push) Canceled after 0s
CI / Smoke checks (push) Canceled after 0s
Coverage / Coverage baseline (push) Canceled after 0s
Security / Cargo audit (push) Canceled after 0s
Security / Cargo deny (push) Canceled after 0s
Security / Secret pattern check (push) Canceled after 0s
Security / Dependency review (push) Canceled after 0s
docs(detmir): record production restore baseline
2026-06-29 13:56:17 +03:00
igor04091968 c1e470ce4d docs(detmir): clarify optional dlp and velociraptor boundaries 2026-06-29 13:33:13 +03:00
igor04091968 f4bdcee459 fix(detmir): format check-aw-full defaults 2026-06-29 13:24:48 +03:00
igor04091968 583cfe7003 docs(detmir): record production restore baseline 2026-06-29 13:20:54 +03:00
igor04091968 34e017673a docs(governance): record protected PR workflow evidence
CI / Rust checks (push) Canceled after 0s
CI / Docs and registry checks (push) Canceled after 0s
CI / Smoke checks (push) Canceled after 0s
Coverage / Coverage baseline (push) Canceled after 0s
Security / Cargo audit (push) Canceled after 0s
Security / Cargo deny (push) Canceled after 0s
Security / Secret pattern check (push) Canceled after 0s
Security / Dependency review (push) Canceled after 0s
2026-06-24 01:11:54 +03:00
igor04091968 7e6c067adf docs(governance): prepare branch protection and PR review evidence 2026-06-23 23:00:16 +03:00
igor04091968 54129d82b6 docs(governance): record public issue tracker evidence 2026-06-23 22:49:13 +03:00
igor04091968 c2e0afae10 docs(governance): prepare public issue creation package 2026-06-23 22:10:51 +03:00
igor04091968 337da4bb5f feat(workforce): add ClickHouse workforce analytics contour 2026-06-23 21:52:57 +03:00
igor04091968 341799ff9d docs(clickhouse): plan dictionaries and aggregate sprints 2026-06-23 13:33:59 +03:00
igor04091968 77c705a649 docs(governance): add review process residual risks and issue plan 2026-06-22 21:42:43 +03:00
igor04091968 f310aeff49 docs(risk): document residual risks and public issue plan 2026-06-22 21:08:33 +03:00
igor04091968 44f5d6ecc2 docs(readme): update maturity assessment status 2026-06-22 21:00:11 +03:00
igor04091968 a0d268b416 docs(maturity): update public validation weaknesses 2026-06-22 20:51:22 +03:00
IgorRachkov a03829ba25 docs: add professional maturity assessment (Copilot review 2026-06-22) 2026-06-22 20:38:36 +03:00
igor04091968 d412f35644 docs(status): record public CI security coverage validation 2026-06-22 20:35:06 +03:00
igor04091968 cc0b2ff53d feat(detmir-check): add portal and DLP health checks 2026-06-22 01:02:52 +03:00
igor04091968 e893c296ac feat(contour): add local-friendly contour run visibility 2026-06-22 00:59:42 +03:00
igor04091968 18da4e7cfd docs: document gitea duplicate status 2026-06-22 00:47:32 +03:00
igor04091968 4f90aba2a1 chore(security): harden public secret scan and document policy 2026-06-21 14:17:05 +03:00
igor04091968 9f3278f0dc docs(status): freeze registry readiness project status 2026-06-21 13:57:00 +03:00
igor04091968 4970d31a81 chore(public): add CI coverage security and OSS process visibility 2026-06-21 08:54:53 +03:00
igor04091968 f5e9c81c5b docs(registry): add Russian build runner and release evidence plan 2026-06-21 07:10:43 +03:00
igor04091968 6861df9be4 docs(registry): document Russian Gitea contour and registry docs policy 2026-06-21 05:56:11 +03:00
igor04091968 4ba055fcdf docs(registry): document Russian Gitea contour and backup evidence 2026-06-21 05:11:42 +03:00
IgorRachkovandGitHub 8b25ec345e Update README.md 2026-06-20 16:51:08 +03:00
IgorRachkovandGitHub 09dd01f502 Update README.md 2026-06-20 16:36:54 +03:00
IgorRachkovandGitHub c241e49b5a Update README.md 2026-06-20 16:32:10 +03:00
IgorRachkovandGitHub 401d9612f0 Update README.md 2026-06-20 16:23:19 +03:00
IgorRachkovandGitHub 830f622627 Update README.md 2026-06-20 16:08:19 +03:00
igor04091968 cfeaf772ea fix(aw-db): add guarded sqlite vacuum maintenance 2026-06-20 11:04:10 +03:00
igor04091968 f800c676ce docs(pilot): freeze readiness and demo safety guardrails 2026-06-20 09:57:34 +03:00
IgorRachkovandGitHub 73578e8388 Update PILOT_DEMO_SCENARIO_RU.md 2026-06-19 09:47:43 +03:00
igor04091968 9b4b847723 feat(detmir): add support scripts docs and daily/weekly/monthly runners 2026-06-18 22:26:28 +03:00
igor04091968 c9ad5ecca7 chore(scripts): add full diagnostics package for detmir 2026-06-18 22:11:38 +03:00
igor04091968 ced3b0fb20 docs: clarify DetMir support responsibility and SLA 2026-06-18 17:33:00 +03:00
igor04091968 5f8a25e056 docs: update support scope with web server 2026-06-17 23:33:27 +03:00
igor04091968 66dc0a09b1 docs: add network and virtualization support tasks 2026-06-17 23:21:36 +03:00
igor04091968 4c96879915 docs: remove 1C from DetMir support scope 2026-06-17 23:16:42 +03:00
igor04091968 371a1e2728 docs: narrow DetMir support scope 2026-06-17 23:06:14 +03:00
igor04091968 b7209771f6 docs: add DetMir support task list 2026-06-17 23:01:54 +03:00
IgorRachkovandGitHub 525f45e0f7 Merge pull request #37 from igor04091968/refactor/portal-telemetry-ingest
refactor(portal): move telemetry ingest into module
2026-06-15 15:31:34 +03:00
igor04091968 66b0b6cffb refactor(portal): move telemetry ingest into module 2026-06-15 14:24:10 +03:00
IgorRachkovandGitHub 57ef5abf7e Merge pull request #36 from igor04091968/refactor/portal-http-response
refactor(portal): move HTTP response helpers into module
2026-06-15 12:04:21 +03:00
IgorRachkovandGitHub ae1909a28a Update README.md 2026-06-15 09:58:03 +03:00
IgorRachkovandGitHub 064fbd05fb Update README.md 2026-06-15 09:57:24 +03:00
igor04091968 fed7cc2eaa refactor(portal): move HTTP response helpers into module 2026-06-15 07:54:19 +03:00
IgorRachkovandGitHub cd61a530d6 Merge pull request #35 from igor04091968/refactor/portal-static-assets
refactor(portal): move static assets into module
2026-06-15 07:15:25 +03:00
igor04091968 58535fa5c6 refactor(portal): move static assets into module 2026-06-15 07:01:13 +03:00
IgorRachkovandGitHub 7f1abdb9a7 Merge pull request #34 from igor04091968/refactor/portal-snapshot-cache
refactor(portal): move snapshot cache helpers into module
2026-06-15 01:43:58 +03:00
igor04091968 5ca325034f refactor(portal): move snapshot cache helpers into module 2026-06-15 01:36:23 +03:00
IgorRachkovandGitHub 5312da175d Merge pull request #33 from igor04091968/refactor/portal-role-access
refactor(portal): move role access helpers into module
2026-06-15 00:43:06 +03:00
igor04091968 fa1ddf64b4 refactor(portal): wire role access module 2026-06-15 00:03:44 +03:00
igor04091968 643d5d2d69 refactor(portal): move role access helpers into module 2026-06-15 00:00:04 +03:00
IgorRachkovandGitHub b629879958 Merge pull request #32 from igor04091968/refactor/portal-readiness-api
refactor(portal): move readiness API helpers into module
2026-06-14 23:56:40 +03:00
igor04091968 78a560dc3e refactor(portal): move readiness API helpers into module 2026-06-14 23:50:25 +03:00
IgorRachkovandGitHub 803c3169d7 Merge pull request #31 from igor04091968/refactor/portal-api-contracts
refactor(portal): move API contract summary into module
2026-06-14 23:25:49 +03:00
igor04091968 03f10435ce refactor(portal): move API contract summary into module 2026-06-14 23:14:30 +03:00
IgorRachkovandGitHub 2f8193e7b3 Merge pull request #30 from igor04091968/refactor/portal-path-query-helpers
refactor(portal): move path and query helpers into module
2026-06-14 23:05:35 +03:00
igor04091968 e76fa5a5c2 refactor(portal): move path and query helpers into module 2026-06-14 22:17:12 +03:00
IgorRachkovandGitHub 68c0fd1a37 Merge pull request #29 from igor04091968/refactor/portal-command-runner
refactor(portal): move command runner into module
2026-06-14 22:11:39 +03:00
igor04091968 d19b3d478f refactor(portal): move command runner into module 2026-06-14 22:00:44 +03:00
IgorRachkovandGitHub 0cd6e4f856 Merge pull request #28 from igor04091968/refactor/portal-roles-module
refactor(portal): move role model into module
2026-06-14 21:43:48 +03:00
igor04091968 acf767360f refactor(portal): move role model into module 2026-06-14 21:25:30 +03:00
IgorRachkovandGitHub 9ad5b2fc34 Merge pull request #27 from igor04091968/release/github-release-assets
rust-binary-build / build-linux-x86_64 (push) Canceled after 0s
ci: publish Rust binary package to GitHub Releases on tags
2026-06-14 21:00:59 +03:00
igor04091968 8596cd057b ci: publish Rust binary package to GitHub Releases on tags 2026-06-14 18:32:04 +03:00
IgorRachkovandGitHub 42b0fdb718 Merge pull request #26 from igor04091968/codex/rust-professionalization
docs(rust): professionalize production guardrails
2026-06-14 18:14:27 +03:00
IgorRachkov 452354a8e3 fix(ci): copy release binaries without preserving unsupported metadata
Rust clippy diagnostic / detmir-portal-clippy-diagnostic (push) Canceled after 0s
2026-06-14 17:55:14 +03:00
IgorRachkov 5fb37bfbaf ci: add binary package path compatibility aliases 2026-06-14 16:56:48 +03:00
IgorRachkov cdd8c292db ci: focus binary workflow on release packaging 2026-06-14 16:55:47 +03:00
IgorRachkov 9cdad90e3a ci: package release binaries with manifest and checksums 2026-06-14 16:51:37 +03:00
IgorRachkov da1a21ee47 ci: add Rust release packaging helper 2026-06-14 16:51:08 +03:00
IgorRachkov 1fdc13aa73 ci: run binary build workflow on pull requests 2026-06-14 16:38:31 +03:00
IgorRachkov 374b320ba4 docs: document GitHub binary build policy 2026-06-14 16:29:07 +03:00
IgorRachkov 4dbb39b8ee ci: limit binary artifact to release outputs 2026-06-14 16:28:42 +03:00
IgorRachkov 26de69b6e2 ci: add pinned GitHub binary build workflow 2026-06-14 16:28:28 +03:00
IgorRachkov f3d5a8161d ci: run professionalization checks with Rust 1.94.0 2026-06-14 16:18:28 +03:00
IgorRachkov ffaae2b459 ci: run workspace checks with Rust 1.94.0 2026-06-14 16:18:13 +03:00
IgorRachkov 8b8dec0754 ci: pin Rust toolchain to 1.94.0 2026-06-14 16:17:59 +03:00
IgorRachkov cd6d8b5119 ci: force pinned Cargo in professionalization workflow 2026-06-14 16:17:32 +03:00
IgorRachkov a303a0d7f5 ci: force pinned Cargo in rust workspace workflow 2026-06-14 16:17:17 +03:00
IgorRachkov f711e6babb ci: pin Rust toolchain for GitHub Actions 2026-06-14 16:17:04 +03:00
IgorRachkov 00fb35096d ci(portal): mark existing clippy debt explicitly 2026-06-14 15:08:13 +03:00
IgorRachkov d33ce4d64c ci: capture detmir portal clippy diagnostics 2026-06-14 15:02:18 +03:00
IgorRachkov 61ad96bc1a fix(portal): compare request headers without static lifetime 2026-06-14 15:00:00 +03:00
IgorRachkov 98505ed7b4 ci: add detmir portal clippy diagnostic 2026-06-14 14:55:39 +03:00
IgorRachkov a961501b6d fix(portal): avoid owned header comparison 2026-06-14 14:52:54 +03:00
IgorRachkov 0a54751b7e ci: scope professionalization clippy checks 2026-06-14 14:49:11 +03:00
IgorRachkov 313237f167 ci: add Rust professionalization check workflow 2026-06-14 14:43:55 +03:00
IgorRachkov de1a5c2893 docs(portal): document metrics contract 2026-06-14 14:36:56 +03:00
IgorRachkov e93368fa84 docs(portal): clarify readiness semantics 2026-06-14 14:36:37 +03:00
IgorRachkov 067a257b6a docs(portal): clarify version endpoint contract 2026-06-14 14:35:53 +03:00
IgorRachkov 07b754090a docs(portal): clarify liveness probe contract 2026-06-14 14:35:36 +03:00
IgorRachkov 5d3b3e96bb docs(portal): explain production query limits 2026-06-14 14:35:13 +03:00
IgorRachkov 3742fc63fe docs(portal): document request correlation contracts 2026-06-14 14:34:48 +03:00
IgorRachkov 48121aec0d docs(portal): annotate structured access logging 2026-06-14 14:34:22 +03:00
IgorRachkov 2618fb9e45 docs(portal): document production runtime boundary 2026-06-14 14:33:58 +03:00
IgorRachkov a747e4c1bb docs(core): document runtime guardrails 2026-06-14 14:33:42 +03:00
IgorRachkovandGitHub 51eed69fe4 Update FULL_DEPLOYMENT_MANUAL_RU.md 2026-06-13 11:42:38 +03:00
IgorRachkovandGitHub 8caedd11d5 Update FULL_DEPLOYMENT_MANUAL_RU.md 2026-06-13 11:37:30 +03:00
IgorRachkovandGitHub 3b5fe0c116 Update COMPETITIVE_POSITIONING_RU.md 2026-06-12 22:17:34 +03:00
IgorRachkovandGitHub a2575233b7 Update COMPETITIVE_POSITIONING_RU.md 2026-06-12 22:14:34 +03:00
igor04091968 8236789781 Merge branch 'hardening/pilot-v1-defects-cleanup'
# Conflicts:
#	docs/roadmap/TASK_013_DETMIR_PRODUCTION_VALIDATION.md
2026-06-12 21:40:31 +03:00
igor04091968 e217ed509a docs: add RC evidence pack 2026-06-12 20:03:18 +03:00
IgorRachkovandGitHub 02967629ff Update README.md 2026-06-12 15:30:45 +03:00
igor04091968 a8c0482e76 chore: improve release candidate preflight 2026-06-12 12:23:25 +03:00
igor04091968 b3b2ff1f07 chore: mark release candidate script executable 2026-06-12 12:14:45 +03:00
igor04091968 248afd2253 chore: add release candidate build automation 2026-06-12 12:13:41 +03:00
igor04091968 fd5c788569 ci: add pilot v1 hardening guards 2026-06-12 11:25:57 +03:00
igor04091968 106d796d95 fix(hayabusa): restore scheduled EVTX upload pipeline 2026-06-12 10:57:39 +03:00
igor04091968 7919051688 fix(smoke): align contour checks with production routes 2026-06-12 10:55:21 +03:00
igor04091968 61a9887ebb docs: document dlp ioc enrichment pipeline 2026-06-12 03:46:43 +03:00
igor04091968 aece5c381f docs: harden opencode handover guidance 2026-06-11 07:10:04 +03:00
igor04091968 163a57237c docs: add opencode system handover plan 2026-06-11 06:49:30 +03:00
igor04091968 5b21e03726 docs: tighten agent instructions 2026-06-11 06:38:41 +03:00
igor04091968 2104da912d fix(server): sanitize stale activity host 2026-06-11 06:29:07 +03:00
igor04091968 2ccf33e1f5 fix(windows): finish rust collector cleanup 2026-06-11 06:28:35 +03:00
igor04091968 1c710dfe94 fix(windows): make collector fallback rust-primary 2026-06-11 06:18:47 +03:00
igor04091968 4d21b7a32e docs: record Grafana all-employees panel deployment 2026-06-09 05:06:39 +03:00
igor04091968 c1d6f97096 fix(grafana): clarify all-employees activity panel 2026-06-09 04:58:12 +03:00
igor04091968 95ceee421f docs: document worktime Grafana user normalization 2026-06-09 04:51:28 +03:00
igor04091968 bae67494e2 feat: add UEBA confidence guardrails 2026-06-07 22:34:45 +03:00
IgorRachkovandGitHub da84a9a800 Create TASK_016_UEBA_CONFIDENCE_GUARDRAILS.md 2026-06-07 22:18:11 +03:00
igor04091968 f2b966d5d4 docs: review ueba critical evidence 2026-06-07 22:13:43 +03:00
IgorRachkovandGitHub 32e1a97877 Create TASK_015_UEBA_CRITICAL_EVIDENCE_REVIEW 2026-06-07 22:03:49 +03:00
igor04091968 8310bc3616 ops: remediate deployment drift 2026-06-07 22:00:41 +03:00
IgorRachkovandGitHub c751035374 Create TASK_014_DEPLOYMENT_DRIFT_REMEDIATION.md 2026-06-07 21:48:29 +03:00
igor04091968 347347b835 docs: add production validation audit 2026-06-07 21:39:32 +03:00
IgorRachkovandGitHub ff6d7155cd Create TASK_013_DETMIR_PRODUCTION_VALIDATION.md 2026-06-07 21:25:57 +03:00
igor04091968 1b817866a4 docs: enter demo freeze housekeeping 2026-06-07 21:17:12 +03:00
igor04091968 037d47b592 test: add browser conformance smoke 2026-06-07 20:36:26 +03:00
IgorRachkovandGitHub 5820de1d85 Add visual conformance smoke testing documentation 2026-06-07 20:25:46 +03:00
igor04091968 a2f5d83662 docs: add roadmap conformance audit 2026-06-07 20:04:41 +03:00
IgorRachkovandGitHub 0b34782b8e TASK_009
Added a detailed conformance audit document for DetMir/AWatch roadmap, outlining required checks, goals, and audit areas.
2026-06-07 19:52:03 +03:00
igor04091968 cff6e7c425 docs: add pilot validation package 2026-06-07 19:09:41 +03:00
IgorRachkovandGitHub 56134e832a Update TASK_010_PILOT_VALIDATION.md 2026-06-07 19:00:51 +03:00
IgorRachkovandGitHub cb943d1328 Create TASK_010_PILOT_VALIDATION.md 2026-06-07 19:00:16 +03:00
igor04091968 3318c89629 docs: add enterprise deployment guide 2026-06-07 17:21:09 +03:00
IgorRachkovandGitHub 9fa085a53e Update TASK_009_ENTERPRISE_DEPLOYMENT_GUIDE.md 2026-06-07 17:15:50 +03:00
IgorRachkovandGitHub 7c57bac03c Create TASK_009_ENTERPRISE_DEPLOYMENT_GUIDE.md 2026-06-07 17:14:23 +03:00
igor04091968 f99633cabd docs: add registry readiness package 2026-06-07 17:10:18 +03:00
IgorRachkovandGitHub 755c8b6f0f Update TASK_008_REGISTRY_READINESS.md 2026-06-07 17:04:42 +03:00
igor04091968 888dbc32ef docs: add customer demo pack 2026-06-07 16:59:50 +03:00
IgorRachkovandGitHub 4c39dd3fb0 Update TASK_007_CUSTOMER_DEMO_PACK.md 2026-06-07 16:45:47 +03:00
igor04091968 2b41fb14c7 feat(portal): add executive action center 2026-06-07 16:39:49 +03:00
IgorRachkovandGitHub 307170b128 Update TASK_006_EXECUTIVE_ACTION_CENTER.md 2026-06-07 16:21:52 +03:00
IgorRachkovandGitHub a734815a63 Create TASK_006_EXECUTIVE_ACTION_CENTER.md 2026-06-07 16:20:27 +03:00
IgorRachkovandGitHub 41e28adbf9 Delete TASK_006_PFSENSE_CONTRACT_LAYER.md 2026-06-07 16:17:15 +03:00
igor04091968 731478411a feat(agent): add rust agent baseline scaffold 2026-06-07 16:08:31 +03:00
IgorRachkovandGitHub 00ff5e7ddf Update TASK_005_RUST_AGENT_BASELINE.md 2026-06-07 15:50:39 +03:00
igor04091968 8fcb49f2c7 feat(portal): add rule-based risk narrative 2026-06-07 15:46:38 +03:00
IgorRachkovandGitHub 5277097032 Update TASK_004_RISK_NARRATIVE.md 2026-06-07 15:32:18 +03:00
igor04091968 0b0bab61bb refactor(portal): split hardening and kpi modules 2026-06-07 14:36:42 +03:00
IgorRachkovandGitHub 2fb3271558 Create TASK_003A_PORTAL_HARDENING_CLEANUP.md 2026-06-07 14:17:15 +03:00
igor04091968 07dfb95b6b feat(portal): harden readiness and explain workforce KPI 2026-06-07 14:12:32 +03:00
IgorRachkovandGitHub 7df665a457 Update TASK_003_EXPLAINABLE_KPI.md 2026-06-07 13:34:33 +03:00
IgorRachkovandGitHub e43c502bcb Update TASK_002_PRODUCTION_HARDENING.md 2026-06-07 13:32:57 +03:00
IgorRachkovandGitHub 77ad99d8a7 Update README.md 2026-06-07 13:28:52 +03:00
igor04091968 46d3ca5622 docs: add product roadmap tasks 2026-06-07 13:22:15 +03:00
igor04091968 80af0acbd4 fix(ops): harden worktime degraded recovery 2026-06-07 12:28:30 +03:00
igor04091968 aa641cba2f fix(ops): harden worktime recovery and collector guard 2026-06-07 11:41:20 +03:00
igor04091968 9c72539401 docs+portal: add pilot v1 demo pack 2026-06-06 21:33:31 +03:00
igor04091968 b062237168 fix(worktime): hide action-only ops from workforce rollups 2026-06-06 19:47:24 +03:00
igor04091968 53a0e4b2ab fix(portal): clarify unassigned department labels 2026-06-06 19:10:00 +03:00
igor04091968 838a94f265 fix(ansible): set awatch portal auth realm 2026-06-06 18:22:39 +03:00
igor04091968 eac9e9da83 fix(ansible): recover gateway credentials when missing 2026-06-06 17:34:54 +03:00
igor04091968 2358dcc453 fix(portal): distinguish 1c portfolio companies 2026-06-06 16:41:43 +03:00
igor04091968 43c6fda4ae docs: link portal screenshots guide from readme 2026-06-06 15:34:16 +03:00
igor04091968 47381dc8e9 docs: reference architecture screenshot evidence 2026-06-06 15:32:03 +03:00
igor04091968 53997ad861 fix(portal): align deployed role smoke and screenshot 2026-06-06 15:28:29 +03:00
igor04091968 05e9af02db docs+portal: polish architecture smoke and screenshot 2026-06-06 14:52:18 +03:00
igor04091968 b8cde77d12 docs+portal: expose product evolution and architecture 2026-06-06 14:39:44 +03:00
igor04091968 38b65e97d6 docs: define collector and platform strategy 2026-06-06 14:25:54 +03:00
igor04091968 f3fd655ada fix(installer): refresh rust windows install kit 2026-06-06 14:06:04 +03:00
igor04091968 bff85094ec docs: remove remaining detmir naming 2026-06-06 13:40:26 +03:00
igor04091968 d68c46e359 docs: normalize public product naming 2026-06-06 13:33:48 +03:00
igor04091968 2eb96855c7 docs: link pilot screenshots 2026-06-06 13:20:50 +03:00
igor04091968 9297d60a33 docs: add pilot v1 acceptance evidence 2026-06-06 13:09:28 +03:00
igor04091968 067ad0939c feat(portal): harden pilot v1 role contracts 2026-06-06 12:54:00 +03:00
igor04091968 9c57d6d2ce feat(rust): advance powershell migration 2026-06-06 08:58:46 +03:00
igor04091968 2ec1836e8c chore(architecture): remove dpd portal legacy 2026-06-05 08:55:13 +03:00
igor04091968 bc2f56ef8f test(portal): harden api contract validation 2026-06-05 08:40:49 +03:00
igor04091968 5e6bbd47a1 feat(portal): add api contracts for future ui 2026-06-05 08:18:12 +03:00
igor04091968 ec9de0e039 feat(portal): make DPD portal a full mirror 2026-06-05 00:32:31 +03:00
igor04091968 a74fa24996 feat(portal): add parallel DPD pilot portal 2026-06-05 00:08:14 +03:00
igor04091968 36fbcb1d63 fix(portal): keep technical health out of security view 2026-06-04 23:33:40 +03:00
igor04091968 afc40fd3ca fix(portal): prevent security view loading stalls 2026-06-04 23:20:53 +03:00
igor04091968 6a6c69e803 fix(portal): enable clickhouse security event aggregate 2026-06-04 23:01:32 +03:00
igor04091968 2cb1afad4f fix(portal): hide technical terms in executive demo view 2026-06-04 22:52:03 +03:00
igor04091968 88dcd8ecaa fix(portal): localize loading states for pilot 2026-06-04 22:30:06 +03:00
igor04091968 8957161317 fix(portal): polish security events fallback states 2026-06-04 22:16:12 +03:00
igor04091968 da17047b38 feat(portal): add optional ClickHouse security events summary 2026-06-04 21:51:41 +03:00
igor04091968 90378229ae fix(portal): harden detmir check command 2026-06-04 20:57:07 +03:00
igor04091968 f13a15f9db fix(portal): complete security role view 2026-06-04 20:47:25 +03:00
igor04091968 10f0d15407 feat(portal): add role views 2026-06-04 20:32:27 +03:00
igor04091968 0992679100 feat(portal): localize management UI 2026-06-04 20:00:15 +03:00
igor04091968 dfecc637b5 feat(portal): add loading refresh status 2026-06-04 19:35:31 +03:00
igor04091968 a672941cc7 fix(portal): sanitize corrupt workforce labels 2026-06-04 19:14:45 +03:00
igor04091968 f29d85eeb2 fix(gateway): force utf-8 charset 2026-06-04 18:48:47 +03:00
igor04091968 d32fa041cf docs: add sales positioning 2026-06-04 18:42:19 +03:00
igor04091968 bf41e43315 docs: add customer pilot pack 2026-06-04 18:36:15 +03:00
igor04091968 9b94c7747e docs: add pilot readiness audit 2026-06-04 18:29:30 +03:00
igor04091968 10c44dd1f0 fix(portal): align management block order 2026-06-04 18:09:13 +03:00
igor04091968 68674a3c6f fix(portal): put risk narrative first in reports 2026-06-04 17:45:32 +03:00
igor04091968 da6e80a90c feat(portal): prioritize risk narrative 2026-06-04 17:31:50 +03:00
igor04091968 f60cca7f59 feat(portal): connect risk narrative layers 2026-06-04 15:30:46 +03:00
igor04091968 d0e502f622 feat(portal): correlate workforce and security risk 2026-06-04 15:14:35 +03:00
igor04091968 81cc69196a feat(portal): add department risk heatmap 2026-06-04 15:05:27 +03:00
igor04091968 1b233dfd83 feat(portal): add executive dashboard 2026-06-04 14:52:08 +03:00
igor04091968 3ccf8b50cb feat(portal): add case management workflow 2026-06-04 14:36:31 +03:00
igor04091968 fca5b57455 feat(portal): export investigation packs 2026-06-04 14:22:50 +03:00
igor04091968 cd95792196 feat(portal): audit incident review changes 2026-06-04 14:11:34 +03:00
igor04091968 20fb47b457 feat(portal): add incident review workflow 2026-06-04 13:46:25 +03:00
igor04091968 4c66e67661 feat(portal): add risk incident candidates 2026-06-04 13:32:55 +03:00
igor04091968 3c7e1de96c feat(portal): add business risk timeline 2026-06-04 13:23:39 +03:00
igor04091968 0341e114a9 feat(agent): add safe lowlevel extension point 2026-06-04 13:08:07 +03:00
igor04091968 313c49eb4e feat(portal): explain department business risk 2026-06-04 13:03:42 +03:00
igor04091968 27e9ef2046 feat(portal): add department business risk 2026-06-04 12:52:40 +03:00
igor04091968 a30ab9cc95 feat(portal): add agent coverage sla 2026-06-04 12:41:03 +03:00
igor04091968 bb00651bce feat(portal): add agent quality node summary 2026-06-04 12:28:18 +03:00
igor04091968 437da860d4 feat(portal): add agent quality history 2026-06-04 12:08:48 +03:00
igor04091968 e55b68868d feat(portal): add agent data trust explain 2026-06-04 11:59:50 +03:00
igor04091968 95e7c0f314 feat(agent): expose data quality in portal 2026-06-04 11:49:14 +03:00
igor04091968 d7616fd9a9 feat(agent): harden rust worktime fallback mode 2026-06-04 11:21:54 +03:00
igor04091968 ff234af701 feat(agent): switch worktime sessions to rust agent 2026-06-04 10:36:19 +03:00
igor04091968 152705a999 feat(agent): add windows and freebsd read-only collectors 2026-06-04 09:22:35 +03:00
igor04091968 3f19efb7e3 feat(agent): add rust telemetry agent v0.3 2026-06-04 09:19:08 +03:00
igor04091968 9f47dcaefa feat(portal): add executive workforce risk layer 2026-06-04 08:46:37 +03:00
igor04091968 9f5f99ae65 feat(portal): harden industrial dashboard tabs smoke 2026-06-04 08:21:53 +03:00
igor04091968 2645082183 feat(portal): apply industrial workforce dashboard layout 2026-06-04 07:50:08 +03:00
igor04091968 96090f7c10 fix(portal): apply selected report period 2026-06-04 07:23:12 +03:00
igor04091968 96c90efbde feat(portal): add industrial operations modules 2026-06-04 07:15:20 +03:00
igor04091968 4197efe482 fix(portal): use management language in navigation 2026-06-04 07:09:30 +03:00
igor04091968 8b621a62d6 feat(portal): reshape into workforce security forensics cabinet 2026-06-04 06:57:02 +03:00
igor04091968 1e869e1a14 fix(portal): restore production runtime health 2026-06-03 23:22:02 +03:00
igor04091968 a297220353 docs(release): add audit package v0.3 2026-06-03 22:43:43 +03:00
igor04091968 a75a412801 ci: make shellcheck gate release-safe 2026-06-03 22:34:39 +03:00
igor04091968 ad9a3c24cb docs(release): add readiness v0.2 hardening 2026-06-03 22:32:16 +03:00
igor04091968 8db9f180ab docs(release): add readiness v0.1 package 2026-06-03 21:53:23 +03:00
igor04091968 5aab90f27d feat(ops): surface readiness in portal 2026-06-03 21:45:03 +03:00
igor04091968 59d5e1f1fc feat(ops): sign readiness bundle 2026-06-03 20:56:35 +03:00
igor04091968 3dc7214b58 feat(ops): add signed readiness bundle 2026-06-03 20:30:05 +03:00
igor04091968 ea5bedc3c3 feat(ops): export DetMir readiness act 2026-06-03 20:11:30 +03:00
igor04091968 f5f0cebfc4 feat(ops): add DetMir production readiness check 2026-06-03 19:58:45 +03:00
igor04091968 1312ee3ee5 fix(ops): harden runtime placeholder guards 2026-06-03 19:49:06 +03:00
igor04091968 2e64f9b26c fix(ops): guard production Influx placeholders 2026-06-03 19:32:31 +03:00
igor04091968 efd09012d1 feat(ueba): add per-user and department baseline skeleton 2026-06-03 19:07:46 +03:00
igor04091968 c2e2f64c08 feat(portal): make UEBA risk policy configurable 2026-06-03 18:00:08 +03:00
igor04091968 2cb2260bb2 feat(portal): add UEBA risk scoring 2026-06-03 17:34:21 +03:00
igor04091968 2bf8ecfea8 feat(portal): anonymize workforce report exports 2026-06-03 15:50:14 +03:00
igor04091968 d155aed779 feat(portal): export workforce index evidence 2026-06-03 15:40:27 +03:00
igor04091968 468496f83a feat(portal): add lightweight workforce explain endpoint 2026-06-03 15:25:25 +03:00
igor04091968 b99d349121 feat(portal): explain workforce index scoring 2026-06-03 14:19:32 +03:00
igor04091968 3d50a1bea7 feat(workforce): explain role profile scoring 2026-06-03 14:07:25 +03:00
igor04091968 7bc1d4170a feat(workforce): add interpretation policy config 2026-06-03 13:03:24 +03:00
igor04091968 0302d888f6 feat(workforce): add trend interpretation insights 2026-06-03 12:48:51 +03:00
igor04091968 01ff3b8e29 feat(workforce): persist daily management history 2026-06-03 12:13:26 +03:00
igor04091968 c1119b460b feat(workforce): add department comparison layer 2026-06-03 11:42:24 +03:00
igor04091968 233524049e feat(workforce): add role-based activity weights 2026-06-03 11:21:33 +03:00
igor04091968 5ec9f508db feat(commercial): prioritize workforce module 2026-06-03 10:46:41 +03:00
igor04091968 3a4e70c3b7 feat(portal): add commercial reports view 2026-06-03 10:36:42 +03:00
igor04091968 bf4e38f35e docs(proof): add operational evidence profile 2026-06-03 10:16:50 +03:00
igor04091968 1aa35b1f8e docs(release): prepare public review release 2026-06-03 09:49:12 +03:00
igor04091968 e94174a89c docs(registry): add expert test scenario 2026-06-03 09:27:08 +03:00
igor04091968 5598894717 docs(release): add June 2026 audit 2026-06-03 09:23:24 +03:00
igor04091968 a1801fca5f docs(public): sanitize release materials 2026-06-03 08:52:20 +03:00
igor04091968 18653e1a2f docs(public): sanitize runbook examples 2026-06-03 08:34:23 +03:00
igor04091968 35653b45b6 docs(registry): strengthen software registry package 2026-06-03 08:30:37 +03:00
igor04091968 c2d1f40033 docs(registry): expand registry and license package 2026-06-03 08:30:18 +03:00
igor04091968 4ca1c30bc6 docs(public): complete registry-facing docs 2026-06-03 08:21:59 +03:00
igor04091968 9fc218dc49 chore(public): sanitize repository presentation 2026-06-03 08:02:15 +03:00
igor04091968 dd3163624b fix(detmir): stabilize grafana freshness guard 2026-06-03 07:06:39 +03:00
igor04091968 dbef90a09e refactor(detmir): retire python runtime paths 2026-06-03 03:27:52 +03:00
igor04091968 109c31f291 docs(detmir): add registry proof package 2026-06-03 01:46:15 +03:00
igor04091968 5f217b6dbb docs(detmir): document registry positioning 2026-06-03 01:31:19 +03:00
igor04091968 083c962982 docs(detmir): add operational threat model 2026-06-03 01:27:25 +03:00
igor04091968 ec0a5fdd55 feat(detmir): automate DLP evidence screenshot sync 2026-06-03 01:14:42 +03:00
igor04091968 122c4bbfc0 feat(detmir): add DLP evidence portal viewer 2026-06-03 00:33:31 +03:00
igor04091968 189885d73c feat(detmir): focus security incidents portal card 2026-06-03 00:09:20 +03:00
igor04091968 17e8e7b56a feat(detmir): add portal incident acknowledgement 2026-06-02 22:17:25 +03:00
igor04091968 87453a4ef0 fix(detmir): repair portal deep links 2026-06-02 21:01:08 +03:00
igor04091968 8cf45de4da ui(detmir): rename security incidents tab 2026-06-02 20:48:21 +03:00
igor04091968 ca4944b978 feat(detmir): deploy read-only operator portal 2026-06-02 20:35:58 +03:00
igor04091968 ac2901c0dc docs(detmir): plan operator portal gui 2026-06-02 20:17:47 +03:00
igor04091968 f513836396 feat(detmir): add grafana data correctness guard 2026-06-02 20:06:06 +03:00
igor04091968 e4b49e9284 fix(grafana): refresh detmir aw dashboard queries 2026-06-02 19:50:34 +03:00
igor04091968 e1f78b7735 fix(detmir): stabilize rust health gates 2026-06-02 19:15:17 +03:00
igor04091968 63774cb8c4 feat(detmir): add rust linux install planners 2026-06-02 18:23:44 +03:00
igor04091968 632962a90e feat(detmir): add rust aw reliability planner 2026-06-02 18:17:56 +03:00
igor04091968 19e3682bc8 feat(detmir): add rust-first operations tooling 2026-06-02 17:57:58 +03:00
igor04091968 60670d30a8 fix(grafana): avoid empty financial readiness stat 2026-05-29 08:23:27 +03:00
igor04091968 32e5c1eb4f fix(grafana): clean management dashboard labels 2026-05-29 07:51:53 +03:00
igor04091968 af4d354b54 feat(grafana): show proved active app work 2026-05-29 07:22:45 +03:00
igor04091968 09e58b4170 chore(install-kit): rebuild awindows package 2026-05-29 06:46:19 +03:00
igor04091968 e4a25ab9c0 feat(worktime): add true active app evidence and contour smoke 2026-05-29 00:35:00 +03:00
igor04091968 8b8f3e362d fix(grafana): make pfsense timeseries render 2026-05-28 18:24:00 +03:00
igor04091968 05e8856bb2 feat(grafana): extend pfsense dashboard visualization 2026-05-28 18:14:10 +03:00
igor04091968 fb66378a1c feat(grafana): visualize pfsense loki logs 2026-05-28 12:02:31 +03:00
igor04091968 ec11d8b303 docs: simplify dashboard landing pages 2026-05-28 11:15:31 +03:00
igor04091968 79eff54774 docs(wiki): document collector and grafana hardening 2026-05-28 11:05:59 +03:00
igor04091968 cc9e4a0669 fix(aw): harden collectors and grafana exports 2026-05-28 10:54:46 +03:00
igor04091968 524ab33f63 fix(activity): restore app-level window stream on rdp host 2026-05-27 11:36:58 +03:00
igor04091968 51cb43666a docs(wiki): add security, sales and missing wiki pages 2026-05-27 06:40:08 +03:00
igor04091968 3042bd5042 feat(detmir): ship ops, dlp, 1c and mcp updates 2026-05-27 05:56:43 +03:00
igor04091968 033ae810f3 fix(windows): rotate deploy archives and hide pfsense menu 2026-05-26 19:01:28 +03:00
igor04091968 0d56ab20d6 feat(worktime): add management report layer 2026-05-23 11:56:36 +03:00
igor04091968 71b614125f fix(1c): age generated business event exports for same-cycle ingest 2026-05-22 22:25:07 +03:00
igor04091968 f1d4bdfc2c feat(1c): add read-only business event normalizer 2026-05-22 22:00:49 +03:00
igor04091968 61770c1658 feat(1c): scaffold business event extraction layer 2026-05-22 21:47:52 +03:00
igor04091968 6b246aaa14 docs(1c): publish ai investigator runtime summary 2026-05-22 21:36:55 +03:00
igor04091968 6cb1e0a95b fix(rdp): decouple activity view from disconnected sessions 2026-05-22 20:02:47 +03:00
igor04091968 8af3fd27f4 feat(1c): decouple company intelligence from 1c names 2026-05-22 17:22:10 +03:00
igor04091968 ba12146e43 fix(1c): alias recovery incident company fields 2026-05-22 16:50:40 +03:00
igor04091968 be4609cd25 feat(1c): add codex recovery brief layer 2026-05-22 16:46:32 +03:00
igor04091968 4f1b935bdc fix(1c): harden post-ingest manager brief refresh 2026-05-22 16:34:53 +03:00
igor04091968 5670ecd84b feat(1c): add executive regulation to manager brief 2026-05-22 16:27:19 +03:00
igor04091968 11c0f06e0a fix(1c): mark weekly digest runner executable 2026-05-22 16:03:23 +03:00
igor04091968 ee4eea7e1a feat(1c): add weekly digest and hard priority pages 2026-05-22 16:03:04 +03:00
igor04091968 70d12dca1d feat(1c): add priority ranking and weekly trend page 2026-05-22 15:50:22 +03:00
igor04091968 ff93ddd3cd test(1c): update delta page wording expectation 2026-05-22 15:31:41 +03:00
igor04091968 45f869fa6b fix(1c): polish manager delta page wording 2026-05-22 15:31:25 +03:00
igor04091968 151778dde6 fix(1c): backfill delta from brief top lists 2026-05-22 15:30:18 +03:00
igor04091968 798bbd9a7b feat(1c): add brief delta analysis and changes page 2026-05-22 15:28:43 +03:00
igor04091968 68a82a95ee fix(1c): normalize problematic company fields from clickhouse 2026-05-22 15:01:20 +03:00
igor04091968 d70f121b5a fix(1c): alias problematic company fields for html pages 2026-05-22 15:00:13 +03:00
igor04091968 6a68b77ffc feat(1c): add brief history and problematic company pages 2026-05-22 14:59:01 +03:00
igor04091968 6fa86a1880 feat(1c): add human company detail page 2026-05-22 14:46:53 +03:00
igor04091968 e440681b3a fix(1c): silence favicon 404 on executive brief page 2026-05-22 14:41:14 +03:00
igor04091968 cde039f785 feat(1c): add browser executive brief page 2026-05-22 14:39:44 +03:00
igor04091968 aac57064e0 fix(1c): track manager brief runner executable 2026-05-22 14:31:55 +03:00
igor04091968 10d63d41f0 feat(1c): add codex-based manager brief pipeline 2026-05-22 14:21:37 +03:00
igor04091968 729f06200c feat(1c): expose company api on lan 2026-05-22 13:36:43 +03:00
igor04091968 31a5e30984 fix(grafana): make investigation timeline load by default 2026-05-22 12:06:15 +03:00
igor04091968 4da6e68b96 feat(1c): add manual company registry overrides 2026-05-22 11:41:14 +03:00
igor04091968 8993fbf0be feat(1c): normalize company registry aliases 2026-05-22 11:29:40 +03:00
igor04091968 2e682ec0c3 feat(1c): ingest company registry workbook 2026-05-22 11:14:31 +03:00
igor04091968 5c4966f1ff fix(1c): harden company telemetry rollout 2026-05-22 11:01:33 +03:00
igor04091968 243edccdd3 fix(1c): deploy company api and schema updates 2026-05-22 10:47:57 +03:00
igor04091968 02e896b05a feat(1c): add read-only companies export layer 2026-05-22 10:46:06 +03:00
igor04091968 31fe43f4bc docs(1c): clarify company activity forecasting semantics 2026-05-22 10:33:42 +03:00
igor04091968 1ca43398a4 feat(1c): derive company activity from file-base telemetry 2026-05-22 10:25:09 +03:00
igor04091968 f7114bde03 feat(1c): add company intelligence forecasting layer 2026-05-22 10:11:53 +03:00
igor04091968 da58d682c1 fix(grafana): make file-1c dashboards live by default 2026-05-22 09:48:28 +03:00
igor04091968 172d113536 docs(1c): add production deployment guide 2026-05-22 09:06:39 +03:00
igor04091968 1e23d5acb6 fix(1c): preserve file upload task principal 2026-05-22 08:34:22 +03:00
igor04091968 0d4300dfaf fix(1c): harden live ingestion path 2026-05-22 08:17:42 +03:00
igor04091968 5c6c23ba0f feat(1c): automate live file telemetry ingestion 2026-05-22 08:09:00 +03:00
igor04091968 41f7a869e5 feat(1c): make file dashboards live and provisioned 2026-05-22 00:36:54 +03:00
igor04091968 04b45ecf03 feat(1c): add file-based analytics stack scaffold 2026-05-21 23:34:55 +03:00
igor04091968 b420104f1f feat(alerts): enable telegram and improve operator text 2026-05-21 22:41:15 +03:00
igor04091968 0c88b519ee feat(hayabusa): add auto-case scoring and 6h automation 2026-05-21 20:47:49 +03:00
igor04091968 4e4898828b fix(hayabusa): close windows direct-upload path 2026-05-21 19:15:30 +03:00
igor04091968 712fccb630 feat(hayabusa): automate detached server-side processing 2026-05-21 17:52:01 +03:00
igor04091968 c0b9dad534 fix(hayabusa): normalize zip extraction 2026-05-21 16:37:31 +03:00
igor04091968 dc2240d635 fix(hayabusa): close phase17 live production validation 2026-05-21 16:02:23 +03:00
igor04091968 1b77bd6a47 docs(history): add sanitized codex history 2026-05-21 15:39:46 +03:00
igor04091968 63f00587a6 docs(runbook): add phase17 hayabusa validation path 2026-05-21 15:24:24 +03:00
igor04091968 99143b108b feat(aw-rus): sync deploy stack, install kit, and health checks 2026-05-21 15:16:23 +03:00
IgorRachkovandGitHub 45f9907450 Update PRESENTATION_RU.md 2026-05-15 17:02:18 +03:00
igor04091968 77e073c20c docs(presentation): refresh AW-rus DLP review screenshot 2026-05-15 09:56:15 +03:00
igor04091968 9b22f6f3fb docs(presentation): add per-user worktime report screenshot 2026-05-15 09:52:01 +03:00
igor04091968 4f11094081 fix(grafana): make DLP overview use live influx data 2026-05-15 09:45:13 +03:00
igor04091968 25c3ac3d2d docs(presentation): refresh DLP overview screenshot 2026-05-15 09:37:33 +03:00
igor04091968 4fb1c9335e docs(grafana): add dashboard import playbook and presentation assets 2026-05-15 09:20:02 +03:00
igor04091968 9927cdb195 hardening: strengthen deploy and validation gates 2026-05-15 08:07:18 +03:00
igor04091968 15941e48cb docs(sales): add russian sales overview and switch calendar day start 2026-05-15 07:16:24 +03:00
igor04091968 6620d52f6c feat(grafana): russify dlp dashboard labels 2026-05-15 06:45:43 +03:00
igor04091968 ab0ee7d49d fix(windows): capture localized admin rdp activity 2026-05-15 04:50:33 +03:00
igor04091968 45f990e838 feat(grafana): add management infosec dashboard 2026-05-15 04:03:37 +03:00
igor04091968 93a3169203 feat(dlp): add infosec grafana dashboard 2026-05-15 02:22:48 +03:00
igor04091968 97b458528c fix(grafana): simplify RDP dashboard labels 2026-05-15 01:52:25 +03:00
igor04091968 174d65181b feat(grafana): redesign RDP dashboard for management 2026-05-15 01:38:48 +03:00
igor04091968 873e43fb2e feat(worktime): publish RDP activity to Grafana 2026-05-15 01:23:23 +03:00
igor04091968 22de305c8a feat(worktime): localize rdp worktime report 2026-05-15 00:46:36 +03:00
igor04091968 0eaaf82432 feat(worktime): improve rdp report operator view 2026-05-15 00:42:51 +03:00
igor04091968 6c41717ef9 fix(worktime): restore real per-user RDP report pipeline 2026-05-15 00:37:15 +03:00
igor04091968 1399eaf605 fix(webui): restore resilient aw-rus ui patching 2026-05-14 23:14:31 +03:00
igor04091968 0098abe426 fix(dlp): block self-test case creation 2026-05-14 20:49:00 +03:00
igor04091968 76d1377736 fix(activity): restore real window watchers and isolate rdp bridge 2026-05-14 20:02:46 +03:00
igor04091968 59e93c1c69 fix(windows): restore fileops collector freshness 2026-05-14 15:46:03 +03:00
igor04091968 0cce6fd08e feat(dfir): add hayabusa forensic workflow integration 2026-05-14 15:04:07 +03:00
igor04091968 563bd910d1 fix(webui): allow case management fetch in CSP 2026-05-13 23:20:59 +03:00
igor04091968 91c3b46f16 fix(ops): sync verified production baseline for AW-Rus DLP 2026-05-13 22:50:42 +03:00
igor04091968 22aadd5c03 fix(bot): align AW-Rus worktime check with idle RDP state 2026-05-13 08:47:53 +03:00
igor04091968 f6ee903b10 fix(worktime): isolate global session collector and reset RDP bridge flow 2026-05-13 08:18:47 +03:00
igor04091968 b69f4d83cc docs(security): add implemented DLP functional profile for infosec 2026-05-13 07:35:22 +03:00
igor04091968 eb09251118 DLP 2026-05-13 07:22:27 +03:00
igor04091968 2e5242f5ba feat(dlp): add dedicated health check and grafana dashboard 2026-05-13 07:08:23 +03:00
igor04091968 6ebf0ac67e feat(dlp): add server-side analyzer, evidence chain, compliance scheduler, and syslog forwarder 2026-05-13 04:54:08 +03:00
igor04091968 0f1b82233a fix(webui): restore DLP overlay with recursion fail-safe guard 2026-05-13 03:49:44 +03:00
igor04091968 14f36f502d fix(webui): disable DLP bucket overlay in safe-mode to stop recursion 2026-05-13 03:46:34 +03:00
igor04091968 068c5a8b0b fix(webui): normalize unknown host refs in category-builder hash/storage 2026-05-13 03:41:17 +03:00
igor04091968 9153d85d8e fix(webui): harden category-builder unknown bucket query rewrite 2026-05-13 03:37:01 +03:00
igor04091968 070a8f8f7e feat(dlp): add compliance reporting scheduler and admin CLI 2026-05-13 03:31:40 +03:00
igor04091968 28a4dae67f feat(dlp): implement case management service and DLP Review integration 2026-05-13 03:08:53 +03:00
igor04091968 bc44052391 fix(health): treat stale file-operations buckets as warning by default 2026-05-13 02:35:59 +03:00
igor04091968 fd2e9ac59d feat(dlp): implement SIEM/SOAR integrations (CEF, webhook, syslog, systemd timers) 2026-05-13 02:30:52 +03:00
igor04091968 067457198d fix(webui): avoid category-builder crash on aw-watcher-window_unknown buckets 2026-05-13 02:10:04 +03:00
igor04091968 8619c14735 fix(deploy): make AW DB merge idempotent and non-fatal on name-unique conflicts 2026-05-13 02:04:28 +03:00
igor04091968 0bc0fe7d78 fix(webui): rewrite unknown watcher buckets for all query routes 2026-05-12 16:57:03 +03:00
igor04091968 4e99100e6c fix(worktime): add API self-heal and bot worktime remediation deploy vars 2026-05-12 16:48:30 +03:00
igor04091968 bfa7c5bff2 fix(worktime): normalize bridge durations and tighten autoheal backfill window 2026-05-12 06:06:14 +03:00
igor04091968 64158e97a4 feat(worktime): add automatic self-heal timer for zero-activity regressions 2026-05-12 06:01:07 +03:00
igor04091968 012bfb266f fix(worktime): compute non-zero bridge durations from timestamp deltas 2026-05-12 05:25:14 +03:00
igor04091968 f180e2c3dc feat(ops): add diagnostics and manual restart script for AW stack 2026-05-12 05:19:37 +03:00
igor04091968 c624db5dd7 feat(dlp-policy): add approval workflow, full audit trail, and CRUD documentation 2026-05-12 01:11:01 +03:00
igor04091968 518c445157 feat(dlp-policy): add agent heartbeat/desired refresh channel for push-pull policy sync 2026-05-12 01:01:08 +03:00
igor04091968 d812a8bb80 fix(dlp): enforce server policy mode and preserve policy-engine settings in hardening 2026-05-12 00:07:05 +03:00
igor04091968 b213552d9d feat(dlp): add enterprise phase scaffolds (policy role, content analysis, siem, case, compliance, cli) 2026-05-11 23:18:20 +03:00
igor04091968 8be0de1c85 feat(ansible): add post-deploy aw-health-check gate 2026-05-11 22:57:57 +03:00
igor04091968 9342e8b907 feat(ops): extend aw health check with dlp transport freshness 2026-05-11 22:36:52 +03:00
igor04091968 5d7137379c docs(runbook): add deterministic WAL failover test for windows collectors 2026-05-11 22:32:58 +03:00
igor04091968 544304765f feat(dlp): add WAL transport and collector health telemetry 2026-05-11 21:57:23 +03:00
igor04091968 e0561bf865 fix(windows): disable outlook popup and enforce smtp-only email monitoring 2026-05-11 20:46:39 +03:00
igor04091968 24dd5ae2b4 fix(worktime): add fallback heartbeat when query user returns no sessions 2026-05-10 10:59:21 +03:00
igor04091968 4b5c75bdbb feat(dlp): deploy hayabusa IOC refresh on aw-server with post-deploy checks 2026-05-10 03:13:28 +03:00
igor04091968 c43f9b8708 feat(dlp): add hayabusa sigma IOC extraction pipeline 2026-05-09 17:02:31 +03:00
igor04091968 249ba67511 fix(ansible): align validation report dir defaults across windows playbooks 2026-05-09 13:07:27 +03:00
igor04091968 ce428fb5f8 fix(worktime-bridge): do not let active=false mask unknown-session fallback 2026-05-09 12:31:16 +03:00
igor04091968 2bf667fccc fix(worktime-bridge): treat unknown non-system RDP sessions as active 2026-05-09 12:29:42 +03:00
igor04091968 380983b546 fix(worktime-api): count unknown RDP sessions with valid user/session as active 2026-05-09 12:28:37 +03:00
igor04091968 6ab29a9590 fix(worktime): harden query-user parsing and skip unknown fallback 2026-05-09 12:24:52 +03:00
igor04091968 f22cd27e31 fix(ansible): harden WinRM deploy wrapper with md4+retry 2026-05-09 11:00:59 +03:00
igor04091968 c9df042d96 tools(ansible): add no-proxy wrapper for WinRM deploy 2026-05-09 10:13:58 +03:00
igor04091968 aa0ccc4ecf fix(ansible): run smoke-check on controller + per-user validation dir 2026-05-09 09:42:36 +03:00
igor04091968 2997e7430a fix(ansible): always enable launch tasks + validate window bucket 2026-05-09 08:48:05 +03:00
igor04091968 7a320d7862 fix(windows): stabilize standalone agent + DLP/worktime collectors 2026-05-09 08:29:18 +03:00
igor04091968 5ddceb4dc3 chore: добавить артефакты анализа кода в .gitignore 2026-05-09 08:29:18 +03:00
IgorRachkovandGitHub 95bf1edd37 Merge pull request #24 from igor04091968/codex/-e2e-grafana-+-prometheus
Harden Grafana Prometheus e2e readiness
2026-05-09 05:22:16 +03:00
IgorRachkov dc8988e5f7 Harden Grafana Prometheus e2e readiness 2026-05-09 03:58:52 +03:00
igor04091968 230a9c6936 fix(windows): harden worktime session collector encoding and query parsing 2026-05-08 10:54:18 +03:00
igor04091968 6a36febfeb docs: capture todo - deploy standalone agent on SHARKON2025 2026-05-08 08:15:53 +03:00
igor04091968 e80272a55f docs(installer): document standalone service mode and minimal host/port setup 2026-05-08 00:44:48 +03:00
igor04091968 1c3d7896cd feat(installer): add standalone Windows service deployment for DLP agent 2026-05-08 00:41:00 +03:00
igor04091968 211a6a6eac fix(windows-deploy): enforce awHostname override and safe single-instance collector restart 2026-05-08 00:41:00 +03:00
igor04091968 6f5e5eb751 Revert "merge: apply windows standalone service installer and awHostname hardening"
This reverts commit e643576aa9, reversing
changes made to 669501f20a.
2026-05-08 00:41:00 +03:00
igor04091968 e643576aa9 merge: apply windows standalone service installer and awHostname hardening 2026-05-08 00:40:19 +03:00
igor04091968 f5f4f84bef feat(installer): add standalone Windows service deployment for DLP agent 2026-05-08 00:39:28 +03:00
igor04091968 693e6832a6 fix(windows-deploy): enforce awHostname override and safe single-instance collector restart 2026-05-08 00:34:52 +03:00
igor04091968 538ff74611 feat(dlp): add health status and graceful shutdown for browser collector 2026-05-07 23:54:52 +03:00
igor04091968 b992ad2234 feat(dlp): graceful shutdown and COM cleanup for email collector 2026-05-07 23:52:03 +03:00
igor04091968 ac59d44719 feat(dlp): add WAL buffering and health snapshots for collectors 2026-05-07 23:47:03 +03:00
igor04091968 f3c5e9ea53 feat(dlp-hardening): secure inventory and extend aggregator sources 2026-05-07 23:38:20 +03:00
igor04091968 38107b3c84 ansible: remove per-host passwords from inventory\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> 2026-05-07 23:37:47 +03:00
igor04091968 8ad02ae194 docs(gsd): bootstrap planning and phase-1 discussion artifacts 2026-05-07 21:39:32 +03:00
igor04091968 2fd0ca8eda docs: add copilot instructions 2026-05-07 21:37:42 +03:00
igor04091968 9c59f74928 chore(install-kit): sync package payload with current repo baseline 2026-05-07 19:50:07 +03:00
igor04091968 0c5069c255 chore(ops): harden rollout gates and sanitize generated artifacts 2026-05-07 19:50:02 +03:00
igor04091968andCopilot f0db2a227b revert(pssa): restore validate-deployment.ps1 from commit 63904d2 (stable)
Restored clean version to fix corruption introduced earlier.

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

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

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-07 12:50:35 +03:00
igor04091968 3e8a6981f5 fix(shell): silence remaining ShellCheck warnings for validate_install_kit and install_aw_linux_remote_worker\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> 2026-05-07 12:39:32 +03:00
igor04091968 c9f3aad89c fix(shell): address ShellCheck warnings (remove SC2181 checks, safer prompt_secret, add selective suppressions)\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> 2026-05-07 12:35:23 +03:00
igor04091968 669501f20a fix(worktime): treat russian session state 'Активно' as active 2026-05-07 09:07:19 +03:00
igor04091968 cd5fd95faf fix(aw): stabilize ui bridge and restore dlp endpoint collector 2026-05-07 08:57:36 +03:00
IgorRachkovandGitHub 5e5cb30fab Merge pull request #15 from igor04091968/devin/1777897921-print-docname-ansible-innosetup
fix(print): исправление имени документа «Печать документа» + интеграция email collector в деплой
2026-05-07 07:34:42 +03:00
igor04091968 8b92b1136f chore: merge main into PR #15 2026-05-07 07:34:36 +03:00
IgorRachkovandGitHub 083b05d096 Merge pull request #19 from igor04091968/codex/-rdp
Fix Windows/RDP collectors startup and make bucket creation idempotent
2026-05-07 07:34:20 +03:00
igor04091968 8571832615 chore: merge main into PR #19 2026-05-07 07:33:53 +03:00
IgorRachkovandGitHub eb25a230fc Merge pull request #3 from igor04091968/codex/explain-codebase-structure-to-beginner
Add newcomer onboarding guide and link it from README
2026-05-07 07:32:03 +03:00
IgorRachkovandGitHub 96ee17f156 Merge pull request #6 from igor04091968/codex/validate-ansible-playbooks-in-repository
Add install-kit validation tools and add Ansible syntax check to quality gate
2026-05-07 07:31:54 +03:00
IgorRachkovandGitHub 66a8c47319 Merge pull request #7 from igor04091968/codex/find-and-fix-error-in-powershell-dlp-script
Refine detection of masked Cyrillic print titles and normalize printer names
2026-05-07 07:31:50 +03:00
IgorRachkovandGitHub 5a89a79803 Merge pull request #13 from igor04091968/devin/1777839496-strategic-dlp-roadmap
docs: стратегический DLP roadmap — AWatch-rus vs InfoWatch Traffic Monitor
2026-05-07 07:31:44 +03:00
IgorRachkovandGitHub a00bbdee06 Merge pull request #16 from igor04091968/codex/analyze-powershell-scripts-for-functionality
Fix DLP PowerShell collectors: config default, HTTP handling, event cleanup, headless fallback
2026-05-07 07:31:38 +03:00
IgorRachkovandGitHub 9ef09cb892 Merge pull request #18 from igor04091968/codex/review-dlp-related-code
Add print-job dedupe, improve clipboard guards and add polling error logs
2026-05-07 07:31:33 +03:00
igor04091968 8849354dda fix(worktime): codify ui-bridge in ansible and install flow 2026-05-07 07:20:14 +03:00
igor04091968 d91a396c00 feat(webui): add HTML and panel view for worktime reports 2026-05-07 06:41:42 +03:00
igor04091968 8a91734a27 fix(worktime): use Europe/Moscow day boundary for aw-rus today reports 2026-05-07 06:30:12 +03:00
igor04091968 1df0e18a95 fix(collectors): stabilize browser+DLP collectors and add rdp worktime report script 2026-05-06 23:32:03 +03:00
igor04091968 a6a05b82a9 fix(webui): persist worktime report links to :5610 in ansible/installkit 2026-05-06 23:24:52 +03:00
igor04091968 03fefcc257 fix(deploy): harden windows rollout, validation and installkit payload 2026-05-06 20:14:51 +03:00
igor04091968 9eb02185dd fix(windows): stabilize AW deploy/validate and watcher launch script 2026-05-06 20:05:43 +03:00
IgorRachkov b2a1a6289c Fix Windows RDP collectors startup 2026-05-06 17:23:47 +03:00
IgorRachkov 945efd203f Harden DLP endpoint collector error handling and print dedupe 2026-05-05 07:55:03 +03:00
igor04091968 82ee9ab4fa tools: add AW health checks and test DLP policy 2026-05-05 06:38:04 +03:00
igor04091968andDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> eb83a8b08f docs(wiki): add GitHub wiki pages for documentation
- Home page with navigation and quick start
- Architecture overview with layers and data flows
- Components documentation for all system parts
- Interactive map guide with usage instructions
- DLP Endpoint Monitoring detailed guide
- Browser Domains Monitoring guide
- WebUI Russian Patches documentation
- Windows Installation guide
- Server Setup guide
- Monitoring Setup with Prometheus/Grafana

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-05-05 01:56:30 +03:00
igor04091968andDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 7a89f33e3b docs(diagrams): add interactive architecture map HTML
- Interactive component map with visual connections
- Click to see component details and data flows
- Search functionality for quick component lookup
- Connection highlighting when selecting components
- Responsive design with color-coded layers
- Component information panel with ports, protocols, flows

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-05-05 01:51:25 +03:00
igor04091968andDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> d8320799e6 docs(diagrams): add component-level architecture diagrams
- DLP Endpoint Monitoring diagram with data flows
- Browser Domains Monitoring with categorization
- WebUI Russian Localization patches structure
- DLP Events Aggregation pipeline
- Prometheus Metrics Exporter architecture
- System overview with all components and connections

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-05-05 01:49:55 +03:00
igor04091968andDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> db1998c428 docs(architecture): add readable architecture diagrams
- Add Mermaid diagram for high-level architecture
- Add simple ASCII architecture for quick understanding
- Document data flows for all monitoring scenarios
- Include deployment steps and port mappings
- Add quick start guide and key scenarios

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-05-05 01:46:50 +03:00
igor04091968andDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> f6074facf5 docs(graphify): add detailed knowledge graph documentation for wiki
- Explain what knowledge graph is and why it's useful
- Document all 27 communities with purposes
- Describe key components: DLP monitoring, WebUI patches, collectors
- Provide usage guide for interactive visualization
- Include statistics and architecture recommendations

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-05-05 01:45:32 +03:00
igor04091968andDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 3f3e3a953f docs(graphify): add ActivityWatch knowledge graph visualization
- Generate AST-based knowledge graph with 404 nodes and 933 edges
- 27 communities showing code structure and relationships
- Interactive HTML visualization for exploring code architecture
- Report documenting main functional clusters

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-05-05 01:44:27 +03:00
igor04091968andDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> d5735ba127 feat(grafana): add ActivityWatch Prometheus exporter and dashboards
- Add Python exporter for ActivityWatch API metrics collection
- Configure Prometheus to scrape ActivityWatch exporter
- Create Grafana dashboard for ActivityWatch overview
- Integrate ActivityWatch monitoring into existing docker-compose stack
- Add Prometheus datasource for ActivityWatch metrics

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-05-05 01:32:50 +03:00
igor04091968andDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 10c22a6c88 fix(windows): add STA parameter for DLP collector and use TEMP for lock files
- Add -STA parameter when launching endpoint-signals collector for clipboard access
- Move launch-watchers lock files from ProgramData to TEMP to avoid permission issues
- This fixes DLP endpoint collector startup in RDP sessions

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-05-05 00:55:04 +03:00
igor04091968andDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 9e5cb144d4 fix(windows): standardize config paths to AWatch-rus and add bucket hostname filter
- Replace default config paths from C:\ProgramData\ActivityWatch to C:\ProgramData\AWatch-rus
  in dlp-endpoint-signals-collector.ps1 and email-outbound-collector.ps1
- Add isLikelyClientHost() function to reject IP/localhost as valid hostname
  for bucket selection in aw-ru-patch.js
- Add docs/dlp-reliability-roadmap.md and docs/powershell-analysis.md
- Update README.md with links to new documentation

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-05-04 23:24:49 +03:00
IgorRachkov d88ef78184 Fix DLP collector defaults, HTTP error handling, and cleanup 2026-05-04 21:47:03 +03:00
Devin AIandFashion Lisa a35a8a51bd fix(print): detect generic doc names (Печать документа) + integrate email collector into deploy
Print document naming:
- Add Test-IsGenericDocumentName() to detect placeholder names like
  'Печать документа', 'Print Document', 'Remote Downlevel Document', etc.
- Add Test-NeedsBetterDocumentName() that combines mojibake, generic and
  numeric-only checks into a single gate for PrintService event log fallback
- Replace Test-LooksLikeMojibakeQuestionMarks with Test-NeedsBetterDocumentName
  in Win32_PrintJob polling and Get-PrintServiceDocumentFallback

Email collector deployment integration:
- Ansible: add email-outbound-collector.ps1 to deploy_aw_windows.yml file list
- Ansible: add aw_windows_email_collector_enabled/mode vars
- InnoSetup: add email-outbound-collector.ps1 to [Files] section
- Common module: add EmailCollectorScript to Copy-ActivityWatchCollectorAssets,
  New-ActivityWatchDeploymentConfig, launch-watchers script
- Deploy scripts: pass EmailCollectorScript through pipeline
- Deployment configs: add emailCollectorScript path
- Sync all changes to install-kit copy

Co-Authored-By: Fashion Lisa <igor04091968@gmail.com>
2026-05-04 18:07:14 +00:00
igor04091968 bfee99d679 fix(windows): prevent collector process storms and scope query patch 2026-05-04 21:05:44 +03:00
igor04091968 5a2684fb3b fix: proper single UTF-8 BOM 2026-05-04 07:24:14 +03:00
igor04091968 e2b2f805fb Merge remote-tracking branch 'origin/devin/1777752962-file-collector-bucket' 2026-05-04 06:05:39 +03:00
IgorRachkovGitHubFashion LisaDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
eea851141f feat(dlp): enforcement + email outbound collector (#14)
* feat(dlp): add enforcement — USB write-block, print cancel, clipboard clear

Phase 2.5: when DLP policy rule has action="block", the collector
now actively prevents the action instead of just logging:

- USB: Set-Disk -IsReadOnly via Get-Partition/Get-Disk pipeline
- Print: Remove-CimInstance Win32_PrintJob for matching jobs
- Clipboard: Set-Clipboard -Value $null to clear sensitive content

Each enforcement adds enforced=true/false to incident telemetry.
Windows balloon notification shown to user on every block action.
Backward-compatible: existing action="alert" rules unchanged.

Co-Authored-By: Fashion Lisa <igor04091968@gmail.com>

* feat(dlp): add email outbound collector — Outlook COM + SMTP monitor

Two collection modes:
- outlook: polls Sent Items via COM, extracts metadata (subject hash,
  recipients hash, attachment names, body length)
- smtp: monitors SMTP connections (25/587/465/2525) via Get-NetTCPConnection

DLP policy rules: endpoint.email[] with regex matching on subject,
recipients, sender, attachments, externalOnly flag.

Enforcement: action=block moves mail to Drafts (Outlook mode).
Privacy: subject/recipients stored as SHA256, body never read.
Co-Authored-By: Fashion Lisa <igor04091968@gmail.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-05-04 00:23:06 +03:00
igor04091968 6578f6341f fix: add UTF-8 BOM for PS 5.1 2026-05-04 00:22:15 +03:00
igor04091968 cc33ffa3dc fix: add UTF-8 BOM for PowerShell 5.1 compatibility 2026-05-04 00:14:32 +03:00
igor04091968 ff548a5840 Merge PR #14: feat(dlp) enforcement + email outbound collector 2026-05-03 23:41:00 +03:00
Devin AIandFashion Lisa f916764d53 feat(dlp): add email outbound collector — Outlook COM + SMTP monitor
Two collection modes:
- outlook: polls Sent Items via COM, extracts metadata (subject hash,
  recipients hash, attachment names, body length)
- smtp: monitors SMTP connections (25/587/465/2525) via Get-NetTCPConnection

DLP policy rules: endpoint.email[] with regex matching on subject,
recipients, sender, attachments, externalOnly flag.

Enforcement: action=block moves mail to Drafts (Outlook mode).
Privacy: subject/recipients stored as SHA256, body never read.
Co-Authored-By: Fashion Lisa <igor04091968@gmail.com>
2026-05-03 20:30:18 +00:00
Devin AIandFashion Lisa 2bab84f9f9 feat(dlp): add enforcement — USB write-block, print cancel, clipboard clear
Phase 2.5: when DLP policy rule has action="block", the collector
now actively prevents the action instead of just logging:

- USB: Set-Disk -IsReadOnly via Get-Partition/Get-Disk pipeline
- Print: Remove-CimInstance Win32_PrintJob for matching jobs
- Clipboard: Set-Clipboard -Value $null to clear sensitive content

Each enforcement adds enforced=true/false to incident telemetry.
Windows balloon notification shown to user on every block action.
Backward-compatible: existing action="alert" rules unchanged.

Co-Authored-By: Fashion Lisa <igor04091968@gmail.com>
2026-05-03 20:21:39 +00:00
Devin AIandFashion Lisa 6640299a48 docs: add strategic DLP roadmap vs InfoWatch Traffic Monitor
Co-Authored-By: Fashion Lisa <igor04091968@gmail.com>
2026-05-03 20:18:19 +00:00
igor04091968 3e565b7a2e fix: create /root/bootstrap directory before copying files
Add ansible.builtin.file task to ensure /root/bootstrap exists
before copying RU patch files to it (prevents first-deploy failure)
2026-05-03 23:08:48 +03:00
igor04091968 3fa15f826d fix: env file before hotfixes + improved error handling
- Move env file creation before apply_webui_ru_patch.sh execution
- Replace ignore_errors with failed_when: false + register + debug output
- Provides visible feedback on hotfix script execution result
2026-05-03 22:56:31 +03:00
igor04091968 08ba731345 fix: apply WebUI hotfixes via apply_webui_ru_patch.sh + filter undefined hostname
- Add CATEGORY_HELPER filter for 'undefined' in addition to 'unknown'
- Add copy of apply_webui_ru_patch.sh to /opt/activitywatch/aw-server/
- Add task to run apply_webui_ru_patch.sh for Trends/Timespiral/Category helper hotfixes
- Fix in both deploy_aw_server.yml (ansible and install-kit)
2026-05-03 22:52:39 +03:00
igor04091968 df497839f6 Merge remote-tracking branch 'origin/main' into devin/1777752962-file-collector-bucket 2026-05-03 22:28:37 +03:00
IgorRachkovGitHubDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
36e4255ad9 fix: handle undefined bucket filters in AQL query rewriter (#12)
The network patch that intercepts /api/0/query/ requests only handled
'unknown' hostnames in bucket IDs. When the WebUI activity store has
uninitialized bucket IDs (e.g. browser watcher not installed on a host),
find_bucket("undefined") or query_bucket("undefined") calls reach the
server and fail with BucketQueryError.

Extend rewriteUnknownCategoryBuilderQueryBody to:
- Replace query_bucket(find_bucket("undefined")) and flood() wrappers
  with empty arrays ([]) so the query continues without missing data.
- Rewrite aw-watcher-{window,afk}_undefined to the preferred host,
  matching the existing 'unknown' hostname logic.

Applied to both aw-server/ and install-kit copies of aw-ru-patch.js.

Fixes: BucketЗапросError on Trends page for host SHARKON2025

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-05-03 22:27:50 +03:00
igor04091968 3971c459ef Feat: implement automated DLP incident aggregation on server (timer + service) 2026-05-03 01:50:17 +03:00
Devin AI b7a7ac42e4 Improve print DLP telemetry reliability 2026-05-02 22:45:48 +00:00
Devin AI 4359f6d5eb Fix Windows file telemetry playbook wiring 2026-05-02 21:44:18 +00:00
Devin AI 7ea4ebd463 Merge PR #11 DLP incident aggregation prototype 2026-05-02 21:33:42 +00:00
Devin AI 5a4064dc0f Add DLP incident aggregation prototype 2026-05-02 20:55:44 +00:00
igor04091968 c97ffe2cbd Fix: ensure file collector robustness (HttpClient, TLS 1.2, English logs) 2026-05-02 23:42:14 +03:00
Devin AI f45ef0038d Use string JSON body for file collector posts 2026-05-02 20:34:07 +00:00
Devin AI 8088b19dc7 Create file operation bucket before path checks 2026-05-02 20:28:03 +00:00
Devin AI 046aa3ed1d Create file operations bucket on startup 2026-05-02 20:17:43 +00:00
igor04091968 b6f019982d gemeni-3-light 2026-05-02 23:09:54 +03:00
igor04091968andDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> fae2e2ca14 Feat & Fix: implement File Telemetry, restore DB history, and stabilize production
- Added File Operations Collector (Plan A) for Windows endpoints
- Restored historical server DB via merging and moved to durable /var/lib/activitywatch path
- Forced XDG_DATA_HOME and XDG_CONFIG_HOME for aw-server-rust in environment and systemd
- Updated Ansible playbooks to handle new file collector and durable server paths
- Added DB merge and backup-restore automation scripts
- Fixed CORS and RU WebUI persistence in production deployment

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-05-02 20:59:08 +03:00
igor04091968 f436950bda Update after codex restore 2026-05-02 19:36:25 +03:00
igor04091968 d7fedde69d After deploy from hand restore 2026-05-02 17:54:36 +03:00
igor04091968 7f58a49c0a Разворачивание деплоя 2026-05-02 17:28:49 +03:00
Devin AI 2e49310023 Добавить безопасную миграцию prod в AWatch-rus 2026-05-02 11:58:28 +00:00
Devin AI 2ec90b05ae Унифицировать Windows пути AWatch-rus 2026-05-02 09:44:27 +00:00
Devin AI 21f0184115 Согласовать Windows пути с InnoSetup 2026-05-02 09:20:28 +00:00
Devin AI 342ab77f44 Исправить recovery config и разбор RDP sessions 2026-05-02 09:08:14 +00:00
Devin AI 1e92b8b679 Русифицировать и доработать Ansible развёртывание 2026-05-02 09:07:08 +00:00
Devin AI 8c9dfffc7d Ограничить PSScriptAnalyzer ошибками 2026-05-02 08:35:23 +00:00
Devin AI 087883b663 Уточнить настройки PSScriptAnalyzer 2026-05-02 08:34:21 +00:00
Devin AI 4bbf0b8b73 Исправить CI-проверки shellcheck и PSScriptAnalyzer 2026-05-02 08:32:53 +00:00
Devin AI da99d1ac98 Русифицировать DLP PowerShell и исправить имя документа печати 2026-05-02 08:30:37 +00:00
igor04091968 f7cf5556a0 Ofline install InnoSetup 2026-05-02 10:01:39 +03:00
igor04091968 65da55be7a Patch files Innosetup 2026-05-02 04:12:14 +03:00
IgorRachkov 8f10bef904 Limit print title fallback to masked Russian document names 2026-05-01 15:50:02 +03:00
IgorRachkov aca42caf0a Add install-kit to repo drift checker 2026-04-30 20:59:40 +03:00
igor04091968 8278d51840 feat(worktime): add linux remote worker and pve headless tracking 2026-04-30 15:48:09 +03:00
IgorRachkov 9bdf28ed2b Add newcomer onboarding guide for repository structure 2026-04-29 13:09:23 +03:00
igor04091968 fa4bf96ebf feat(ansible): add full-stack installer playbook 2026-04-28 07:10:44 +03:00
igor04091968 b28cfabd57 fix(windows-phase2): codify SHARKON2025 recovery and harden rollout checks 2026-04-28 07:09:29 +03:00
igor04091968 4936d6bca3 fix(webui): translate activity heading separator 2026-04-27 23:57:20 +03:00
igor04091968 c54b237f80 fix(webui): repair category helper host selection 2026-04-27 23:36:58 +03:00
igor04091968 48223fbeeb chore(install): sync playbooks and installers with pve audit 2026-04-27 23:18:32 +03:00
igor04091968 7f131a6310 fix(webui): add stable pve audit activity view 2026-04-27 22:56:05 +03:00
igor04091968 77591c10ce fix(webui): harden pve-detmir activity route and suppress DLP nav on pve hosts 2026-04-27 22:17:51 +03:00
igor04091968 947717251f fix(webui): route pve-detmir activity to watcher-safe view and add audit links 2026-04-27 21:58:37 +03:00
igor04091968 0da0d880c0 feat(pve): add web-admin audit collector to forward Proxmox actions to AW 2026-04-27 21:52:07 +03:00
igor04091968 5adb22754f fix(linux-logger): parse who -u pts fields and avoid duplicate start with systemd 2026-04-27 21:40:17 +03:00
igor04091968 76004e30fe feat(aw-ui): add dedicated pve-detmir host entry in Activity menu 2026-04-27 21:34:56 +03:00
igor04091968 b48b18f225 fix(ansible): default windows state root to C:\ProgramData\ActivityWatch 2026-04-27 21:31:04 +03:00
igor04091968 5a41c4066b feat(windows): add install kit and stabilize dlp print document matching 2026-04-27 21:22:40 +03:00
igor04091968 fff4c10e64 feat(logging): add console and ssh activity logger 2026-04-27 18:17:52 +03:00
igor04091968 5105155323 feat(linux): add remote client rollout for admin host 2026-04-27 17:29:17 +03:00
igor04091968 ced44c347c docs(runtime): capture confirmed admin-host topology 2026-04-27 17:04:31 +03:00
igor04091968 c70fa019ad fix(grafana): finalize ru naming for overview dashboard 2026-04-27 14:09:46 +03:00
igor04091968 e22bd697f7 chore(repo): ignore python cache artifacts 2026-04-27 04:41:41 +03:00
igor04091968 e80ae5fbc3 feat(pfsense): add AW poller and host group sections 2026-04-27 04:41:32 +03:00
igor04091968 70ffd1f3e8 fix(windows): hide launch tasks behind wscript wrappers 2026-04-27 04:16:25 +03:00
igor04091968 1e9628b01b feat(ansible): add incident screenshot controls for windows phase2 2026-04-27 04:04:37 +03:00
igor04091968 f26012ae52 docs(windows): add DLP screenshot and self-test runbook 2026-04-27 03:53:02 +03:00
igor04091968 63c3d96a19 feat(dlp): capture screenshot on incident 2026-04-27 03:29:19 +03:00
igor04091968 5584144978 fix(windows): suppress duplicate logon markers per session 2026-04-27 03:00:53 +03:00
igor04091968 0ae2ac7ec1 fix(windows): tolerate existing session marker bucket 2026-04-27 02:56:09 +03:00
igor04091968 53acdcb6f4 feat(windows): add logon markers and disable local agent logs 2026-04-27 02:42:04 +03:00
igor04091968 bdf44b868e fix(windows): recover all phase2 user tasks after reboot 2026-04-27 02:32:11 +03:00
igor04091968 aa00d5633f fix(web-collector): avoid PowerShell Host collision 2026-04-27 02:21:47 +03:00
igor04091968 6694e0682d feat(worktime): add from/to worktime window 2026-04-27 01:49:27 +03:00
igor04091968 a61b4cee2b fix(webui): prevent settings crash on missing class.data 2026-04-27 01:47:16 +03:00
igor04091968 428a8623a9 feat(worktime): apply baseline classes via ansible 2026-04-26 20:05:44 +03:00
igor04091968 48bb4e4c4c feat(dlp): surface operator incidents in alerts 2026-04-26 15:42:07 +03:00
igor04091968 bb200cab96 feat(dlp): expose menu and document web review flow 2026-04-26 15:39:40 +03:00
igor04091968 1b968f9382 fix(webui): stabilize and localize timespiral 2026-04-26 10:12:39 +03:00
igor04091968 97fe09a150 fix(ansible): align ensemble rollout with current windows deploy 2026-04-26 09:13:41 +03:00
igor04091968 7ef8166504 feat(aw): expand ru webui patch and monitoring assets 2026-04-26 08:54:41 +03:00
igor04091968 f54284a0f9 feat(docs-playbooks): update phase2 runbooks and add windows winrm rollout playbook 2026-04-25 18:00:13 +03:00
igor04091968 bf27369b2a feat(dlp-phase2): add endpoint signal collector for clipboard usb print and policy wiring 2026-04-25 16:26:07 +03:00
igor04091968 c3a49e658c feat(dlp-phase1): add policy-based incident pipeline and capability gap analysis 2026-04-25 16:11:05 +03:00
igor04091968 016a323afc feat(ansible): add hardened matrix rollout for Proxmox CTs with RU patch 2026-04-25 15:57:30 +03:00
igor04091968 9e53272ce9 feat(ansible): add full Proxmox zero-to-service provisioning playbook 2026-04-25 15:43:31 +03:00
igor04091968 92eaa0635c chore: add local quality-gate script for offline validation 2026-04-25 15:30:34 +03:00
888 changed files with 196611 additions and 688 deletions
+1
View File
@@ -0,0 +1 @@
windows/installkit/innosetup/AWatch-rus-InstallKit.exe filter=lfs diff=lfs merge=lfs -text
+48 -1
View File
@@ -1 +1,48 @@
* @igor04091968
# AWatch-rus CODEOWNERS
#
# Ownership is used for review routing and engineering accountability.
# It does not certify security, legal readiness, performance or fitness for
# production use. Contributors remain responsible for the safety of their
# submissions.
# Repository default owner.
* @igor04091968 @rachkovii68-bisness
# Rust workspace and crates.
/adk-rust/ @igor04091968 @rachkovii68-bisness
**/Cargo.toml @igor04091968 @rachkovii68-bisness
**/Cargo.lock @igor04091968 @rachkovii68-bisness
**/*.rs @igor04091968 @rachkovii68-bisness
# Operational scripts and automation.
/scripts/ @igor04091968 @rachkovii68-bisness
# Product documentation, demo material and screenshots.
/docs/ @igor04091968 @rachkovii68-bisness
/docs/demo/ @igor04091968 @rachkovii68-bisness
/docs/screenshots/ @igor04091968 @rachkovii68-bisness
/docs/assets/screenshots/ @igor04091968 @rachkovii68-bisness
# Registry-readiness evidence and policy documents.
/docs/registry/ @igor04091968 @rachkovii68-bisness
# GitHub public mirror validation metadata and governance controls.
/.github/workflows/ @igor04091968 @rachkovii68-bisness
/.github/workflows/ci.yml @igor04091968 @rachkovii68-bisness
/.github/workflows/security.yml @igor04091968 @rachkovii68-bisness
/.github/workflows/coverage.yml @igor04091968 @rachkovii68-bisness
/.github/pull_request_template.md @igor04091968 @rachkovii68-bisness
/.github/ISSUE_TEMPLATE/ @igor04091968 @rachkovii68-bisness
/.github/CODEOWNERS @igor04091968 @rachkovii68-bisness
/docs/BRANCH_PROTECTION_POLICY_RU.md @igor04091968 @rachkovii68-bisness
/docs/BRANCH_PROTECTION_EVIDENCE_RU.md @igor04091968 @rachkovii68-bisness
/docs/PR_REVIEW_WORKFLOW_RU.md @igor04091968 @rachkovii68-bisness
/docs/PR_REVIEW_EVIDENCE_RU.md @igor04091968 @rachkovii68-bisness
# Ansible deployment automation.
/ansible/ @igor04091968 @rachkovii68-bisness
# Security and contribution governance.
/SECURITY.md @igor04091968 @rachkovii68-bisness
/CONTRIBUTING.md @igor04091968 @rachkovii68-bisness
/deny.toml @igor04091968 @rachkovii68-bisness
+42
View File
@@ -0,0 +1,42 @@
name: Bug report
description: Report a reproducible defect with sanitized evidence.
title: "fix: "
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
Do not publish secrets, tokens, private keys, personal data, real employee logs or customer evidence. Use demo/anonymized evidence.
- type: textarea
id: summary
attributes:
label: Summary
description: What is broken?
validations:
required: true
- type: textarea
id: steps
attributes:
label: Reproduction steps
description: Minimal steps using sanitized data.
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behavior
validations:
required: true
- type: textarea
id: actual
attributes:
label: Actual behavior
validations:
required: true
- type: textarea
id: evidence
attributes:
label: Sanitized evidence
description: Logs, screenshots or output with secrets and personal data removed.
validations:
required: false
@@ -0,0 +1,42 @@
name: Feature request
description: Request a capability without using customer or employee data.
title: "feat: "
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
Do not publish secrets, personal data, real employee logs or non-anonymized customer evidence.
- type: textarea
id: problem
attributes:
label: Problem
description: What user or operator problem should this solve?
validations:
required: true
- type: textarea
id: proposal
attributes:
label: Proposed behavior
description: Describe the desired behavior conservatively.
validations:
required: true
- type: dropdown
id: area
attributes:
label: Area
options:
- Workforce
- Security analytics
- Forensics
- Registry-readiness
- Install/deployment
- Documentation
validations:
required: true
- type: textarea
id: evidence
attributes:
label: Demo/anonymized evidence
validations:
required: false
@@ -0,0 +1,29 @@
name: Registry-readiness task
description: Track registry-readiness documentation, evidence or process gaps.
title: "docs(registry): "
labels: ["registry-readiness", "documentation"]
body:
- type: markdown
attributes:
value: |
Do not claim legal completion of registry registration. Do not publish secrets, personal data, real employee logs or private infrastructure credentials.
- type: textarea
id: scope
attributes:
label: Scope
description: What registry-readiness gap is being tracked?
validations:
required: true
- type: textarea
id: evidence
attributes:
label: Required evidence
description: List sanitized evidence or documents needed.
validations:
required: true
- type: textarea
id: remaining
attributes:
label: Remaining gaps
validations:
required: false
@@ -0,0 +1,30 @@
name: Security hardening task
description: Track hardening without exposing sensitive data.
title: "chore(security): "
labels: ["security", "hardening"]
body:
- type: markdown
attributes:
value: |
Do not publish exploits, secrets, private keys, tokens, personal data, real employee logs or customer evidence.
- type: textarea
id: hardening
attributes:
label: Hardening target
description: What should be hardened?
validations:
required: true
- type: textarea
id: risk
attributes:
label: Risk addressed
description: Explain the risk without sensitive details.
validations:
required: true
- type: textarea
id: validation
attributes:
label: Validation
description: Checks or evidence required.
validations:
required: true
+54 -9
View File
@@ -1,13 +1,58 @@
## Summary
- what changed
- why it changed
- risk and rollback notes
Describe what changed and why.
## Checklist
## Impact
- [ ] No real secrets or credentials committed
- [ ] Server-side scripts validated (`bash -n`)
- [ ] PowerShell scripts validated (`Invoke-ScriptAnalyzer`)
- [ ] Docs updated (full paths and runbook steps)
- [ ] Rollback steps documented
- Runtime impact: `none / changed / not applicable`
- API impact: `none / changed / not applicable`
- UI impact: `none / changed / not applicable`
- Documentation impact: `none / changed / not applicable`
- Rollback impact: `none / documented / not applicable`
- Evidence impact: `none / registry docs updated / release evidence required`
## Validation
List commands executed. Use `skipped: <reason>` when a check requires a live
stand or unavailable tool.
## Review Checklist
- [ ] Linked issue is provided, or the PR explains why no issue is applicable.
- [ ] Runtime/API/UI impact is stated.
- [ ] Registry claims are checked and remain conservative.
- [ ] Secrets, PII, employee logs and customer identifiers are absent.
- [ ] Tests/checks executed are listed, or skipped checks have reasons.
- [ ] Evidence docs are updated when the change affects governance, registry
readiness or release evidence.
- [ ] GitHub Actions are public validation only, not registry release evidence.
- [ ] I checked that this PR does not publish secrets, tokens, passwords,
private keys, recovery codes or live credentials.
- [ ] I checked that this PR does not publish personal data, real employee data,
customer logs or customer infrastructure identifiers.
- [ ] I checked registry claims: no completed registry submission, no
FSTEC/FSB certification claim, no SIEM/DLP replacement claim.
- [ ] I ran relevant checks or documented why a check was skipped.
- [ ] I stated runtime/API/UI impact.
- [ ] I stated documentation impact.
- [ ] I stated smoke-test result or why smoke testing is not applicable.
- [ ] I stated rollback and evidence impact.
- [ ] I checked that GitHub Actions remains public mirror validation only.
- [ ] I checked that registry release evidence still requires the Russian
build-runner.
## Registry / Public Mirror Scope
- GitHub is public mirror validation only.
- Primary registry release evidence must be produced on the Russian
build-runner.
- Update `docs/registry/` when registry-readiness behavior or evidence changes.
## Safety
- No secrets, tokens, passwords or private keys.
- No personal data.
- No real employee logs.
- No customer evidence unless anonymized.
- No unsupported claims about certification, DLP/SIEM replacement or legal
registry completion.
+94 -27
View File
@@ -1,47 +1,114 @@
name: shell-and-powershell-ci
name: CI
# GitHub Actions is public mirror validation only.
# Primary registry release evidence must be produced on Russian build-runner.
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
workflow_dispatch:
permissions:
contents: read
jobs:
shell-check:
rust-checks:
name: Rust checks
runs-on: ubuntu-latest
defaults:
run:
shell: bash
working-directory: adk-rust
steps:
- name: Checkout
uses: actions/checkout@v4
with:
lfs: false
- name: Install shellcheck
run: sudo apt-get update && sudo apt-get install -y shellcheck
- name: Install stable Rust
uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- name: Run shellcheck
run: |
find . -type f -name "*.sh" -print0 | xargs -0 -r shellcheck
- name: cargo fmt
run: cargo fmt --all --check
powershell-analyzer:
- name: cargo test
run: cargo test --workspace
- name: cargo clippy
run: cargo clippy --workspace --all-targets -- -D warnings
- name: cargo build
run: cargo build --workspace
docs-registry-checks:
name: Docs and registry checks
runs-on: ubuntu-latest
defaults:
run:
shell: bash
steps:
- name: Checkout
uses: actions/checkout@v4
with:
lfs: false
- name: Install PSScriptAnalyzer
shell: pwsh
run: |
Set-PSRepository -Name PSGallery -InstallationPolicy Trusted
Install-Module PSScriptAnalyzer -Scope CurrentUser -Force
- name: Registry script syntax
run: bash -n scripts/registry_readiness_check.sh
- name: Analyze PowerShell scripts
shell: pwsh
- name: Registry readiness
run: bash scripts/registry_readiness_check.sh
- name: Release evidence script syntax
run: |
$targets = @(
"windows/*.ps1",
"windows/*.psm1",
"windows/*.psd1"
)
$issues = Invoke-ScriptAnalyzer -Path $targets -Recurse -Severity Error,Warning
if ($issues) {
$issues | Format-Table -AutoSize
throw "PSScriptAnalyzer detected issues."
}
if [[ -f scripts/build_release_evidence.sh ]]; then
bash -n scripts/build_release_evidence.sh
fi
if [[ -f scripts/check_release_evidence.sh ]]; then
bash -n scripts/check_release_evidence.sh
fi
- name: Whitespace diff check
run: git diff --check
smoke-checks:
name: Smoke checks
runs-on: ubuntu-latest
defaults:
run:
shell: bash
steps:
- name: Checkout
uses: actions/checkout@v4
with:
lfs: false
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Deployment readiness smoke
run: |
if command -v node >/dev/null 2>&1 && [[ -f scripts/deployment-readiness-smoke.mjs ]]; then
node scripts/deployment-readiness-smoke.mjs
else
echo "skipped: node or scripts/deployment-readiness-smoke.mjs missing"
fi
- name: Pilot validation smoke
run: |
if command -v node >/dev/null 2>&1 && [[ -f scripts/pilot-validation-smoke.mjs ]]; then
node scripts/pilot-validation-smoke.mjs
else
echo "skipped: node or scripts/pilot-validation-smoke.mjs missing"
fi
- name: Browser conformance smoke
run: |
if [[ -f scripts/browser-conformance-smoke.mjs ]]; then
echo "skipped: requires live stand unless explicitly run by operator"
else
echo "skipped: scripts/browser-conformance-smoke.mjs missing"
fi
+47
View File
@@ -0,0 +1,47 @@
name: Coverage
# GitHub Actions is public mirror validation only.
# Primary registry release evidence must be produced on Russian build-runner.
on:
push:
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
coverage-baseline:
name: Coverage baseline
runs-on: ubuntu-latest
defaults:
run:
shell: bash
working-directory: adk-rust
steps:
- name: Checkout
uses: actions/checkout@v4
with:
lfs: false
- name: Install stable Rust
uses: dtolnay/rust-toolchain@stable
- name: Install cargo-llvm-cov
uses: taiki-e/install-action@cargo-llvm-cov
- name: Generate coverage summary
run: |
mkdir -p ../coverage
cargo llvm-cov --workspace --summary-only | tee ../coverage/coverage-summary.txt
- name: Upload coverage summary
uses: actions/upload-artifact@v4
with:
name: coverage-summary
path: coverage/coverage-summary.txt
- name: Future threshold placeholder
run: |
echo "Coverage threshold is not enforced yet; this workflow tracks baseline and regressions."
+29
View File
@@ -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
+74
View File
@@ -0,0 +1,74 @@
name: rust-binary-build
on:
workflow_dispatch:
pull_request:
branches: [ "main" ]
paths:
- 'rust-toolchain.toml'
- 'adk-rust/**'
- 'scripts/package_rust_release_binaries.py'
- '.github/workflows/rust-binary-build.yml'
push:
tags:
- 'v*'
permissions:
contents: write
jobs:
build-linux-x86_64:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install pinned Rust toolchain
run: |
rustup toolchain install 1.94.0 --profile minimal --component rustfmt --component clippy
rustup override set 1.94.0
rustup show active-toolchain
cargo +1.94.0 --version
rustc +1.94.0 --version
- name: Build release binaries
run: cargo +1.94.0 build --manifest-path adk-rust/Cargo.toml --workspace --release
- name: Package release binaries
run: |
python3 scripts/package_rust_release_binaries.py \
--release-dir adk-rust/target/release \
--out-dir dist/awatch-rus-linux-x86_64 \
--archive dist/awatch-rus-linux-x86_64-release-binaries.tar.gz \
--target linux-x86_64 \
--commit "${GITHUB_SHA}" \
--ref "${GITHUB_REF}" \
--run-id "${GITHUB_RUN_ID}"
- name: Upload release binaries artifact
uses: actions/upload-artifact@v4
with:
name: awatch-rus-linux_x86_64-release-binaries
path: |
dist/awatch-rus-linux_x86_64-release-binaries.tar.gz
dist/awatch-rus-linux_x86_64-release-binaries.tar.gz.sha256
dist/awatch-rus-linux_x86_64/BINARIES.txt
dist/awatch-rus-linux_x86_64/SHA256SUMS.txt
dist/awatch-rus-linux_x86_64/BUILD_MANIFEST.json
if-no-files-found: error
retention-days: 30
- name: Publish GitHub Release assets
if: startsWith(github.ref, 'refs/tags/v')
uses: softprops/action-gh-release@v2
with:
generate_release_notes: true
fail_on_unmatched_files: true
files: |
dist/awatch-rus-linux_x86_64-release-binaries.tar.gz
dist/awatch-rus-linux_x86_64-release-binaries.tar.gz.sha256
dist/awatch-rus-linux_x86_64/BINARIES.txt
dist/awatch-rus-linux_x86_64/SHA256SUMS.txt
dist/awatch-rus-linux_x86_64/BUILD_MANIFEST.json
@@ -0,0 +1,40 @@
name: Rust clippy diagnostic
on:
push:
branches:
- codex/rust-professionalization
workflow_dispatch:
jobs:
detmir-portal-clippy-diagnostic:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Rust 1.85 with rustfmt and clippy
run: |
rustup toolchain install 1.85.0 --profile minimal --component rustfmt --component clippy
rustup default 1.85.0
- name: Capture detmir-portal clippy output
working-directory: adk-rust
run: |
set +e
cargo clippy -p detmir-portal --all-targets -- -D warnings > ../detmir-portal-clippy.log 2>&1
status=$?
echo "clippy_exit_status=${status}" > ../detmir-portal-clippy-status.txt
tail -n 240 ../detmir-portal-clippy.log
exit ${status}
- name: Upload detmir-portal clippy log
if: always()
uses: actions/upload-artifact@v4
with:
name: detmir-portal-clippy-log
path: |
detmir-portal-clippy.log
detmir-portal-clippy-status.txt
@@ -0,0 +1,73 @@
name: Rust professionalization check
on:
pull_request:
branches:
- main
paths:
- 'rust-toolchain.toml'
- 'adk-rust/crates/detmir-core/**'
- 'adk-rust/crates/detmir-portal/**'
- 'scripts/check_private_config_guard.sh'
- 'scripts/check_portal_contract_sync.mjs'
- '.github/workflows/rust-professionalization-check.yml'
workflow_dispatch:
jobs:
rust-check:
name: changed Rust crates smoke
runs-on: ubuntu-latest
timeout-minutes: 40
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install pinned Rust toolchain
run: |
rustup toolchain install 1.94.0 --profile minimal --component rustfmt --component clippy
rustup override set 1.94.0
rustup show active-toolchain
cargo +1.94.0 --version
rustc +1.94.0 --version
- name: Cargo fmt check
working-directory: adk-rust
run: cargo +1.94.0 fmt --all -- --check
- name: Test detmir-core
working-directory: adk-rust
run: cargo +1.94.0 test -p detmir-core
- name: Test detmir-portal
working-directory: adk-rust
run: cargo +1.94.0 test -p detmir-portal
- name: Clippy detmir-core
working-directory: adk-rust
run: cargo +1.94.0 clippy -p detmir-core --all-targets -- -D warnings
- name: Clippy detmir-portal with captured log
working-directory: adk-rust
run: |
set +e
cargo +1.94.0 clippy -p detmir-portal --all-targets -- -D warnings > ../detmir-portal-clippy.log 2>&1
status=$?
echo "clippy_exit_status=${status}" > ../detmir-portal-clippy-status.txt
tail -n 80 ../detmir-portal-clippy.log
exit ${status}
- name: Upload detmir-portal clippy log
if: always()
uses: actions/upload-artifact@v4
with:
name: detmir-portal-clippy-log
path: |
detmir-portal-clippy.log
detmir-portal-clippy-status.txt
- name: Private config guard
run: bash scripts/check_private_config_guard.sh
- name: Portal contract sync
run: node scripts/check_portal_contract_sync.mjs
+35
View File
@@ -0,0 +1,35 @@
name: rust-workspace
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
workflow_dispatch:
jobs:
rust-workspace:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install pinned Rust toolchain
run: |
rustup toolchain install 1.94.0 --profile minimal --component rustfmt --component clippy
rustup override set 1.94.0
rustup show active-toolchain
cargo +1.94.0 --version
rustc +1.94.0 --version
- name: Format
run: cargo +1.94.0 fmt --manifest-path adk-rust/Cargo.toml --all -- --check
- name: Test
run: cargo +1.94.0 test --manifest-path adk-rust/Cargo.toml --workspace
- name: Clippy
run: cargo +1.94.0 clippy --manifest-path adk-rust/Cargo.toml --workspace --all-targets -- -D warnings
- name: Release build
run: cargo +1.94.0 build --manifest-path adk-rust/Cargo.toml --workspace --release
+86
View File
@@ -0,0 +1,86 @@
name: Security
# GitHub Actions is public mirror validation only.
# Primary registry release security evidence must be produced on Russian build-runner.
on:
push:
pull_request:
workflow_dispatch:
permissions:
contents: read
pull-requests: read
jobs:
cargo-audit:
name: Cargo audit
runs-on: ubuntu-latest
defaults:
run:
shell: bash
working-directory: adk-rust
steps:
- name: Checkout
uses: actions/checkout@v4
with:
lfs: false
- name: Install stable Rust
uses: dtolnay/rust-toolchain@stable
- name: Install cargo-audit
uses: taiki-e/install-action@cargo-audit
- name: cargo audit
run: cargo audit
cargo-deny:
name: Cargo deny
runs-on: ubuntu-latest
defaults:
run:
shell: bash
steps:
- name: Checkout
uses: actions/checkout@v4
with:
lfs: false
- name: Install stable Rust
uses: dtolnay/rust-toolchain@stable
- name: Install cargo-deny
uses: taiki-e/install-action@cargo-deny
- name: cargo deny
run: cargo deny check advisories licenses sources --config ../deny.toml
working-directory: adk-rust
secret-pattern-check:
name: Secret pattern check
runs-on: ubuntu-latest
defaults:
run:
shell: bash
steps:
- name: Checkout
uses: actions/checkout@v4
with:
lfs: false
- name: Scan for obvious secret patterns
run: python3 scripts/public_secret_pattern_check.py
dependency-review:
name: Dependency review
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
- name: Checkout
uses: actions/checkout@v4
with:
lfs: false
- name: Dependency Review
uses: actions/dependency-review-action@v4
+40 -1
View File
@@ -1,12 +1,51 @@
# Local secrets
secrets/deploy.secrets.env
/secrets/
/private-config/*
!/private-config/
!/private-config/README.md
!/private-config/.gitkeep
!/private-config/*.example
!/private-config/*.template
/ansible/inventory.ini
/codex_history.txt
# Runtime / reports
*.log
*.tmp
*.bak
windows/*.report.json
.rollout-logs/
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
+246
View File
@@ -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
View File
@@ -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.
+29 -2
View File
@@ -1,5 +1,9 @@
# Contributing
GitHub is public mirror validation only. Primary registry release evidence is
produced separately on the Russian build-runner and documented under
`docs/registry/`.
## Branching
- Работайте в feature-ветке, не пушьте напрямую в `main`.
@@ -15,13 +19,36 @@
## Required checks before PR
- `bash -n` для всех `*.sh`.
- `cargo fmt --all --check` from `adk-rust/`.
- `cargo test --workspace` from `adk-rust/`, unless the PR is documentation-only
and the skip is documented.
- `cargo clippy --workspace --all-targets -- -D warnings` from `adk-rust/`.
- `bash -n` для всех changed `*.sh`.
- `bash scripts/registry_readiness_check.sh` when registry docs/process changes.
- `node scripts/deployment-readiness-smoke.mjs` when Node.js is available.
- `node scripts/pilot-validation-smoke.mjs` when Node.js is available.
- `Invoke-ScriptAnalyzer` для `windows/*.ps1`, `windows/*.psm1`, `windows/*.psd1`.
- Проверка, что нет секретов (`secrets/deploy.secrets.env` не должен быть в индексе git).
- Проверка, что нет секретов (`secrets/deploy.secrets.env` не должен быть в
индексе git).
- Обновлены инструкции и runbook при изменении поведения.
## Registry-readiness docs
- Registry-readiness documents live in `docs/registry/`.
- Public GitHub CI is not registry release evidence.
- Registry release evidence must be generated on the Russian build-runner.
- GitHub remains public mirror validation only.
## Secrets and personal data
- Do not commit secrets, tokens, passwords, cookies or private keys.
- Do not commit personal data.
- Do not commit real employee logs.
- Use demo/anonymized evidence for issues, PRs, docs and screenshots.
## PR content
- Изменения и обоснование.
- Риск и rollback.
- Какие команды валидации были выполнены.
- Какие проверки были пропущены и почему, если пропуск был необходим.
+8
View File
@@ -0,0 +1,8 @@
# Установка экземпляра для эксперта
Основной пошаговый документ находится здесь:
- [`docs/INSTALL_FOR_EXPERT_RU.md`](docs/INSTALL_FOR_EXPERT_RU.md)
Файл в корне оставлен как совместимая ссылка для внешних проверок и старых
закладок.
+202
View File
@@ -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.
+40
View File
@@ -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 или средство защиты информации.
+823 -47
View File
@@ -1,63 +1,839 @@
# AWatch-rus
Практический каркас проекта для повторного развёртывания ActivityWatch Server в новом окружении с LXC-контейнером на Proxmox, русифицированным Web UI, systemd-юнитами, шаблонными скриптами деплоя и эксплуатационной документацией.
[![CI](https://github.com/igor04091968/AWatch-rus/actions/workflows/ci.yml/badge.svg)](https://github.com/igor04091968/AWatch-rus/actions/workflows/ci.yml)
[![Security](https://github.com/igor04091968/AWatch-rus/actions/workflows/security.yml/badge.svg)](https://github.com/igor04091968/AWatch-rus/actions/workflows/security.yml)
[![Coverage](https://github.com/igor04091968/AWatch-rus/actions/workflows/coverage.yml/badge.svg)](https://github.com/igor04091968/AWatch-rus/actions/workflows/coverage.yml)
## Что входит
AWatch-rus - программный комплекс операционного контроля,
технического аудита, оценки трудоотдачи сотрудников и мониторинга
корпоративной ИТ-инфраструктуры на базе ActivityWatch, Rust-сервисов
автоматизации, Grafana/Prometheus-витрин и модулей расследования инцидентов.
- `docs/preparation.md` — подготовка инфраструктуры и входных параметров.
- `docs/deployment.md` — пошаговый деплой LXC и ActivityWatch Server.
- `docs/runbook.md` — быстрый runbook для оператора.
- `docs/operations.md` — регламент сопровождения, бэкапов, обновлений и rollback.
- `docs/windows/ensemble.md` — orchestration-пакет для Windows-деплоя и проверки.
- `proxmox/` — шаблонные скрипты подготовки и наполнения CT на стороне Proxmox.
- `aw-server/` — установочные скрипты, env-шаблон, systemd unit и RU patch для Web UI.
- `ansible/` — Ansible-ensemble для автоматизированного сервера (Debian/CT).
- `windows/` — PowerShell toolkit: single-user, domain-users, ensemble orchestration, hardening/recovery, validation.
Проект не позиционируется как сертифицированная DLP/SIEM/EDR/XDR/СЗИ,
не заявляет ML/LLM UEBA и не подменяет штатные средства защиты, хотя
DLP-сигналы, evidence и Hayabusa используются как аналитические и
расследовательские слои.
## Базовый сценарий
## Назначение
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`.
- AWatch-rus Workforce: активность сотрудников, загрузка, RDP/1C/рабочие
приложения и управленческие отчеты для владельца бизнеса.
- AWatch-rus Security: DLP-сигналы, evidence, очередь кейсов и audit действий оператора без заявления продукта как сертифицированной СЗИ.
- AWatch-rus Forensics: цепочки событий, Hayabusa/offline-разбор и материалы для внутреннего расследования.
- Контроль доступности и свежести данных ActivityWatch.
- Учет активного времени, Windows RDP-сессий окон, приложений и рабочих интервалов а также активности пользователей в Linux/Unix системах.
- витрины Grafana для администратора, оператора ИБ и руководителя(dashboards).
- Автоматизация runbook-проверок, health-check, SLO и безопасного auto-heal.
- Сбор evidence по инцидентам и аудит действий оператора.
Скрипты `proxmox/create-ct.sh` и `proxmox/push-aw-artifacts.sh` по умолчанию читают:
## Rust-first runtime
- `secrets/deploy.secrets.env`
Основной серверный runtime AWatch-rus переведен на Rust(ранее использовался инструментарий powershell):status/check/auto-heal,
SLO, worktime, DLP server-side helpers, evidence и install-kit tooling.
## Принципы
Оставшиеся PowerShell runtime/fallback/installer/repair scripts сохраняются
как документированный слой отката, установки и поддержки до отдельной задачи
удаления с burn-in периодом, canary test, rollback plan и acceptance gate.
- Никаких реальных секретов, токенов и боевых IP в репозитории.
- Все переменные вынесены в `.example` / `.env` шаблоны.
- Документация ориентирована на повторяемое развёртывание, а не на одноразовую ручную установку.
- Rollback и backup описаны как обязательная часть каждой операции.
Python, присутствующий в коде репозитория, остается для вспомогательных направлений: Telegram bot
runtime(для оперативного оповещения), OCR/content-analysis, 1C/AI/ETL integration и MCP/dev helpers. Эти части не являются ядром Rust-first runtime.
## Минимальная структура
Портальный слой зафиксирован как Rust server-rendered HTML + HTMX-compatible
JSON API, OpenAPI и TypeScript declarations. Dioxus не используется и не
рассматривается для Pilot v1.0. React, Tauri и Electron также не входят в
текущий основной UI, но возможна их интеграция в проект.
- CT/LXC на Debian 12
- ActivityWatch Server Rust release
- Web UI override в `/opt/activitywatch/webui-ru`
- systemd unit `activitywatch-server.service`
- bind/listen через переменные окружения
## Product Evolution
## Ограничения
AWatch-rus является рабочей платформой Workforce + Security + Forensics.
Архитектура предусматривает расширение на агентные и agentless-источники
данных. Planned/Future элементы ниже не являются реализованной функциональностью
и не должны трактоваться как готовые collectors или integrations.
- Интеграции с InfluxDB/Grafana/LDAP оставлены как следующий слой, не как обязательная база.
Implemented:
## Быстрые ссылки
- 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`.
- `/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`
Planned:
- Provider detail expansion under `/portal/architecture`.
- PowerShell Provider как planned/agentless direction, не как возврат новых
runtime-функций на PowerShell.
- SSH Provider.
- Syslog Provider.
- 1C Provider как формализация текущего file-based 1C analytics направления.
- Russian OS support validation.
Future:
- Extended Enterprise connectors.
- SCUD/VPN integrations.
- React/TypeScript Enterprise UI.
- Tauri Desktop Forensics.
## Pilot v1 demo
Pilot v1 demo показывает AWatch-rus как рабочую платформу Workforce Analytics +
Security Analytics + Forensics для ролей `executive`, `manager`, `security`,
`forensics` и `admin`.
Демо-материалы:
- [сценарий 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).
Pilot validation:
- [чеклист проверки пилота](docs/PILOT_VALIDATION_CHECKLIST_RU.md);
- [pilot freeze readiness](docs/PILOT_FREEZE_READINESS_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).
Границы показа:
- pfSense показывается только как `contract_only/readiness`, без заявления
production ingestion или SIEM;
- pfSense в текущем пилоте допускается только как contract/readiness/optional
integration layer;
- UEBA Score v1 является прозрачной rule-based моделью, без ML/LLM;
- demo fixtures не содержат реальных IP-адресов, hostname, логинов, ФИО,
подразделений заказчика или событий безопасности;
- planned/future providers не являются реализованными collectors.
## Что видит оператор
- Работал ли пользователь за компьютером или в удаленной сессии.
- Когда была активность, простой и переключение окон.
- Какие приложения, сайты и процессы чаще всего были в работе.
- Есть ли события, важные для ИБ: копирование, печать, USB, подозрительные сайты.
- Не пропали ли данные с рабочих компьютеров и RDP-сессий.
## Кому это полезно в работе
- Владельцу и руководителю - видеть активность, загрузку команды,
простои, перегрузки и рабочие приложения.
- ИБ - заметить 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).
### Главный вывод
![Главный вывод AWatch-rus](docs/screenshots/01-executive-overview.png)
Руководитель видит главный риск первым, затем сводку по достоверности
показателей, полноте данных, кандидатам на проверку и рискам подразделений.
### Карта рисков подразделений
![Карта рисков подразделений](docs/screenshots/02-risk-heatmap.png)
Карта рисков показывает, где одновременно проседают активность, покрытие
агентов, доверие к показателям и количество ситуаций для проверки.
### Представление безопасности
![Представление безопасности](docs/screenshots/03-security-view.png)
ИБ получает очередь кандидатов на проверку, связанные расследования и материалы
без просмотра сырых логов и без автоматического принятия решений.
### Представление эксплуатации
![Представление эксплуатации](docs/screenshots/04-operations-view.png)
Эксплуатация видит полноту данных, качество агентского сбора, ошибки сбора и
понятный статус событий безопасности через ClickHouse.
### Пакет расследования
![Пакет расследования](docs/screenshots/05-investigation-pack.png)
Пакет расследования связывает материалы, историю проверки и итоговый вывод,
который ответственный сотрудник может подтвердить вручную.
### Итоговый отчет
![Итоговый отчет](docs/screenshots/06-markdown-report.png)
Markdown-отчет собирает главный вывод, риски подразделений, материалы
расследований и рекомендации в формате, удобном для передачи руководителю.
### Архитектура продукта
![Архитектура продукта](docs/screenshots/07-product-architecture.png)
Страница `/portal/architecture` показывает текущие компоненты, planned
extensions и future-направления без создания новых API или фиктивных
collectors.
## Если дашборд пустой
Обычно это значит одно из трех: выбран слишком узкий период времени, рабочий компьютер давно не присылал события или временно не обновилась витрина в Grafana. Начните с периода `Last 24 hours`, затем переходите к техническим разделам ниже.
## Поставка и регистрация
- Ежедневная/еженедельная проверка эксплуатационного контура:
[матрица проверки контура](docs/CONTOUR_CHECK_MATRIX_RU.md).
- Проверка после инженерных изменений: cargo/security gates, browser smoke и
production smoke:
[эксплуатационный validation runbook](docs/OPERATIONS_VALIDATION_RUNBOOK_RU.md).
- 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).
### Подготовка к реестру российского ПО
- Основной российский Git-контур / Gitea-дубликат GitHub-репозитория:
`https://git.iri1968.dpdns.org/awatch-rus/AWatch-rus`.
- GitHub используется как публичное зеркало и public validation surface.
- Gitea operator account: `igor`; пароль/токены не хранятся в репозитории.
- Доказательная документация:
[docs/registry/](docs/registry/REGISTER_RU_SOFTWARE_READINESS_RU.md).
- Gitea Wiki используется только как навигация, не как единственный источник
документов.
- Российский build-runner и release evidence описаны в
[RU_BUILD_RUNNER_READINESS_RU.md](docs/registry/RU_BUILD_RUNNER_READINESS_RU.md).
- Текущий status freeze проекта:
[docs/PROJECT_STATUS_RU.md](docs/PROJECT_STATUS_RU.md).
- Остаточные риски:
[docs/RESIDUAL_RISKS_RU.md](docs/RESIDUAL_RISKS_RU.md).
- План публичных GitHub issues:
[docs/PUBLIC_ISSUES_PLAN_RU.md](docs/PUBLIC_ISSUES_PLAN_RU.md).
- GitHub remains public mirror only.
### Public engineering transparency
- Public CI, coverage baseline and security scanning are enabled on GitHub.
- Issue templates, PR template and public roadmap are maintained for process
visibility.
- Public secret scanning policy:
[docs/SECURITY_SCANNING_POLICY_RU.md](docs/SECURITY_SCANNING_POLICY_RU.md).
- GitHub remains public mirror validation only.
- Primary registry contour remains Gitea plus the Russian build-runner.
- Quality status:
[docs/QUALITY_STATUS_RU.md](docs/QUALITY_STATUS_RU.md).
- Residual risks:
[docs/RESIDUAL_RISKS_RU.md](docs/RESIDUAL_RISKS_RU.md).
- Public issues plan:
[docs/PUBLIC_ISSUES_PLAN_RU.md](docs/PUBLIC_ISSUES_PLAN_RU.md).
- Public issue templates are prepared and real GitHub issue URLs are recorded
in the manifest; this improves roadmap visibility but does not claim
community adoption:
[creation runbook](docs/PUBLIC_ISSUES_CREATION_RUNBOOK_RU.md),
[manifest](docs/public-issues/public-issues-manifest.json).
### Engineering governance and residual risks
- Review checklist:
[docs/REVIEW_CHECKLIST_RU.md](docs/REVIEW_CHECKLIST_RU.md).
- Residual risks register:
[docs/RESIDUAL_RISKS_RU.md](docs/RESIDUAL_RISKS_RU.md).
- Public issues plan:
[docs/PUBLIC_ISSUES_PLAN_RU.md](docs/PUBLIC_ISSUES_PLAN_RU.md).
- Public issues creation runbook:
[docs/PUBLIC_ISSUES_CREATION_RUNBOOK_RU.md](docs/PUBLIC_ISSUES_CREATION_RUNBOOK_RU.md).
- Public issues manifest:
[docs/public-issues/public-issues-manifest.json](docs/public-issues/public-issues-manifest.json).
- Advisory branch protection policy:
[docs/BRANCH_PROTECTION_POLICY_RU.md](docs/BRANCH_PROTECTION_POLICY_RU.md).
- Branch protection evidence template:
[docs/BRANCH_PROTECTION_EVIDENCE_RU.md](docs/BRANCH_PROTECTION_EVIDENCE_RU.md).
- PR-based review workflow:
[docs/PR_REVIEW_WORKFLOW_RU.md](docs/PR_REVIEW_WORKFLOW_RU.md).
- PR review evidence template:
[docs/PR_REVIEW_EVIDENCE_RU.md](docs/PR_REVIEW_EVIDENCE_RU.md).
- CODEOWNERS and PR template are maintained for review routing and public
change-control visibility.
- Visible external code review is still pending until public reviewed PRs exist.
- Branch protection policy is documented as advisory; it is not claimed as
enabled here.
- [Позиционирование для реестра российского ПО](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)
- [Остаточные риски](docs/RESIDUAL_RISKS_RU.md)
- [План публичных issues](docs/PUBLIC_ISSUES_PLAN_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)
- [План внедрения ClickHouse Dictionaries для DetMir](docs/clickhouse/DICTIONARIES_IMPLEMENTATION_PLAN_RU.md)
- [ClickHouse Workforce scaffold](clickhouse-workforce/README.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)
- Актуальные ссылки по этой тематике: https://www.securitylab.ru/analytics/573771.php (Как собрать ролевую модель доступа при хаосе в инфраструктуре)
---
## 📊 **ОЦЕНКА ЗРЕЛОСТИ И КАЧЕСТВА ПРОЕКТА** (обновлено 22 июня 2026)
### **1️⃣ ОБЩИЕ МЕТРИКИ ПРОЕКТА**
| Метрика | Значение | Тренд | Оценка |
|---------|----------|-------|--------|
| **Возраст проекта** | 58 дней | ✅ Active | Молодой, но стабильный |
| **Размер репо** | ~11 MB | ✅ Compact | Хорошо структурирован |
| **Основной язык** | Rust | ✅ Production | Правильный выбор |
| **Лицензия** | Apache 2.0 | ✅ Open-friendly | Коммерчески дружелюбно |
| **Звезды** | 3 ⭐ | ⚠️ Нишевой продукт | Целевая аудитория |
| **Форки** | 2 | ⚠️ Низко | Early-stage / pilot-stage OSS |
| **Open Issues** | 1 | ⚠️ Низкая публичная активность | Issue templates уже есть |
| **Последний коммит** | 22 июня 2026 | ✅ **СЕГОДНЯ** | **АКТИВНО РАЗРАБАТЫВАЕТСЯ** |
| **Проектный статус** | main branch | ✅ Единая стратегия | Production-ready focus |
| **Public CI** | passed | ✅ Visible | GitHub Actions mirror validation |
| **Coverage workflow** | passed | ✅ Visible | Baseline workflow, threshold позже |
| **Security workflow** | passed | ✅ Visible | cargo audit/deny + secret scan |
| **Secret scan** | hardened + passed | ✅ Conservative | Fail-closed public scanner |
---
### **2️⃣ АРХИТЕКТУРНАЯ ЗРЕЛОСТЬ: 9.2/10** 🏗️
#### ✅ **Rust-first Migration (ПОЛНОСТЬЮ ЗАВЕРШЕНА)**
```
Миграция на Rust: 32+ фазы, ВСЕ ЗАВЕРШЕНЫ ✅
Phase 0-7: Foundation & Read-only [DONE ✅]
Phase 8-17: State orchestration & Telegram [DONE ✅]
Phase 18-26: DLP & Hayabusa services [DONE ✅]
Phase 27-32: AW health & maintenance [DONE ✅]
Текущий статус: 30+ Rust crates в production
- detmir-auto ✅
- detmir-status ✅
- detmir-check ✅
- dlp-policy-engine ✅
- dlp-case-management ✅
- dlp-compliance ✅
- aw-db-maintenance ✅ (НОВОЕ: vacuum с integrity check!)
- aw-hayabusa-tools ✅
```
#### 🆕 **НОВОЕ: SQLite VACUUM & MAINTENANCE**
```rust
adk-rust/crates/aw-db-maintenance:
- Trim mode: удаление старых allowlisted rows (по умолчанию dry-run)
- VACUUM mode: компактирование DB с PRAGMA integrity_check
- Lock-based concurrency protection
- Service stop/start guards
- Backup-before-delete policy
- Rollback из /var/lib/activitywatch/backups/db/aw-sqlite-before-db-vacuum-*.db
```
**Это серьёзное, enterprise-grade решение для production DB maintenance.**
---
### **3️⃣ ДОКУМЕНТАЦИЯ: EXCEPTIONAL (10/10)** 📚
#### 🎯 **Полнота документации**
```
КЛАССИФИКАЦИЯ ДОКУМЕНТОВ:
DEPLOYMENT:
✅ ENTERPRISE_DEPLOYMENT_GUIDE_RU.md
✅ DEPLOYMENT_TOPOLOGIES_RU.md
✅ SIZING_GUIDE_RU.md
✅ BACKUP_AND_RECOVERY_RU.md
✅ SECURITY_HARDENING_RU.md
✅ FULL_DEPLOYMENT_MANUAL_RU.md
REGISTRY (для реестра РПО):
✅ REGISTRY_PRODUCT_PASSPORT_RU.md
✅ REGISTRY_ARCHITECTURE_RU.md
✅ REGISTRY_FUNCTIONAL_SCOPE_RU.md
✅ REGISTRY_DEPENDENCY_STATEMENT_RU.md
✅ REGISTRY_DEPLOYMENT_MODEL_RU.md
✅ REGISTRY_COMMERCIAL_POSITIONING_RU.md
PILOT & VALIDATION:
✅ PILOT_V1_RU.md
✅ PILOT_DEMO_SCENARIO_RU.md
✅ PILOT_FREEZE_READINESS_RU.md (НОВОЕ!)
✅ PILOT_VALIDATION_CHECKLIST_RU.md
✅ PILOT_SUCCESS_CRITERIA_RU.md
OPERATIONAL:
✅ OPERATIONS_RUNBOOK_RU.md
✅ OPERATIONS_RUNBOOK_WORKTIME_RU.md
✅ ADMIN_GUIDE_RU.md
✅ OPERATOR_GUIDE_RU.md
✅ ARCHITECTURE_RU.md
RISK & SECURITY:
✅ THREAT_MODEL_RU.md
✅ SECURITY_HARDENING_RU.md
✅ RISK_NARRATIVE_RU.md
✅ PRODUCTION_INCIDENT_REPORT_2026-06-07_RU.md
TECHNICAL:
✅ Wiki (Getting Started, Infrastructure, CI/CD, QA)
✅ Grafana dashboards guide
✅ Windows Collector Suite
✅ adk-rust/RUNBOOK.md (32 фазы миграции!)
SALES & POSITIONING:
✅ COMPETITIVE_POSITIONING_RU.md
✅ SALES_POSITIONING_RU.md
✅ CUSTOMER_PILOT_PACK_RU.md
✅ CUSTOMER_DEMO_SCENARIO_RU.md
TOTAL: 60+ документов НА РУССКОМ ЯЗЫКЕ
```
**Это НЕ типичный уровень документации. Это КОРПОРАТИВНЫЙ СТАНДАРТ.**
---
### **4️⃣ КАЧЕСТВО КОДА: 8.5/10** 💎
#### ✅ Сильные стороны:
```rust
// 1. Правильная обработка ошибок
// Все Rust crates используют Result<T, Error> с context
cargo clippy --workspace --all-targets -- -D warnings
// 2. Structured JSON output для всех операций
detmir-status --json
detmir-check --json
detmir-dlp --json
// Машинечитаемые контракты везде!
// 3. Safety gates и guardrails
// - dry-run по умолчанию для mutation команд
// - allowlist для systemd restart
// - lock files для concurrent protection
// - audit logging для всех действий
// 4. Idempotent Ansible playbooks
// - deploy_aw_server.yml идемпотентен
// - WinRM retry с exponential backoff
// - Syntax checks перед apply
// 5. Production-grade operational patterns
// - systemd drop-ins для переключения binaries
// - Rollback scripts задокументированы
// - Shadow-mode validation перед switch
```
#### ⚠️ Оставшиеся слабые стороны:
```
⚠️ Низкая публичная активность в issue tracker
- issue templates есть
- public roadmap есть
- открытых публичных задач пока мало
⚠️ Низкая community adoption
- мало forks/stars
- проект пока выглядит как early-stage / pilot-stage OSS
- это нормально для нового специализированного продукта
⚠️ Restore test еще не выполнен
- backup Gitea работает
- SHA256 verification работает
- daily timer работает
- restore_tested пока false
⚠️ Российский build-runner пока planned
- release evidence scripts есть
- первый настоящий release build на awatch-build-01 еще не выполнен
⚠️ Юридический пакет правообладателя еще pending
- техническая readiness сильная
- юридическая часть для реестра еще требует отдельной подготовки
```
#### ✅ Уже закрыто после последних коммитов:
```
✅ Public CI/CD visibility
✅ Public coverage workflow
✅ Public security scanning
✅ Secret scan policy
✅ SECURITY.md
✅ CONTRIBUTING.md
✅ ROADMAP.md
✅ Issue templates
✅ PR template
✅ CODEOWNERS
✅ Review checklist
✅ Branch protection policy documented
✅ Registry docs
✅ Russian Gitea contour
✅ GitHub public mirror validation
✅ Gitea backup
✅ Status freeze
```
---
### **5️⃣ PRODUCTION READINESS: 9/10** 🚀
#### ✅ Enterprise Features
```
✅ Multi-role RBAC (executive, manager, security, forensics, admin)
✅ DLP incident management с evidence хранилищем
✅ SLO monitoring и автоматический heal
✅ Ansible-powered deployment с idempotency
✅ Backup/restore procedures
✅ Grafana dashboards version-controlled
✅ Hayabusa forensics integration
✅ Telegram bot уведомления
✅ ClickHouse data warehouse
✅ Prometheus/Influx exporters
✅ SAFETY PATTERNS:
- read-only smoke tests перед production
- --dry-run по умолчанию для risky operations
- Rollback procedures documented
- Production incident report существует (2026-06-07)
- Lock-based concurrency protection
```
#### ⚠️ Production Risks
```
⚠️ Один разработчик (igor04091968) — BUS FACTOR ⚠️
- Все коммиты от одного человека
- Нет code reviews видно
- Нет pull request culture
⚠️ Молодой проект (56 дней)
- Нет долгосрочной production history
- Нет documented post-mortems (кроме одного)
⚠️ Limited public activity / community adoption
- 2 форка, 3 звезды
- Issue templates и roadmap есть, но публичных задач пока мало
- Community adoption низкая, это не технический blocker
⚠️ Registry release evidence еще не завершен
- GitHub Actions зеленые, но это только public mirror validation
- Первый release evidence build должен быть выполнен на awatch-build-01
- Gitea restore_tested пока false
```
---
### **6️⃣ РОССИЙСКИЙ РЫНОК READY: 9.5/10** 🇷🇺
#### ✅ Идеальная позиция для РФ
```
✅ ЛОКАЛИЗАЦИЯ:
- Полностью на русском (все документы)
- Russian UI patch для ActivityWatch
- Поддержка русских Windows локализаций
- Cyrillic-aware logging
✅ РЕЕСТР РПО / REGISTRY-READINESS:
- Подготовлен registry-readiness пакет документов
- Product passport и architecture documents описаны
- Dependency statement зафиксирован
- Российский Gitea-контур поднят
- GitHub Actions используется только как public mirror validation
- Release evidence требует российского build-runner
✅ ТЕХНОЛОГИЧЕСКИЙ STACK:
- Rust (не зависит от США)
- Debian/Ubuntu Linux
- Grafana/Prometheus (open-source)
- ClickHouse (российская компания!)
- Hayabusa (DFIR forensics)
- Ansible (open infrastructure)
✅ NO CLOUD-DEPENDENCY:
- Полностью on-prem
- Нет телеметрии в облако
- Нет SaaS lock-in
- Может быть air-gapped
✅ HONESTY POSITIONING:
- НЕ претендует на ФСТЕК/ФСБ сертификацию
- НЕ использует ML/LLM (transparent rule-based UEBA)
- Явно указывает границы показа (contract_only для pfSense)
- Не маскирует ограничения
```
---
### **7️⃣ PILOT v1 FREEZE READINESS (НОВОЕ!)** 🎯
Заметил в README новый документ:
```
✅ docs/PILOT_FREEZE_READINESS_RU.md (добавлен недавно)
```
Это указывает на:
- **Проект готовится к Pilot freeze** (закрытию features)
- **Feature complete для Pilot v1.0**
- **Production readiness gates активны**
```
PILOT V1 SCOPE (ГОТОВО):
- Workforce Analytics ✅
- Security DLP Analytics ✅
- Forensics Reporting ✅
- Evidence Management ✅
- UEBA v1 (rule-based) ✅
- Telegram notifications ✅
- Grafana dashboards ✅
- Role-based access ✅
```
---
### **8️⃣ ИСТОРИЧЕСКАЯ ВЕХИ РАЗРАБОТКИ** 📅
```
2026-06-01: Миграция на Rust начинается (фазы 0-7)
2026-06-07: Production incident (подробный postmortem)
2026-06-09: Grafana panels development
2026-06-11: Security hardening improvements
2026-06-12: Release candidate preflight
2026-06-12-19: Intensive hardening phase
2026-06-20: Pilot freeze readiness doc добавлен
2026-06-21: Public CI/Coverage/Security workflows добавлены
2026-06-22: GitHub Actions validation прошел после hardening secret scan
ВЫВОД: Проект в PRODUCTION HARDENING фазе перед Pilot release
```
---
### **9️⃣ КОНКУРЕНТНЫЙ АНАЛИЗ** 🏆
Проект позиционирует себя против:
```
КОНКУРЕНТЫ (по docs/COMPETITIVE_POSITIONING_RU.md):
- Splunk (слишком дорого, облако)
- Okta (не для локального ИБ)
- ArcSight (legacy, дорого)
- ELK Stack (требует экспертизы)
- Grafana Loki (только logs, не worktime)
УНИКАЛЬНОСТЬ AWatch-rus:
✅ Workforce + Security + Forensics в одном
✅ Русский язык & локализация
✅ Без облака & без ML-черного ящика
✅ Open-source компоненты (ActivityWatch)
✅ Прозрачность (rule-based UEBA)
✅ РФ registry ready
```
---
### **🔟 FINAL ASSESSMENT: ПЕРЕОЦЕНКА**
| Категория | Была | Сейчас | Изменение | Комментарий |
|-----------|------|--------|-----------|------------|
| **Полнота** | 8.5 | **9.2** | ⬆️ +0.7 | DB maintenance added |
| **Качество** | 8.0 | **8.5** | ⬆️ +0.5 | Production incident handled professionally |
| **Профессионализм** | 9.0 | **9.3** | ⬆️ +0.3 | Pilot freeze readiness shows maturity |
| **Российский рынок** | 9.0 | **9.5** | ⬆️ +0.5 | Registry docs enhanced, freeze ready |
| **Production Ready** | 8.5 | **9.0** | ⬆️ +0.5 | Safety gates, rollback procedures validated |
| **Public Validation** | 6.5 | **8.8** | ⬆️ +2.3 | CI/Coverage/Security workflows green |
| **ИТОГО** | **8.6** | **9.1** | ⬆️ **+0.5** | **PRODUCTION GRADE** |
---
### **🎯 КЛЮЧЕВЫЕ ВЫВОДЫ**
```
1. ✅ ПРОЕКТ ГОТОВ К PRODUCTION PILOTING
- Rust-first migration полностью завершена
- Safety gates реализованы
- Documentation на уровне enterprise
- DB maintenance добавлено (новое)
2. ✅ ИДЕАЛЕН ДЛЯ РОССИЙСКОГО РЫНКА
- Полностью локализован
- Registry documents готовы
- Технологический stack без зависимостей
3. ✅ PUBLIC VALIDATION VISIBILITY УЖЕ ЗАКРЫТА
- Public CI/CD visibility ✅
- Public coverage workflow ✅
- Public security scanning ✅
- Secret scan policy hardened ✅
- GitHub public mirror validation ✅
4. ⚠️ ОСТАВШИЕСЯ РИСКИ
- Один разработчик
- Нет visible code review
- Низкая публичная активность issue tracker
- Низкая community adoption
- Gitea restore test еще не выполнен
- Российский build-runner пока planned
- Branch protection policy documented, but enablement not yet verified
5. 🚀 TIMELINE К PRODUCTION:
- Pilot v1 freeze: готовится (freeze readiness doc)
- Beta release: Q3 2026 (est.)
- GA production: Q4 2026 (est.)
6. 📊 QUALITY METRICS:
- Code: Rust clippy strict mode ✅
- Testing: Cargo test suite ✅
- Public coverage workflow ✅
- Public security workflow ✅
- Deployment: Ansible idempotent ✅
- Operations: Runbook-driven ✅
- Documentation: 60+ doc pages ✅
```
---
## 💡 **РЕКОМЕНДАЦИИ**
### Для потенциального инвестора/партнера:
```
✅ ИНВЕСТИРОВАТЬ: Проект достаточно зрелый для pilot
✅ ТРЕБОВАТЬ: Bus factor mitigation (второй разработчик)
✅ ТРЕБОВАТЬ: Community code review process (GitHub PRs)
✅ ТРЕБОВАТЬ: Первый release evidence build на российском build-runner
✅ ТРЕБОВАТЬ: Restore test Gitea backup на отдельном сервере
⚠️ НАБЛЮДАТЬ: Feedback из first customers на Pilot v1
```
### Для Russian enterprises:
```
✅ ИСПОЛЬЗОВАТЬ: Как operational intelligence platform
✅ НЕ ИСПОЛЬЗОВАТЬ: Как certified DLP/SIEM (не позиционируется)
✅ ТРЕБОВАТЬ: Support contract перед production
✅ ПЛАНИРОВАТЬ: Intern training на Rust maintenance
```
+309
View File
@@ -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` - стратегия
позиционирования.
+67
View File
@@ -0,0 +1,67 @@
# AWatch-rus Roadmap
This roadmap is public planning. It does not claim completion of unverified
work and does not replace `docs/registry/` evidence for registry-readiness.
## Registry-readiness
- Maintain `docs/registry/` as the authoritative registry-readiness
documentation package.
- Keep conservative product claims and explicit remaining gaps.
- Prepare final rightsholder confirmation and legal review separately.
## Russian Git/build contour
- Keep self-hosted Gitea as the target Russian Git contour for
registry-readiness.
- Keep GitHub as public mirror validation only.
- Provision `awatch-build-01` as a separate Russian build-runner.
## Release evidence
- Run release candidate checks on the Russian build-runner.
- Generate source archive, binary archive, SBOM, SHA256SUMS, smoke logs and
release evidence manifest.
- Keep public GitHub Actions separate from registry release evidence.
## Backup/restore test
- Complete a test restore of Gitea backup on a separate server.
- Keep `restore_tested=false` until evidence exists.
- Document offsite backup in RF before registry submission.
## Coverage and CI
- Use public CI for engineering transparency.
- Track coverage baseline without enforcing a threshold at first.
- Add coverage threshold after baseline review.
## Security scanning
- Maintain cargo audit, cargo deny, dependency review and secret-pattern checks.
- Treat public security checks as advisory validation.
- Produce registry release security evidence in the Russian build contour.
## Russian OS compatibility
- Validate deployment and agent behavior on target Russian OS variants.
- Document unsupported combinations explicitly.
## Pilot hardening
- Keep demo data anonymized.
- Improve smoke coverage for install kit and operational reports.
- Preserve clear rollback and backup-first operational procedures.
## Future UI
- Future UI work remains planned unless backed by implemented code and tests.
- Public roadmap entries are not product claims.
## Not claimed / out of scope
- No claim of FSTEC/FSB certification.
- No claim of replacing DLP or SIEM.
- No claim of ML/LLM-based detection.
- No claim of automatic remediation.
- No claim of legal completion of Russian software registry registration.
+430
View File
@@ -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 там, где многие компании либо переплачивают за тяжелые платформы, либо вообще живут без управляемого контроля.
+41
View File
@@ -0,0 +1,41 @@
# Security Policy
## Reporting a vulnerability
Please report suspected vulnerabilities privately to the project maintainer
before publishing technical details. If a private contact channel is not
available, open a GitHub issue with a minimal description and no exploit,
secret, customer data, employee logs or personal data.
Do not include:
- passwords, tokens, cookies or private keys;
- real employee logs;
- personal data;
- private network details;
- customer evidence that has not been anonymized.
Use demo or anonymized evidence whenever possible.
## Security scope
AWatch-rus is not positioned as a certified security product. It is not a replacement for DLP or SIEM platforms. Public security checks are advisory validation for engineering transparency.
## Public validation
GitHub Actions security checks run in the public mirror:
- cargo audit;
- cargo deny;
- secret-pattern check;
- dependency review for pull requests.
GitHub remains public mirror validation only. Registry release security
evidence must be produced in the Russian build contour on the Russian
build-runner.
## Registry-readiness note
Security checks do not confirm legal completion of Russian software registry
registration. Final submission requires rightsholder confirmation and legal
review.
+454
View File
@@ -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.
+17
View File
@@ -0,0 +1,17 @@
#!/bin/sh
set -eu
REPO_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
cd "$REPO_DIR"
echo "==> Fetch origin"
git fetch origin
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
echo "==> Pull origin/$CURRENT_BRANCH"
git pull --ff-only origin "$CURRENT_BRANCH"
echo "==> Done"
git status --short --branch
Executable
+34
View File
@@ -0,0 +1,34 @@
#!/bin/sh
set -eu
REPO_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
cd "$REPO_DIR"
BRANCH=$(git rev-parse --abbrev-ref HEAD)
if [ "${1:-}" = "" ]; then
echo "Usage: $0 \"commit message\"" >&2
exit 1
fi
COMMIT_MESSAGE=$1
echo "==> Git status"
git status --short --branch
echo "==> Stage changes"
git add -A
if git diff --cached --quiet; then
echo "No staged changes to commit."
exit 0
fi
echo "==> Commit"
git commit -m "$COMMIT_MESSAGE"
echo "==> Push origin/$BRANCH"
git push origin "$BRANCH"
echo "==> Done"
+27
View File
@@ -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.
+185
View File
@@ -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.
+4
View File
@@ -0,0 +1,4 @@
target/
.env
.env.*
!.env.example
+3513
View File
File diff suppressed because it is too large Load Diff
+96
View File
@@ -0,0 +1,96 @@
[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-workforce-ingest",
"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"
+127
View File
@@ -0,0 +1,127 @@
# 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; nightly SQLite
compaction is handled by the same binary in `--vacuum` mode and scheduled
separately from the trim job.
- `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.
## SQLite Maintenance Safety
`aw-db-maintenance` has two separate modes:
- default trim mode removes only old allowlisted `process_start` /
`process_stop` rows from the configured session bucket and is dry-run unless
`--apply` is passed;
- `--vacuum` compacts the SQLite DB with `VACUUM INTO`, checks
`PRAGMA integrity_check`, preserves owner/mode, and replaces the DB only after
backup and integrity success.
Both apply modes use `AW_DB_MAINTENANCE_LOCK_PATH` /
`--lock-path` to block concurrent trim/VACUUM runs. VACUUM also checks the
configured `activitywatch-server.service` through systemd, refuses unknown or
failed unit states, stops the service before compaction, and starts it again
through a guard on success or error.
Do not run VACUUM during business hours, active incident response, evidence
collection, active backup/restore, or when the ActivityWatch service/unit state
is unclear. Rollback is replacing the SQLite DB from
`/var/lib/activitywatch/backups/db/aw-sqlite-before-db-vacuum-*.db` while
`activitywatch-server.service` is stopped, then starting the service and
checking `aw-db-health`/`detmir-status`.
The Ansible deploy installs the VACUUM unit files but does not enable the
nightly timer unless `aw_db_vacuum_timer_enabled=true` is set explicitly.
Disable it with:
```bash
systemctl disable --now aw-db-vacuum.timer
```
## 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.
+1812
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -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);
}
}
+18
View File
@@ -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
+562
View File
@@ -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,894 @@
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::Command;
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 DEFAULT_SERVICE_UNIT: &str = "activitywatch-server.service";
const DEFAULT_LOCK_PATH: &str = "/run/aw-db-maintenance.lock";
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)]
vacuum: bool,
#[arg(
long,
default_value = DEFAULT_SERVICE_UNIT,
env = "AW_DB_MAINTENANCE_SERVICE_UNIT"
)]
service_unit: String,
#[arg(long, default_value = DEFAULT_LOCK_PATH, env = "AW_DB_MAINTENANCE_LOCK_PATH")]
lock_path: PathBuf,
#[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,
lock_path: String,
skipped_reason: Option<String>,
}
#[derive(Debug, Serialize)]
struct VacuumReport {
apply: bool,
generated_at_utc: String,
db_path: String,
service_unit: String,
service_was_active: bool,
service_restarted: bool,
backup_path: Option<String>,
backup_created: bool,
lock_path: String,
db_size_before_bytes: Option<u64>,
vacuumed_path: Option<String>,
vacuumed_size_bytes: Option<u64>,
integrity_check: Option<String>,
replaced_db: bool,
skipped_reason: Option<String>,
}
struct VacuumResult {
backup_path: PathBuf,
vacuumed_path: PathBuf,
db_size_before_bytes: u64,
vacuumed_size_bytes: u64,
integrity_check: String,
}
struct ServiceGuard {
unit: String,
was_active: bool,
restored: bool,
}
struct TempFileGuard {
path: PathBuf,
keep: bool,
}
struct LockFileGuard {
path: PathBuf,
}
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();
if cli.vacuum {
let report = build_vacuum_report(&cli)?;
if cli.json {
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
print_vacuum_text(&report);
}
} else {
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;
let _lock_guard = if cli.apply && planned > 0 {
Some(LockFileGuard::acquire(&cli.lock_path)?)
} else {
None
};
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, "aw-sqlite-before-db-maintenance");
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,
))
}
fn build_vacuum_report(cli: &Cli) -> Result<VacuumReport> {
if !cli.db_path.exists() {
return Ok(vacuum_report(
cli,
false,
None,
false,
false,
false,
None,
None,
None,
None,
Some("database not found".to_string()),
));
}
if !cli.apply {
return Ok(vacuum_report(
cli,
false,
Some(file_size(&cli.db_path)?),
false,
false,
false,
None,
None,
None,
None,
Some("dry-run".to_string()),
));
}
let _lock_guard = LockFileGuard::acquire(&cli.lock_path)?;
let mut service_guard = ServiceGuard::stop_if_active(&cli.service_unit)?;
let service_was_active = service_guard.was_active;
let result = vacuum_sqlite_db(&cli.db_path, &cli.backup_dir)?;
let service_restarted = service_guard.restore()?;
Ok(vacuum_report(
cli,
true,
Some(result.db_size_before_bytes),
true,
service_was_active,
service_restarted,
Some(result.backup_path),
Some(result.vacuumed_path),
Some(result.vacuumed_size_bytes),
Some(result.integrity_check),
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,
lock_path: cli.lock_path.display().to_string(),
skipped_reason,
}
}
#[allow(clippy::too_many_arguments)]
fn vacuum_report(
cli: &Cli,
apply: bool,
db_size_before_bytes: Option<u64>,
backup_created: bool,
service_was_active: bool,
service_restarted: bool,
backup_path: Option<PathBuf>,
vacuumed_path: Option<PathBuf>,
vacuumed_size_bytes: Option<u64>,
integrity_check: Option<String>,
skipped_reason: Option<String>,
) -> VacuumReport {
VacuumReport {
apply,
generated_at_utc: Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true),
db_path: cli.db_path.display().to_string(),
service_unit: cli.service_unit.clone(),
service_was_active,
service_restarted,
backup_path: backup_path.map(|path| path.display().to_string()),
backup_created,
lock_path: cli.lock_path.display().to_string(),
db_size_before_bytes,
vacuumed_path: vacuumed_path.map(|path| path.display().to_string()),
vacuumed_size_bytes,
integrity_check,
replaced_db: apply && skipped_reason.is_none(),
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, prefix: &str) -> PathBuf {
backup_dir.join(format!(
"{}-{}.db",
prefix,
Utc::now().format("%Y%m%dT%H%M%SZ")
))
}
fn vacuum_sqlite_db(db_path: &Path, backup_dir: &Path) -> Result<VacuumResult> {
fs::create_dir_all(backup_dir)
.with_context(|| format!("create backup dir {}", backup_dir.display()))?;
let db_size_before_bytes = file_size(db_path)?;
let backup_path = backup_path(backup_dir, "aw-sqlite-before-db-vacuum");
copy_sqlite_via_backup(db_path, &backup_path)?;
let vacuumed_path = vacuumed_path(db_path)?;
let mut vacuum_cleanup = TempFileGuard::new(vacuumed_path.clone());
vacuum_into(db_path, &vacuumed_path)?;
preserve_sqlite_metadata(db_path, &vacuumed_path)?;
let vacuumed_size_bytes = file_size(&vacuumed_path)?;
let integrity_check = integrity_check(&vacuumed_path)?;
remove_sqlite_sidecars(db_path)?;
fs::rename(&vacuumed_path, db_path).with_context(|| {
format!(
"replace {} with {}",
db_path.display(),
vacuumed_path.display()
)
})?;
vacuum_cleanup.disarm();
Ok(VacuumResult {
backup_path,
vacuumed_path,
db_size_before_bytes,
vacuumed_size_bytes,
integrity_check,
})
}
fn vacuum_into(src: &Path, dst: &Path) -> Result<()> {
let conn = open_connection(src, true)?;
let sql = format!("VACUUM INTO {}", sqlite_string_literal(dst));
conn.execute_batch(&sql)
.with_context(|| format!("VACUUM INTO {}", dst.display()))
}
fn integrity_check(path: &Path) -> Result<String> {
let conn = open_connection(path, false)?;
let result: String = conn.query_row("PRAGMA integrity_check", [], |row| row.get(0))?;
if result != "ok" {
bail!("integrity_check failed for {}: {result}", path.display());
}
Ok(result)
}
fn remove_sqlite_sidecars(db_path: &Path) -> Result<()> {
for suffix in ["-wal", "-shm", "-journal"] {
let sidecar = sqlite_sidecar_path(db_path, suffix)?;
match fs::remove_file(&sidecar) {
Ok(()) => {}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
Err(err) => return Err(err).with_context(|| format!("remove {}", sidecar.display())),
}
}
Ok(())
}
fn sqlite_sidecar_path(db_path: &Path, suffix: &str) -> Result<PathBuf> {
let file_name = db_path
.file_name()
.and_then(|value| value.to_str())
.context("database path must have a file name")?;
Ok(db_path.with_file_name(format!("{file_name}{suffix}")))
}
fn vacuumed_path(db_path: &Path) -> Result<PathBuf> {
let file_name = db_path
.file_name()
.and_then(|value| value.to_str())
.context("database path must have a file name")?;
Ok(db_path.with_file_name(format!(
"{file_name}.vacuumed-{}",
Utc::now().format("%Y%m%dT%H%M%SZ")
)))
}
fn file_size(path: &Path) -> Result<u64> {
Ok(fs::metadata(path)
.with_context(|| format!("stat {}", path.display()))?
.len())
}
fn sqlite_string_literal(path: &Path) -> String {
format!("'{}'", path.display().to_string().replace('\'', "''"))
}
fn preserve_sqlite_metadata(src: &Path, dst: &Path) -> Result<()> {
let metadata = fs::metadata(src).with_context(|| format!("stat {}", src.display()))?;
let permissions = metadata.permissions();
fs::set_permissions(dst, permissions)
.with_context(|| format!("preserve permissions for {}", dst.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
let dst_metadata = fs::metadata(dst).with_context(|| format!("stat {}", dst.display()))?;
if dst_metadata.uid() != metadata.uid() || dst_metadata.gid() != metadata.gid() {
let status = Command::new("chown")
.arg(format!("{}:{}", metadata.uid(), metadata.gid()))
.arg(dst)
.status()
.context("run chown for vacuumed SQLite DB")?;
if !status.success() {
bail!("chown failed for {}", dst.display());
}
}
}
Ok(())
}
fn systemctl_is_active(unit: &str) -> Result<bool> {
let load_state = systemctl_load_state(unit)?;
if load_state != "loaded" {
bail!("refusing SQLite VACUUM because systemd unit {unit} load_state={load_state:?}");
}
let output = Command::new("systemctl")
.args(["is-active", unit])
.output()
.with_context(|| format!("systemctl is-active {unit}"))?;
let state = String::from_utf8_lossy(&output.stdout).trim().to_string();
if output.status.success() && state == "active" {
return Ok(true);
}
if output.status.code() == Some(3) && state == "inactive" {
return Ok(false);
}
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
bail!(
"refusing SQLite VACUUM because systemctl is-active {unit} returned state={state:?}, status={}, stderr={stderr:?}",
output.status
);
}
fn systemctl_load_state(unit: &str) -> Result<String> {
let output = Command::new("systemctl")
.args(["show", "-p", "LoadState", "--value", unit])
.output()
.with_context(|| format!("systemctl show LoadState {unit}"))?;
let state = String::from_utf8_lossy(&output.stdout).trim().to_string();
if output.status.success() && !state.is_empty() {
return Ok(state);
}
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
bail!(
"refusing SQLite VACUUM because systemctl show LoadState {unit} failed with status={}, stderr={stderr:?}",
output.status
);
}
fn systemctl_action(action: &str, unit: &str) -> Result<()> {
let status = Command::new("systemctl")
.args([action, unit])
.status()
.with_context(|| format!("systemctl {action} {unit}"))?;
if status.success() {
Ok(())
} else {
bail!("systemctl {action} {unit} failed with status {status}");
}
}
impl ServiceGuard {
fn stop_if_active(unit: &str) -> Result<Self> {
let was_active = systemctl_is_active(unit)?;
if was_active {
systemctl_action("stop", unit)?;
}
Ok(Self {
unit: unit.to_string(),
was_active,
restored: !was_active,
})
}
fn restore(&mut self) -> Result<bool> {
if self.was_active && !self.restored {
systemctl_action("start", &self.unit)?;
self.restored = true;
}
Ok(self.was_active)
}
}
impl Drop for ServiceGuard {
fn drop(&mut self) {
if self.was_active && !self.restored {
let _ = systemctl_action("start", &self.unit);
}
}
}
impl TempFileGuard {
fn new(path: PathBuf) -> Self {
Self { path, keep: false }
}
fn disarm(&mut self) {
self.keep = true;
}
}
impl Drop for TempFileGuard {
fn drop(&mut self) {
if !self.keep {
let _ = fs::remove_file(&self.path);
}
}
}
impl LockFileGuard {
fn acquire(path: &Path) -> Result<Self> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("create lock parent {}", parent.display()))?;
}
let mut file = match OpenOptions::new().write(true).create_new(true).open(path) {
Ok(file) => file,
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
bail!("maintenance lock already exists: {}", path.display());
}
Err(err) => return Err(err).with_context(|| format!("create lock {}", path.display())),
};
writeln!(
file,
"pid={} generated_at_utc={}",
std::process::id(),
Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true)
)
.with_context(|| format!("write lock {}", path.display()))?;
Ok(Self {
path: path.to_path_buf(),
})
}
}
impl Drop for LockFileGuard {
fn drop(&mut self) {
let _ = fs::remove_file(&self.path);
}
}
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);
println!("lock_path: {}", report.lock_path);
if let Some(path) = &report.backup_path {
println!("backup_path: {path}");
}
if let Some(reason) = &report.skipped_reason {
println!("skipped_reason: {reason}");
}
}
fn print_vacuum_text(report: &VacuumReport) {
println!(
"aw-db-vacuum: {}",
if report.apply { "apply" } else { "dry-run" }
);
println!("db_path: {}", report.db_path);
println!("service_unit: {}", report.service_unit);
println!("service_was_active: {}", report.service_was_active);
println!("service_restarted: {}", report.service_restarted);
println!("backup_created: {}", report.backup_created);
println!("lock_path: {}", report.lock_path);
if let Some(path) = &report.backup_path {
println!("backup_path: {path}");
}
if let Some(size) = report.db_size_before_bytes {
println!("db_size_before_bytes: {size}");
}
if let Some(path) = &report.vacuumed_path {
println!("vacuumed_path: {path}");
}
if let Some(size) = report.vacuumed_size_bytes {
println!("vacuumed_size_bytes: {size}");
}
if let Some(check) = &report.integrity_check {
println!("integrity_check: {check}");
}
println!("replaced_db: {}", report.replaced_db);
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,
vacuum: false,
service_unit: DEFAULT_SERVICE_UNIT.to_string(),
lock_path: dir.path().join("maintenance.lock"),
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,
vacuum: false,
service_unit: DEFAULT_SERVICE_UNIT.to_string(),
lock_path: dir.path().join("maintenance.lock"),
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);
}
#[test]
fn vacuum_apply_compacts_database_and_preserves_rows() {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("sqlite.db");
create_vacuum_fixture_db(&db);
let before = file_size(&db).unwrap();
let result = vacuum_sqlite_db(&db, dir.path()).unwrap();
let after = file_size(&db).unwrap();
assert!(result.vacuumed_size_bytes < result.db_size_before_bytes);
assert!(after < before);
assert_eq!(result.integrity_check, "ok");
assert!(result.backup_path.exists());
assert_eq!(count_rows(&db), 32);
}
#[test]
fn vacuum_dry_run_skips_mutation() {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("sqlite.db");
create_vacuum_fixture_db(&db);
let cli = Cli {
db_path: db.clone(),
backup_dir: dir.path().join("backups"),
session_bucket: None,
host: None,
retention_days: 7,
chunk_size: 100,
apply: false,
vacuum: true,
service_unit: DEFAULT_SERVICE_UNIT.to_string(),
lock_path: dir.path().join("maintenance.lock"),
json: true,
};
let report = build_vacuum_report(&cli).unwrap();
assert!(!report.backup_created);
assert!(!report.replaced_db);
assert_eq!(report.skipped_reason.as_deref(), Some("dry-run"));
assert_eq!(count_rows(&db), 32);
}
#[test]
fn apply_refuses_when_lock_exists() {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("aw.db");
create_fixture_db(&db);
let lock_path = dir.path().join("maintenance.lock");
fs::write(&lock_path, "busy").unwrap();
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,
vacuum: false,
service_unit: DEFAULT_SERVICE_UNIT.to_string(),
lock_path,
json: true,
};
let err = build_report(&cli).unwrap_err().to_string();
assert!(err.contains("maintenance lock already exists"));
assert_eq!(count_events(&db), 3);
}
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 create_vacuum_fixture_db(path: &Path) {
let conn = Connection::open(path).unwrap();
conn.execute_batch(
"create table items (id integer primary key autoincrement, payload text);",
)
.unwrap();
let payload = "x".repeat(4096);
for _ in 0..64 {
conn.execute("insert into items (payload) values (?1)", [&payload])
.unwrap();
}
for id in 1..=32 {
conn.execute("delete from items where id = ?1", [id])
.unwrap();
}
}
fn count_events(path: &Path) -> i64 {
Connection::open(path)
.unwrap()
.query_row("select count(*) from events", [], |row| row.get(0))
.unwrap()
}
fn count_rows(path: &Path) -> i64 {
Connection::open(path)
.unwrap()
.query_row("select count(*) from items", [], |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
+495
View File
@@ -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());
}
}
+17
View File
@@ -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
+19
View File
@@ -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,18 @@
[package]
name = "aw-workforce-ingest"
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
reqwest.workspace = true
serde.workspace = true
serde_json.workspace = true
tempfile.workspace = true
urlencoding.workspace = true
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,10 @@
fn main() {
let code = match aw_workforce_ingest::run_from_args() {
Ok(()) => 0,
Err(err) => {
eprintln!("{err:#}");
1
}
};
std::process::exit(code);
}
@@ -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);
}
}
+138
View File
@@ -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));
}
}
+20
View File
@@ -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
+174
View File
@@ -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");
}
}
+143
View File
@@ -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"));
}
}
+255
View File
@@ -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));
}
}
+17
View File
@@ -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
+555
View File
@@ -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));
}
}
+17
View File
@@ -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
+608
View File
@@ -0,0 +1,608 @@
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://10.10.10.13:5600";
const DEFAULT_HOST: &str = "SHARKON2025";
const DEFAULT_RDP_HOST: &str = "192.168.100.19";
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,
#[arg(long, default_value_t = true)]
dlp_enabled: 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 mut cli = Cli::parse();
if cli.server == DEFAULT_SERVER {
if let Some(value) = env_nonempty("CHECK_AW_FULL_SERVER")
.or_else(|| env_nonempty("AW_SMOKE_AW_SERVER"))
.or_else(|| env_nonempty("AW_SERVER"))
{
cli.server = value;
}
}
if cli.host == DEFAULT_HOST {
if let Some(value) = env_nonempty("CHECK_AW_FULL_HOST")
.or_else(|| env_nonempty("AW_SMOKE_SOURCE_HOSTNAME"))
.or_else(|| env_nonempty("AW_LOGICAL_HOST_ID"))
.or_else(|| env_nonempty("AW_MONITORED_WINDOWS_HOSTNAME"))
{
cli.host = value;
}
}
if cli.rdp_host == DEFAULT_RDP_HOST {
if let Some(value) = env_nonempty("CHECK_AW_FULL_RDP_HOST")
.or_else(|| env_nonempty("AW_SMOKE_WINDOWS_HOST"))
.or_else(|| env_nonempty("AW_WINDOWS_HOST"))
{
cli.rdp_host = value;
}
}
if let Some(value) =
env_nonempty("AW_DLP_ENABLED").or_else(|| env_nonempty("DETMIR_DLP_ENABLED"))
{
cli.dlp_enabled = parse_env_flag(&value);
}
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
.iter()
.copied()
.filter(|(bucket, _)| cli.dlp_enabled || !bucket.starts_with("aw-dlp-"))
{
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);
}
if !cli.dlp_enabled {
println!(
" {:<42} {:<8} {:<20} {}",
"DLP buckets",
"-",
"disabled",
colors.paint(colors.cyan, "SKIPPED")
);
}
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 effective_ts = if bucket == "aw-watcher-afk" {
bucket_metadata_end(client, server, &bucket_full).unwrap_or(ts)
} else {
ts
};
let age_sec = (now - effective_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 bucket_metadata_end(client: &Client, server: &str, bucket: &str) -> Option<DateTime<Utc>> {
let url = format!("{server}/api/0/buckets/{bucket}");
let value = get_json(client, &url).ok()?;
value
.pointer("/metadata/end")
.and_then(Value::as_str)
.and_then(parse_ts)
}
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", server)
.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(),
}
}
fn env_nonempty(name: &str) -> Option<String> {
std::env::var(name).ok().filter(|value| !value.is_empty())
}
fn parse_env_flag(value: &str) -> bool {
matches!(
value.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on"
)
}
#[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"]);
}
}
+20
View File
@@ -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
+661
View File
@@ -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
+119
View File
@@ -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);
}
}
+17
View File
@@ -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
File diff suppressed because it is too large Load Diff
+12
View File
@@ -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

Some files were not shown because too many files have changed in this diff Show More