From 19e3682bc8c303e8c3a8470fbbf09cdc387ecc51 Mon Sep 17 00:00:00 2001 From: igor04091968 Date: Tue, 2 Jun 2026 17:57:58 +0300 Subject: [PATCH] feat(detmir): add rust-first operations tooling --- adk-rust/.gitignore | 4 + adk-rust/Cargo.lock | 3007 +++++++++++++++++ adk-rust/Cargo.toml | 79 + adk-rust/README.md | 89 + adk-rust/RUNBOOK.md | 1510 +++++++++ adk-rust/crates/aw-browser-smoke/Cargo.toml | 14 + adk-rust/crates/aw-browser-smoke/src/main.rs | 146 + adk-rust/crates/aw-contour-smoke/Cargo.toml | 18 + adk-rust/crates/aw-contour-smoke/src/main.rs | 461 +++ adk-rust/crates/aw-db-health/Cargo.toml | 18 + adk-rust/crates/aw-db-health/src/main.rs | 562 +++ adk-rust/crates/aw-db-maintenance/Cargo.toml | 18 + adk-rust/crates/aw-db-maintenance/src/main.rs | 359 ++ adk-rust/crates/aw-health-check/Cargo.toml | 15 + adk-rust/crates/aw-health-check/src/main.rs | 495 +++ .../crates/aw-prune-local-state/Cargo.toml | 16 + .../crates/aw-prune-local-state/src/main.rs | 621 ++++ adk-rust/crates/aw-rus-healthd/Cargo.toml | 17 + adk-rust/crates/aw-rus-healthd/src/main.rs | 1032 ++++++ adk-rust/crates/aw-slo-monitor/Cargo.toml | 19 + adk-rust/crates/aw-slo-monitor/src/main.rs | 1078 ++++++ adk-rust/crates/check-aw-data/Cargo.toml | 17 + adk-rust/crates/check-aw-data/src/main.rs | 555 +++ adk-rust/crates/check-aw-full/Cargo.toml | 17 + adk-rust/crates/check-aw-full/src/main.rs | 537 +++ .../check-install-kit-vs-repo/Cargo.toml | 17 + .../check-install-kit-vs-repo/src/main.rs | 283 ++ adk-rust/crates/detmir-auto/Cargo.toml | 20 + adk-rust/crates/detmir-auto/src/main.rs | 661 ++++ adk-rust/crates/detmir-aw-client/Cargo.toml | 15 + adk-rust/crates/detmir-aw-client/src/lib.rs | 119 + adk-rust/crates/detmir-check/Cargo.toml | 17 + adk-rust/crates/detmir-check/src/main.rs | 522 +++ adk-rust/crates/detmir-core/Cargo.toml | 12 + adk-rust/crates/detmir-core/src/lib.rs | 88 + adk-rust/crates/detmir-dlp/Cargo.toml | 11 + adk-rust/crates/detmir-dlp/src/main.rs | 107 + adk-rust/crates/detmir-heal-safe/Cargo.toml | 14 + adk-rust/crates/detmir-heal-safe/src/main.rs | 321 ++ adk-rust/crates/detmir-state/Cargo.toml | 16 + adk-rust/crates/detmir-state/src/lib.rs | 209 ++ adk-rust/crates/detmir-status/.gitignore | 1 + adk-rust/crates/detmir-status/Cargo.toml | 23 + adk-rust/crates/detmir-status/README.md | 19 + .../src/bin/detmir-adk-status.rs | 3 + .../detmir-status/src/bin/detmir-status.rs | 3 + adk-rust/crates/detmir-status/src/lib.rs | 93 + .../crates/diag-and-manual-restart/Cargo.toml | 15 + .../diag-and-manual-restart/src/main.rs | 250 ++ adk-rust/crates/dlp-admin-cli/Cargo.toml | 15 + adk-rust/crates/dlp-admin-cli/src/main.rs | 373 ++ adk-rust/crates/dlp-aggregator/Cargo.toml | 18 + adk-rust/crates/dlp-aggregator/src/main.rs | 618 ++++ .../crates/dlp-case-management/Cargo.toml | 17 + .../crates/dlp-case-management/src/main.rs | 880 +++++ adk-rust/crates/dlp-cef-exporter/Cargo.toml | 16 + adk-rust/crates/dlp-cef-exporter/src/main.rs | 505 +++ adk-rust/crates/dlp-compliance/Cargo.toml | 16 + adk-rust/crates/dlp-compliance/src/main.rs | 414 +++ .../crates/dlp-content-analyzer/Cargo.toml | 14 + .../crates/dlp-content-analyzer/src/main.rs | 387 +++ adk-rust/crates/dlp-health-check/Cargo.toml | 17 + adk-rust/crates/dlp-health-check/src/main.rs | 1523 +++++++++ .../crates/dlp-influx-exporter/Cargo.toml | 16 + .../crates/dlp-influx-exporter/src/main.rs | 1096 ++++++ adk-rust/crates/dlp-policy-engine/Cargo.toml | 17 + adk-rust/crates/dlp-policy-engine/src/main.rs | 1089 ++++++ .../crates/dlp-syslog-forwarder/Cargo.toml | 15 + .../crates/dlp-syslog-forwarder/src/main.rs | 446 +++ adk-rust/crates/dlp-webhook-sender/Cargo.toml | 16 + .../crates/dlp-webhook-sender/src/main.rs | 504 +++ .../crates/extract-ioc-from-sigma/Cargo.toml | 18 + .../crates/extract-ioc-from-sigma/src/main.rs | 499 +++ adk-rust/crates/hayabusa-tools/Cargo.toml | 37 + .../hayabusa-tools/src/bin/autoprocess.rs | 300 ++ .../hayabusa-tools/src/bin/case_alert.rs | 184 + .../hayabusa-tools/src/bin/from_windows.rs | 243 ++ .../hayabusa-tools/src/bin/link_case.rs | 55 + adk-rust/crates/hayabusa-tools/src/lib.rs | 526 +++ .../crates/merge-aw-server-dbs/Cargo.toml | 16 + .../crates/merge-aw-server-dbs/src/main.rs | 376 +++ .../crates/prod-backup-restore/Cargo.toml | 17 + .../crates/prod-backup-restore/src/main.rs | 476 +++ adk-rust/crates/prod-rollout/Cargo.toml | 17 + adk-rust/crates/prod-rollout/src/main.rs | 662 ++++ adk-rust/crates/quality-gate/Cargo.toml | 14 + adk-rust/crates/quality-gate/src/main.rs | 236 ++ .../crates/rdp-worktime-report/Cargo.toml | 18 + .../crates/rdp-worktime-report/src/main.rs | 615 ++++ .../crates/rebuild-install-kit/Cargo.toml | 18 + .../crates/rebuild-install-kit/src/main.rs | 379 +++ .../crates/tsj-guardian-status/Cargo.toml | 22 + .../crates/tsj-guardian-status/src/main.rs | 2110 ++++++++++++ .../crates/tsj-guardian-watchdog/Cargo.toml | 17 + .../crates/tsj-guardian-watchdog/src/main.rs | 345 ++ .../crates/validate-install-kit/Cargo.toml | 20 + .../crates/validate-install-kit/src/main.rs | 611 ++++ .../verify-innosetup-installer/Cargo.toml | 16 + .../verify-innosetup-installer/src/main.rs | 263 ++ adk-rust/crates/worktime-api/Cargo.toml | 18 + adk-rust/crates/worktime-api/src/main.rs | 2585 ++++++++++++++ adk-rust/crates/worktime-autoheal/Cargo.toml | 15 + adk-rust/crates/worktime-autoheal/src/main.rs | 815 +++++ .../worktime-influx-exporter/Cargo.toml | 16 + .../worktime-influx-exporter/src/main.rs | 1025 ++++++ adk-rust/crates/worktime-prewarm/Cargo.toml | 16 + adk-rust/crates/worktime-prewarm/src/main.rs | 348 ++ adk-rust/crates/worktime-ui-bridge/Cargo.toml | 15 + .../crates/worktime-ui-bridge/src/main.rs | 997 ++++++ ansible/README.md | 5 +- ansible/deploy_aw_server.yml | 1131 ++++++- ansible/deploy_aw_windows.yml | 42 +- ansible/deploy_proxmox_web_gateway.yml | 155 +- ansible/deploy_tsj_guardian_bot_proxmox.yml | 149 +- ansible/group_vars/all.example.yml | 7 +- ansible/group_vars/all.yml | 2 +- ansible/group_vars/aw_windows.yml | 7 +- ansible/group_vars/proxmox-bot.example.yml | 6 + ansible/group_vars/windows.example.yml | 9 +- ansible/roles/dlp-integrations/tasks/main.yml | 111 + .../proxmox-web-gateway-index.html.j2 | 29 +- ansible/templates/proxmox-web-gateway.conf.j2 | 224 +- aw-server/aw-browser-smoke.service | 28 + aw-server/aw-browser-smoke.timer | 12 + aw-server/aw-db-maintenance.service | 8 + aw-server/aw-db-maintenance.timer | 10 + aw-server/aw-dlp-influx-exporter.timer | 4 +- aw-server/aw-hayabusa-drop.service | 2 +- aw-server/aw-health-check-wrapper.sh | 8 + aw-server/aw-prune-local-state.sh | 45 +- aw-server/aw-rus-healthd.py | 160 +- aw-server/aw-rus-healthd.timer | 7 +- aw-server/aw-server.env.example | 15 +- aw-server/aw-slo-monitor-rust-switch.conf | 3 + aw-server/aw-slo-monitor.py | 465 +++ aw-server/aw-slo-monitor.service | 16 + aw-server/aw-slo-monitor.timer | 12 + aw-server/aw-worktime-api.py | 469 ++- aw-server/aw-worktime-autoheal.timer | 7 +- aw-server/aw-worktime-influx-exporter.timer | 4 +- aw-server/aw-worktime-prewarm.service | 15 + aw-server/aw-worktime-prewarm.sh | 98 + aw-server/aw-worktime-prewarm.timer | 11 + aw-server/aw-worktime-ui-bridge.timer | 6 +- aw-server/check-aw-data-wrapper.sh | 16 + .../aw-dlp-content-analyzer.sh | 5 + aw-server/dlp-integrations/cef-exporter.timer | 7 +- .../dlp-integrations/syslog-forwarder.timer | 7 +- .../dlp-integrations/syslog_forwarder.py | 8 +- .../dlp-integrations/test_syslog_forwarder.py | 59 + .../dlp-integrations/webhook-sender.timer | 7 +- aw-server/hayabusa/aw-hayabusa.sh | 2 +- aw-server/health-check.sh | 19 +- aw-server/test_aw_rus_healthd.py | 151 + aw-server/test_aw_slo_monitor.py | 268 ++ aw-server/test_aw_worktime_api.py | 366 +- check-aw-data.sh | 124 +- check-aw-full.sh | 99 +- .../ai/test_company_intelligence_api.py | 10 +- docs/DETMIR_UNIFIED_OPERATING_MODEL_RU.md | 22 +- docs/dlp-cross-os-native-enforcement.md | 198 ++ docs/dlp-enforcement.md | 32 + docs/metagpt-aw-scout.md | 58 + docs/runbook.md | 79 +- docs/strategic-dlp-roadmap.md | 18 + docs/wiki/Server-Infrastructure.md | 21 +- docs/wiki/Windows-Collector-Suite.md | 6 +- docs/windows-deploy-startup-model.md | 16 +- install-kit-awindows-20260427-211240.tar.gz | Bin 151236 -> 185718 bytes install-kit-awindows-20260427-211240.zip | Bin 179640 -> 220469 bytes .../MANIFEST.txt | 56 +- .../README-INSTALL-KIT.txt | 1 + .../ansible/README.md | 5 +- .../ansible/deploy_aw_server.yml | 1073 +++++- .../ansible/deploy_aw_windows.yml | 42 +- .../ansible/group_vars/all.example.yml | 7 +- .../ansible/group_vars/windows.example.yml | 9 +- .../aw-server/aw-browser-smoke.service | 28 + .../aw-server/aw-browser-smoke.timer | 12 + .../aw-server/aw-rus-healthd.py | 160 +- .../aw-server/aw-rus-healthd.timer | 7 +- .../aw-server/aw-server.env.example | 15 +- .../aw-server/aw-slo-monitor.py | 465 +++ .../aw-server/aw-slo-monitor.service | 16 + .../aw-server/aw-slo-monitor.timer | 12 + .../aw-server/aw-worktime-api.py | 469 ++- .../aw-server/aw-worktime-prewarm.service | 15 + .../aw-server/aw-worktime-prewarm.sh | 98 + .../aw-server/aw-worktime-prewarm.timer | 11 + .../aw-server/health-check.sh | 173 + .../scripts/aw-webui-browser-smoke.mjs | 498 +++ .../scripts/aw-webui-browser-smoke.sh | 10 + .../scripts/check_install_kit_vs_repo.sh | 82 + .../scripts/quality-gate.sh | 72 + .../scripts/rebuild_install_kit.sh | 172 + .../scripts/validate_install_kit.sh | 106 + .../scripts/verify_innosetup_installer.sh | 73 + .../windows/AWatchRusCollectorGuardService.cs | 123 + .../windows/ActivityWatch.Windows.Common.psm1 | 156 +- .../windows/aw-collector-guard.ps1 | 681 ++++ .../windows/deploy-domain-users.ps1 | 2 +- .../windows/deploy-ensemble.ps1 | 2 +- .../windows/deploy-single-user.ps1 | 2 +- .../dlp-endpoint-signals-collector.ps1 | 160 +- .../windows/dlp-policy.example.json | 13 + .../dlp-policy.native-cross-os.example.json | 145 + .../windows/hardening-recovery.ps1 | 2 +- .../install-collector-guard-service.ps1 | 88 + .../windows/validate-deployment.ps1 | 117 +- ops/detmir-ai/ai-tool | 111 + ops/detmir-ai/detmir-ai | 46 + ops/detmir-ai/detmir-ai-profile.sh | 8 + ops/detmir-ai/detmir-auto | 169 + ops/detmir-ai/detmir-auto-rust-shadow.service | 20 + ops/detmir-ai/detmir-auto-rust-shadow.timer | 13 + ops/detmir-ai/detmir-auto.service | 17 + ops/detmir-ai/detmir-auto.timer | 12 + ops/detmir-ai/detmir-check | 4 + ops/detmir-ai/detmir-dlp | 4 + ops/detmir-ai/detmir-heal-safe | 41 + ops/detmir-ai/detmir-status | 4 + ops/detmir-ai/polli-chat | 138 + proxmox/test_tsj_guardian_bot.py | 1183 +++++++ proxmox/tsj_guardian_bot.py | 998 +++++- proxmox/tsj_guardian_watchdog.sh | 56 + scripts/aw-contour-smoke-10.10.10.2.sh | 22 + scripts/aw-contour-smoke-local.sh | 14 + scripts/aw-webui-browser-smoke.mjs | 498 +++ scripts/aw-webui-browser-smoke.sh | 23 + scripts/build_dlp_ioc_from_hayabusa.sh | 23 +- .../check_detmir_rust_release_artifacts.sh | 81 + scripts/check_install_kit_vs_repo.sh | 26 +- scripts/diag_and_manual_restart.sh | 18 + scripts/dlp-health-check.py | 565 +++- scripts/legacy/aw-prune-local-state.sh | 42 + scripts/legacy/check-aw-data.sh | 188 ++ scripts/merge_aw_server_dbs.py | 104 +- scripts/metagpt-aw-scout.sh | 172 + scripts/prod_backup_restore.sh | 23 + scripts/prod_rollout.sh | 37 + scripts/quality-gate.sh | 42 +- scripts/rdp-worktime-report.sh | 18 + scripts/rebuild_install_kit.sh | 46 +- scripts/test_dlp_health_check.py | 421 +++ scripts/validate_install_kit.sh | 27 + scripts/verify_innosetup_installer.sh | 73 + windows/AWatchRusCollectorGuardService.cs | 123 + windows/ActivityWatch.Windows.Common.psm1 | 156 +- windows/aw-collector-guard.ps1 | 681 ++++ windows/deploy-domain-users.ps1 | 2 +- windows/deploy-ensemble.ps1 | 2 +- windows/deploy-single-user.ps1 | 2 +- windows/dlp-endpoint-signals-collector.ps1 | 160 +- windows/dlp-policy-client.ps1 | 4 +- windows/dlp-policy.example.json | 13 + .../dlp-policy.native-cross-os.example.json | 145 + windows/file-operations-collector.ps1 | 28 +- windows/hardening-recovery.ps1 | 2 +- windows/install-collector-guard-service.ps1 | 88 + .../innosetup/AWatch-rus-InnoSetup.iss | 4 + .../innosetup/AWatch-rus-InstallKit.exe | 4 +- .../innosetup-rdp-package-filelist.md | 5 + windows/validate-deployment.ps1 | 117 +- 263 files changed, 51678 insertions(+), 718 deletions(-) create mode 100644 adk-rust/.gitignore create mode 100644 adk-rust/Cargo.lock create mode 100644 adk-rust/Cargo.toml create mode 100644 adk-rust/README.md create mode 100644 adk-rust/RUNBOOK.md create mode 100644 adk-rust/crates/aw-browser-smoke/Cargo.toml create mode 100644 adk-rust/crates/aw-browser-smoke/src/main.rs create mode 100644 adk-rust/crates/aw-contour-smoke/Cargo.toml create mode 100644 adk-rust/crates/aw-contour-smoke/src/main.rs create mode 100644 adk-rust/crates/aw-db-health/Cargo.toml create mode 100644 adk-rust/crates/aw-db-health/src/main.rs create mode 100644 adk-rust/crates/aw-db-maintenance/Cargo.toml create mode 100644 adk-rust/crates/aw-db-maintenance/src/main.rs create mode 100644 adk-rust/crates/aw-health-check/Cargo.toml create mode 100644 adk-rust/crates/aw-health-check/src/main.rs create mode 100644 adk-rust/crates/aw-prune-local-state/Cargo.toml create mode 100644 adk-rust/crates/aw-prune-local-state/src/main.rs create mode 100644 adk-rust/crates/aw-rus-healthd/Cargo.toml create mode 100644 adk-rust/crates/aw-rus-healthd/src/main.rs create mode 100644 adk-rust/crates/aw-slo-monitor/Cargo.toml create mode 100644 adk-rust/crates/aw-slo-monitor/src/main.rs create mode 100644 adk-rust/crates/check-aw-data/Cargo.toml create mode 100644 adk-rust/crates/check-aw-data/src/main.rs create mode 100644 adk-rust/crates/check-aw-full/Cargo.toml create mode 100644 adk-rust/crates/check-aw-full/src/main.rs create mode 100644 adk-rust/crates/check-install-kit-vs-repo/Cargo.toml create mode 100644 adk-rust/crates/check-install-kit-vs-repo/src/main.rs create mode 100644 adk-rust/crates/detmir-auto/Cargo.toml create mode 100644 adk-rust/crates/detmir-auto/src/main.rs create mode 100644 adk-rust/crates/detmir-aw-client/Cargo.toml create mode 100644 adk-rust/crates/detmir-aw-client/src/lib.rs create mode 100644 adk-rust/crates/detmir-check/Cargo.toml create mode 100644 adk-rust/crates/detmir-check/src/main.rs create mode 100644 adk-rust/crates/detmir-core/Cargo.toml create mode 100644 adk-rust/crates/detmir-core/src/lib.rs create mode 100644 adk-rust/crates/detmir-dlp/Cargo.toml create mode 100644 adk-rust/crates/detmir-dlp/src/main.rs create mode 100644 adk-rust/crates/detmir-heal-safe/Cargo.toml create mode 100644 adk-rust/crates/detmir-heal-safe/src/main.rs create mode 100644 adk-rust/crates/detmir-state/Cargo.toml create mode 100644 adk-rust/crates/detmir-state/src/lib.rs create mode 100644 adk-rust/crates/detmir-status/.gitignore create mode 100644 adk-rust/crates/detmir-status/Cargo.toml create mode 100644 adk-rust/crates/detmir-status/README.md create mode 100644 adk-rust/crates/detmir-status/src/bin/detmir-adk-status.rs create mode 100644 adk-rust/crates/detmir-status/src/bin/detmir-status.rs create mode 100644 adk-rust/crates/detmir-status/src/lib.rs create mode 100644 adk-rust/crates/diag-and-manual-restart/Cargo.toml create mode 100644 adk-rust/crates/diag-and-manual-restart/src/main.rs create mode 100644 adk-rust/crates/dlp-admin-cli/Cargo.toml create mode 100644 adk-rust/crates/dlp-admin-cli/src/main.rs create mode 100644 adk-rust/crates/dlp-aggregator/Cargo.toml create mode 100644 adk-rust/crates/dlp-aggregator/src/main.rs create mode 100644 adk-rust/crates/dlp-case-management/Cargo.toml create mode 100644 adk-rust/crates/dlp-case-management/src/main.rs create mode 100644 adk-rust/crates/dlp-cef-exporter/Cargo.toml create mode 100644 adk-rust/crates/dlp-cef-exporter/src/main.rs create mode 100644 adk-rust/crates/dlp-compliance/Cargo.toml create mode 100644 adk-rust/crates/dlp-compliance/src/main.rs create mode 100644 adk-rust/crates/dlp-content-analyzer/Cargo.toml create mode 100644 adk-rust/crates/dlp-content-analyzer/src/main.rs create mode 100644 adk-rust/crates/dlp-health-check/Cargo.toml create mode 100644 adk-rust/crates/dlp-health-check/src/main.rs create mode 100644 adk-rust/crates/dlp-influx-exporter/Cargo.toml create mode 100644 adk-rust/crates/dlp-influx-exporter/src/main.rs create mode 100644 adk-rust/crates/dlp-policy-engine/Cargo.toml create mode 100644 adk-rust/crates/dlp-policy-engine/src/main.rs create mode 100644 adk-rust/crates/dlp-syslog-forwarder/Cargo.toml create mode 100644 adk-rust/crates/dlp-syslog-forwarder/src/main.rs create mode 100644 adk-rust/crates/dlp-webhook-sender/Cargo.toml create mode 100644 adk-rust/crates/dlp-webhook-sender/src/main.rs create mode 100644 adk-rust/crates/extract-ioc-from-sigma/Cargo.toml create mode 100644 adk-rust/crates/extract-ioc-from-sigma/src/main.rs create mode 100644 adk-rust/crates/hayabusa-tools/Cargo.toml create mode 100644 adk-rust/crates/hayabusa-tools/src/bin/autoprocess.rs create mode 100644 adk-rust/crates/hayabusa-tools/src/bin/case_alert.rs create mode 100644 adk-rust/crates/hayabusa-tools/src/bin/from_windows.rs create mode 100644 adk-rust/crates/hayabusa-tools/src/bin/link_case.rs create mode 100644 adk-rust/crates/hayabusa-tools/src/lib.rs create mode 100644 adk-rust/crates/merge-aw-server-dbs/Cargo.toml create mode 100644 adk-rust/crates/merge-aw-server-dbs/src/main.rs create mode 100644 adk-rust/crates/prod-backup-restore/Cargo.toml create mode 100644 adk-rust/crates/prod-backup-restore/src/main.rs create mode 100644 adk-rust/crates/prod-rollout/Cargo.toml create mode 100644 adk-rust/crates/prod-rollout/src/main.rs create mode 100644 adk-rust/crates/quality-gate/Cargo.toml create mode 100644 adk-rust/crates/quality-gate/src/main.rs create mode 100644 adk-rust/crates/rdp-worktime-report/Cargo.toml create mode 100644 adk-rust/crates/rdp-worktime-report/src/main.rs create mode 100644 adk-rust/crates/rebuild-install-kit/Cargo.toml create mode 100644 adk-rust/crates/rebuild-install-kit/src/main.rs create mode 100644 adk-rust/crates/tsj-guardian-status/Cargo.toml create mode 100644 adk-rust/crates/tsj-guardian-status/src/main.rs create mode 100644 adk-rust/crates/tsj-guardian-watchdog/Cargo.toml create mode 100644 adk-rust/crates/tsj-guardian-watchdog/src/main.rs create mode 100644 adk-rust/crates/validate-install-kit/Cargo.toml create mode 100644 adk-rust/crates/validate-install-kit/src/main.rs create mode 100644 adk-rust/crates/verify-innosetup-installer/Cargo.toml create mode 100644 adk-rust/crates/verify-innosetup-installer/src/main.rs create mode 100644 adk-rust/crates/worktime-api/Cargo.toml create mode 100644 adk-rust/crates/worktime-api/src/main.rs create mode 100644 adk-rust/crates/worktime-autoheal/Cargo.toml create mode 100644 adk-rust/crates/worktime-autoheal/src/main.rs create mode 100644 adk-rust/crates/worktime-influx-exporter/Cargo.toml create mode 100644 adk-rust/crates/worktime-influx-exporter/src/main.rs create mode 100644 adk-rust/crates/worktime-prewarm/Cargo.toml create mode 100644 adk-rust/crates/worktime-prewarm/src/main.rs create mode 100644 adk-rust/crates/worktime-ui-bridge/Cargo.toml create mode 100644 adk-rust/crates/worktime-ui-bridge/src/main.rs create mode 100644 aw-server/aw-browser-smoke.service create mode 100644 aw-server/aw-browser-smoke.timer create mode 100644 aw-server/aw-db-maintenance.service create mode 100644 aw-server/aw-db-maintenance.timer create mode 100644 aw-server/aw-health-check-wrapper.sh create mode 100644 aw-server/aw-slo-monitor-rust-switch.conf create mode 100644 aw-server/aw-slo-monitor.py create mode 100644 aw-server/aw-slo-monitor.service create mode 100644 aw-server/aw-slo-monitor.timer create mode 100644 aw-server/aw-worktime-prewarm.service create mode 100644 aw-server/aw-worktime-prewarm.sh create mode 100644 aw-server/aw-worktime-prewarm.timer create mode 100644 aw-server/check-aw-data-wrapper.sh create mode 100644 aw-server/dlp-integrations/test_syslog_forwarder.py create mode 100644 aw-server/test_aw_slo_monitor.py create mode 100644 docs/dlp-cross-os-native-enforcement.md create mode 100644 docs/metagpt-aw-scout.md create mode 100644 install-kit-awindows-20260427-211240/aw-server/aw-browser-smoke.service create mode 100644 install-kit-awindows-20260427-211240/aw-server/aw-browser-smoke.timer create mode 100644 install-kit-awindows-20260427-211240/aw-server/aw-slo-monitor.py create mode 100644 install-kit-awindows-20260427-211240/aw-server/aw-slo-monitor.service create mode 100644 install-kit-awindows-20260427-211240/aw-server/aw-slo-monitor.timer create mode 100644 install-kit-awindows-20260427-211240/aw-server/aw-worktime-prewarm.service create mode 100644 install-kit-awindows-20260427-211240/aw-server/aw-worktime-prewarm.sh create mode 100644 install-kit-awindows-20260427-211240/aw-server/aw-worktime-prewarm.timer create mode 100644 install-kit-awindows-20260427-211240/aw-server/health-check.sh create mode 100644 install-kit-awindows-20260427-211240/scripts/aw-webui-browser-smoke.mjs create mode 100644 install-kit-awindows-20260427-211240/scripts/aw-webui-browser-smoke.sh create mode 100644 install-kit-awindows-20260427-211240/scripts/check_install_kit_vs_repo.sh create mode 100644 install-kit-awindows-20260427-211240/scripts/quality-gate.sh create mode 100644 install-kit-awindows-20260427-211240/scripts/rebuild_install_kit.sh create mode 100644 install-kit-awindows-20260427-211240/scripts/validate_install_kit.sh create mode 100644 install-kit-awindows-20260427-211240/scripts/verify_innosetup_installer.sh create mode 100644 install-kit-awindows-20260427-211240/windows/AWatchRusCollectorGuardService.cs create mode 100644 install-kit-awindows-20260427-211240/windows/aw-collector-guard.ps1 create mode 100644 install-kit-awindows-20260427-211240/windows/dlp-policy.native-cross-os.example.json create mode 100644 install-kit-awindows-20260427-211240/windows/install-collector-guard-service.ps1 create mode 100644 ops/detmir-ai/ai-tool create mode 100644 ops/detmir-ai/detmir-ai create mode 100644 ops/detmir-ai/detmir-ai-profile.sh create mode 100644 ops/detmir-ai/detmir-auto create mode 100644 ops/detmir-ai/detmir-auto-rust-shadow.service create mode 100644 ops/detmir-ai/detmir-auto-rust-shadow.timer create mode 100644 ops/detmir-ai/detmir-auto.service create mode 100644 ops/detmir-ai/detmir-auto.timer create mode 100644 ops/detmir-ai/detmir-check create mode 100644 ops/detmir-ai/detmir-dlp create mode 100644 ops/detmir-ai/detmir-heal-safe create mode 100644 ops/detmir-ai/detmir-status create mode 100644 ops/detmir-ai/polli-chat create mode 100644 proxmox/tsj_guardian_watchdog.sh create mode 100644 scripts/aw-webui-browser-smoke.mjs create mode 100644 scripts/aw-webui-browser-smoke.sh create mode 100644 scripts/check_detmir_rust_release_artifacts.sh create mode 100644 scripts/legacy/aw-prune-local-state.sh create mode 100644 scripts/legacy/check-aw-data.sh create mode 100644 scripts/metagpt-aw-scout.sh create mode 100644 scripts/test_dlp_health_check.py create mode 100644 scripts/verify_innosetup_installer.sh create mode 100644 windows/AWatchRusCollectorGuardService.cs create mode 100644 windows/aw-collector-guard.ps1 create mode 100644 windows/dlp-policy.native-cross-os.example.json create mode 100644 windows/install-collector-guard-service.ps1 diff --git a/adk-rust/.gitignore b/adk-rust/.gitignore new file mode 100644 index 0000000..55509a8 --- /dev/null +++ b/adk-rust/.gitignore @@ -0,0 +1,4 @@ +target/ +.env +.env.* +!.env.example diff --git a/adk-rust/Cargo.lock b/adk-rust/Cargo.lock new file mode 100644 index 0000000..4fca3cf --- /dev/null +++ b/adk-rust/Cargo.lock @@ -0,0 +1,3007 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adk-core" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "257e0d5a39ef6130bc5fe7f0a8d9c1b28a3fbb690f28854525f9493ebf3df4f5" +dependencies = [ + "async-trait", + "chrono", + "futures", + "serde", + "serde_json", + "thiserror", + "tokio", + "tracing", + "uuid", +] + +[[package]] +name = "adk-rust" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79ce7ff944c94f27123ecfcd137ce4a8601a81409af9c7979b770fa0d1ffc43c" +dependencies = [ + "adk-core", + "anyhow", + "async-trait", + "futures", + "serde", + "serde_json", + "tokio", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "ascii" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "aw-browser-smoke" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "tempfile", +] + +[[package]] +name = "aw-contour-smoke" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "clap", + "reqwest", + "serde_json", + "tempfile", + "url", +] + +[[package]] +name = "aw-db-health" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "clap", + "rusqlite", + "serde", + "serde_json", + "tempfile", +] + +[[package]] +name = "aw-db-maintenance" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "clap", + "rusqlite", + "serde", + "serde_json", + "tempfile", +] + +[[package]] +name = "aw-health-check" +version = "0.1.0" +dependencies = [ + "anyhow", + "reqwest", + "serde_json", + "tempfile", +] + +[[package]] +name = "aw-prune-local-state" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "serde", + "serde_json", + "tempfile", +] + +[[package]] +name = "aw-rus-healthd" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "clap", + "detmir-core", + "reqwest", + "serde", + "serde_json", + "tempfile", +] + +[[package]] +name = "aw-slo-monitor" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "clap", + "reqwest", + "serde", + "serde_json", + "tempfile", + "urlencoding", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "check-aw-data" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "clap", + "reqwest", + "serde_json", + "tempfile", +] + +[[package]] +name = "check-aw-full" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "clap", + "reqwest", + "serde_json", + "tempfile", +] + +[[package]] +name = "check-install-kit-vs-repo" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "serde", + "serde_json", + "sha2", + "tempfile", +] + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "chunked_transfer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "detmir-auto" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "clap", + "detmir-core", + "detmir-state", + "fs2", + "serde", + "serde_json", + "tempfile", +] + +[[package]] +name = "detmir-aw-client" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "detmir-core", + "reqwest", + "serde", + "serde_json", +] + +[[package]] +name = "detmir-check" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "clap", + "detmir-aw-client", + "detmir-core", + "reqwest", + "serde", + "serde_json", +] + +[[package]] +name = "detmir-core" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "serde", +] + +[[package]] +name = "detmir-dlp" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", +] + +[[package]] +name = "detmir-heal-safe" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "detmir-core", + "serde", + "serde_json", +] + +[[package]] +name = "detmir-state" +version = "0.1.0" +dependencies = [ + "anyhow", + "detmir-core", + "serde", + "serde_json", + "tempfile", +] + +[[package]] +name = "detmir-status" +version = "0.1.0" +dependencies = [ + "adk-rust", + "anyhow", + "clap", + "detmir-state", + "serde", + "serde_json", +] + +[[package]] +name = "diag-and-manual-restart" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "clap", + "tempfile", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dlp-admin-cli" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "clap", + "reqwest", + "serde_json", + "urlencoding", +] + +[[package]] +name = "dlp-aggregator" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "clap", + "detmir-aw-client", + "rusqlite", + "serde", + "serde_json", + "tempfile", + "urlencoding", +] + +[[package]] +name = "dlp-case-management" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "clap", + "rusqlite", + "serde_json", + "sha2", + "tiny_http", + "urlencoding", +] + +[[package]] +name = "dlp-cef-exporter" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "clap", + "reqwest", + "serde", + "serde_json", + "tempfile", +] + +[[package]] +name = "dlp-compliance" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "clap", + "reqwest", + "serde", + "serde_json", + "urlencoding", +] + +[[package]] +name = "dlp-content-analyzer" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "regex", + "serde", + "serde_json", +] + +[[package]] +name = "dlp-health-check" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "clap", + "detmir-core", + "reqwest", + "serde", + "serde_json", + "tempfile", +] + +[[package]] +name = "dlp-influx-exporter" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "clap", + "reqwest", + "serde", + "serde_json", + "urlencoding", +] + +[[package]] +name = "dlp-policy-engine" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "clap", + "rusqlite", + "serde_json", + "sha2", + "tiny_http", + "urlencoding", +] + +[[package]] +name = "dlp-syslog-forwarder" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "clap", + "reqwest", + "serde", + "serde_json", +] + +[[package]] +name = "dlp-webhook-sender" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "clap", + "reqwest", + "serde", + "serde_json", + "tempfile", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "extract-ioc-from-sigma" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "regex", + "serde", + "serde_json", + "serde_yaml", + "tempfile", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + +[[package]] +name = "hayabusa-tools" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "clap", + "fs2", + "regex", + "reqwest", + "serde", + "serde_json", + "tempfile", + "urlencoding", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "log" +version = "0.4.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "memchr" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" + +[[package]] +name = "merge-aw-server-dbs" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "rusqlite", + "serde_json", + "tempfile", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prod-backup-restore" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "clap", + "serde", + "serde_json", + "tempfile", +] + +[[package]] +name = "prod-rollout" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "clap", + "serde", + "serde_json", + "tempfile", +] + +[[package]] +name = "quality-gate" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "tempfile", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rdp-worktime-report" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "clap", + "reqwest", + "serde", + "serde_json", + "tempfile", +] + +[[package]] +name = "rebuild-install-kit" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "flate2", + "sha2", + "tar", + "tempfile", + "zip", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tiny_http" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82" +dependencies = [ + "ascii", + "chunked_transfer", + "httpdate", + "log", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tsj-guardian-status" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "clap", + "detmir-state", + "serde", + "serde_json", + "tempfile", +] + +[[package]] +name = "tsj-guardian-watchdog" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "detmir-core", + "serde", + "serde_json", + "tempfile", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "validate-install-kit" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "flate2", + "serde", + "serde_json", + "sha2", + "tar", + "tempfile", + "zip", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "verify-innosetup-installer" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "serde", + "serde_json", + "tempfile", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen 0.46.0", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "worktime-api" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "clap", + "reqwest", + "serde", + "serde_json", + "tiny_http", + "url", + "urlencoding", +] + +[[package]] +name = "worktime-autoheal" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "clap", + "reqwest", + "serde", + "serde_json", +] + +[[package]] +name = "worktime-influx-exporter" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "clap", + "reqwest", + "serde", + "serde_json", + "urlencoding", +] + +[[package]] +name = "worktime-prewarm" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "clap", + "reqwest", + "serde", + "serde_json", + "urlencoding", +] + +[[package]] +name = "worktime-ui-bridge" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "clap", + "reqwest", + "serde", + "serde_json", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap", + "memchr", + "thiserror", + "zopfli", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] diff --git a/adk-rust/Cargo.toml b/adk-rust/Cargo.toml new file mode 100644 index 0000000..507acb1 --- /dev/null +++ b/adk-rust/Cargo.toml @@ -0,0 +1,79 @@ +[workspace] +resolver = "3" +members = [ + "crates/detmir-auto", + "crates/detmir-aw-client", + "crates/aw-db-health", + "crates/aw-db-maintenance", + "crates/aw-health-check", + "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/aw-slo-monitor", + "crates/aw-rus-healthd", + "crates/detmir-check", + "crates/detmir-core", + "crates/dlp-health-check", + "crates/dlp-content-analyzer", + "crates/dlp-admin-cli", + "crates/dlp-policy-engine", + "crates/dlp-case-management", + "crates/dlp-compliance", + "crates/hayabusa-tools", + "crates/dlp-cef-exporter", + "crates/dlp-aggregator", + "crates/dlp-influx-exporter", + "crates/worktime-influx-exporter", + "crates/worktime-autoheal", + "crates/worktime-api", + "crates/worktime-prewarm", + "crates/worktime-ui-bridge", + "crates/dlp-syslog-forwarder", + "crates/dlp-webhook-sender", + "crates/detmir-dlp", + "crates/detmir-heal-safe", + "crates/detmir-status", + "crates/detmir-state", + "crates/tsj-guardian-status", + "crates/tsj-guardian-watchdog", +] + +[workspace.package] +edition = "2024" +rust-version = "1.85" +license = "Apache-2.0" +publish = false + +[workspace.dependencies] +adk-rust = { version = "0.9.1", default-features = false } +anyhow = "1" +chrono = { version = "0.4", default-features = false, features = ["clock", "serde", "std"] } +clap = { version = "4", features = ["derive", "env"] } +detmir-aw-client = { path = "crates/detmir-aw-client" } +detmir-core = { path = "crates/detmir-core" } +detmir-state = { path = "crates/detmir-state" } +fs2 = "0.4" +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" +sha2 = "0.10" +tempfile = "3" +tiny_http = "0.12" +url = "2" +urlencoding = "2" diff --git a/adk-rust/README.md b/adk-rust/README.md new file mode 100644 index 0000000..9075563 --- /dev/null +++ b/adk-rust/README.md @@ -0,0 +1,89 @@ +# 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/` - 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. +- `check-aw-full` - read-only local AW/RDP full check replacement for the + legacy shell helper. +- `dlp-aggregator` - AW server DLP warehouse aggregator replacement. +- `dlp-influx-exporter` - AW server DLP InfluxDB line protocol exporter replacement. +- `worktime-autoheal` - AW server worktime autoheal and backfill replacement. +- `worktime-influx-exporter` - AW server worktime InfluxDB line protocol exporter replacement. +- `worktime-prewarm` - AW server worktime report cache prewarm replacement. +- `worktime-ui-bridge` - AW server worktime sessions to AFK/window bridge replacement. +- `dlp-syslog-forwarder` - AW server DLP syslog integration replacement. +- `dlp-webhook-sender` - AW server DLP webhook integration replacement. +- `dlp-cef-exporter` - AW server DLP CEF/syslog exporter replacement. +- `extract-ioc-from-sigma` - offline Sigma/Hayabusa IOC export replacement + used by the DLP IOC preload wrapper. +- `merge-aw-server-dbs` - ActivityWatch SQLite DB merge replacement used by + legacy root DB recovery/deploy tooling. +- `prod-backup-restore` - safe planner/checker for the destructive production + backup-restore flow; apply remains explicit legacy-only at this stage. +- `prod-rollout` - safe planner/orchestrator for production AW server/Windows + rollout; normal script runs are plan-only and real rollout requires + explicit `--apply`. +- `rdp-worktime-report` - local RDP worktime CSV/JSON report helper + replacement for the legacy shell/Python script. +- `aw-contour-smoke` - Rust replacement for the Proxmox-side DetMir contour + smoke checks, used through a Rust-first project wrapper. +- `diag-and-manual-restart` - Rust replacement for the AW/DLP diagnostic and + explicit manual restart helper, with conservative no-restart healthy path. +- `aw-browser-smoke` - Rust launcher for the browser smoke test; Playwright + remains the execution engine and the legacy Node script remains fallback. +- `detmir-status` - read-only DetMir state normalizer with text, JSON, and ADK + `Content` output. Also builds `detmir-adk-status` as a compatibility binary. + +## Migration Runbook + +Use `RUNBOOK.md` as the operational plan for replacing Python and shell modules +with Rust. It defines migration phases, safety gates, rollback rules, and the +order in which DetMir modules should be moved. + +## Commands + +```bash +cd adk-rust +cargo fmt --all +cargo check --workspace +cargo build --release --workspace +``` + +Run the current status module: + +```bash +cd adk-rust +cargo run -p detmir-status -- --json +cargo run -p detmir-status -- --adk-json +cargo run -p detmir-status -- status --json +``` + +## Migration Rules + +- New replacements go under `crates/` and are added to workspace `members`. +- Keep modules standalone: config comes from files, environment variables, or + explicit CLI flags; no laptop-only assumptions. +- Default to read-only behavior first; add mutation/recovery paths only with + tests and explicit operator-safe failure modes. +- Do not embed secrets in binaries, source files, or examples. +- Every module should expose machine-readable JSON output where practical. diff --git a/adk-rust/RUNBOOK.md b/adk-rust/RUNBOOK.md new file mode 100644 index 0000000..362af88 --- /dev/null +++ b/adk-rust/RUNBOOK.md @@ -0,0 +1,1510 @@ +# Runbook: перевод DetMir на Rust / ADK-Rust + +Дата фиксации: `2026-06-01` + +Цель: постепенно заменить хрупкие Python/shell operational scripts на +самодостаточные Rust-бинарники, не ломая текущий production-контур DetMir. + +Этот runbook является рабочим планом миграции. Если фактический runtime +расходится с этим документом, сначала фиксируется baseline, затем обновляется +runbook. + +Текущий resume snapshot проекта хранится в +`.ai/runtime/detmir-current-session.md`. Перед продолжением миграции после +перерыва или context reset сначала читать его, затем `RUNBOOK.md` и +`/home/igor/.codex/skills/detmir-rust-migration/references/current-state.md`. + +## 1. Целевое состояние + +В результате миграции DetMir должен иметь: + +- единый Rust workspace в `adk-rust/`; +- один standalone-бинарник или crate на каждую операционную функцию; +- стабильные machine-readable JSON-контракты вместо парсинга human text; +- одинаковые exit codes для старых и новых команд; +- systemd units/timers, не зависящие от ноутбука, venv, pip и локальных путей; +- безопасный auto-heal с dry-run, lock, cooldown, allowlist и rollback; +- ADK-compatible envelopes там, где вывод передается агентам/боту/LLM. + +Rust не должен использоваться как самоцель. Если компонент надежнее оставить в +Ansible, PowerShell или Playwright, он остается там до появления практической +причины для переноса. + +## 2. Текущая отправная точка + +Уже создано: + +- `adk-rust/Cargo.toml` - workspace; +- `adk-rust/crates/detmir-status` - read-only status module with text, JSON and ADK JSON output; +- `/usr/local/bin/detmir-status` на Proxmox, проверен против + `/var/lib/detmir-ai/latest-state.json`; +- `/usr/local/bin/detmir-adk-status` на Proxmox, проверен против + `/var/lib/detmir-ai/latest-state.json` как compatibility binary. + +Ключевые текущие legacy-компоненты: + +| Компонент | Текущая реализация | Риск переноса | +|---|---|---| +| `detmir-status` | Rust binary + thin compatibility wrapper | выполнено | +| `detmir-check` | Rust binary + thin compatibility wrapper | выполнено | +| `detmir-dlp` | Rust SSH wrapper + thin compatibility wrapper | выполнено | +| `detmir-auto` | Rust production via systemd drop-in + legacy script retained | switched | +| `detmir-heal-safe` | Rust binary deployed + legacy script retained | switched for Rust auto | +| `tsj_guardian_watchdog.sh` | Rust service via systemd drop-in + legacy shell retained | switched | +| Telegram `/status`/decision backend | Rust helper + permanent Python bot runtime | backend only | +| `aw-rus-healthd.py` | Rust production via systemd drop-in + legacy Python retained | switched | +| `scripts/dlp-health-check.py` | Rust production binary + legacy Python backup retained | switched | +| DLP aggregator | Rust production via systemd drop-in + legacy Python retained | switched | +| AW DLP Influx exporter | Rust production via systemd drop-in + legacy Python retained | switched | +| AW worktime Influx exporter | Rust production via systemd drop-in + legacy Python retained | switched | +| AW worktime prewarm | Rust production via systemd drop-in + legacy shell retained | switched | +| AW worktime API | Rust production via systemd drop-in + legacy Python retained | switched | +| DLP syslog forwarder | Rust production via systemd drop-in + legacy Python retained | switched | +| DLP webhook sender | Rust production via systemd drop-in + legacy Python retained | switched | +| DLP CEF exporter | Rust production via systemd drop-in + legacy Python retained | switched | +| `tsj_guardian_bot.py` | Permanent Python Telegram runtime | не переносить | +| AW remaining worktime modules | mostly Rust production; inspect leftovers before next item | средний | +| deploy/install scripts | shell/Ansible/PowerShell | высокий, переносить последними | + +## 3. Архитектура workspace + +Целевая структура: + +```text +adk-rust/ + Cargo.toml + Cargo.lock + RUNBOOK.md + crates/ + detmir-core/ + detmir-aw-client/ + detmir-systemd/ + detmir-state/ + detmir-status/ + detmir-check/ + detmir-dlp/ + detmir-auto/ + detmir-heal-safe/ + tsj-guardian-status/ + tsj-guardian-watchdog/ +``` + +Назначение shared crates: + +| Crate | Назначение | +|---|---| +| `detmir-core` | ошибки, CLI output, timeouts, retry, exit codes, config loading | +| `detmir-aw-client` | ActivityWatch HTTP API, buckets, events, timestamps | +| `detmir-systemd` | безопасный wrapper вокруг `systemctl`, allowlist, dry-run | +| `detmir-state` | чтение/запись `/var/lib/detmir-ai`, atomic writes, retention | +| `detmir-report` | Markdown/JSON reports, ADK envelopes, report bundle assembly | + +Бинарные модули должны быть маленькими и собираться из shared crates. Логику не +дублировать между `check`, `status`, `auto` и ботом. + +## 4. Общие контракты для каждого Rust-модуля + +Каждый новый модуль обязан иметь: + +- `--json` для машинного вывода; +- `--pretty` или обычный text output для человека, если команда операторская; +- `--config ` либо documented env vars; +- `--dry-run` для любых действий, которые меняют состояние; +- `--timeout` там, где есть сеть/SSH/HTTP; +- `--no-color` если вывод может попадать в systemd/Telegram/report; +- стабильные exit codes: + - `0` - OK; + - `1` - usage/config/runtime error; + - `2` - проверка выполнена, но состояние WARN/FAIL; + - `3` - action запрещен safety policy; +- structured logs через `tracing` для daemon/action-команд; +- unit tests на чистую логику; +- fixture tests на реальные JSON samples; +- README с примером локального и серверного запуска. + +## 5. Safety policy + +Нельзя сразу заменять управляющие скрипты без shadow-mode. + +Обязательные правила: + +- сначала read-only parity, потом mutation; +- старый и новый модуль должны некоторое время работать параллельно; +- новый модуль не получает право писать state, рестартить сервисы или удалять + файлы до прохождения acceptance gates; +- все state writes только через atomic temp file + rename; +- heal-команды только по allowlist units; +- no implicit sudo: если нужен `sudo`, он должен быть явно виден в deploy/unit; +- lock file обязателен для `auto` и `heal`; +- cooldown обязателен для restart/start actions; +- каждый risky action пишет audit entry; +- rollback должен быть одной командой systemd/service symlink switch. + +## 6. Фазы миграции + +### Phase 0. Baseline и фиксация контрактов + +Цель: перед переносом зафиксировать, что именно считается корректным поведением. + +Действия: + +1. Снять текущие outputs legacy-команд: + - `detmir-status`; + - `detmir-check --json`; + - `detmir-dlp`; + - `detmir-auto` на зеленом контуре; + - `detmir-heal-safe` в dry-safe сценарии или на mock host. +2. Сохранить sanitized fixtures в `adk-rust/fixtures/`. +3. Зафиксировать JSON schemas: + - status summary; + - DetMir check result; + - DLP health result; + - auto run state; + - heal action log. +4. Зафиксировать exit code matrix старых команд. +5. Зафиксировать runtime paths: + - `/var/lib/detmir-ai`; + - `/usr/local/bin`; + - relevant systemd units/timers. + +Гейт готовности: + +```bash +cd adk-rust +cargo fmt --all -- --check +cargo check --workspace +``` + +Результат фазы: миграция не начинается вслепую; есть baseline для сравнения. + +### Phase 1. Rust foundation + +Цель: создать shared crates, чтобы не плодить разные реализации HTTP, времени, +ошибок, JSON и systemd. + +Модули: + +- `detmir-core`; +- `detmir-state`; +- `detmir-aw-client`; +- `detmir-systemd`. + +Минимальная функциональность: + +- RFC3339/UTC timestamp parsing; +- HTTP JSON client with timeout/retry; +- TCP check; +- atomic JSON write; +- retention cleanup; +- exit-code helper; +- common `StatusLevel`: `OK`, `WARN`, `FAIL`; +- bucket mode enum: `fresh`, `inactive_ok`, `event_driven`; +- systemd read-only checks; +- systemd action allowlist, но без включения mutation by default. + +Гейт: + +```bash +cd adk-rust +cargo fmt --all -- --check +cargo clippy --workspace --all-targets -- -D warnings +cargo test --workspace +``` + +### Phase 2. Read-only operator modules + +Цель: заменить самые безопасные команды, не меняющие состояние. + +Порядок: + +1. `detmir-status` + - объединить с совместимым `detmir-adk-status`; + - читать `/var/lib/detmir-ai/latest-state.json`; + - выдавать text, JSON, ADK JSON; + - заменить shell wrapper после parity. +2. `detmir-check` + - перенести Python checks в Rust; + - сохранить семантику `FRESH`, `INACTIVE`, `EVENT-DRIVEN`, `STALE`, `DEAD`; + - сохранить `summary.bucket_ok/stale/dead/service_failures/service_warnings`; + - не менять thresholds без отдельного решения. +3. `detmir-dlp` + - сначала переписать SSH wrapper; + - затем отдельно переносить `dlp-health-check.py` на AW server. + +Shadow-mode для `detmir-check`: + +```bash +detmir-check --json > /tmp/detmir-check.old.json +adk-rust/target/release/detmir-check --json > /tmp/detmir-check.new.json +jq -S '.summary' /tmp/detmir-check.old.json > /tmp/old.summary.json +jq -S '.summary' /tmp/detmir-check.new.json > /tmp/new.summary.json +diff -u /tmp/old.summary.json /tmp/new.summary.json +``` + +Гейт: + +- old/new agree on `ok`; +- old/new agree on summary counters; +- new module returns same exit code class; +- no network side effects; +- server run succeeds from systemd-like environment. + +### Phase 3. State/report orchestration + +Цель: заменить `detmir-auto` без немедленной замены heal. + +Первый Rust `detmir-auto` должен: + +- брать lock; +- создавать run directory; +- запускать `detmir-check` и `detmir-dlp`; +- писать `summary-before.json`; +- писать `state-*.json`; +- обновлять symlinks: + - `latest-run`; + - `latest-state.json`; + - `latest-report.md`; +- выполнять retention cleanup; +- уметь `--no-heal`; +- по умолчанию на первом этапе не выполнять heal, а только писать + `would_heal=true`. + +Pollinations/LLM report: + +- не должен быть hard dependency для статуса; +- failure LLM report не должен ломать state update; +- raw JSON summary всегда должен сохраняться даже при ошибке report generation. + +Гейт: + +- Rust `detmir-auto --no-heal` дает такой же `latest-state.json` по смыслу; +- symlink updates atomic enough for readers; +- timer можно прогнать вручную без изменения heal behavior; +- `detmir-status` видит новое state без изменений. + +Текущее состояние: + +- `detmir-auto-rust` установлен на Proxmox как shadow binary + `/usr/local/bin/detmir-auto-rust`; +- `detmir-auto-rust` умеет вызывать Rust heal через `--enable-heal` или + `DETMIR_AUTO_HEAL=1`, но shadow unit явно запускается с `--no-heal`; +- production `detmir-auto.service` переключен на `/usr/local/bin/detmir-auto-rust` + через drop-in `/etc/systemd/system/detmir-auto.service.d/20-rust-switch.conf`; +- legacy `/usr/local/bin/detmir-auto` сохранен для rollback; +- отдельный `detmir-auto-rust-shadow.timer` отключен после успешного + production timer observation; unit-файлы и shadow state сохранены; +- shadow пишет только в `/var/lib/detmir-ai/shadow/detmir-auto-rust`; +- `detmir-auto-rust-shadow.service` использует `SuccessExitStatus=2`, чтобы + найденный shadow FAIL фиксировался в JSON, но не загрязнял + `systemctl --failed`; +- перед стартом shadow service проверяет, что production `detmir-auto.service` + не активен; +- последняя systemd shadow-проверка после retry в AW client: + `severity=OK`, `check_rc=0`, `dlp_rc=0`, buckets `8/0/0`, DLP `22/0/0`. +- production Rust start проверен: `ExecStart=/usr/local/bin/detmir-auto-rust + --command-timeout-seconds 180`, latest-state `OK`, `systemctl --failed` + пустой. +- scheduled production timer cycle проверен после switch: process status `0`, + latest-state `OK`, report generated, `systemctl --failed` пустой. + +### Phase 4. Safe heal + +Цель: перенести `detmir-heal-safe`, но только после read-only parity. + +Обязательные guardrails: + +- `--dry-run` default на первых deploy; +- allowlist units: + - `activitywatch-server.service`; + - `aw-worktime-api.service`; + - `aw-worktime-ui-bridge.timer`; + - `activitywatch-dlp-aggregator.timer` только если unit существует; +- запрет wildcard restart; +- `systemctl reset-failed` только для allowlist; +- cooldown между restart попытками; +- audit log в run directory; +- hard timeout на SSH/systemctl; +- never touch Windows/RDP recovery from this module. + +Гейт: + +- dry-run показывает exact actions; +- на зеленом контуре не рестартит ничего; +- на mock/failing unit рестартит только allowlisted unit; +- после heal всегда запускается повторный check; +- exit code не маскирует FAIL. + +Текущее состояние: + +- создан crate `detmir-heal-safe`; +- binary развернут на Proxmox как `/usr/local/bin/detmir-heal-safe-rust`; +- production `/usr/local/bin/detmir-heal-safe` пока не заменен; +- production Rust auto использует `/usr/local/bin/detmir-heal-safe-rust` через + `DETMIR_HEAL_BIN`; +- default mode: dry-run, mutation только через `--apply`; +- optional timer start выключен по умолчанию, включается только + `--start-optional`; +- green dry-run проверен: restart/start не планируются; +- green apply проверен: выполнены только `reset-failed` и DLP health snapshot, + контур остался `OK`, `systemctl --failed` пустой. +- red/mock heal test проверен на Proxmox в isolated state-dir: + `summary-before=FAIL`, heal вызван, check/dlp повторены, final state `OK`, + process rc `0`. + +### Phase 5. Watchdog и lightweight services + +Цель: заменить простые shell watchdogs и wrappers. + +Порядок: + +1. `tsj-guardian-watchdog.sh` -> `tsj-guardian-watchdog`; +2. lightweight service wrappers; +3. bounded cleanup/retention jobs. + +Особое внимание: + +- bot heartbeat file parsing; +- duplicate `gost` instance dedupe; +- service restart only on stale/missing heartbeat; +- no killing unrelated processes. + +Гейт: + +- dry-run mode показывает, какой PID был бы убит; +- process matching покрыт тестами; +- systemd timer/service rollback сохранен. + +Текущее состояние: + +- создан crate `tsj-guardian-watchdog`; +- binary развернут на Proxmox как + `/usr/local/bin/tsj-guardian-watchdog-rust`; +- production `tsj-guardian-watchdog.service` переключен на Rust через drop-in + `/etc/systemd/system/tsj-guardian-watchdog.service.d/20-rust-switch.conf`; +- legacy `/opt/infra-admin/tsj-bot/tsj_guardian_watchdog.sh` сохранен; +- dry-run выявил bug legacy-подхода: `pgrep -f` не матчился как literal из-за + `+` в `gost` pattern; +- Rust версия использует literal process scan через `ps`; +- one-shot apply удалил лишний duplicate `gost` PID и оставил systemd MainPID; +- service mode работает как: + +```bash +/usr/local/bin/tsj-guardian-watchdog-rust --apply --loop-forever --interval-seconds 60 +``` + +Rollback: + +```bash +sudo rm -f /etc/systemd/system/tsj-guardian-watchdog.service.d/20-rust-switch.conf +sudo systemctl daemon-reload +sudo systemctl restart tsj-guardian-watchdog.service +``` + +### Phase 6. AW server health and DLP modules + +Цель: перенести серверные Python health checks/exporters. + +Порядок: + +1. `aw-server/aw-rus-healthd.py` - выполнено, production через + `aw-rus-healthd.service.d/20-rust-switch.conf`; +2. `scripts/dlp-health-check.py` - выполнено, `/usr/local/bin/dlp-health-check` + заменён Rust-бинарником; +3. `scripts/aggregate_dlp_events.py` - выполнено, production через + `activitywatch-dlp-aggregator.service.d/20-rust-switch.conf`; +4. DLP integrations: + - syslog forwarder - выполнено, production через + `aw-dlp-syslog-forwarder.service.d/20-rust-switch.conf`; + - webhook sender - выполнено, production через + `aw-dlp-webhook-sender.service.d/20-rust-switch.conf`; + - CEF exporter - выполнено, production через + `aw-dlp-cef-exporter.service.d/20-rust-switch.conf`; +5. `aw-server/aw-dlp-influx-exporter.py` - выполнено, production через + `aw-dlp-influx-exporter.service.d/20-rust-switch.conf`; +6. `aw-server/aw-worktime-influx-exporter.py` - выполнено, production через + `aw-worktime-influx-exporter.service.d/20-rust-switch.conf`; +7. `aw-server/aw-worktime-prewarm.sh` - выполнено, production через + `aw-worktime-prewarm.service.d/20-rust-switch.conf`; +8. `aw-server/aw-worktime-ui-bridge.py` - выполнено, production через + `aw-worktime-ui-bridge.service.d/20-rust-switch.conf`; +9. `aw-server/aw-worktime-autoheal.sh` - выполнено, production через + `aw-worktime-autoheal.service.d/20-rust-switch.conf`; +10. remaining worktime modules. + +Правила: + +- exporters должны сохранять Prometheus/Influx output format; +- API endpoints не менять без compatibility layer; +- для каждого exporter сначала golden output fixture; +- service unit меняется только после side-by-side run. + +Гейт: + +- old/new metrics names match; +- dashboard datasource health не ломается; +- unit tests старого поведения перенесены или сохранены; +- `systemctl --failed` clean после deploy. + +### Phase 7. Telegram bot backend helpers + +Цель: не переносить Telegram bot runtime на Rust. Python остается постоянным +production runtime для Telegram polling/sending, proxy, retries и side effects. +На Rust выносятся только backend helpers, status aggregation, decision/gating и +безопасные read-only/action contracts, которые бот вызывает как внешние команды. + +Порядок: + +1. `[done]` Бот продолжает жить на Python. +2. `[started]` Команды `/status`, `/detmir`, health summaries начинают читать Rust JSON. + Первым вынесен `tsj-guardian-status`: read-only helper для строки + `detmir_auto` из `/var/lib/detmir-ai/latest-state.json`; вторым шагом тот же + helper начал рендерить `aw_rus_slo` из `AW_RUS_SLO_SUMMARY_CMD`. +3. Recovery/actions вызывают Rust binaries с dry-run/audit. +4. `[decision]` Bot runtime на Rust не переносится; новые улучшения Telegram + делаются в Python runtime или через Rust backend helpers. + +Почему так: + +- Telegram runtime, proxy, retries и async edge cases уже стабильно покрыты + Python-кодом и тестами; +- перенос runtime даст мало пользы и высокий риск регрессий; +- максимальная польза от Rust сначала в backend-командах и safety contracts. + +Гейт: + +- `/status` показывает те же строки или лучше; +- smoke message проходит; +- watchdog активен; +- restart bot не теряет config/secrets; +- секреты не попадают в logs/tests. + +### Phase 8. Deploy/install scripts + +Цель: переносить последними, только после стабилизации runtime binaries. + +Что может остаться не на Rust: + +- Ansible playbooks; +- Windows PowerShell collector deployment; +- Playwright browser smoke; +- packaging scripts, если они надежны и редко исполняются. + +Что имеет смысл перенести: + +- validation CLI; +- install-kit consistency checker; +- local report generators; +- deterministic packaging helpers. + +Гейт: + +- install-kit output byte/content expected; +- rollback installer path documented; +- production deploy не зависит от laptop-only paths. + +## 7. Deployment workflow + +Сборка: + +```bash +cd /mnt/usb_hdd2/Projects/ActivityWatch-Russian/adk-rust +cargo fmt --all -- --check +cargo clippy --workspace --all-targets -- -D warnings +cargo test --workspace +cargo build --release --workspace +``` + +Установка одного бинарника на Proxmox: + +```bash +install -o root -g root -m 0755 \ + target/release/ \ + /usr/local/bin/ +``` + +Через Ansible: + +```bash +ansible proxmox -i ansible/inventory.ini -m copy -a \ + 'src=/mnt/usb_hdd2/Projects/ActivityWatch-Russian/adk-rust/target/release/ dest=/usr/local/bin/ owner=root group=root mode=0755 backup=yes' +``` + +Проверка на сервере: + +```bash + --version || true + --help + --json +systemctl --failed +``` + +## 8. Rollback workflow + +Для каждого заменяемого компонента должен быть rollback: + +1. Старый script остается на сервере как `.legacy` или в package backup. +2. systemd unit меняется через drop-in или controlled template. +3. Перед switch: + - сохранить `systemctl cat `; + - сохранить checksum старого binary/script; + - сохранить latest known good command. +4. Rollback command документируется в PR/run note. + +Текущий rollback для `detmir-auto` после Rust switch: + +```bash +sudo rm -f /etc/systemd/system/detmir-auto.service.d/20-rust-switch.conf +sudo systemctl daemon-reload +sudo systemctl restart detmir-auto.service +``` + +Перед switch сохранены: + +```bash +/var/lib/detmir-ai/switch-backups/detmir-auto.service.before-rust-20260531-192809.txt +/var/lib/detmir-ai/switch-backups/detmir-auto.sha256.before-rust-20260531-192809.txt +``` + +Пример: + +```bash +cp -a /usr/local/bin/detmir-check /usr/local/bin/detmir-check.legacy +install -o root -g root -m 0755 target/release/detmir-check /usr/local/bin/detmir-check +detmir-check --json +``` + +Если новый binary провален: + +```bash +mv /usr/local/bin/detmir-check.legacy /usr/local/bin/detmir-check +systemctl restart detmir-auto.timer || true +``` + +## 9. Acceptance checklist + +Перед заменой legacy-команды: + +- [ ] old/new CLI documented; +- [ ] old/new JSON compared on real fixture; +- [ ] exit codes match by class; +- [ ] unit tests pass; +- [ ] integration smoke pass on target host; +- [ ] no secrets in logs; +- [ ] systemd environment tested; +- [ ] rollback command known; +- [ ] docs updated; +- [ ] operator command example added. + +Перед включением mutation/recovery: + +- [ ] `--dry-run` verified; +- [ ] allowlist verified; +- [ ] lock verified; +- [ ] cooldown verified; +- [ ] audit log verified; +- [ ] green contour causes no action; +- [ ] failing contour causes only intended action; +- [ ] post-action check verified; +- [ ] rollback tested. + +## 10. Quality gates + +Локально: + +```bash +cd adk-rust +cargo fmt --all -- --check +cargo clippy --workspace --all-targets -- -D warnings +cargo test --workspace +``` + +На Proxmox: + +```bash +detmir-status --json +detmir-check --json +detmir-dlp +detmir-auto --no-heal +systemctl --failed +``` + +На AW server: + +```bash +aw-rus-healthd --json +dlp-health-check --json +/usr/local/bin/aw-slo-monitor-rust --state-dir /var/lib/activitywatch/slo-rust-shadow --json +systemctl --failed +``` + +Для Telegram: + +```bash +systemctl is-active tsj-guardian-bot tsj-guardian-watchdog gost-tg +``` + +## 11. Приоритет ближайших работ + +Немедленный порядок: + +1. `[done]` Создать `detmir-core`, `detmir-state`, `detmir-aw-client`. +2. `[done]` Объединить `detmir-status` и `detmir-adk-status` в один Rust binary. +3. `[done]` Перенести `detmir-check`. +4. `[done]` Сделать shadow compare `detmir-check` old/new. +5. `[done]` Перенести `detmir-dlp` wrapper. +6. `[done]` Начать `detmir-auto --no-heal`. +7. `[done]` Накопить scheduled shadow/prod parity для `detmir-auto-rust`, затем выполнить controlled switch. +8. `[done]` Перенести `detmir-heal-safe` с dry-run/apply guardrails и подключить к Rust auto. +9. `[done]` Наблюдать timer цикл Rust production и отключить redundant shadow timer. +10. `[done]` Перенести `tsj-guardian-watchdog.sh` на Rust service loop. +11. `[done]` Перенести AW-RUS SLO monitor/summary path на Rust: + `aw-slo-monitor-rust` пишет `aw-slo-samples.jsonl`, + `aw-slo-summary.json` и `aw-slo-summary.txt`; production включен через + `/etc/systemd/system/aw-slo-monitor.service.d/20-rust-switch.conf`. +12. `[done]` Выполнить controlled correction старых ложных SLO bad-сэмплов: + backup создан, 133 записи с единственной причиной + `worktime_today_html body too small ... < 5000` исправлены, 59 + не-ложных/неоднозначных bad-сэмплов оставлены в истории. +13. `[done]` Вынести Telegram `/status` aggregation layer в Rust: + `tsj-guardian-status --status-text` собирает полный текст статуса, Python + runtime Telegram оставлен и использует старую сборку только как fallback. +14. `[done]` Вынести incident/escalation decision path в Rust: + `tsj-guardian-status` принимает JSON через stdin и решает suggestions, + transient quorum/defer и timeout escalation/fallback. Python runtime + выполняет только side effects и сохраняет fallback на старую логику. +15. `[done]` Вынести operator action routing/gating в Rust: + `tsj-guardian-status --operator-action-decision` нормализует action aliases, + назначает handler/risk, блокирует неизвестные action и защищает + update install/rollback confirm без pending-запроса. Python runtime + продолжает выполнять команды и сохраняет fallback. +16. `[done]` Вынести DLP policy/mode decision path в Rust: + `tsj-guardian-status --dlp-policy-decision` определяет monitor/enforce/mixed, + считает block/total по endpoint groups, строит toggle/target policy plan и + changed_rules. Python runtime продолжает выполнять API PUT и Windows policy + sync, fallback на старую Python-логику сохранен. +17. `[done]` Вынести confirmation state machines в Rust: + `tsj-guardian-status --confirmation-decision` решает TTL/expire, cancel, + first_confirm и apply code/stage validation для pfSense, OpenVPN, + Proxmox selection и Proxmox restore. Python runtime продолжает создавать + pending-запросы и выполнять side effects после `allowed=true`, fallback на + старую Python-валидацию сохранен. +18. `[done]` Вынести autoheal/action plan extraction в Rust: + `tsj-guardian-status --autoheal-plan-decision` классифицирует AW-Rus + failures в Windows collector heal, server-side DLP heal, worktime rebuild, + SLO no-direct-target и задержку перед повторной проверкой. Python runtime + продолжает выполнять recovery-команды и повторный probe, fallback на старую + Python-маршрутизацию сохранен. Production deploy проверен на Proxmox: + sample decisions OK, `tsj-guardian-bot`, `tsj-guardian-watchdog` и + `gost-tg` active, `detmir-status` OK / `ok_for_operator=true`, свежий + journal после правильной выкладки helper без повторных unexpected-argument + warnings. +19. `[done]` Укрепить deploy artifacts после миграции: + `deploy_aw_server.yml` переведен на единый `aw_rust_release_dir` для Rust + binary `stat`/`copy`, чтобы `CARGO_TARGET_DIR` работал одинаково для + Proxmox/Telegram и AW-server deploy. Добавлен + `scripts/check_detmir_rust_release_artifacts.sh`; проверено + `CARGO_TARGET_DIR=/home/igor/.cache/detmir-adk-rust-target cargo build + --release --workspace`, artifact check OK, `deploy_aw_server.yml + --syntax-check` OK. +20. `[done]` Прогнать production AW-server deploy contract и закрыть найденные + эксплуатационные дефекты: + - Influx tokens для deploy берутся из окружения; при восстановлении с + сервера значения не печатать. + - Legacy DB merge выключен по умолчанию через + `aw_legacy_db_merge_enabled: false`, потому что backup SQLite на + 12G root FS уперся в `No space left on device`. Повторять merge только + после отдельного disk plan. + - Browser smoke по умолчанию принудительно использует `chromium-cli`; + `node-playwright` на текущем Node ломается `rimraf: callback function + required`. + - `aw-rus-healthd-rust` теперь проверяет AW interactive buckets по + `metadata.end`, а event buckets выбирают свежайшее событие из окна + `limit=20` с учетом `timestamp + duration`; это устраняет ложные + stale/warn на длинных ActivityWatch событиях и guard heartbeat ordering. + - После reset runtime budget `AWatchRusCollectorGuard` и однократного + перезапуска AW watcher tasks production verification зеленый: + `aw-rus-healthd-rust` `ok=14 warn=0 fail=0`, `detmir-status` OK, + `systemctl --failed` на AW и Proxmox пусто, `deploy_aw_server.yml + --syntax-check` OK. +21. `[done]` Выполнить безопасную уборку AW app data и защитить timers от + лавинного восстановления: + - удалены только старые browser-smoke run-директории, manual profile, + неиспользуемый `/opt/activitywatch/releases/v0.13.2` и неиспользуемый + `/opt/activitywatch/aw-rus-ops/venv`; + - текущий `aw-server-rust-v0.13.2`, SQLite DB/WAL/SHM и rollback-critical + backups не удалялись; + - root FS улучшен примерно с `96%` до `85%`; + - AW timers переведены на staggered `OnCalendar`, чтобы после ручного + restart/daemon-reload не запускать все missed persistent jobs одной + пачкой; + - legacy `aw_to_influx_exporter.service` удален из systemd, потому что + production уже использует Rust DLP/worktime Influx exporters; + - `aw-rus-healthd-rust` получил timeout для wrapper-команд, чтобы + `aw-health-check`/`dlp-health-check` не могли подвесить healthd. +22. `[done]` Перенести DLP content analyzer text path в Rust: + - добавлен `dlp-content-analyzer` crate; + - CLI совместим с `aw-dlp-content-analyzer --text --dictionary-pack + --regex-pack`; + - реализованы dictionary/regex pack matching и checksum validators для + ИНН, СНИЛС, паспорта РФ; + - позиции совпадений считаются в Python-compatible character offsets, не + byte offsets; + - image/OCR path оставлен через legacy Python fallback, чтобы не тащить + OCR/Tesseract в Rust на этом шаге; + - `/usr/local/bin/aw-dlp-content-analyzer` теперь wrapper, который + предпочитает `/usr/local/bin/aw-dlp-content-analyzer-rust`, но сохраняет + Python fallback; + - parity на AW server проверен на `152-fz-pdn`, `contacts`, `secrets`, + `financial`: match counts, values и offsets совпали; + - artifact check расширен `dlp-content-analyzer`. +23. `[done]` Перенести DLP admin CLI в Rust: + - добавлен `dlp-admin-cli` crate; + - сохранены команды `policies list/active`, `incidents list`, + `cases list/create`, `health check`; + - HTTP client использует no-proxy для локальных AW/DLP сервисов; + - `/usr/local/bin/dlp-admin-cli` установлен как Rust production CLI, + legacy `/usr/local/bin/dlp-admin-cli.py` оставлен для rollback/reference; + - parity на AW server совпал с Python для `health`, `policies active`, + `incidents list --since-hours 24 --limit 5`, `cases list --limit 5`; + - production verification зеленый: AW failed units 0, + `aw-rus-healthd-rust` 14/0/0, `dlp-health-check` 22/0/0, + `detmir-status` OK, `detmir-check` OK, Proxmox failed units 0; + - artifact check расширен `dlp-admin-cli`. +24. `[done]` Перенести DLP policy engine service в Rust: + - добавлен `dlp-policy-engine` crate; + - сохранены существующая SQLite schema/DB и API: policies + list/create/get/update/delete, active bundle/version, + submit/approve/draft/activate, rollback, agent heartbeat/desired, + audit; + - checksum policy совместим с Python: + `json.dumps(..., ensure_ascii=False, sort_keys=True, + separators=(",", ":"))` + SHA-256; + - env contract сохранен: + `AW_DLP_POLICY_ENGINE_BIND_HOST`, `AW_DLP_POLICY_ENGINE_PORT`, + `AW_DLP_POLICY_ENGINE_DB_PATH`; + - shadow parity на AW server совпал с Python для list/active/version и + agent heartbeat/desired; + - Rust исправляет documented `/api/0/dlp/policies/audit?limit=N`: legacy + FastAPI возвращал 422 из-за route-order конфликта с `{policy_id}`; + - production `/usr/local/bin/aw-dlp-policy-engine-rust` подключен через + `/etc/systemd/system/aw-dlp-policy-engine.service.d/20-rust-switch.conf`; + - rollback: удалить этот drop-in, `systemctl daemon-reload`, restart + `aw-dlp-policy-engine.service`; + - production verification зеленый: active policy `default-policy` v6, + audit endpoint OK, AW failed units 0, `aw-rus-healthd-rust` 14/0/0, + `dlp-health-check` 22/0/0, `detmir-status` OK, `detmir-check` OK, + Proxmox failed units 0; + - artifact check расширен `dlp-policy-engine`. +25. `[done]` Перенести DLP case management service в Rust: + - добавлен `dlp-case-management` crate; + - сохранены существующая SQLite schema/DB и API: `/health`, cases + list/create/get/update, comments list/create, Hayabusa forensics link; + - сохранены self-test rejection и evidence SHA-256 contract: + `json.dumps(..., ensure_ascii=False, sort_keys=True, + separators=(",", ":"))` + SHA-256; + - env contract сохранен: + `AW_DLP_CASE_BIND_HOST`, `AW_DLP_CASE_PORT`, `AW_DLP_CASE_DB_PATH`; + - local HTTP smoke на temp DB проверил create/list/patch/comment/hayabusa + link/self-test reject; + - shadow parity на AW server совпал с Python для health, case list/filter, + case detail и comments на копии production DB; + - production `/usr/local/bin/aw-dlp-case-management-rust` подключен через + `/etc/systemd/system/aw-dlp-case-management.service.d/20-rust-switch.conf`; + - rollback: удалить этот drop-in, `systemctl daemon-reload`, restart + `aw-dlp-case-management.service`; + - production verification зеленый: case list OK/latest id 35, + AW failed units 0, `aw-rus-healthd-rust` 14/0/0, `dlp-health-check` + 22/0/0, `detmir-status` OK, `detmir-check` OK, Proxmox failed units 0; + - artifact check расширен `dlp-case-management`. +26. `[done]` Перенести DLP compliance report scheduler/generator в Rust: + - добавлен `dlp-compliance` crate; + - сохранены CLI `--month`, `--profile`, `--profiles`, `--stdout-json`; + - сохранены env fallback для AW API: `AW_DLP_AW_API_BASE`, + `AW_SERVER_URL`, default `http://127.0.0.1:5600`; + - сохранены output/template envs и artifact names: + `-.html/json`; + - shadow parity на AW server в temp output dirs совпал с Python для + `152-fz,pci-dss`: profile/period/aw_api_base/stats и наличие artifacts; + - production `/usr/local/bin/aw-dlp-compliance-rust` подключен через + `/etc/systemd/system/aw-dlp-report-scheduler.service.d/20-rust-switch.conf`; + - rollback: удалить этот drop-in и `systemctl daemon-reload`; + - production oneshot run успешен: `152-fz-2026-06` и `pci-dss-2026-06` + html/json созданы, timer active, AW failed units 0, + `aw-rus-healthd-rust` 14/0/0, `dlp-health-check` 22/0/0, + `detmir-status` OK, `detmir-check` OK, Proxmox failed units 0; + - artifact check расширен `dlp-compliance`. +27. `[done]` Перенести Hayabusa case alert/link helpers в Rust: + - добавлен `hayabusa-tools` crate с бинарниками + `aw-hayabusa-case-alert-rust` и `aw-hayabusa-link-case-rust`; + - сохранены CLI/env контракты legacy helpers: + `--case-id`, `--intake-json`, `--case-api-base`, `--mode`, + `--link-source`, `AW_HAYABUSA_*`; + - `case-alert` сохраняет scoring/severity, `top_rules`, comment text, + case create/update/link path и Telegram alert text; + - Telegram request errors в Rust не печатают bot token/URL; + - `/usr/local/bin/aw-hayabusa-case-alert` и + `/usr/local/bin/aw-hayabusa-link-case` теперь wrapper-команды, которые + предпочитают Rust binaries и fallback на Python из + `/opt/activitywatch/aw-rus-ops/hayabusa`; + - shadow compare на реальном `/opt/hayabusa/state/latest-intake.json` с + `AW_HAYABUSA_AUTO_CASE_ENABLED=false` и + `AW_HAYABUSA_TELEGRAM_ENABLED=false` дал `summary_equal=true`; + - production verification зеленый: AW failed units 0, + `aw-rus-healthd-rust` 14/0/0, `dlp-health-check` 22/0/0, + `detmir-status` OK, Proxmox failed units 0; + - artifact check расширен `aw-hayabusa-case-alert-rust` и + `aw-hayabusa-link-case-rust`. +28. `[done]` Перенести remaining Hayabusa offline helpers в Rust: + - `hayabusa-tools` расширен бинарниками + `aw-hayabusa-from-windows-rust` и `aw-hayabusa-autoprocess-rust`; + - `from-windows` сохраняет server-side workflow: + Ansible WinRM export, latest zip discovery, fetch в drop-dir, + `aw-hayabusa accept`, `process-inbox`, optional link-case и печать + `LATEST_INTAKE`; + - `autoprocess` сохраняет drop-dir scanner, lock file, + `.caseid`/`.meta.json` sidecars, host/mode/link_source inference, + case-alert capture, sidecar/archive move в report dir; + - `/usr/local/bin/aw-hayabusa-from-windows` и + `/usr/local/bin/aw-hayabusa-autoprocess` теперь wrapper-команды, + которые предпочитают Rust и fallback на Python из + `/opt/activitywatch/aw-rus-ops/hayabusa`; + - `aw-hayabusa-drop.service` теперь запускает + `/usr/local/bin/aw-hayabusa-autoprocess` напрямую, без `python3`, чтобы + wrapper мог выбрать Rust; + - no-mutation smoke на AW server: `--help` для всех Hayabusa helpers, + empty drop-dir для `autoprocess`, `systemctl cat` ExecStart OK; + - production verification зеленый: AW failed units 0, + `aw-rus-healthd-rust` 14/0/0, `dlp-health-check` 22/0/0, + `detmir-status` OK, Proxmox failed units 0; + - artifact check расширен `aw-hayabusa-from-windows-rust` и + `aw-hayabusa-autoprocess-rust`. +29. `[done]` Перенести AW ops helper `aw-health-check` в Rust: + - добавлен crate `aw-health-check`; + - сохранены проверки legacy shell: systemd services/timer, + ActivityWatch `/api/0/info`, Worktime `/health`, DLP transport + freshness через `dlp-health-check --json`, и drift checks для + `startOfDay`, `always_active_pattern`, `landingpage`; + - env contract читается из процесса и `/etc/activitywatch/aw-server.env`; + - production `/usr/local/bin/aw-health-check` теперь wrapper, который + предпочитает `/usr/local/bin/aw-health-check-rust` и fallback на + `/opt/activitywatch/aw-rus-ops/health-check.sh`; + - shadow parity на AW server совпал с shell по exit code и ключевым + health-строкам; + - production verification зеленый: `aw-health-check` OK, + `aw-rus-healthd-rust` 14/0/0, `dlp-health-check` 22/0/0, + `detmir-status` OK, `ok_for_operator=true`, failed units 0; + - artifact check расширен `aw-health-check`. +30. `[done]` Перенести AW ops helper `check-aw-data` в Rust: + - добавлен crate `check-aw-data`; + - корневой `check-aw-data.sh` стал Rust-first wrapper с fallback на + `scripts/legacy/check-aw-data.sh`; + - на AW server production `/usr/local/bin/check-aw-data` теперь wrapper, + который предпочитает `/usr/local/bin/check-aw-data-rust`, по умолчанию + использует `http://127.0.0.1:5600` и fallback на + `/opt/activitywatch/aw-rus-ops/check-aw-data.sh`; + - сохранены операторские статусы `FRESH`, `STALE`, `DEAD`, `EMPTY`, + `EVENT-DRIVEN`, `INACTIVE` и CORS probe; + - быстрый режим по умолчанию использует `metadata.end` из + `/api/0/buckets`; глубокое чтение event ids включается флагом + `--with-event-ids`; + - production verification: `/usr/local/bin/check-aw-data --no-color` + завершился за 2s, buckets fresh/event-driven/inactive as expected, + `CORS: OK (HTTP 200)`; + - final gates зеленые: AW failed units 0, `aw-rus-healthd-rust` 14/0/0, + `dlp-health-check` 22/0/0, `detmir-auto --no-heal` OK, + `detmir-status` OK и `ok_for_operator=true`; + - artifact check расширен `check-aw-data`. +31. `[done]` Перенести AW maintenance helper `aw-prune-local-state` в Rust: + - добавлен crate `aw-prune-local-state`; + - `/usr/local/bin/aw-prune-local-state.sh` теперь Rust-first wrapper, + который без аргументов запускает Rust binary в legacy-compatible + `--apply` режиме и fallback на shell из + `/opt/activitywatch/aw-rus-ops/aw-prune-local-state.sh`; + - Rust binary по умолчанию работает как dry-run, поддерживает `--json`, + `--apply`, retention/keep параметры и строгий allowlist путей; + - safety policy: не удалять SQLite DB вне `backups/db`, не удалять + `switch-backups`, `before-rust`, `rollback` и корневые state каталоги; + - cleanup расширен на старые `browser-smoke` run-директории как project + app data; rollback-critical backups и AW SQLite DB не трогались; + - production recovery во время проверки: rootfs AW server был 100% + заполнен, `aw-server-rust` попал в `poisoned lock`; через Rust helper + удалены старые browser-smoke runs на 347 MB суммарно, затем + `activitywatch-server.service` восстановлен без удаления DB; + - final gates зеленые: AW failed units 0, `aw-rus-healthd-rust` 14/0/0, + `dlp-health-check` 22/0/0, `check-aw-data` OK, `detmir-auto --no-heal` + OK, `detmir-status` OK и `ok_for_operator=true`; + - Telegram bot/watchdog временно остановлены по операторской команде и не + перезапускались в рамках этого шага; + - artifact check расширен `aw-prune-local-state`. +32. `[done]` Устранить нехватку места на AW server через Proxmox resize: + - CT `203` (`aw-server`, `10.10.10.13`) rootfs расширен через Proxmox + `pct resize 203 rootfs +20G`; + - перед resize сохранен config backup: + `/var/lib/detmir-ai/switch-backups/ct203-aw-server.before-rootfs-resize-20260602T054826Z.conf`; + - rootfs изменился с `12G` на `32G`, свободное место стало около `20G`; + - Proxmox storage `local-btrfs` после resize имеет около `106G` + доступного места; + - AW `/api/0/info` и `/api/0/buckets` проверены с AW server и Proxmox; + - final gates зеленые: AW health 14/0/0, DLP health 22/0/0, + `detmir-check` service_warnings=0, `detmir-auto --no-heal` OK, + `detmir-status` OK и `ok_for_operator=true`, failed units 0; + - Telegram bot/watchdog оставлены `inactive` по операторской команде. +33. `[done]` Остановить неконтролируемый рост `aw-session-events`: + - read-only SQLite audit показал, что `/var/lib/activitywatch/aw-server-rust/sqlite.db` + занимает около `6.8G`, `freelist_count=0`; VACUUM сам по себе не + освободит место, потому что размер занят live events; + - основной источник роста: `aw-session-events_SHARKON2025` - около + `6.9M` строк и `~5GB` payload, с пиками `1.2M-2.4M` process-level + событий в сутки за 2026-05-29..2026-06-01; + - production live config на RDP был `pollSeconds=5` и + `sessionEvents.processEventsEnabled=true`; + - отключена постоянная process-level публикация: + `aw_windows_process_events_enabled=false` в production/example/default + Ansible vars и `sessionEvents.processEventsEnabled=false` в live + `C:\ProgramData\AWatch-rus\deployment-config.json`; + - live config backup сохранен на RDP: + `C:\ProgramData\AWatch-rus\switch-backups\deployment-config.before-disable-process-events-20260602T061836Z.json`; + - старый `worktime-session-collector.ps1` PID `8476` остановлен, collector + поднят заново штатными `ActivityWatch Launch [...]` tasks/guard; + - delta-gate: `metadata.end` bucket `aw-session-events_SHARKON2025` + остался `2026-06-02T06:27:11.197Z` через 75 секунд, постоянный поток + остановлен; + - базовый сбор не сломан: `detmir-check --json` OK, + `detmir-status` severity OK, AW failed units 0, Proxmox failed units 0, + AW API и Worktime API отвечают; + - SQLite row deletion/compact не выполнялись на этом этапе: сначала + стабилизирован источник роста, retention/trim старых process events - + отдельный destructive этап с backup. +34. `[done]` Controlled trim старых process-level `aw-session-events`: + - перед mutation сохранен полный rollback backup SQLite DB/WAL/SHM: + `/var/lib/activitywatch/backups/db/aw-sqlite-before-session-events-trim-20260602T064127Z`; + - на время операции остановлены AW-related timers/services и + `activitywatch-server.service`, чтобы не было writer'ов к SQLite; + - scoped delete удалил только события bucket + `aw-session-events_SHARKON2025` с `eventType=process_start` или + `eventType=process_stop`; + - удалено `6,906,190` шумных process-level событий; + - сохранены logon events: после trim в `aw-session-events_SHARKON2025` + осталось `174` события, recent samples имеют `eventType=logon`; + - `PRAGMA integrity_check` до и после `VACUUM`: `ok`; + - DB уменьшилась с `6.8G` до `350M`, rootfs AW server вернулся к + `38%` использования и около `19G` free; + - после restart AW был `poisoned lock` из-за timer запросов во время + старта; выполнен controlled restart при остановленных timers, затем + `/api/0/info` и `/events?limit=1` вернулись OK; + - timers включены обратно, failed units на AW и Proxmox: `0`; + - final gates зеленые: `aw-health-check` OK, `dlp-health-check` + `22/0/0`, `detmir-check` bucket_ok=8 stale=0 dead=0, + `detmir-auto --no-heal` OK, `detmir-status` severity OK и + `ok_for_operator=true`; + - Telegram bot/watchdog оставлены `inactive` по операторской команде. +35. `[done]` Добавить Rust guard от повторного роста AW SQLite: + - добавлен crate `aw-db-health`; + - helper read-only открывает `/var/lib/activitywatch/aw-server-rust/sqlite.db` + через SQLite read-only flags и не меняет DB; + - проверяет: + - размер `sqlite.db` (`warn=2GiB`, `fail=5GiB` по умолчанию); + - размер `sqlite.db-wal` (`warn=256MiB`, `fail=1GiB`); + - число строк `aw-session-events_` (`warn=10000`, `fail=100000`); + - recent process-level events за окно 600 секунд + (`warn=1`, `fail=100`); + - latest `eventType/source` для быстрой диагностики; + - опционально `--windows-config` и + `sessionEvents.processEventsEnabled`; + - поддерживает text output и `--json`; + - `aw-health-check-rust` теперь вызывает `/usr/local/bin/aw-db-health --json` + и включает DB growth guard в общий AW health path; + - `scripts/check_detmir_rust_release_artifacts.sh` требует + `aw-db-health`; + - `ansible/deploy_aw_server.yml` устанавливает + `/usr/local/bin/aw-db-health` и обновленный + `/usr/local/bin/aw-health-check-rust`; установка health helpers + продублирована до Influx token assert, чтобы DB guard обновлялся даже + если playbook позже останавливается на пустом внешнем Influx token; + - production `/usr/local/bin/aw-db-health --json`: + `ok=true`, `db=352.3MiB`, `wal=4.0MiB`, session rows `174`, + recent process events `0`, latest `eventType=logon`; + - production `/usr/local/bin/aw-health-check`: `AW DB growth guard passed`; + - local gates: `cargo fmt --all -- --check`, `cargo test -p aw-db-health` + `3 passed`, `cargo test -p aw-health-check` `2 passed`, + release build OK, artifact check OK; + - final gates зеленые: `dlp-health-check` `22/0/0`, `detmir-check` OK, + `detmir-auto --no-heal` OK, `detmir-status` severity OK, + failed units на AW/Proxmox `0`; + - Telegram bot/watchdog оставлены `inactive`. +35.1. `[done]` Добавить автономную guarded чистку AW SQLite: + - добавлен crate `aw-db-maintenance`; + - helper dry-run по умолчанию, mutation только с `--apply`; + - allowlist удаления: только bucket `aw-session-events_` и только + события `eventType=process_start` или `eventType=process_stop`; + - logon/session events не удаляются; + - retention по умолчанию `7` дней; + - перед удалением создается SQLite backup в + `/var/lib/activitywatch/backups/db/aw-sqlite-before-db-maintenance-*.db`; + если строк к удалению нет, backup не создается и DB не пишется; + - удаление chunked, без `VACUUM`, чтобы не создавать длительный downtime; + - добавлены `aw-server/aw-db-maintenance.service` и + `aw-server/aw-db-maintenance.timer`; + - timer включен на AW server: `OnCalendar=Sun *-*-* 03:30:00`, + `RandomizedDelaySec=15m`, `Persistent=true`; + - production dry-run: `planned_delete_rows=0`, `deleted_rows=0`; + - ручной service smoke: `Result=success`, `ExecMainStatus=0`, + `planned_delete_rows=0`, `deleted_rows=0`, `backup_created=false`; + - production timer active; ближайший запуск по remote `systemctl + list-timers`: `Sun 2026-06-07 03:40:05 UTC`; + - gates: `cargo fmt --all -- --check`, `cargo test -p aw-db-maintenance` + `3 passed`, `cargo clippy -p aw-db-maintenance --all-targets -- -D + warnings`, release build OK, artifact check OK, `quality-gate.sh` OK, + `ansible-playbook deploy_aw_server.yml --syntax-check` OK; + - final production gates: AW failed units `0`, `aw-db-health` OK + (`sqlite.db=359.6MiB`, WAL `4.0MiB`, session rows `174`, recent process + events `0`, latest `eventType=logon`), DetMir status OK with + `dlp_counts={ok:22,warn:0,fail:0}`, `ok_for_operator=true`. +36. `[done]` Устранить blocker полного AW server deploy на Influx token: + - проблема: `deploy_aw_server.yml` падал на assert + `aw_worktime_influx_enabled=true`, потому что локальные env + `AW_WORKTIME_INFLUX_TOKEN` и `AW_DLP_INFLUX_TOKEN` пустые; + - runtime `/etc/activitywatch/aw-server.env` на AW server уже содержал оба + token, поэтому правильный путь - сохранить remote secrets, а не + отключать exporters; + - playbook теперь через `slurp` с `no_log: true` читает текущий + `/etc/activitywatch/aw-server.env`, выбирает effective token: + local env token если задан, иначе existing remote token; + - asserts проверяют effective tokens и не печатают секреты; + - запись `/etc/activitywatch/aw-server.env` использует effective tokens, + чтобы full deploy не затирал рабочие Influx credentials пустыми + значениями; + - полный `ansible-playbook -i inventory.ini deploy_aw_server.yml + -e aw_rust_release_dir=/home/igor/.cache/detmir-adk-rust-target/release` + прошел до конца: `failed=0`, `ok=282`; + - final gates после deploy зеленые: `aw-db-health` OK, + `aw-health-check` OK, `dlp-health-check` `22/0/0`, + `detmir-check` OK, `detmir-auto --no-heal` OK, + `detmir-status` severity OK, failed units на AW/Proxmox `0`; + - Telegram bot/watchdog оставлены `inactive`. +37. `[done]` Закрепить safe default для Windows `processEventsEnabled`: + - исправлены Windows deploy/recovery defaults, чтобы новый deploy, + ensemble/domain deploy, hardening recovery и общий + `New-ActivityWatchDeploymentConfig` не включали process-level + `aw-session-events` без явного параметра; + - изменены `windows/deploy-single-user.ps1`, + `windows/deploy-ensemble.ps1`, `windows/deploy-domain-users.ps1`, + `windows/hardening-recovery.ps1`, + `windows/ActivityWatch.Windows.Common.psm1` и + `windows/validate-deployment.ps1`; + - синхронизированы текстовые копии в + `install-kit-awindows-20260427-211240/windows/`; + - invariant: existing live config сохраняется, но missing/new config + default всегда `sessionEvents.processEventsEnabled=false`; включать + можно только явно и временно для forensic/debug окна; + - local gates: PowerShell AST parse для измененных scripts/modules OK, + `ansible-playbook -i inventory.ini deploy_aw_windows.yml --syntax-check` + OK; + - production deploy: обновленные PowerShell файлы скопированы в + `C:\Program Files\AWatch-rus\windows` с UTF-8 BOM для Windows + PowerShell 5.1; remote backup перед заменой: + `C:\ProgramData\AWatch-rus\switch-backups\windows-safe-default-20260602T091213Z`; + - `hardening-recovery.ps1` выполнен по existing + `C:\ProgramData\AWatch-rus\deployment-config.json`; после recovery + config остался `processEventsEnabled=false`, `logonEnabled=true`, + `pollSeconds=5`, users включают `SHARKON2025\Администратор`; + - exact task check: `ActivityWatch Launch [SHARKON2025_Администратор]` + существует, ошибочный + `ActivityWatch Launch [SHARKON2025_Administrator]` отсутствует, + `ActivityWatch Recovery` существует; + - `AWatchRusCollectorGuard` running/automatic; Windows + `validate-deployment.ps1` вернул `overallOk=True`; + - production AW DB guard: session rows `174`, recent process events `0`, + latest `eventType=logon`, DB около `353MiB`; + - final gates зеленые: `check-aw-data` buckets fresh/event-driven OK, + `aw-health-check` OK, `dlp-health-check` `22/0/0`, + `detmir-check` bucket_ok `8`, stale `0`, dead `0`, + `detmir-auto --no-heal` OK, `detmir-status` severity OK и + `ok_for_operator=true`, failed units на AW/Proxmox `0`; + - краткий transient после recovery: один прогон `detmir-auto` увидел + `file-operations sendFailuresDelta`; повтор через 30 секунд показал + `sendFailuresDelta=0`, поэтому коррекция collectors не потребовалась; + - Telegram bot/watchdog оставлены `inactive` по принятому решению. +38. `[done]` Пересобрать и проверить Windows install kit: + - выполнен штатный `./scripts/rebuild_install_kit.sh`; + - пересобраны: + `install-kit-awindows-20260427-211240/`, + `install-kit-awindows-20260427-211240.zip`, + `install-kit-awindows-20260427-211240.tar.gz` и `MANIFEST.txt`; + - `./scripts/check_install_kit_vs_repo.sh`: compared `62`, + missing `0`, mismatched `0`, PowerShell mismatches `0`; + - manifest checksum verification: OK, entries `66`; + - archives verification: zip и tar.gz распакованы во временные каталоги, + опасные defaults не найдены; подтверждены + `[bool]$ProcessEventsEnabled = $false`, + `effectiveProcessEventsEnabled ... else { $false }`, + `sessionProcessEventsEnabled ... else { $false }`, + `aw_windows_process_events_enabled: false`; + - PowerShell AST parse для kit scripts/modules OK; + - `ansible-playbook -i inventory.example.ini deploy_aw_windows.yml + --syntax-check` из install-kit ansible каталога OK. +39. `[done]` Перенести install-kit consistency checker на Rust: + - добавлен crate `check-install-kit-vs-repo`; + - `scripts/check_install_kit_vs_repo.sh` теперь Rust-first wrapper: + ищет `CHECK_INSTALL_KIT_VS_REPO_RUST`, + `$CARGO_TARGET_DIR/release/check-install-kit-vs-repo`, + `adk-rust/target/release/check-install-kit-vs-repo`, + `/usr/local/bin/check-install-kit-vs-repo`, затем использует Python + fallback; + - сохранен текстовый output contract: + `Compared files`, `Missing in repo`, `Mismatched content`, + `PowerShell mismatches`; добавлен `--json`; + - поведение усилено: missing/mismatch теперь дают non-zero exit code, + чтобы рассинхрон install-kit не проходил молча; + - `scripts/check_detmir_rust_release_artifacts.sh` теперь требует + `check-install-kit-vs-repo`; + - после обновления wrapper выполнен `./scripts/rebuild_install_kit.sh`, + чтобы install kit нес актуальный Rust-first wrapper; + - gates: `cargo fmt --all -- --check`, `cargo test -p + check-install-kit-vs-repo` (`3 passed`), `cargo clippy -p + check-install-kit-vs-repo --all-targets -- -D warnings`, release build + OK, `./scripts/check_install_kit_vs_repo.sh` OK, negative mismatch smoke + rc `1`, `./scripts/validate_install_kit.sh` OK, artifact check OK. +40. `[done]` Перенести install-kit validator на Rust: + - добавлен crate `validate-install-kit`; + - `scripts/validate_install_kit.sh` теперь Rust-first wrapper: + ищет `VALIDATE_INSTALL_KIT_RUST`, + `$CARGO_TARGET_DIR/release/validate-install-kit`, + `adk-rust/target/release/validate-install-kit`, + `/usr/local/bin/validate-install-kit`, затем использует legacy + Bash/Python fallback; + - сохранен stage output: + `[1/4] Required files presence`, + `[2/4] Manifest checksum verification`, + `[3/4] Manifest completeness`, + `[4/4] Archive composition check`, + `validate_install_kit: OK`; добавлен `--json`; + - Rust validator проверяет required files, SHA256 из `MANIFEST.txt`, + полноту manifest относительно install-kit directory, совпадение состава + zip/tar.gz и корректный archive prefix; + - `scripts/rebuild_install_kit.sh` теперь включает + `scripts/validate_install_kit.sh` в install-kit `scripts/`; + - `scripts/check_detmir_rust_release_artifacts.sh` теперь требует + `validate-install-kit`; + - после wrapper change выполнен `./scripts/rebuild_install_kit.sh`; + - gates: `cargo fmt --all -- --check`, `cargo test -p + validate-install-kit` (`4 passed`), `cargo clippy -p + validate-install-kit --all-targets -- -D warnings`, release build OK, + Rust-first `./scripts/validate_install_kit.sh` OK + (`MANIFEST complete: 67 files tracked`, `Archives match: 68 files`), + `./scripts/check_install_kit_vs_repo.sh` OK (`compared 62`, + mismatches `0`), negative manifest checksum smoke rc `1`, negative + archive mismatch smoke rc `1`, artifact check OK. +41. `[done]` Перенести InnoSetup installer verifier на Rust: + - добавлен crate `verify-innosetup-installer`; + - `scripts/verify_innosetup_installer.sh` теперь Rust-first wrapper: + ищет `VERIFY_INNOSETUP_INSTALLER_RUST`, + `$CARGO_TARGET_DIR/release/verify-innosetup-installer`, + `adk-rust/target/release/verify-innosetup-installer`, + `/usr/local/bin/verify-innosetup-installer`, затем использует legacy + Wine/Bash fallback; + - Rust verifier создает чистый Wine prefix, silent-install'ит + `windows/installkit/innosetup/AWatch-rus-InstallKit.exe` в + `C:\AWatchRusExtract`, сверяет payload guard/policy файлов с + репозиторием и проверяет self-test marker + `collector guard self-test OK`; + - первый прогон корректно поймал устаревший installer payload: + `aw-collector-guard.ps1` и `install-collector-guard-service.ps1` + отличались от repo после safe-default hardening; + - installer пересобран штатным `windows/installkit/innosetup/build_with_wine.sh`, + после чего Rust-first verifier завершился `verify_innosetup_installer: + OK`; + - `scripts/check_detmir_rust_release_artifacts.sh` теперь требует + `verify-innosetup-installer`. +42. `[done]` Перенести install-kit rebuild path на Rust: + - добавлен crate `rebuild-install-kit`; + - `scripts/rebuild_install_kit.sh` теперь Rust-first wrapper: + ищет `REBUILD_INSTALL_KIT_RUST`, + `$CARGO_TARGET_DIR/release/rebuild-install-kit`, + `adk-rust/target/release/rebuild-install-kit`, + `/usr/local/bin/rebuild-install-kit`, затем использует legacy Bash + fallback; + - Rust rebuild сохраняет `*.deployment-config.json`, пересобирает + directory tree, `MANIFEST.txt`, `.zip` и `.tar.gz`; + - копирование реализовано через read + replace-write, а не `fs::copy`, + потому что текущий mounted-диск возвращал `Operation not permitted` на + truncate/copy existing files; + - install-kit теперь включает сам `scripts/rebuild_install_kit.sh`, чтобы + комплект был самодостаточным для дальнейшей пересборки; + - Rust-first rebuild verified через `validate_install_kit`, + `check_install_kit_vs_repo`, archive script `cmp`, and release artifact + check. +43. `[done]` Перенести `quality-gate.sh` на Rust-first orchestrator: + - добавлен crate `quality-gate`; + - `scripts/quality-gate.sh` теперь Rust-first wrapper: + ищет `QUALITY_GATE_RUST`, + `$CARGO_TARGET_DIR/release/quality-gate`, + `adk-rust/target/release/quality-gate`, `/usr/local/bin/quality-gate`, + затем использует legacy Bash fallback; + - Rust orchestrator сохраняет stage contract: Bash syntax, + ShellCheck when available, Node syntax when available, PowerShell parse + and collector guard self-test when `pwsh` available, Ansible syntax when + `ansible-playbook` available; + - final gates: `cargo fmt --all -- --check`, targeted tests for + `verify-innosetup-installer`, `rebuild-install-kit`, `quality-gate`, + `validate-install-kit`, clippy `-D warnings`, release builds, + Rust-first `quality-gate: OK`, install-kit validate/check OK, + InnoSetup verifier OK, and DetMir read-only status OK with + `ok_for_operator=true`. +44. `[done]` Перенести offline Sigma/Hayabusa IOC extraction path на Rust: + - добавлен crate `extract-ioc-from-sigma`; + - `scripts/build_dlp_ioc_from_hayabusa.sh` теперь Rust-first wrapper: + ищет `EXTRACT_IOC_FROM_SIGMA_RUST`, + `$CARGO_TARGET_DIR/release/extract-ioc-from-sigma`, + `adk-rust/target/release/extract-ioc-from-sigma`, + `/usr/local/bin/extract-ioc-from-sigma`, затем использует legacy Python + `scripts/extract_ioc_from_sigma.py`; + - Rust binary сохраняет CLI-контракт `--rules-root`, `--out-dir`, + `--table-name` и output files `ioc_blacklist.json`, + `ioc_blacklist.csv`, `ioc_blacklist.sql`; + - extraction contract сохранен: `Image|endswith`, + `CommandLine|contains`, `OriginalFileName`, `Hashes|SHA256` и SHA256 + values embedded in `Hashes` strings; + - parity fix: Rust использует Unicode lowercase и PyYAML-compatible + `True`/`False` rendering для YAML 1.1 boolean-like literals, чтобы не + расширять IOC set относительно legacy Python; + - real Hayabusa rules parity: + `rules_scanned=4963`, `iocs_extracted=13510`, + `commandline_contains=10192`, `original_filename=457`, + `process_image_endswith=1171`, `sha256=1690`, + `missing_in_rust=0`, `extra_in_rust=0`; + - gates: `cargo fmt --all -- --check`, `cargo test -p + extract-ioc-from-sigma` (`3 passed`), `cargo clippy -p + extract-ioc-from-sigma --all-targets -- -D warnings`, release build OK, + `scripts/check_detmir_rust_release_artifacts.sh` OK, + Rust-first `scripts/quality-gate.sh` OK. +45. `[done]` Перенести `scripts/rdp-worktime-report.sh` на Rust-first helper: + - добавлен crate `rdp-worktime-report`; + - `scripts/rdp-worktime-report.sh` теперь Rust-first wrapper: + ищет `RDP_WORKTIME_REPORT_RUST`, + `$CARGO_TARGET_DIR/release/rdp-worktime-report`, + `adk-rust/target/release/rdp-worktime-report`, + `/usr/local/bin/rdp-worktime-report`, затем использует legacy embedded + Python fallback; + - Rust binary сохраняет CLI/env contract: `--day today|yesterday`, + `--from YYYY-MM-DD --to YYYY-MM-DD`, `AW_BASE_URL`, + `AW_WORKTIME_HOST`, `AW_WORKTIME_DEFAULT_SAMPLE_SECONDS`, + `AW_WORKTIME_MAX_SAMPLE_SECONDS`, `OUT_DIR`; + - output contract сохранен: `rdp-worktime-_.csv`, + `rdp-worktime-_.json`, stdout paths plus `CSV:`/`JSON:`; + - calculation contract сохранен: bucket + `aw-worktime-sessions_`, active samples by `active=true` or + Russian/English active state, interval merge, sampleSeconds/pollSeconds, + duration and next-timestamp fallback, canonical `HOST\user` userId; + - live parity on yesterday against Python fallback: + `rust_csv_rows=3`, `python_csv_rows=3`, `csv_equal=True`, + JSON `host/bucket_id/from/to/rows` all equal; + - gates: `cargo fmt --all -- --check`, `cargo test -p + rdp-worktime-report` (`3 passed`), `cargo clippy -p + rdp-worktime-report --all-targets -- -D warnings`, release build OK, + `scripts/check_detmir_rust_release_artifacts.sh` OK, + `scripts/quality-gate.sh` OK, DetMir read-only status OK with + `dlp_counts={ok:22,warn:0,fail:0}` and `ok_for_operator=true`. +46. `[done]` Перенести Proxmox DetMir contour smoke на Rust-first helper: + - добавлен crate `aw-contour-smoke`; + - `scripts/aw-contour-smoke-10.10.10.2.sh` теперь Rust-first wrapper: + ищет `AW_CONTOUR_SMOKE_RUST`, + `$CARGO_TARGET_DIR/release/aw-contour-smoke`, + `adk-rust/target/release/aw-contour-smoke`, + `/usr/local/sbin/aw-contour-smoke`, + `/usr/local/bin/aw-contour-smoke`, затем использует legacy Bash + fallback; + - `scripts/aw-contour-smoke-local.sh` умеет временно доставлять release + binary на Proxmox для shadow smoke без постоянного platform change; + - remote parity: Rust и legacy оба вернули `OK=30 WARN=0 FAIL=3 SKIP=0`, + `rc=2`; все 3 fail совпали и относятся к существующему gateway-auth + `HTTP 401` на `/go/proxmox-gui`, `/go/file1c-brief`, + `/go/file1c-actions`, не к Rust regression; + - gates: `cargo fmt --all -- --check`, `cargo test -p + aw-contour-smoke` (`2 passed`), `cargo clippy -p aw-contour-smoke + --all-targets -- -D warnings`, release build OK, + `bash -n` wrappers OK, artifact check OK, `quality-gate.sh` OK. +47. `[done]` Перенести `scripts/diag_and_manual_restart.sh` на Rust-first + diagnostic helper: + - добавлен crate `diag-and-manual-restart`; + - wrapper ищет `DIAG_AND_MANUAL_RESTART_RUST`, + `$CARGO_TARGET_DIR/release/diag-and-manual-restart`, + `adk-rust/target/release/diag-and-manual-restart`, + `/usr/local/bin/diag-and-manual-restart`, затем использует legacy Bash + fallback; + - здоровый контур не перезапускается: helper запускает `aw-health-check` + и `dlp-health-check`, печатает `Diagnostics: healthy. Restart not + needed.` и выходит `0`; + - mutation path требует явного подтверждения или `--yes`; live restart не + запускался, потому что контур был зеленый; + - gates: fmt/test/clippy/release OK, `bash -n` OK, Rust-first healthy path + rc `0`, legacy fallback healthy path rc `0`, artifact check OK, + `quality-gate.sh` OK, DetMir read-only OK. +48. `[done]` Перенести browser smoke launcher на Rust-first helper: + - добавлен crate `aw-browser-smoke`; + - `scripts/aw-webui-browser-smoke.sh` теперь Rust-first wrapper: + ищет `AW_BROWSER_SMOKE_RUST`, + `$CARGO_TARGET_DIR/release/aw-browser-smoke`, + `adk-rust/target/release/aw-browser-smoke`, + `/usr/local/bin/aw-browser-smoke`, затем использует legacy Node + fallback; + - Playwright/Chromium логика остается в + `scripts/aw-webui-browser-smoke.mjs`; Rust отвечает за устойчивый запуск, + `NODE_PATH`, passthrough args и сохранение child exit code; + - fake-node проверки подтвердили passthrough `.mjs` path, user args, + `NODE_PATH` и exit-code propagation для Rust launcher и legacy fallback; + - live browser smoke через Rust-first wrapper: `ok=true`, страницы + `aw_webui_home`, `worktime_today_html`, + `worktime_management_html` все OK; + - gates: `cargo fmt --all -- --check`, `cargo test -p aw-browser-smoke` + (`3 passed`), `cargo clippy -p aw-browser-smoke --all-targets -- -D + warnings`, release build OK, `bash -n` OK, artifact check OK, + `quality-gate.sh` OK, DetMir read-only OK with + `dlp_counts={ok:22,warn:0,fail:0}` and `ok_for_operator=true`. +49. `[done]` Перенести `check-aw-full.sh` на Rust-first read-only helper: + - добавлен crate `check-aw-full`; + - root wrapper `check-aw-full.sh` ищет `CHECK_AW_FULL_RUST`, + `$CARGO_TARGET_DIR/release/check-aw-full`, + `adk-rust/target/release/check-aw-full`, `/usr/local/bin/check-aw-full`, + затем выполняет embedded legacy Bash fallback; + - для прямого legacy compare добавлен `CHECK_AW_FULL_FORCE_LEGACY=1`; + - Rust helper сохраняет операторский контракт: AW server connectivity, + CORS probe, 8 bucket freshness rows, RDP WinRM/SSH TCP checks, summary + `FRESH/STALE/DEAD`, recovery hint when stale/dead collectors exist; + - live parity: Rust-first и legacy оба показали connectivity OK, CORS OK, + WinRM OK, SSH OK, `FRESH=8 STALE=0 DEAD=0`; bucket statuses совпали, + один live event id у `Window watcher` ожидаемо сдвинулся между + последовательными запусками; + - gates: `cargo fmt --all -- --check`, `cargo test -p check-aw-full` + (`4 passed`), `cargo clippy -p check-aw-full --all-targets -- -D + warnings`, release build OK, `bash -n` OK, artifact check OK, + `quality-gate.sh` OK, DetMir read-only OK with + `dlp_counts={ok:22,warn:0,fail:0}` and `ok_for_operator=true`. +50. `[done]` Перенести `scripts/merge_aw_server_dbs.py` merge engine на Rust: + - добавлен crate `merge-aw-server-dbs`; + - `scripts/merge_aw_server_dbs.py` остается совместимым с текущим + `python3 merge_aw_server_dbs.py` вызовом, но в начале exec'ает Rust + binary при наличии `MERGE_AW_SERVER_DBS_RUST`, + `$CARGO_TARGET_DIR/release/merge-aw-server-dbs`, + `adk-rust/target/release/merge-aw-server-dbs` или + `/usr/local/bin/merge-aw-server-dbs`; + - `MERGE_AW_SERVER_DBS_FORCE_LEGACY=1` форсирует Python fallback; + - `ansible/deploy_aw_server.yml` теперь optional устанавливает + `/usr/local/bin/merge-aw-server-dbs`, если release artifact существует, + сохраняя прежний `python3 /usr/local/bin/merge_aw_server_dbs.py` command; + - Rust сохраняет контракт `--base`, `--overlay`, `--output`, SQLite + backup-copy base DB, bucket matching by id/key/name, duplicate event + suppression by `(starttime,endtime,data)`, JSON summary; + - fallback bugfix: Python legacy больше не падает на `UNIQUE(name)` + name-conflict/update path и считает только реально вставленные buckets; + - offline parity fixture: Rust и Python fallback дали одинаковый JSON + `inserted_buckets=1`, `inserted_events=3` и одинаковые SQLite table rows + (`buckets=3`, `events=5`) на кейсе duplicate event + name-conflict + bucket; + - gates: `cargo fmt --all -- --check`, `cargo test -p + merge-aw-server-dbs` (`2 passed`), `cargo clippy -p + merge-aw-server-dbs --all-targets -- -D warnings`, release build OK, + `python3 -m py_compile scripts/merge_aw_server_dbs.py`, artifact check + OK, `quality-gate.sh` OK, DetMir read-only OK after DLP baseline retry + with `dlp_counts={ok:22,warn:0,fail:0}` and `ok_for_operator=true`. +51. `[done]` Перенести `scripts/prod_backup_restore.sh` в safe-by-default + Rust planner/checker: + - добавлен crate `prod-backup-restore`; + - `scripts/prod_backup_restore.sh` теперь Rust-first wrapper: + ищет `PROD_BACKUP_RESTORE_RUST`, + `$CARGO_TARGET_DIR/release/prod-backup-restore`, + `adk-rust/target/release/prod-backup-restore`, + `/usr/local/bin/prod-backup-restore`; + - обычный запуск больше не выполняет restore при наличии Rust artifact: + он печатает plan-only flow и помечает destructive steps; + - если Rust artifact отсутствует, wrapper отказывается запускать + destructive legacy случайно и просит собрать Rust planner; + - старый destructive flow доступен только явно: + `scripts/prod_backup_restore.sh --apply-legacy`; + - Rust `--apply` намеренно запрещен на этом этапе; + - `--check-inputs --json` валидирует env/files/commands без вывода + секретов, проверяет `AW_SSH_PASSWORD`, `AW_WINRM_PASSWORD`, `sshpass`, + `ansible-playbook`, inventory и `merge_aw_server_dbs.py`; + - план сохраняет порядок legacy flow: scp merge script, remote backup dir, + DB existence checks, DB backups, stop `activitywatch-server`, merge DB, + install merged DB, 3 ansible playbooks, final AW validation; + - safe smoke: plan text OK, JSON valid (`steps=12`, `missing_count=0`), + destructive steps present and marked, password names/values не попадают в + planned commands, Rust `--apply` exits rc `1`; + - gates: `cargo fmt --all -- --check`, `cargo test -p + prod-backup-restore` (`3 passed`), `cargo clippy -p + prod-backup-restore --all-targets -- -D warnings`, release build OK, + `bash -n scripts/prod_backup_restore.sh`, artifact check OK, + `quality-gate.sh` OK, DetMir read-only OK with + `dlp_counts={ok:22,warn:0,fail:0}` and `ok_for_operator=true`; + transient `service_warnings=1` disappeared on immediate + `detmir-check --json` repeat (`service_warnings=0`). +52. `[done]` Перенести `scripts/prod_rollout.sh` в Rust-first safe + planner/orchestrator: + - добавлен crate `prod-rollout`; + - `scripts/prod_rollout.sh` теперь Rust-first wrapper: + ищет `PROD_ROLLOUT_RUST`, `$CARGO_TARGET_DIR/release/prod-rollout`, + `adk-rust/target/release/prod-rollout`, `/usr/local/bin/prod-rollout`; + - обычный запуск больше не стартует production deploy автоматически: + он показывает plan-only rollout flow; + - реальный rollout через Rust доступен только явно: + `scripts/prod_rollout.sh --apply`; + - старый Bash rollout сохранен только явно: + `scripts/prod_rollout.sh --apply-legacy`; + - `--check-inputs --json` валидирует `AW_SSH_PASSWORD`, + `AW_WINRM_PASSWORD`, `git`, `ansible`, `ansible-playbook`, inventory, + `quality-gate.sh`, `deploy_aw_server.yml`, `deploy_aw_windows.yml` и + `post_validate_aw_windows.yml` без вывода секретов; + - план сохраняет legacy порядок: `quality-gate`, Ansible ping AW server, + Ansible win_ping AW Windows, `deploy_aw_server --check --diff`, real + `deploy_aw_server`, `deploy_aw_windows --check --diff`, real + `deploy_aw_windows`, `post_validate_aw_windows`; + - mutation в плане помечена только для `deploy-aw-server` и + `deploy-aw-windows`; + - apply-runner пишет stdout/stderr каждого шага в + `.rollout-logs//`, общий ход - в `rollout.log`; + - safe smoke: `scripts/prod_rollout.sh --check-inputs --json` вернул + `mode=plan-only`, `missing_count=0`, `steps=8`, mutations + `deploy-aw-server` и `deploy-aw-windows`; + - gates: `cargo fmt --all -- --check`, `cargo test -p prod-rollout` + (`3 passed`), `cargo clippy -p prod-rollout --all-targets -- -D + warnings`, release build OK, `bash -n scripts/prod_rollout.sh`, + artifact check OK. + +Отложить: + +- перенос Telegram bot runtime снят с плана: Python остается постоянным + runtime, Rust используется только для backend helpers; +- перенос оставшихся install/runtime scripts на Rust; +- переписывание PowerShell Windows collector path; +- любые network/firewall/VPN изменения; +- pfSense полностью frozen/no-touch: текущего потенциала достаточно, NAT/gateway + tooling не переносить и не дергать без отдельной явной команды. + +## 12. Stop conditions + +Миграцию остановить и откатить конкретный модуль, если: + +- новый binary дает иной `severity` без объяснимой причины; +- меняет exit code class на зеленом или красном контуре; +- пишет неполный/corrupt state; +- вызывает restart на зеленом контуре; +- ломает Telegram `/status`; +- ломает Grafana/Influx/Prometheus metric names; +- требует laptop-only path или интерактивный shell; +- выводит секреты в stdout/journald/report. + +## 13. Рабочий принцип + +Правильный перенос на Rust - это не переписывание строк один-в-один. + +Для каждого legacy script надо сделать: + +1. понять контракт; +2. зафиксировать fixture; +3. написать typed model; +4. реализовать read-only parity; +5. включить shadow-mode; +6. заменить production command; +7. только потом добавлять action/mutation; +8. удалить legacy только после периода стабильной эксплуатации. diff --git a/adk-rust/crates/aw-browser-smoke/Cargo.toml b/adk-rust/crates/aw-browser-smoke/Cargo.toml new file mode 100644 index 0000000..1645204 --- /dev/null +++ b/adk-rust/crates/aw-browser-smoke/Cargo.toml @@ -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 diff --git a/adk-rust/crates/aw-browser-smoke/src/main.rs b/adk-rust/crates/aw-browser-smoke/src/main.rs new file mode 100644 index 0000000..355c22a --- /dev/null +++ b/adk-rust/crates/aw-browser-smoke/src/main.rs @@ -0,0 +1,146 @@ +use std::ffi::{OsStr, OsString}; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use anyhow::{Context, Result, bail}; +use clap::Parser; + +const PLAYWRIGHT_NODE_MODULES: &str = "/home/igor/.agents/skills/playwright/node_modules"; + +#[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, + + #[arg(long)] + node: Option, + + #[arg(last = true)] + args: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct LaunchPlan { + node: PathBuf, + script: PathBuf, + args: Vec, + node_path: Option, +} + +fn main() { + let code = match run() { + Ok(code) => code, + Err(err) => { + eprintln!("{err:#}"); + 1 + } + }; + std::process::exit(code); +} + +fn run() -> Result { + let cli = Cli::parse(); + let plan = build_launch_plan(&cli)?; + run_child(&plan) +} + +fn build_launch_plan(cli: &Cli) -> Result { + 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 { + if current.is_some_and(|value| !value.is_empty()) { + return None; + } + let path = Path::new(PLAYWRIGHT_NODE_MODULES); + if path.is_dir() { + Some(path.as_os_str().to_os_string()) + } else { + None + } +} + +fn run_child(plan: &LaunchPlan) -> Result { + 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")); + } +} diff --git a/adk-rust/crates/aw-contour-smoke/Cargo.toml b/adk-rust/crates/aw-contour-smoke/Cargo.toml new file mode 100644 index 0000000..5cb1b61 --- /dev/null +++ b/adk-rust/crates/aw-contour-smoke/Cargo.toml @@ -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 diff --git a/adk-rust/crates/aw-contour-smoke/src/main.rs b/adk-rust/crates/aw-contour-smoke/src/main.rs new file mode 100644 index 0000000..938c89e --- /dev/null +++ b/adk-rust/crates/aw-contour-smoke/src/main.rs @@ -0,0 +1,461 @@ +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) { + self.ok += 1; + println!("[OK] {}", msg.as_ref()); + } + + fn fail(&mut self, msg: impl AsRef) { + self.fail += 1; + println!("[FAIL] {}", msg.as_ref()); + } + + fn skip(&mut self, msg: impl AsRef) { + 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 { + let cli = Cli::parse(); + match cli.mode { + Mode::ProxmoxRemote => run_proxmox_remote(), + } +} + +fn run_proxmox_remote() -> Result { + 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_redirect( + &mut counts, + &no_redirect_http, + "go proxmox gui", + "https://127.0.0.1/go/proxmox-gui", + &[301, 302, 307, 308], + Some("https://10.10.10.2:8006/"), + ); + check_http_redirect( + &mut counts, + &no_redirect_http, + "go file1c brief", + "https://127.0.0.1/go/file1c-brief", + &[301, 302, 307, 308], + Some("http://10.10.10.2:8710/manager/brief"), + ); + check_http_redirect( + &mut counts, + &no_redirect_http, + "go file1c actions", + "https://127.0.0.1/go/file1c-actions", + &[301, 302, 307, 308], + Some("http://10.10.10.2:8710/manager/actions"), + ); + + 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 { + 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_http_redirect( + counts: &mut Counts, + client: &Client, + name: &str, + url: &str, + expected: &[u16], + expected_location: Option<&str>, +) { + match client.head(url).send() { + Ok(response) => { + let code = response.status().as_u16(); + let location = response + .headers() + .get(reqwest::header::LOCATION) + .and_then(|value| value.to_str().ok()) + .unwrap_or(""); + let location_ok = expected_location.is_none_or(|expected| location.contains(expected)); + if expected.contains(&code) && location_ok { + counts.pass(format!( + "{name} HTTP {code} {}", + if location.is_empty() { url } else { location } + )); + } else { + counts.fail(format!( + "{name} HTTP {code} {}", + if location.is_empty() { url } else { location } + )); + } + } + 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); + } +} diff --git a/adk-rust/crates/aw-db-health/Cargo.toml b/adk-rust/crates/aw-db-health/Cargo.toml new file mode 100644 index 0000000..3d1eae0 --- /dev/null +++ b/adk-rust/crates/aw-db-health/Cargo.toml @@ -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 diff --git a/adk-rust/crates/aw-db-health/src/main.rs b/adk-rust/crates/aw-db-health/src/main.rs new file mode 100644 index 0000000..0a6e440 --- /dev/null +++ b/adk-rust/crates/aw-db-health/src/main.rs @@ -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 = "SHARKON2025"; + +#[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, + + #[arg(long, env = "AW_WORKTIME_HOST")] + host: Option, + + #[arg(long, env = "AW_DB_HEALTH_WINDOWS_CONFIG")] + windows_config: Option, + + #[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, +} + +#[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 { + 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 { + 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 { + 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> { + 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 { + 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 { + 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> { + 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 { + 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 { + Ok(fs::metadata(path) + .with_context(|| format!("stat {}", path.display()))? + .len()) +} + +fn file_size_optional(path: &Path) -> Result { + 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 { + Utc::now() + .timestamp_nanos_opt() + .ok_or_else(|| anyhow!("current timestamp out of range")) +} + +fn json_field(data: &str, key: &str) -> Option { + let value = serde_json::from_str::(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); + } +} diff --git a/adk-rust/crates/aw-db-maintenance/Cargo.toml b/adk-rust/crates/aw-db-maintenance/Cargo.toml new file mode 100644 index 0000000..7a4ecf7 --- /dev/null +++ b/adk-rust/crates/aw-db-maintenance/Cargo.toml @@ -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 diff --git a/adk-rust/crates/aw-db-maintenance/src/main.rs b/adk-rust/crates/aw-db-maintenance/src/main.rs new file mode 100644 index 0000000..6a03156 --- /dev/null +++ b/adk-rust/crates/aw-db-maintenance/src/main.rs @@ -0,0 +1,359 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use anyhow::{Context, Result, bail}; +use chrono::{SecondsFormat, Utc}; +use clap::Parser; +use rusqlite::{Connection, DatabaseName, OpenFlags, params}; +use serde::Serialize; +use serde_json::Value; + +const DEFAULT_DB_PATH: &str = "/var/lib/activitywatch/aw-server-rust/sqlite.db"; +const DEFAULT_BACKUP_DIR: &str = "/var/lib/activitywatch/backups/db"; +const DEFAULT_HOST: &str = "SHARKON2025"; +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, + + #[arg(long, env = "AW_WORKTIME_HOST")] + host: Option, + + #[arg(long, default_value_t = 7, env = "AW_DB_MAINTENANCE_RETENTION_DAYS")] + retention_days: i64, + + #[arg(long, default_value_t = 1000, env = "AW_DB_MAINTENANCE_CHUNK_SIZE")] + chunk_size: usize, + + #[arg(long)] + apply: bool, + + #[arg(long)] + json: bool, +} + +#[derive(Debug, Serialize)] +struct Report { + apply: bool, + generated_at_utc: String, + db_path: String, + backup_path: Option, + bucket: String, + bucketrow: Option, + retention_days: i64, + cutoff_ns: i64, + allowed_event_types: Vec<&'static str>, + planned_delete_rows: usize, + deleted_rows: usize, + backup_created: bool, + skipped_reason: Option, +} + +fn main() { + let code = match run() { + Ok(code) => code, + Err(err) => { + eprintln!("{err:#}"); + 1 + } + }; + std::process::exit(code); +} + +fn run() -> Result { + let cli = Cli::parse(); + let report = build_report(&cli)?; + if cli.json { + println!("{}", serde_json::to_string_pretty(&report)?); + } else { + print_text(&report); + } + Ok(0) +} + +fn build_report(cli: &Cli) -> Result { + if cli.retention_days < 1 { + bail!("retention_days must be >= 1"); + } + if cli.chunk_size == 0 { + bail!("chunk_size must be > 0"); + } + let bucket = cli.session_bucket.clone().unwrap_or_else(|| { + format!( + "aw-session-events_{}", + cli.host.as_deref().unwrap_or(DEFAULT_HOST) + ) + }); + let cutoff_ns = (Utc::now().timestamp() - cli.retention_days * 86_400) * 1_000_000_000; + let conn = open_connection(&cli.db_path, cli.apply)?; + let bucketrow = bucket_row(&conn, &bucket)?; + let Some(bucketrow) = bucketrow else { + return Ok(base_report( + cli, + bucket, + None, + cutoff_ns, + 0, + 0, + None, + false, + Some("session bucket not found".to_string()), + )); + }; + + let delete_ids = find_deletable_event_ids(&conn, bucketrow, cutoff_ns)?; + let planned = delete_ids.len(); + let mut backup_file = None; + let mut backup_created = false; + let mut deleted = 0; + if cli.apply && planned > 0 { + fs::create_dir_all(&cli.backup_dir) + .with_context(|| format!("create backup dir {}", cli.backup_dir.display()))?; + let backup = backup_path(&cli.backup_dir); + copy_sqlite_via_backup(&cli.db_path, &backup)?; + backup_file = Some(backup); + backup_created = true; + deleted = delete_events(&conn, &delete_ids, cli.chunk_size)?; + } + + Ok(base_report( + cli, + bucket, + Some(bucketrow), + cutoff_ns, + planned, + deleted, + backup_file, + backup_created, + None, + )) +} + +#[allow(clippy::too_many_arguments)] +fn base_report( + cli: &Cli, + bucket: String, + bucketrow: Option, + cutoff_ns: i64, + planned_delete_rows: usize, + deleted_rows: usize, + backup_path: Option, + backup_created: bool, + skipped_reason: Option, +) -> Report { + Report { + apply: cli.apply, + generated_at_utc: Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true), + db_path: cli.db_path.display().to_string(), + backup_path: backup_path.map(|path| path.display().to_string()), + bucket, + bucketrow, + retention_days: cli.retention_days, + cutoff_ns, + allowed_event_types: ALLOWED_EVENT_TYPES.to_vec(), + planned_delete_rows, + deleted_rows, + backup_created, + skipped_reason, + } +} + +fn open_connection(path: &Path, writable: bool) -> Result { + 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> { + 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> { + 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::(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 { + let mut deleted = 0; + for chunk in ids.chunks(chunk_size) { + let tx = conn.unchecked_transaction()?; + { + let mut stmt = tx.prepare("delete from events where id = ?1")?; + for id in chunk { + deleted += stmt.execute([id])?; + } + } + tx.commit()?; + } + Ok(deleted) +} + +fn backup_path(backup_dir: &Path) -> PathBuf { + backup_dir.join(format!( + "aw-sqlite-before-db-maintenance-{}.db", + Utc::now().format("%Y%m%dT%H%M%SZ") + )) +} + +fn print_text(report: &Report) { + println!( + "aw-db-maintenance: {}", + if report.apply { "apply" } else { "dry-run" } + ); + println!("db_path: {}", report.db_path); + println!("bucket: {}", report.bucket); + println!("retention_days: {}", report.retention_days); + println!("planned_delete_rows: {}", report.planned_delete_rows); + println!("deleted_rows: {}", report.deleted_rows); + println!("backup_created: {}", report.backup_created); + if let Some(path) = &report.backup_path { + println!("backup_path: {path}"); + } + if let Some(reason) = &report.skipped_reason { + println!("skipped_reason: {reason}"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_allows_process_start_stop_events() { + assert!(is_allowed_process_event(r#"{"eventType":"process_start"}"#)); + assert!(is_allowed_process_event( + r#"{"data":{"eventType":"process_stop"}}"# + )); + assert!(!is_allowed_process_event(r#"{"eventType":"logon"}"#)); + assert!(!is_allowed_process_event(r#"not-json"#)); + } + + #[test] + fn dry_run_does_not_delete_or_backup() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("aw.db"); + create_fixture_db(&db); + let cli = Cli { + db_path: db.clone(), + backup_dir: dir.path().join("backups"), + session_bucket: Some("aw-session-events_TEST".to_string()), + host: None, + retention_days: 7, + chunk_size: 100, + apply: false, + json: true, + }; + let report = build_report(&cli).unwrap(); + assert_eq!(report.planned_delete_rows, 2); + assert_eq!(report.deleted_rows, 0); + assert!(!report.backup_created); + assert_eq!(count_events(&db), 3); + } + + #[test] + fn apply_deletes_only_old_process_events_and_keeps_logon() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("aw.db"); + create_fixture_db(&db); + let cli = Cli { + db_path: db.clone(), + backup_dir: dir.path().join("backups"), + session_bucket: Some("aw-session-events_TEST".to_string()), + host: None, + retention_days: 7, + chunk_size: 1, + apply: true, + json: true, + }; + let report = build_report(&cli).unwrap(); + assert_eq!(report.planned_delete_rows, 2); + assert_eq!(report.deleted_rows, 2); + assert!(report.backup_created); + assert_eq!(count_events(&db), 1); + } + + fn create_fixture_db(path: &Path) { + let conn = Connection::open(path).unwrap(); + conn.execute_batch( + "create table buckets (name text); + create table events (id integer primary key autoincrement, bucketrow integer, starttime integer, endtime integer, data text);", + ) + .unwrap(); + conn.execute( + "insert into buckets (name) values ('aw-session-events_TEST')", + [], + ) + .unwrap(); + conn.execute( + "insert into events (bucketrow,starttime,endtime,data) values (1,1,2,'{\"eventType\":\"process_start\"}')", + [], + ) + .unwrap(); + conn.execute( + "insert into events (bucketrow,starttime,endtime,data) values (1,3,4,'{\"eventType\":\"process_stop\"}')", + [], + ) + .unwrap(); + conn.execute( + "insert into events (bucketrow,starttime,endtime,data) values (1,5,6,'{\"eventType\":\"logon\"}')", + [], + ) + .unwrap(); + } + + fn count_events(path: &Path) -> i64 { + Connection::open(path) + .unwrap() + .query_row("select count(*) from events", [], |row| row.get(0)) + .unwrap() + } +} diff --git a/adk-rust/crates/aw-health-check/Cargo.toml b/adk-rust/crates/aw-health-check/Cargo.toml new file mode 100644 index 0000000..4ee4a9e --- /dev/null +++ b/adk-rust/crates/aw-health-check/Cargo.toml @@ -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 diff --git a/adk-rust/crates/aw-health-check/src/main.rs b/adk-rust/crates/aw-health-check/src/main.rs new file mode 100644 index 0000000..4e01c57 --- /dev/null +++ b/adk-rust/crates/aw-health-check/src/main.rs @@ -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, + warnings: Vec, +} + +fn main() { + let code = match run() { + Ok(code) => code, + Err(err) => { + eprintln!("{err:#}"); + 1 + } + }; + std::process::exit(code); +} + +fn run() -> Result { + 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 { + 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 { + 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::()); + 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) { + 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::(&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) { + 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::(&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 { + 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> { + let mut env = std::env::vars().collect::>(); + 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 { + 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, 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, key: &str, default: i64) -> i64 { + env.get(key) + .and_then(|value| value.parse::().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/SHARKON2025/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/SHARKON2025/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"); + } +} diff --git a/adk-rust/crates/aw-prune-local-state/Cargo.toml b/adk-rust/crates/aw-prune-local-state/Cargo.toml new file mode 100644 index 0000000..bd0bfff --- /dev/null +++ b/adk-rust/crates/aw-prune-local-state/Cargo.toml @@ -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 diff --git a/adk-rust/crates/aw-prune-local-state/src/main.rs b/adk-rust/crates/aw-prune-local-state/src/main.rs new file mode 100644 index 0000000..9b2de29 --- /dev/null +++ b/adk-rust/crates/aw-prune-local-state/src/main.rs @@ -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, + + #[arg(long)] + backup_dir: Option, + + #[arg(long)] + browser_smoke_dir: Option, + + #[arg(long)] + tmp_dir: Option, + + #[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, + size_bytes: u64, +} + +#[derive(Debug, Serialize)] +struct Summary { + apply: bool, + planned: usize, + deleted: usize, + failed: usize, + bytes: u64, + items: Vec, + errors: Vec, +} + +#[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 { + 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, +) -> 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) -> 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, +) -> 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> { + 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> { + 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 { + SystemTime::now() + .duration_since(modified) + .ok() + .map(|age| age.as_secs() / 86_400) +} + +fn dir_size(path: &Path) -> Result { + 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 { + 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::().ok()) + .unwrap_or(default) +} + +fn env_usize(name: &str, default: usize) -> usize { + std::env::var(name) + .ok() + .and_then(|value| value.parse::().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()); + } +} diff --git a/adk-rust/crates/aw-rus-healthd/Cargo.toml b/adk-rust/crates/aw-rus-healthd/Cargo.toml new file mode 100644 index 0000000..1e459c3 --- /dev/null +++ b/adk-rust/crates/aw-rus-healthd/Cargo.toml @@ -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 diff --git a/adk-rust/crates/aw-rus-healthd/src/main.rs b/adk-rust/crates/aw-rus-healthd/src/main.rs new file mode 100644 index 0000000..233b4c3 --- /dev/null +++ b/adk-rust/crates/aw-rus-healthd/src/main.rs @@ -0,0 +1,1032 @@ +use std::collections::HashMap; +use std::fs; +use std::io::Write; +use std::net::{SocketAddr, TcpStream}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result}; +use chrono::{DateTime, Duration as ChronoDuration, SecondsFormat, Utc}; +use clap::Parser; +use detmir_core::{exit_codes, parse_utc_rfc3339}; +use reqwest::blocking::Client; +use serde::Serialize; +use serde_json::{Value, json}; + +const ENV_FILE: &str = "/etc/activitywatch/aw-server.env"; + +#[derive(Debug, Parser)] +#[command(about = "Unified AW-RUS health orchestrator.")] +struct Cli { + #[arg(long, default_value = "http://127.0.0.1:5600")] + aw_server: String, + + #[arg(long, default_value = "http://127.0.0.1:5610")] + worktime_api: String, + + #[arg(long, default_value = "192.168.100.18")] + rdp_host: String, + + #[arg(long, default_value = "SHARKON2025")] + rdp_hostname: String, + + #[arg(long, default_value = "/var/lib/activitywatch/health")] + state_dir: PathBuf, + + #[arg( + long, + default_value = "/var/lib/activitywatch/health/windows-validation" + )] + validation_dir: PathBuf, + + #[arg(long, default_value_t = 900)] + session_max_age_seconds: i64, + + #[arg(long, default_value_t = 900)] + interactive_max_age_seconds: i64, + + #[arg(long, default_value_t = 86400)] + session_events_max_age_seconds: i64, + + #[arg(long, default_value_t = 300)] + guard_max_age_seconds: i64, + + #[arg(long)] + guard_required: bool, + + #[arg(long, default_value_t = 259200)] + validation_max_age_seconds: i64, + + #[arg(long, default_value_t = 3.0)] + tcp_timeout_seconds: f64, + + #[arg(long)] + json: bool, +} + +impl Cli { + fn apply_env(mut self) -> Self { + load_env_file(Path::new(ENV_FILE)); + if !cli_arg_present("--aw-server") { + self.aw_server = env_string("AW_SERVER_URL").unwrap_or(self.aw_server); + } + if !cli_arg_present("--worktime-api") { + self.worktime_api = env_string("AW_RUS_HEALTH_WORKTIME_API") + .or_else(|| env_string("AW_WORKTIME_REPORT_BASE")) + .unwrap_or(self.worktime_api); + } + if !cli_arg_present("--rdp-host") { + self.rdp_host = env_string("AW_MONITORED_WINDOWS_HOST").unwrap_or(self.rdp_host); + } + if !cli_arg_present("--rdp-hostname") { + self.rdp_hostname = + env_string("AW_MONITORED_WINDOWS_HOSTNAME").unwrap_or(self.rdp_hostname); + } + if !cli_arg_present("--state-dir") { + self.state_dir = env_path("AW_RUS_HEALTH_STATE_DIR").unwrap_or(self.state_dir); + } + if !cli_arg_present("--validation-dir") { + self.validation_dir = + env_path("AW_RUS_HEALTH_VALIDATION_DIR").unwrap_or(self.validation_dir); + } + if !cli_arg_present("--session-max-age-seconds") { + self.session_max_age_seconds = env_i64( + "AW_RUS_HEALTH_SESSION_MAX_AGE_SECONDS", + self.session_max_age_seconds, + ); + } + if !cli_arg_present("--interactive-max-age-seconds") { + self.interactive_max_age_seconds = env_i64( + "AW_RUS_HEALTH_INTERACTIVE_MAX_AGE_SECONDS", + self.interactive_max_age_seconds, + ); + } + if !cli_arg_present("--session-events-max-age-seconds") { + self.session_events_max_age_seconds = env_i64( + "AW_RUS_HEALTH_SESSION_EVENTS_MAX_AGE_SECONDS", + self.session_events_max_age_seconds, + ); + } + if !cli_arg_present("--guard-max-age-seconds") { + self.guard_max_age_seconds = env_i64( + "AW_RUS_HEALTH_GUARD_MAX_AGE_SECONDS", + self.guard_max_age_seconds, + ); + } + if !cli_arg_present("--guard-required") { + self.guard_required = env_bool("AW_RUS_HEALTH_GUARD_REQUIRED"); + } + if !cli_arg_present("--validation-max-age-seconds") { + self.validation_max_age_seconds = env_i64( + "AW_RUS_HEALTH_VALIDATION_MAX_AGE_SECONDS", + self.validation_max_age_seconds, + ); + } + if !cli_arg_present("--tcp-timeout-seconds") { + self.tcp_timeout_seconds = env_f64( + "AW_RUS_HEALTH_TCP_TIMEOUT_SECONDS", + self.tcp_timeout_seconds, + ); + } + self + } +} + +fn cli_arg_present(name: &str) -> bool { + std::env::args_os().skip(1).any(|arg| { + let Some(value) = arg.to_str() else { + return false; + }; + value == name + || value + .strip_prefix(name) + .is_some_and(|rest| rest.starts_with('=')) + }) +} + +#[derive(Debug, Clone, Serialize)] +struct CheckResult { + name: String, + status: String, + summary: String, + details: Value, +} + +#[derive(Debug, Serialize)] +struct HealthReport { + generated_at_utc: String, + ok: bool, + counts: HashMap, + results: Vec, +} + +#[derive(Default)] +struct ReportBuilder { + results: Vec, +} + +impl ReportBuilder { + fn add( + &mut self, + name: impl Into, + status: &str, + summary: impl Into, + details: Value, + ) { + self.results.push(CheckResult { + name: name.into(), + status: status.to_string(), + summary: summary.into(), + details, + }); + } + + fn build(&self) -> HealthReport { + let mut counts = HashMap::from([ + ("ok".to_string(), 0), + ("warn".to_string(), 0), + ("fail".to_string(), 0), + ]); + for item in &self.results { + *counts.entry(item.status.clone()).or_insert(0) += 1; + } + HealthReport { + generated_at_utc: utc_iso(), + ok: !self.results.iter().any(|item| item.status == "fail"), + counts, + results: self.results.clone(), + } + } +} + +fn env_string(name: &str) -> Option { + std::env::var(name).ok().filter(|value| !value.is_empty()) +} + +fn env_path(name: &str) -> Option { + env_string(name).map(PathBuf::from) +} + +fn env_i64(name: &str, fallback: i64) -> i64 { + env_string(name) + .and_then(|value| value.parse().ok()) + .unwrap_or(fallback) +} + +fn env_f64(name: &str, fallback: f64) -> f64 { + env_string(name) + .and_then(|value| value.parse().ok()) + .unwrap_or(fallback) +} + +fn env_bool(name: &str) -> bool { + env_string(name) + .map(|value| matches!(value.to_ascii_lowercase().as_str(), "1" | "true" | "yes")) + .unwrap_or(false) +} + +fn load_env_file(path: &Path) { + let Ok(raw) = fs::read_to_string(path) else { + return; + }; + for line in raw.lines().map(str::trim) { + if line.is_empty() || line.starts_with('#') || !line.contains('=') { + continue; + } + let Some((key, value)) = line.split_once('=') else { + continue; + }; + if std::env::var_os(key.trim()).is_none() { + let value = value + .trim() + .trim_matches('"') + .trim_matches('\'') + .to_string(); + // SAFETY: this binary is single-threaded during configuration loading. + unsafe { std::env::set_var(key.trim(), value) }; + } + } +} + +fn utc_iso() -> String { + Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true) +} + +fn parse_ts(value: Option<&str>) -> Option> { + value.and_then(|value| parse_utc_rfc3339(value).ok()) +} + +fn age_seconds(ts: Option>) -> Option { + ts.map(|ts| (Utc::now() - ts).num_seconds().max(0)) +} + +fn event_effective_ts(event: &Value) -> Option> { + let timestamp = parse_ts(event.get("timestamp").and_then(Value::as_str))?; + let duration_ms = event + .get("duration") + .and_then(Value::as_f64) + .filter(|duration| duration.is_finite() && *duration > 0.0) + .map(|duration| (duration * 1000.0).round() as i64) + .unwrap_or(0); + Some(timestamp + ChronoDuration::milliseconds(duration_ms)) +} + +fn http_json(client: &Client, url: &str, attempts: usize) -> Result { + let attempts = attempts.max(1); + let mut last_error = None; + for attempt in 0..attempts { + let result = client + .get(url) + .send() + .with_context(|| format!("HTTP request failed: {url}")) + .and_then(|response| response.error_for_status().context("HTTP status error")) + .and_then(|response| response.json::().context("invalid JSON response")); + match result { + Ok(value) => return Ok(value), + Err(err) => last_error = Some(err), + } + if attempt + 1 < attempts { + std::thread::sleep(Duration::from_millis(500 * (1 << attempt))); + } + } + Err(last_error.unwrap_or_else(|| anyhow::anyhow!("HTTP request failed: {url}"))) +} + +fn latest_bucket_event(client: &Client, api_base: &str, bucket_id: &str) -> Result> { + let events = http_json( + client, + &format!("{api_base}/buckets/{bucket_id}/events?limit=20"), + 2, + )?; + let Some(items) = events.as_array() else { + return Ok(None); + }; + let mut objects = items + .iter() + .filter(|item| item.is_object()) + .cloned() + .collect::>(); + objects.sort_by(|left, right| event_effective_ts(right).cmp(&event_effective_ts(left))); + Ok(objects.into_iter().next()) +} + +fn bucket_metadata_ts(buckets: &Value, bucket_id: &str) -> Option> { + parse_ts( + buckets + .get(bucket_id)? + .get("metadata")? + .get("end")? + .as_str(), + ) +} + +fn host_activity_from_worktime(event: Option<&Value>, max_age_seconds: i64) -> Value { + let Some(event) = event else { + return json!({"fresh": false, "active": false, "age_seconds": null, "timestamp": null}); + }; + let timestamp = event.get("timestamp").and_then(Value::as_str); + let age = age_seconds(parse_ts(timestamp)); + let data = event.get("data").cloned().unwrap_or_else(|| json!({})); + let fresh = age.is_some_and(|age| age <= max_age_seconds); + let active = fresh && data.get("active").and_then(Value::as_bool).unwrap_or(false); + json!({ + "fresh": fresh, + "active": active, + "age_seconds": age, + "timestamp": timestamp, + "data": data, + }) +} + +fn bucket_health( + client: &Client, + api_base: &str, + bucket_id: &str, + max_age_seconds: i64, + missing_status: &str, + stale_status: &str, +) -> (String, String, Value) { + let event = match latest_bucket_event(client, api_base, bucket_id) { + Ok(event) => event, + Err(err) => { + return ( + "fail".to_string(), + format!("bucket query failed: {err}"), + json!({"bucket": bucket_id}), + ); + } + }; + let Some(event) = event else { + return ( + missing_status.to_string(), + "no events".to_string(), + json!({"bucket": bucket_id}), + ); + }; + let timestamp = event.get("timestamp").and_then(Value::as_str); + let effective_ts = event_effective_ts(&event); + let age = age_seconds(effective_ts); + let timestamp_source = if event + .get("duration") + .and_then(Value::as_f64) + .is_some_and(|duration| duration.is_finite() && duration > 0.0) + { + "event.timestamp+duration" + } else { + "event.timestamp" + }; + let details = json!({ + "bucket": bucket_id, + "timestamp": timestamp, + "effective_timestamp": effective_ts.map(|ts| ts.to_rfc3339_opts(SecondsFormat::Secs, true)), + "timestamp_source": timestamp_source, + "age_seconds": age, + }); + match age { + None => ( + "warn".to_string(), + "timestamp parse failed".to_string(), + details, + ), + Some(age) if age > max_age_seconds => { + (stale_status.to_string(), format!("stale ({age}s)"), details) + } + Some(age) => ("ok".to_string(), format!("fresh ({age}s)"), details), + } +} + +fn bucket_timestamp_health( + client: &Client, + api_base: &str, + buckets: &Value, + bucket_id: &str, + max_age_seconds: i64, + missing_status: &str, + stale_status: &str, +) -> (String, String, Value) { + if let Some(ts) = bucket_metadata_ts(buckets, bucket_id) { + let age = age_seconds(Some(ts)); + let timestamp = ts.to_rfc3339_opts(SecondsFormat::Secs, true); + let details = json!({ + "bucket": bucket_id, + "timestamp": timestamp, + "age_seconds": age, + "timestamp_source": "bucket_metadata.end", + }); + return match age { + None => ( + "warn".to_string(), + "timestamp parse failed".to_string(), + details, + ), + Some(age) if age > max_age_seconds => { + (stale_status.to_string(), format!("stale ({age}s)"), details) + } + Some(age) => ("ok".to_string(), format!("fresh ({age}s)"), details), + }; + } + bucket_health( + client, + api_base, + bucket_id, + max_age_seconds, + missing_status, + stale_status, + ) +} + +fn guard_bucket_health( + client: &Client, + api_base: &str, + host: &str, + max_age_seconds: i64, + required: bool, +) -> (String, String, Value) { + let bucket_id = format!("aw-rus-collector-guard_{host}"); + let event = match latest_bucket_event(client, api_base, &bucket_id) { + Ok(event) => event, + Err(err) => { + return ( + if required { "fail" } else { "warn" }.to_string(), + format!("guard bucket query failed: {err}"), + json!({"bucket": bucket_id}), + ); + } + }; + let Some(event) = event else { + return ( + if required { "fail" } else { "warn" }.to_string(), + "no guard heartbeat".to_string(), + json!({"bucket": bucket_id, "required": required}), + ); + }; + let timestamp = event.get("timestamp").and_then(Value::as_str); + let age = age_seconds(parse_ts(timestamp)); + let data = event + .get("data") + .filter(|value| value.is_object()) + .cloned() + .unwrap_or_else(|| json!({})); + let guard_status = data + .get("status") + .and_then(Value::as_str) + .unwrap_or("unknown") + .to_ascii_lowercase(); + let details = json!({ + "bucket": bucket_id, + "timestamp": timestamp, + "age_seconds": age, + "required": required, + "guard_status": guard_status, + "mode": data.get("mode").cloned().unwrap_or(Value::Null), + "live_session_count": data.get("liveSessionCount").cloned().unwrap_or(Value::Null), + "problems": data.get("problems").cloned().unwrap_or_else(|| json!([])), + "actions": data.get("actions").cloned().unwrap_or_else(|| json!([])), + }); + let stale_status = if required { "fail" } else { "warn" }; + match age { + None => ( + "warn".to_string(), + "guard timestamp parse failed".to_string(), + details, + ), + Some(age) if age > max_age_seconds => ( + stale_status.to_string(), + format!("guard stale ({age}s)"), + details, + ), + Some(_) if matches!(guard_status.as_str(), "fail" | "error") => ( + if required { "fail" } else { "warn" }.to_string(), + format!("guard reports {guard_status}"), + details, + ), + Some(_) if guard_status == "warn" => ( + "warn".to_string(), + "guard reports warn".to_string(), + details, + ), + Some(age) => ("ok".to_string(), format!("guard fresh ({age}s)"), details), + } +} + +fn run_command(cmd: &[&str]) -> (i32, String) { + let timeout = env_f64("AW_RUS_HEALTH_WRAPPER_TIMEOUT_SECONDS", 20.0).max(1.0); + let mut child = match Command::new(cmd[0]) + .args(&cmd[1..]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + { + Ok(child) => child, + Err(err) => return (1, err.to_string()), + }; + let started = Instant::now(); + loop { + match child.try_wait() { + Ok(Some(_)) => match child.wait_with_output() { + Ok(output) => { + let mut text = String::from_utf8_lossy(&output.stdout).to_string(); + if !output.stderr.is_empty() { + text.push_str(&String::from_utf8_lossy(&output.stderr)); + } + return (output.status.code().unwrap_or(1), text.trim().to_string()); + } + Err(err) => return (1, err.to_string()), + }, + Ok(None) if started.elapsed() >= Duration::from_secs_f64(timeout) => { + let _ = child.kill(); + let output = child.wait_with_output(); + let mut text = format!("timed out after {timeout:.1}s"); + if let Ok(output) = output { + if !output.stdout.is_empty() { + text.push('\n'); + text.push_str(&String::from_utf8_lossy(&output.stdout)); + } + if !output.stderr.is_empty() { + text.push('\n'); + text.push_str(&String::from_utf8_lossy(&output.stderr)); + } + } + return (124, text.trim().to_string()); + } + Ok(None) => std::thread::sleep(Duration::from_millis(100)), + Err(err) => { + let _ = child.kill(); + return (1, err.to_string()); + } + } + } +} + +fn check_wrapper( + report: &mut ReportBuilder, + name: &str, + cmd: &[&str], + json_mode: bool, + failure_status: &str, +) { + if !Path::new(cmd[0]).exists() { + report.add(name, "warn", "binary missing", json!({"command": cmd})); + return; + } + let (rc, output) = run_command(cmd); + let mut details = json!({"command": cmd, "returncode": rc}); + if json_mode { + match serde_json::from_str::(&output) { + Ok(payload) => details["payload"] = payload, + Err(_) => { + details["raw_output"] = Value::String(output); + report.add(name, "fail", "invalid JSON output", details); + return; + } + } + } else { + details["output"] = Value::String(output); + } + report.add( + name, + if rc == 0 { "ok" } else { failure_status }, + if rc == 0 { "passed" } else { "failed" }, + details, + ); +} + +fn tcp_connect(host: &str, port: u16, timeout_seconds: f64) -> (bool, String) { + let addr = match format!("{host}:{port}").parse::() { + Ok(addr) => addr, + Err(err) => return (false, err.to_string()), + }; + match TcpStream::connect_timeout(&addr, Duration::from_secs_f64(timeout_seconds)) { + Ok(_) => (true, "connected".to_string()), + Err(err) => (false, err.to_string()), + } +} + +fn latest_validation_report(dir: &Path) -> Option { + let mut entries = fs::read_dir(dir) + .ok()? + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| { + path.is_file() + && path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.ends_with("-aw_validate_ansible.json")) + }) + .collect::>(); + entries.sort_by_key(|path| path.metadata().and_then(|meta| meta.modified()).ok()); + entries.pop() +} + +fn write_atomic(path: &Path, content: &str) -> Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let parent = path.parent().unwrap_or_else(|| Path::new(".")); + let mut tmp = tempfile::NamedTempFile::new_in(parent)?; + tmp.write_all(content.as_bytes())?; + tmp.persist(path) + .map_err(|err| anyhow::anyhow!("failed to persist {}: {}", path.display(), err.error))?; + Ok(()) +} + +fn chmod_if_possible(path: &Path, mode: u32) { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if let Ok(meta) = path.metadata() { + let mut perms = meta.permissions(); + perms.set_mode(mode); + let _ = fs::set_permissions(path, perms); + } + } +} + +fn render_text(report: &HealthReport) -> String { + let mut lines = vec![ + "=== AW-RUS Health ===".to_string(), + format!("Timestamp: {}", utc_iso()), + String::new(), + ]; + for item in &report.results { + lines.push(format!( + "[{}] {}: {}", + item.status.to_ascii_uppercase(), + item.name, + item.summary + )); + } + lines.push(String::new()); + lines.push(format!( + "Counts: ok={} warn={} fail={}", + report.counts.get("ok").copied().unwrap_or(0), + report.counts.get("warn").copied().unwrap_or(0), + report.counts.get("fail").copied().unwrap_or(0) + )); + lines.push(format!( + "Overall: {}", + if report.ok { "OK" } else { "FAIL" } + )); + lines.join("\n") +} + +fn normalize_aw_api_base(aw_server: &str) -> String { + let trimmed = aw_server.trim_end_matches('/'); + if trimmed.ends_with("/api/0") { + trimmed.to_string() + } else { + format!("{trimmed}/api/0") + } +} + +fn validation_check(report: &mut ReportBuilder, validation_dir: &Path, max_age_seconds: i64) { + let Some(path) = latest_validation_report(validation_dir) else { + report.add( + "validation:windows", + "warn", + "no validation report snapshot", + json!({"directory": validation_dir}), + ); + return; + }; + let raw = match fs::read_to_string(&path) { + Ok(raw) => raw.trim_start_matches('\u{feff}').to_string(), + Err(err) => { + report.add( + "validation:windows", + "fail", + format!("invalid validation snapshot: {err}"), + json!({"path": path}), + ); + return; + } + }; + let payload = match serde_json::from_str::(&raw) { + Ok(payload) => payload, + Err(err) => { + report.add( + "validation:windows", + "fail", + format!("invalid validation snapshot: {err}"), + json!({"path": path}), + ); + return; + } + }; + let age = path + .metadata() + .and_then(|meta| meta.modified()) + .ok() + .and_then(|modified| modified.elapsed().ok()) + .map(|elapsed| elapsed.as_secs() as i64); + if age.is_some_and(|age| age > max_age_seconds) { + report.add( + "validation:windows", + "warn", + format!("validation snapshot is stale ({}s)", age.unwrap_or(0)), + json!({ + "path": path, + "overall_ok": payload.get("overallOk").cloned().unwrap_or(Value::Null), + "failed_sections": payload.pointer("/summary/failedSections").cloned().unwrap_or_else(|| json!([])), + }), + ); + } else if payload.get("overallOk").and_then(Value::as_bool) == Some(true) { + report.add( + "validation:windows", + "ok", + "validation snapshot OK", + json!({"path": path, "age_seconds": age}), + ); + } else { + report.add( + "validation:windows", + "fail", + "validation snapshot reports failure", + json!({ + "path": path, + "age_seconds": age, + "failed_sections": payload.pointer("/summary/failedSections").cloned().unwrap_or_else(|| json!([])), + }), + ); + } +} + +fn run(cli: &Cli) -> Result { + let client = Client::builder() + .timeout(Duration::from_secs(25)) + .no_proxy() + .build()?; + let mut report = ReportBuilder::default(); + let aw_api_base = normalize_aw_api_base(&cli.aw_server); + + check_wrapper( + &mut report, + "wrapper:aw-health-check", + &["/usr/local/bin/aw-health-check"], + false, + "warn", + ); + check_wrapper( + &mut report, + "wrapper:dlp-health-check", + &["/usr/local/bin/dlp-health-check", "--json"], + true, + "warn", + ); + + match http_json(&client, &format!("{aw_api_base}/info"), 2) { + Ok(info) => report.add( + "http:aw-server", + "ok", + "activitywatch API responded", + json!({"version": info.get("version").cloned().unwrap_or(Value::Null)}), + ), + Err(err) => report.add( + "http:aw-server", + "fail", + format!("activitywatch API failed: {err}"), + json!({"url": format!("{aw_api_base}/info")}), + ), + } + + match http_json( + &client, + &format!("{}/health", cli.worktime_api.trim_end_matches('/')), + 2, + ) { + Ok(payload) => report.add( + "http:worktime-api", + "ok", + "worktime API responded", + json!({"payload": if payload.is_object() { payload } else { json!({}) }}), + ), + Err(err) => report.add( + "http:worktime-api", + "fail", + format!("worktime API failed: {err}"), + json!({"url": cli.worktime_api}), + ), + } + + for (port, label) in [(5985_u16, "winrm"), (3389_u16, "rdp")] { + let (ok, message) = tcp_connect(&cli.rdp_host, port, cli.tcp_timeout_seconds); + report.add( + format!("tcp:{label}"), + if ok { "ok" } else { "fail" }, + if ok { + message + } else { + format!("unreachable: {message}") + }, + json!({"host": cli.rdp_host, "port": port}), + ); + } + + let buckets = match http_json(&client, &format!("{aw_api_base}/buckets"), 2) { + Ok(buckets) if buckets.is_object() => { + report.add( + "aw:buckets-index", + "ok", + "bucket index loaded", + json!({"total": buckets.as_object().map(|value| value.len()).unwrap_or(0)}), + ); + buckets + } + Ok(_) => { + report.add( + "aw:buckets-index", + "fail", + "failed to load bucket index: bucket index is not a dict", + json!({}), + ); + json!({}) + } + Err(err) => { + report.add( + "aw:buckets-index", + "fail", + format!("failed to load bucket index: {err}"), + json!({}), + ); + json!({}) + } + }; + + let host = &cli.rdp_hostname; + let (status, summary, details) = guard_bucket_health( + &client, + &aw_api_base, + host, + cli.guard_max_age_seconds, + cli.guard_required, + ); + report.add("bucket:collector-guard", &status, summary, details); + + let worktime_bucket = format!("aw-worktime-sessions_{host}"); + let worktime_event = if buckets.is_object() { + latest_bucket_event(&client, &aw_api_base, &worktime_bucket) + .ok() + .flatten() + } else { + None + }; + let activity = + host_activity_from_worktime(worktime_event.as_ref(), cli.session_max_age_seconds); + if worktime_event.is_some() { + let (status, summary, mut details) = bucket_health( + &client, + &aw_api_base, + &worktime_bucket, + cli.session_max_age_seconds, + "fail", + "fail", + ); + details["host_activity"] = activity.clone(); + report.add("bucket:worktime-sessions", &status, summary, details); + } else { + report.add( + "bucket:worktime-sessions", + "fail", + "no events", + json!({"bucket": worktime_bucket, "host_activity": activity}), + ); + } + + let interactive_required = activity + .get("active") + .and_then(Value::as_bool) + .unwrap_or(false); + for (bucket_name, label) in [ + ("aw-watcher-afk", "bucket:afk"), + ("aw-watcher-window", "bucket:window"), + ("aw-dlp-endpoint-signals", "bucket:endpoint-signals"), + ] { + let (mut status, mut summary, mut details) = bucket_timestamp_health( + &client, + &aw_api_base, + &buckets, + &format!("{bucket_name}_{host}"), + cli.interactive_max_age_seconds, + if interactive_required { "fail" } else { "warn" }, + if interactive_required { "fail" } else { "warn" }, + ); + details["interactive_required"] = Value::Bool(interactive_required); + details["host_activity"] = activity.clone(); + if !interactive_required && status != "ok" { + details["inactive_summary"] = Value::String(summary); + status = "ok".to_string(); + summary = "inactive: no active interactive users".to_string(); + } + report.add(label, &status, summary, details); + } + + let (mut status, mut summary, details) = bucket_timestamp_health( + &client, + &aw_api_base, + &buckets, + &format!("aw-session-events_{host}"), + cli.session_events_max_age_seconds, + "fail", + "warn", + ); + if status == "warn" { + if let Some(age) = details.get("age_seconds").and_then(Value::as_i64) { + status = "ok".to_string(); + summary = format!("event-driven ({age}s since last logon marker)"); + } + } + report.add("bucket:session-events", &status, summary, details); + + validation_check( + &mut report, + &cli.validation_dir, + cli.validation_max_age_seconds, + ); + Ok(report.build()) +} + +fn main() -> Result<()> { + let cli = Cli::parse().apply_env(); + let report = run(&cli)?; + let json_text = serde_json::to_string_pretty(&report)? + "\n"; + let text = render_text(&report) + "\n"; + let json_path = cli.state_dir.join("aw-rus-health.json"); + let text_path = cli.state_dir.join("aw-rus-health.txt"); + write_atomic(&json_path, &json_text)?; + write_atomic(&text_path, &text)?; + chmod_if_possible(&json_path, 0o644); + chmod_if_possible(&text_path, 0o644); + if cli.json { + print!("{json_text}"); + } else { + print!("{text}"); + } + std::process::exit(if report.ok { + exit_codes::OK + } else { + exit_codes::ERROR + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn activity_from_worktime_marks_recent_active_session() { + let event = json!({"timestamp": "2026-05-18T10:00:00Z", "data": {"active": true}}); + let activity = host_activity_from_worktime(Some(&event), i64::MAX); + assert_eq!(activity["active"], true); + assert_eq!(activity["fresh"], true); + } + + #[test] + fn heartbeat_missing_activity_is_inactive() { + let activity = host_activity_from_worktime(None, 900); + assert_eq!(activity["active"], false); + assert_eq!(activity["fresh"], false); + } + + #[test] + fn bucket_metadata_ts_reads_activitywatch_end_timestamp() { + let buckets = json!({ + "aw-watcher-window_HOST": { + "metadata": { + "end": "2026-06-01T10:54:37.976Z" + } + } + }); + let ts = bucket_metadata_ts(&buckets, "aw-watcher-window_HOST").unwrap(); + assert_eq!( + ts.to_rfc3339_opts(SecondsFormat::Millis, true), + "2026-06-01T10:54:37.976Z" + ); + } + + #[test] + fn event_effective_ts_adds_duration_to_long_activitywatch_event() { + let event = json!({ + "timestamp": "2026-06-01T10:30:29.573Z", + "duration": 1448.403, + }); + let ts = event_effective_ts(&event).unwrap(); + assert_eq!( + ts.to_rfc3339_opts(SecondsFormat::Millis, true), + "2026-06-01T10:54:37.976Z" + ); + } + + #[test] + fn normalizes_aw_api_base() { + assert_eq!( + normalize_aw_api_base("http://127.0.0.1:5600"), + "http://127.0.0.1:5600/api/0" + ); + assert_eq!( + normalize_aw_api_base("http://127.0.0.1:5600/api/0"), + "http://127.0.0.1:5600/api/0" + ); + } +} diff --git a/adk-rust/crates/aw-slo-monitor/Cargo.toml b/adk-rust/crates/aw-slo-monitor/Cargo.toml new file mode 100644 index 0000000..24b87e6 --- /dev/null +++ b/adk-rust/crates/aw-slo-monitor/Cargo.toml @@ -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 diff --git a/adk-rust/crates/aw-slo-monitor/src/main.rs b/adk-rust/crates/aw-slo-monitor/src/main.rs new file mode 100644 index 0000000..bb1fe14 --- /dev/null +++ b/adk-rust/crates/aw-slo-monitor/src/main.rs @@ -0,0 +1,1078 @@ +use std::{ + collections::BTreeMap, + fs::{self, File}, + io::{Read, Write}, + path::{Path, PathBuf}, + process::{Command, Stdio}, + thread, + time::{Duration, Instant}, +}; + +use anyhow::{Context, Result}; +use chrono::{DateTime, SecondsFormat, TimeDelta, Utc}; +use clap::{Parser, ValueEnum}; +use reqwest::{ + blocking::Client, + header::{ACCEPT, CONNECTION, HeaderMap, HeaderValue}, +}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +const ENV_FILE: &str = "/etc/activitywatch/aw-server.env"; +const DEFAULT_STATE_DIR: &str = "/var/lib/activitywatch/slo"; +const DEFAULT_HEALTHD_CMD: &str = "/usr/local/bin/aw-rus-healthd.py --json"; +const DEFAULT_HEALTHD_STATE_FILE: &str = "/var/lib/activitywatch/health/aw-rus-health.json"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +enum HealthdMode { + State, + Run, +} + +#[derive(Debug, Parser)] +#[command(about = "AW-RUS rolling SLO sampler")] +struct Cli { + #[arg(long, default_value = DEFAULT_STATE_DIR)] + state_dir: PathBuf, + + #[arg(long, default_value = DEFAULT_HEALTHD_CMD)] + healthd_cmd: String, + + #[arg(long, value_enum, default_value_t = HealthdMode::State)] + healthd_mode: HealthdMode, + + #[arg(long, default_value = DEFAULT_HEALTHD_STATE_FILE)] + healthd_state_file: PathBuf, + + #[arg(long, default_value_t = 180)] + healthd_state_max_age_seconds: i64, + + #[arg(long, default_value = "http://127.0.0.1:5600")] + aw_base: String, + + #[arg(long, default_value = "http://127.0.0.1:5610")] + worktime_base: String, + + #[arg(long, default_value = "SHARKON2025")] + host: String, + + #[arg(long, default_value_t = 99.97)] + target_percent: f64, + + #[arg(long, default_value_t = 15)] + sample_interval_seconds: i64, + + #[arg(long, default_value_t = 35)] + retention_days: i64, + + #[arg(long, default_value_t = 15.0)] + http_timeout_seconds: f64, + + #[arg(long, default_value_t = 90)] + health_timeout_seconds: u64, + + #[arg(long, default_value_t = 500)] + worktime_html_min_bytes: usize, + + #[arg(long)] + json: bool, +} + +impl Cli { + fn apply_env(mut self) -> Self { + let file_env = load_env_file(Path::new(ENV_FILE)); + if !cli_arg_present("--state-dir") { + self.state_dir = env_path(&file_env, "AW_RUS_SLO_STATE_DIR").unwrap_or(self.state_dir); + } + if !cli_arg_present("--healthd-cmd") { + self.healthd_cmd = + env_string(&file_env, "AW_RUS_SLO_HEALTHD_CMD").unwrap_or(self.healthd_cmd); + } + if !cli_arg_present("--healthd-mode") { + self.healthd_mode = env_string(&file_env, "AW_RUS_SLO_HEALTHD_MODE") + .and_then(|value| match value.as_str() { + "state" => Some(HealthdMode::State), + "run" => Some(HealthdMode::Run), + _ => None, + }) + .unwrap_or(self.healthd_mode); + } + if !cli_arg_present("--healthd-state-file") { + self.healthd_state_file = env_path(&file_env, "AW_RUS_SLO_HEALTHD_STATE_FILE") + .unwrap_or(self.healthd_state_file); + } + if !cli_arg_present("--healthd-state-max-age-seconds") { + self.healthd_state_max_age_seconds = env_i64( + &file_env, + "AW_RUS_SLO_HEALTHD_STATE_MAX_AGE_SECONDS", + self.healthd_state_max_age_seconds, + ); + } + if !cli_arg_present("--aw-base") { + self.aw_base = env_string(&file_env, "AW_RUS_SLO_AW_BASE") + .or_else(|| env_string(&file_env, "AW_SERVER_URL")) + .unwrap_or(self.aw_base); + } + if !cli_arg_present("--worktime-base") { + self.worktime_base = env_string(&file_env, "AW_RUS_SLO_WORKTIME_BASE") + .or_else(|| env_string(&file_env, "AW_RUS_HEALTH_WORKTIME_API")) + .unwrap_or(self.worktime_base); + } + if !cli_arg_present("--host") { + self.host = env_string(&file_env, "AW_RUS_SLO_HOST") + .or_else(|| env_string(&file_env, "AW_MONITORED_WINDOWS_HOSTNAME")) + .unwrap_or(self.host); + } + if !cli_arg_present("--target-percent") { + self.target_percent = + env_f64(&file_env, "AW_RUS_SLO_TARGET_PERCENT", self.target_percent); + } + if !cli_arg_present("--sample-interval-seconds") { + self.sample_interval_seconds = env_i64( + &file_env, + "AW_RUS_SLO_SAMPLE_INTERVAL_SECONDS", + self.sample_interval_seconds, + ); + } + if !cli_arg_present("--retention-days") { + self.retention_days = + env_i64(&file_env, "AW_RUS_SLO_RETENTION_DAYS", self.retention_days); + } + if !cli_arg_present("--http-timeout-seconds") { + self.http_timeout_seconds = env_f64( + &file_env, + "AW_RUS_SLO_HTTP_TIMEOUT_SECONDS", + self.http_timeout_seconds, + ); + } + if !cli_arg_present("--health-timeout-seconds") { + self.health_timeout_seconds = env_u64( + &file_env, + "AW_RUS_SLO_HEALTH_TIMEOUT_SECONDS", + self.health_timeout_seconds, + ); + } + if !cli_arg_present("--worktime-html-min-bytes") { + self.worktime_html_min_bytes = env_usize( + &file_env, + "AW_RUS_SLO_WORKTIME_HTML_MIN_BYTES", + self.worktime_html_min_bytes, + ); + } + self.aw_base = self.aw_base.trim_end_matches('/').to_string(); + self.worktime_base = self.worktime_base.trim_end_matches('/').to_string(); + self + } + + fn urls(&self) -> ProbeUrls { + let host = urlencoding::encode(&self.host); + ProbeUrls { + aw_webui: format!("{}/", self.aw_base), + today_html: format!( + "{}/reports/worktime/today?format=html&day=today&host={host}&allow_stale=1", + self.worktime_base + ), + management_html: format!( + "{}/reports/worktime/management?format=html&day=today&host={host}&allow_stale=1", + self.worktime_base + ), + today_csv: format!( + "{}/reports/worktime/today?format=csv&day=today&host={host}&allow_stale=1", + self.worktime_base + ), + management_json: format!( + "{}/reports/worktime/management?format=json&day=today&host={host}&allow_stale=1", + self.worktime_base + ), + } + } +} + +struct ProbeUrls { + aw_webui: String, + today_html: String, + management_html: String, + today_csv: String, + management_json: String, +} + +#[derive(Debug, Serialize, Deserialize)] +struct Sample { + ts: String, + ok: bool, + healthd_ok: bool, + #[serde(default)] + healthd_counts: Value, + probes: BTreeMap, +} + +#[derive(Debug, Serialize)] +struct Summary { + generated_at_utc: String, + target_percent: f64, + sample_interval_seconds: i64, + current_sample: Sample, + windows: Windows, +} + +#[derive(Debug, Serialize)] +struct Windows { + #[serde(rename = "24h")] + day_24h: WindowSummary, + #[serde(rename = "7d")] + day_7d: WindowSummary, + #[serde(rename = "30d")] + day_30d: WindowSummary, +} + +#[derive(Debug, Serialize, PartialEq)] +struct WindowSummary { + window_seconds: i64, + samples: usize, + good_samples: usize, + bad_samples: usize, + availability_percent: Option, + target_percent: f64, + observed_bad_seconds: i64, + budget_seconds: i64, + budget_remaining_seconds: i64, + status: String, +} + +fn main() { + match run() { + Ok(ok) => std::process::exit(if ok { 0 } else { 1 }), + Err(error) => { + eprintln!("error: {error:#}"); + std::process::exit(2); + } + } +} + +fn run() -> Result { + let cli = Cli::parse().apply_env(); + let client = build_client(cli.http_timeout_seconds)?; + let sample = build_sample(&cli, &client); + let sample_path = cli.state_dir.join("aw-slo-samples.jsonl"); + let samples = + append_and_trim_sample(&sample_path, &sample, cli.retention_days.max(1) * 86_400)?; + let generated_at = Utc::now(); + let summary = build_summary(&cli, sample, &samples, generated_at); + let summary_text = render_summary_text(&summary); + + write_atomic( + &cli.state_dir.join("aw-slo-summary.json"), + &(serde_json::to_string_pretty(&summary)? + "\n"), + )?; + write_atomic( + &cli.state_dir.join("aw-slo-summary.txt"), + &(summary_text.clone() + "\n"), + )?; + + if cli.json { + println!("{}", serde_json::to_string_pretty(&summary)?); + } else { + println!("{summary_text}"); + } + Ok(summary.current_sample.ok) +} + +fn build_client(timeout_seconds: f64) -> Result { + let mut headers = HeaderMap::new(); + headers.insert(CONNECTION, HeaderValue::from_static("close")); + Client::builder() + .timeout(Duration::from_secs_f64(timeout_seconds.max(0.001))) + .no_proxy() + .pool_max_idle_per_host(0) + .default_headers(headers) + .build() + .context("build HTTP client") +} + +fn build_sample(cli: &Cli, client: &Client) -> Sample { + let health = match cli.healthd_mode { + HealthdMode::Run => run_healthd(&cli.healthd_cmd, cli.health_timeout_seconds), + HealthdMode::State => { + read_healthd_state(&cli.healthd_state_file, cli.healthd_state_max_age_seconds) + } + }; + let urls = cli.urls(); + let mut probes = BTreeMap::new(); + probes.insert( + "aw_webui_index".to_string(), + html_probe( + client, + &urls.aw_webui, + 1_000, + &["ActivityWatch", "id=\"app\"", "ru-patch-v5.js"], + ), + ); + probes.insert( + "worktime_today_html".to_string(), + html_probe( + client, + &urls.today_html, + cli.worktime_html_min_bytes, + &["AW-rus", ""], + ), + ); + probes.insert( + "worktime_management_html".to_string(), + html_probe( + client, + &urls.management_html, + cli.worktime_html_min_bytes, + &["AW-rus", ""], + ), + ); + probes.insert( + "worktime_today_csv".to_string(), + http_probe(client, &urls.today_csv), + ); + probes.insert( + "worktime_management_json".to_string(), + json_probe( + client, + &urls.management_json, + &[("host", json!(cli.host))], + &["generated_at_utc", "host", "summary", "rows", "workday"], + ), + ); + + let health_ok = value_bool(&health, "ok"); + let ok = health_ok && probes.values().all(|probe| value_bool(probe, "ok")); + Sample { + ts: iso(Utc::now()), + ok, + healthd_ok: health_ok, + healthd_counts: health.get("counts").cloned().unwrap_or_else(|| json!({})), + probes, + } +} + +fn fetch_url(client: &Client, url: &str, accept: Option<&str>, attempts: usize) -> Value { + let started = Instant::now(); + let attempts = attempts.max(1); + let mut last_error = String::new(); + for attempt in 1..=attempts { + let mut request = client.get(url); + if let Some(accept) = accept { + request = request.header(ACCEPT, accept); + } + match request.send() { + Ok(response) => { + let status = response.status().as_u16(); + let content_type = response + .headers() + .get("content-type") + .and_then(|value| value.to_str().ok()) + .unwrap_or("") + .to_string(); + match response.bytes() { + Ok(body) => { + let ok = (200..300).contains(&status); + return json!({ + "ok": ok, + "status": status, + "body_bytes": body.len(), + "content_type": content_type, + "body": String::from_utf8_lossy(&body), + "attempts": attempt, + "latency_ms": started.elapsed().as_millis() as i64, + "url": url, + }); + } + Err(error) => last_error = error.to_string(), + } + } + Err(error) => last_error = error.to_string(), + } + if attempt < attempts { + thread::sleep(Duration::from_secs(1)); + } + } + json!({ + "ok": false, + "error": last_error, + "body_bytes": 0, + "attempts": attempts, + "latency_ms": started.elapsed().as_millis() as i64, + "url": url, + }) +} + +fn public_probe_result(mut result: Value) -> Value { + if let Some(object) = result.as_object_mut() { + object.remove("body"); + } + result +} + +fn http_probe(client: &Client, url: &str) -> Value { + public_probe_result(fetch_url(client, url, None, 2)) +} + +fn html_probe(client: &Client, url: &str, min_bytes: usize, required_markers: &[&str]) -> Value { + let mut result = fetch_url(client, url, Some("text/html"), 2); + if !value_bool(&result, "ok") { + return public_probe_result(result); + } + let body = result + .get("body") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + let content_type = value_string(&result, "content_type"); + let body_bytes = result + .get("body_bytes") + .and_then(Value::as_u64) + .unwrap_or(0) as usize; + let missing: Vec<&str> = required_markers + .iter() + .copied() + .filter(|marker| !body.contains(marker)) + .collect(); + if body_bytes < min_bytes { + set_probe_error( + &mut result, + format!("body too small: {body_bytes} < {min_bytes}"), + ); + } else if !content_type.to_ascii_lowercase().contains("text/html") { + set_probe_error( + &mut result, + format!( + "unexpected content-type: {}", + if content_type.is_empty() { + "unknown" + } else { + &content_type + } + ), + ); + } else if !missing.is_empty() { + set_probe_error( + &mut result, + format!("missing markers: {}", missing.join(", ")), + ); + if let Some(object) = result.as_object_mut() { + object.insert("missing_markers".to_string(), json!(missing)); + } + } + public_probe_result(result) +} + +fn json_probe( + client: &Client, + url: &str, + expected_values: &[(&str, Value)], + required_keys: &[&str], +) -> Value { + let mut result = fetch_url(client, url, Some("application/json"), 2); + if !value_bool(&result, "ok") { + return public_probe_result(result); + } + let body = result.get("body").and_then(Value::as_str).unwrap_or(""); + let payload: Value = match serde_json::from_str(body) { + Ok(payload) => payload, + Err(error) => { + set_probe_error(&mut result, format!("invalid json: {error}")); + return public_probe_result(result); + } + }; + let Some(object) = payload.as_object() else { + set_probe_error(&mut result, "json root is not object".to_string()); + return public_probe_result(result); + }; + + let missing_keys: Vec<&str> = required_keys + .iter() + .copied() + .filter(|key| !object.contains_key(*key)) + .collect(); + let mismatched: BTreeMap<&str, Value> = expected_values + .iter() + .filter_map(|(key, expected)| { + let actual = object.get(*key)?; + if actual == expected { + None + } else { + Some((*key, json!({"expected": expected, "actual": actual}))) + } + }) + .collect(); + if !missing_keys.is_empty() { + set_probe_error( + &mut result, + format!("missing json keys: {}", missing_keys.join(", ")), + ); + if let Some(result_object) = result.as_object_mut() { + result_object.insert("missing_keys".to_string(), json!(missing_keys)); + } + } else if !mismatched.is_empty() { + set_probe_error(&mut result, "unexpected json values".to_string()); + if let Some(result_object) = result.as_object_mut() { + result_object.insert("mismatched_values".to_string(), json!(mismatched)); + } + } else if let Some(result_object) = result.as_object_mut() { + let mut keys: Vec<&String> = object.keys().collect(); + keys.sort(); + result_object.insert("json_keys".to_string(), json!(keys)); + } + public_probe_result(result) +} + +fn set_probe_error(result: &mut Value, message: String) { + if let Some(object) = result.as_object_mut() { + object.insert("ok".to_string(), json!(false)); + object.insert("error".to_string(), json!(message)); + } +} + +fn run_healthd(command: &str, timeout_seconds: u64) -> Value { + let mut child = match Command::new("/bin/sh") + .arg("-c") + .arg(command) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + { + Ok(child) => child, + Err(error) => return json!({"ok": false, "returncode": null, "error": error.to_string()}), + }; + let deadline = Instant::now() + Duration::from_secs(timeout_seconds.max(1)); + loop { + match child.try_wait() { + Ok(Some(status)) => { + let mut output = String::new(); + if let Some(mut stdout) = child.stdout.take() { + let _ = stdout.read_to_string(&mut output); + } + if let Some(mut stderr) = child.stderr.take() { + let mut err = String::new(); + let _ = stderr.read_to_string(&mut err); + output.push_str(&err); + } + let payload: Value = serde_json::from_str(&output).unwrap_or_else(|_| json!({})); + let ok = status.success() && value_bool(&payload, "ok"); + return json!({ + "ok": ok, + "returncode": status.code(), + "counts": payload.get("counts").cloned().unwrap_or_else(|| json!({})), + "payload": payload, + "output_tail": output_tail(&output), + }); + } + Ok(None) => { + if Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + return json!({ + "ok": false, + "returncode": null, + "error": format!("timeout after {timeout_seconds}s"), + "output_tail": "", + }); + } + thread::sleep(Duration::from_millis(100)); + } + Err(error) => { + let _ = child.kill(); + return json!({"ok": false, "returncode": null, "error": error.to_string()}); + } + } + } +} + +fn read_healthd_state(path: &Path, max_age_seconds: i64) -> Value { + let payload: Value = match fs::read_to_string(path) + .with_context(|| format!("read {}", path.display())) + .and_then(|content| serde_json::from_str(&content).context("parse healthd state json")) + { + Ok(payload) => payload, + Err(error) => return json!({"ok": false, "error": error.to_string(), "path": path}), + }; + let generated = payload + .get("generated_at_utc") + .and_then(Value::as_str) + .and_then(parse_ts); + let Some(generated) = generated else { + return json!({"ok": false, "error": "missing or invalid generated_at_utc", "path": path}); + }; + let age_seconds = (Utc::now() - generated).num_seconds().max(0); + json!({ + "ok": value_bool(&payload, "ok") && age_seconds <= max_age_seconds, + "counts": payload.get("counts").cloned().unwrap_or_else(|| json!({})), + "age_seconds": age_seconds, + "path": path, + "payload": payload, + }) +} + +fn append_and_trim_sample( + path: &Path, + sample: &Sample, + retention_seconds: i64, +) -> Result> { + let sample_ts = parse_ts(&sample.ts).unwrap_or_else(Utc::now); + let cutoff = sample_ts - TimeDelta::seconds(retention_seconds.max(1)); + let mut samples = load_samples(path, cutoff); + samples.push(serde_json::from_value(serde_json::to_value(sample)?)?); + + let mut content = String::new(); + for item in &samples { + content.push_str(&serde_json::to_string(item)?); + content.push('\n'); + } + write_atomic(path, &content)?; + Ok(samples) +} + +fn load_samples(path: &Path, cutoff: DateTime) -> Vec { + let Ok(content) = fs::read_to_string(path) else { + return Vec::new(); + }; + content + .lines() + .filter_map(|line| { + let sample: Sample = serde_json::from_str(line).ok()?; + let ts = parse_ts(&sample.ts)?; + (ts >= cutoff).then_some(sample) + }) + .collect() +} + +fn build_summary( + cli: &Cli, + sample: Sample, + samples: &[Sample], + generated_at: DateTime, +) -> Summary { + let windows = Windows { + day_24h: summarize_window( + samples, + generated_at, + 86_400, + cli.sample_interval_seconds, + cli.target_percent, + ), + day_7d: summarize_window( + samples, + generated_at, + 7 * 86_400, + cli.sample_interval_seconds, + cli.target_percent, + ), + day_30d: summarize_window( + samples, + generated_at, + 30 * 86_400, + cli.sample_interval_seconds, + cli.target_percent, + ), + }; + Summary { + generated_at_utc: iso(generated_at), + target_percent: cli.target_percent, + sample_interval_seconds: cli.sample_interval_seconds, + current_sample: sample, + windows, + } +} + +fn summarize_window( + samples: &[Sample], + now: DateTime, + window_seconds: i64, + sample_interval_seconds: i64, + target_percent: f64, +) -> WindowSummary { + let cutoff = now - TimeDelta::seconds(window_seconds); + let window: Vec<&Sample> = samples + .iter() + .filter(|sample| parse_ts(&sample.ts).is_some_and(|ts| ts >= cutoff)) + .collect(); + let total = window.len(); + let good = window.iter().filter(|sample| sample.ok).count(); + let bad = total - good; + let availability_percent = if total == 0 { + None + } else { + Some(round5((good as f64 / total as f64) * 100.0)) + }; + let observed_bad_seconds = bad as i64 * sample_interval_seconds; + let budget_seconds = (window_seconds as f64 * ((100.0 - target_percent) / 100.0)) as i64; + let budget_remaining_seconds = budget_seconds - observed_bad_seconds; + let status = if total == 0 { + "unknown" + } else if budget_remaining_seconds >= 0 { + "ok" + } else { + "burning" + } + .to_string(); + WindowSummary { + window_seconds, + samples: total, + good_samples: good, + bad_samples: bad, + availability_percent, + target_percent, + observed_bad_seconds, + budget_seconds, + budget_remaining_seconds, + status, + } +} + +fn render_summary_text(summary: &Summary) -> String { + let mut lines = vec![ + "=== AW-RUS SLO ===".to_string(), + format!("Timestamp: {}", summary.generated_at_utc), + format!("Target: {}%", summary.target_percent), + String::new(), + ]; + for (name, data) in [ + ("24h", &summary.windows.day_24h), + ("7d", &summary.windows.day_7d), + ("30d", &summary.windows.day_30d), + ] { + let availability = data + .availability_percent + .map(|value| format!("{value:.5}%")) + .unwrap_or_else(|| "n/a".to_string()); + lines.push(format!( + "{name}: {} availability={} samples={} bad={} bad_seconds={} budget_remaining_seconds={}", + data.status, + availability, + data.samples, + data.bad_samples, + data.observed_bad_seconds, + data.budget_remaining_seconds + )); + } + lines.push(String::new()); + lines.push(format!( + "Current sample: {}", + if summary.current_sample.ok { + "OK" + } else { + "FAIL" + } + )); + for name in [ + "aw_webui_index", + "worktime_today_html", + "worktime_management_html", + "worktime_today_csv", + "worktime_management_json", + ] { + let Some(probe) = summary.current_sample.probes.get(name) else { + continue; + }; + let marker = if value_bool(probe, "ok") { + "OK" + } else { + "FAIL" + }; + let detail = probe + .get("status") + .and_then(Value::as_u64) + .map(|status| status.to_string()) + .or_else(|| { + probe + .get("error") + .and_then(Value::as_str) + .map(ToOwned::to_owned) + }) + .unwrap_or_default(); + lines.push(format!("- {name}: {marker} {detail}")); + } + lines.join("\n") +} + +fn write_atomic(path: &Path, content: &str) -> Result<()> { + let parent = path + .parent() + .with_context(|| format!("resolve parent for {}", path.display()))?; + fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; + let tmp = parent.join(format!( + ".{}.tmp.{}", + path.file_name() + .and_then(|name| name.to_str()) + .unwrap_or("aw-slo"), + std::process::id() + )); + { + let mut file = File::create(&tmp).with_context(|| format!("create {}", tmp.display()))?; + file.write_all(content.as_bytes()) + .with_context(|| format!("write {}", tmp.display()))?; + file.sync_all().ok(); + } + fs::set_permissions(&tmp, fs::Permissions::from_mode_ext(0o644)).ok(); + fs::rename(&tmp, path) + .with_context(|| format!("rename {} to {}", tmp.display(), path.display()))?; + Ok(()) +} + +trait PermissionsExtCompat { + fn from_mode_ext(mode: u32) -> Self; +} + +impl PermissionsExtCompat for fs::Permissions { + #[cfg(unix)] + fn from_mode_ext(mode: u32) -> Self { + use std::os::unix::fs::PermissionsExt; + fs::Permissions::from_mode(mode) + } + + #[cfg(not(unix))] + fn from_mode_ext(_mode: u32) -> Self { + fs::metadata(".") + .map(|metadata| metadata.permissions()) + .unwrap_or_else(|_| fs::Permissions::readonly()) + } +} + +fn load_env_file(path: &Path) -> BTreeMap { + let Ok(content) = fs::read_to_string(path) else { + return BTreeMap::new(); + }; + content + .lines() + .filter_map(|raw| { + let line = raw.trim(); + if line.is_empty() || line.starts_with('#') { + return None; + } + let (key, value) = line.split_once('=')?; + Some(( + key.trim().to_string(), + value + .trim() + .trim_matches('"') + .trim_matches('\'') + .to_string(), + )) + }) + .collect() +} + +fn cli_arg_present(name: &str) -> bool { + std::env::args_os().skip(1).any(|arg| { + let Some(value) = arg.to_str() else { + return false; + }; + value == name + || value + .strip_prefix(name) + .is_some_and(|rest| rest.starts_with('=')) + }) +} + +fn env_string(file_env: &BTreeMap, name: &str) -> Option { + std::env::var(name) + .ok() + .or_else(|| file_env.get(name).cloned()) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +fn env_path(file_env: &BTreeMap, name: &str) -> Option { + env_string(file_env, name).map(PathBuf::from) +} + +fn env_i64(file_env: &BTreeMap, name: &str, fallback: i64) -> i64 { + env_string(file_env, name) + .and_then(|value| value.parse().ok()) + .unwrap_or(fallback) +} + +fn env_u64(file_env: &BTreeMap, name: &str, fallback: u64) -> u64 { + env_string(file_env, name) + .and_then(|value| value.parse().ok()) + .unwrap_or(fallback) +} + +fn env_usize(file_env: &BTreeMap, name: &str, fallback: usize) -> usize { + env_string(file_env, name) + .and_then(|value| value.parse().ok()) + .unwrap_or(fallback) +} + +fn env_f64(file_env: &BTreeMap, name: &str, fallback: f64) -> f64 { + env_string(file_env, name) + .and_then(|value| value.parse().ok()) + .unwrap_or(fallback) +} + +fn parse_ts(value: &str) -> Option> { + DateTime::parse_from_rfc3339(&value.replace('Z', "+00:00")) + .ok() + .map(|dt| dt.with_timezone(&Utc)) +} + +fn iso(dt: DateTime) -> String { + dt.to_rfc3339_opts(SecondsFormat::Micros, true) +} + +fn round5(value: f64) -> f64 { + (value * 100_000.0).round() / 100_000.0 +} + +fn value_bool(value: &Value, key: &str) -> bool { + value.get(key).and_then(Value::as_bool).unwrap_or(false) +} + +fn value_string(value: &Value, key: &str) -> String { + value + .get(key) + .and_then(Value::as_str) + .unwrap_or("") + .to_string() +} + +fn output_tail(output: &str) -> String { + let chars: Vec = output.chars().collect(); + let start = chars.len().saturating_sub(1000); + chars[start..].iter().collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample(ts: &str, ok: bool) -> Sample { + Sample { + ts: ts.to_string(), + ok, + healthd_ok: ok, + healthd_counts: json!({}), + probes: BTreeMap::new(), + } + } + + #[test] + fn summarize_window_calculates_9997_budget() { + let now = parse_ts("2026-05-30T12:00:00Z").unwrap(); + let samples = vec![ + sample("2026-05-30T11:58:00Z", true), + sample("2026-05-30T11:59:00Z", false), + sample("2026-05-30T12:00:00Z", true), + ]; + + let summary = summarize_window(&samples, now, 86_400, 60, 99.97); + + assert_eq!(summary.samples, 3); + assert_eq!(summary.good_samples, 2); + assert_eq!(summary.bad_samples, 1); + assert_eq!(summary.availability_percent, Some(66.66667)); + assert_eq!(summary.budget_seconds, 25); + assert_eq!(summary.budget_remaining_seconds, -35); + assert_eq!(summary.status, "burning"); + } + + #[test] + fn summarize_window_status_uses_remaining_error_budget_for_partial_window() { + let now = parse_ts("2026-05-30T12:00:00Z").unwrap(); + let samples = vec![ + sample("2026-05-30T11:59:30Z", false), + sample("2026-05-30T11:59:45Z", true), + sample("2026-05-30T12:00:00Z", true), + ]; + + let summary = summarize_window(&samples, now, 86_400, 15, 99.97); + + assert_eq!(summary.availability_percent, Some(66.66667)); + assert_eq!(summary.budget_seconds, 25); + assert_eq!(summary.budget_remaining_seconds, 10); + assert_eq!(summary.status, "ok"); + } + + #[test] + fn render_summary_text_includes_budget_remaining() { + let mut probes = BTreeMap::new(); + probes.insert( + "worktime_today_csv".to_string(), + json!({"ok": true, "status": 200}), + ); + let sample = Sample { + ts: "2026-05-30T12:00:00Z".to_string(), + ok: true, + healthd_ok: true, + healthd_counts: json!({}), + probes, + }; + let windows = Windows { + day_24h: WindowSummary { + window_seconds: 86_400, + samples: 10, + good_samples: 10, + bad_samples: 0, + availability_percent: Some(100.0), + target_percent: 99.97, + observed_bad_seconds: 0, + budget_seconds: 25, + budget_remaining_seconds: 25, + status: "ok".to_string(), + }, + day_7d: WindowSummary { + window_seconds: 604_800, + samples: 10, + good_samples: 10, + bad_samples: 0, + availability_percent: Some(100.0), + target_percent: 99.97, + observed_bad_seconds: 0, + budget_seconds: 181, + budget_remaining_seconds: 181, + status: "ok".to_string(), + }, + day_30d: WindowSummary { + window_seconds: 2_592_000, + samples: 10, + good_samples: 10, + bad_samples: 0, + availability_percent: Some(100.0), + target_percent: 99.97, + observed_bad_seconds: 0, + budget_seconds: 777, + budget_remaining_seconds: 777, + status: "ok".to_string(), + }, + }; + let summary = Summary { + generated_at_utc: "2026-05-30T12:00:00Z".to_string(), + target_percent: 99.97, + sample_interval_seconds: 15, + current_sample: sample, + windows, + }; + + let text = render_summary_text(&summary); + + assert!(text.contains("Target: 99.97%")); + assert!(text.contains("24h: ok availability=100.00000%")); + assert!(text.contains("budget_remaining_seconds=25")); + assert!(text.contains("- worktime_today_csv: OK 200")); + } + + #[test] + fn load_samples_skips_bad_and_old_lines() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("samples.jsonl"); + fs::write( + &path, + concat!( + "{\"ts\":\"2026-05-01T00:00:00Z\",\"ok\":true,\"healthd_ok\":true,\"probes\":{}}\n", + "not-json\n", + "{\"ts\":\"2026-05-30T11:59:00Z\",\"ok\":true,\"healthd_ok\":true,\"probes\":{}}\n" + ), + ) + .unwrap(); + + let samples = load_samples(&path, parse_ts("2026-05-30T11:00:00Z").unwrap()); + + assert_eq!(samples.len(), 1); + assert_eq!(samples[0].ts, "2026-05-30T11:59:00Z"); + } +} diff --git a/adk-rust/crates/check-aw-data/Cargo.toml b/adk-rust/crates/check-aw-data/Cargo.toml new file mode 100644 index 0000000..0d74bf0 --- /dev/null +++ b/adk-rust/crates/check-aw-data/Cargo.toml @@ -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 diff --git a/adk-rust/crates/check-aw-data/src/main.rs b/adk-rust/crates/check-aw-data/src/main.rs new file mode 100644 index 0000000..7455e14 --- /dev/null +++ b/adk-rust/crates/check-aw-data/src/main.rs @@ -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://10.10.10.13:5600"; +const DEFAULT_HOST: &str = "SHARKON2025"; +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, + + #[arg(long)] + host: Option, + + #[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 { + 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, + 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 age >= 0 && age < 900 && !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 age >= 0 && age < 300 && 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 { + 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, + 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> { + 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::() + .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 { + 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 { + client + .get(url) + .send() + .with_context(|| format!("GET {url}"))? + .error_for_status() + .with_context(|| format!("GET {url} status"))? + .json::() + .with_context(|| format!("decode JSON from {url}")) +} + +fn check_cors(_client: &Client, server: &str) -> u16 { + let origin = "http://10.10.10.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::() + .unwrap_or(0) +} + +fn event_timestamp(event: &Value) -> Option> { + event + .get("timestamp") + .and_then(Value::as_str) + .and_then(parse_ts) +} + +fn parse_ts(value: &str) -> Option> { + 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 { + 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_SHARKON2025": { + "metadata": { + "end": "2026-06-02T00:00:00Z" + } + } + }); + let event = bucket_event( + "http://127.0.0.1:1", + "aw-watcher-window_SHARKON2025", + 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)); + } +} diff --git a/adk-rust/crates/check-aw-full/Cargo.toml b/adk-rust/crates/check-aw-full/Cargo.toml new file mode 100644 index 0000000..152bff9 --- /dev/null +++ b/adk-rust/crates/check-aw-full/Cargo.toml @@ -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 diff --git a/adk-rust/crates/check-aw-full/src/main.rs b/adk-rust/crates/check-aw-full/src/main.rs new file mode 100644 index 0000000..56e7c08 --- /dev/null +++ b/adk-rust/crates/check-aw-full/src/main.rs @@ -0,0 +1,537 @@ +use std::net::{TcpStream, ToSocketAddrs}; +use std::time::Duration; + +use anyhow::{Context, Result}; +use chrono::{DateTime, Utc}; +use clap::Parser; +use reqwest::blocking::Client; +use serde_json::Value; + +const DEFAULT_SERVER: &str = "http://10.10.10.13:5600"; +const DEFAULT_HOST: &str = "SHARKON2025"; +const DEFAULT_RDP_HOST: &str = "192.168.100.18"; +const BUCKETS: &[(&str, &str)] = &[ + ("aw-watcher-afk", "AFK watcher"), + ("aw-watcher-window", "Window watcher"), + ("aw-worktime-sessions", "Worktime sessions"), + ("aw-session-events", "Session events"), + ("aw-dlp-endpoint-signals", "DLP signals"), + ("aw-dlp-incidents", "DLP incidents"), + ("aw-dlp-review", "DLP review"), + ("aw-dlp-rules", "DLP rules"), +]; + +#[derive(Debug, Parser)] +#[command(about = "Full read-only ActivityWatch check for server, buckets, and RDP host")] +struct Cli { + #[arg(long, default_value = DEFAULT_SERVER)] + server: String, + + #[arg(long, default_value = DEFAULT_HOST)] + host: String, + + #[arg(long, default_value = DEFAULT_RDP_HOST)] + rdp_host: String, + + #[arg(long, default_value_t = 15)] + timeout_seconds: u64, + + #[arg(long)] + no_color: bool, +} + +#[derive(Debug, Clone)] +struct Colors { + red: &'static str, + green: &'static str, + yellow: &'static str, + cyan: &'static str, + reset: &'static str, +} + +impl Colors { + fn new(enabled: bool) -> Self { + if enabled { + Self { + red: "\u{1b}[0;31m", + green: "\u{1b}[0;32m", + yellow: "\u{1b}[1;33m", + cyan: "\u{1b}[0;36m", + reset: "\u{1b}[0m", + } + } else { + Self { + red: "", + green: "", + yellow: "", + cyan: "", + reset: "", + } + } + } + + fn paint(&self, color: &str, text: &str) -> String { + format!("{color}{text}{}", self.reset) + } +} + +#[derive(Debug, Default)] +struct ContextState { + host_inactive: bool, + guard_healthy: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum BucketStatus { + Fresh, + Stale, + Dead, + Empty, + EventDriven, + Inactive, + Unknown, +} + +#[derive(Debug, Clone)] +struct BucketRow { + label: &'static str, + last_id: String, + age: String, + status: BucketStatus, +} + +#[derive(Debug, Default)] +struct Summary { + fresh: usize, + stale: usize, + dead: usize, +} + +fn main() { + let code = match run() { + Ok(code) => code, + Err(err) => { + eprintln!("{err:#}"); + 1 + } + }; + std::process::exit(code); +} + +fn run() -> Result { + let cli = Cli::parse(); + let server = cli.server.trim_end_matches('/').to_string(); + let colors = Colors::new(!cli.no_color && std::env::var_os("NO_COLOR").is_none()); + let timeout = Duration::from_secs(cli.timeout_seconds.max(1)); + let client = Client::builder() + .timeout(timeout) + .no_proxy() + .pool_max_idle_per_host(0) + .build() + .context("build HTTP client")?; + let now = Utc::now(); + + println!( + "{}", + colors.paint( + colors.cyan, + &format!("=== ActivityWatch Full Check: {} ===", cli.host) + ) + ); + println!(); + + println!( + "{}", + colors.paint(colors.cyan, &format!("--- 1. AW Server ({server}) ---")) + ); + print!(" Connectivity... "); + let info = match get_json(&client, &format!("{server}/api/0/info")) { + Ok(value) => value, + Err(_) => { + println!(" {}", colors.paint(colors.red, "FAILED")); + return Ok(1); + } + }; + let Some(version) = info.get("version").and_then(Value::as_str) else { + println!(" {}", colors.paint(colors.red, "FAILED")); + return Ok(1); + }; + println!( + " {} (aw-server {version})", + colors.paint(colors.green, "OK") + ); + + print!(" CORS... "); + let cors_code = check_cors(&client, &server); + if cors_code == 200 { + println!("{}", colors.paint(colors.green, "OK")); + } else { + println!("{} (HTTP {cors_code})", colors.paint(colors.red, "FAIL")); + } + println!(); + + let context = read_context(&client, &server, &cli.host, now); + println!("{}", colors.paint(colors.cyan, "--- 2. Data Buckets ---")); + println!( + " {:<42} {:<8} {:<20} STATUS", + "BUCKET", "EVENTS", "LAST EVENT" + ); + println!( + " {:<42} {:<8} {:<20} ------", + "------------------------------------------", "--------", "--------------------" + ); + + let mut rows = Vec::new(); + for (bucket, label) in BUCKETS { + let row = read_bucket_row(&client, &server, &cli.host, bucket, label, now, &context); + println!( + " {:<42} {:<8} {:<20} {}", + row.label, + row.last_id, + row.age, + render_status(&colors, row.status) + ); + rows.push(row); + } + println!(); + + println!( + "{}", + colors.paint( + colors.cyan, + &format!("--- 3. RDP Host ({}) ---", cli.rdp_host) + ) + ); + print!(" WinRM (5985)... "); + if tcp_open(&cli.rdp_host, 5985, Duration::from_secs(5)) { + println!("{}", colors.paint(colors.green, "OK")); + } else { + println!("{}", colors.paint(colors.red, "UNREACHABLE")); + } + print!(" SSH (22)... "); + if tcp_open(&cli.rdp_host, 22, Duration::from_secs(5)) { + println!("{}", colors.paint(colors.green, "OK")); + } else { + println!( + "{} (normal for Windows)", + colors.paint(colors.yellow, "CLOSED") + ); + } + println!(); + + let summary = summarize(&rows); + println!("{}", colors.paint(colors.cyan, "--- 4. Summary ---")); + println!( + " FRESH: {}", + colors.paint(colors.green, &summary.fresh.to_string()) + ); + println!( + " STALE: {}", + colors.paint(colors.yellow, &summary.stale.to_string()) + ); + println!( + " DEAD: {}", + colors.paint(colors.red, &summary.dead.to_string()) + ); + + if summary.dead > 0 || summary.stale > 0 { + println!(); + println!( + " {} Some collectors may need restart on RDP host", + colors.paint(colors.red, "WARNING:") + ); + println!( + " Run: {}", + colors.paint( + colors.cyan, + "ansible -i ansible/inventory.ini rdp-prod -m win_shell -a 'schtasks /Run /TN \"ActivityWatch Recovery\"'" + ) + ); + } + + println!(); + println!("{}", colors.paint(colors.cyan, "=== Check Complete ===")); + println!(" Timestamp: {}", now.format("%Y-%m-%d %H:%M:%S UTC")); + Ok(0) +} + +fn read_context(client: &Client, server: &str, host: &str, now: DateTime) -> 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, + context: &ContextState, +) -> BucketRow { + let bucket_full = format!("{bucket}_{host}"); + let event = latest_event(client, server, &bucket_full).ok().flatten(); + let Some(event) = event else { + return BucketRow { + label, + last_id: "0".to_string(), + age: "none".to_string(), + status: classify_bucket_no_events(bucket, context), + }; + }; + + let last_id = event + .get("id") + .map(json_value_to_string) + .unwrap_or_else(|| "0".to_string()); + let Some(ts_raw) = event.get("timestamp").and_then(Value::as_str) else { + return BucketRow { + label, + last_id, + age: "?".to_string(), + status: BucketStatus::Unknown, + }; + }; + let Some(ts) = parse_ts(ts_raw) else { + return BucketRow { + label, + last_id, + age: "?".to_string(), + status: BucketStatus::Unknown, + }; + }; + let age_sec = (now - ts).num_seconds().max(0); + BucketRow { + label, + last_id, + age: format_age(age_sec), + status: classify_bucket_age(bucket, age_sec, context), + } +} + +fn classify_bucket_age(bucket: &str, age_sec: i64, context: &ContextState) -> BucketStatus { + match bucket { + "aw-watcher-window" if context.host_inactive => return BucketStatus::Inactive, + "aw-dlp-endpoint-signals" if context.host_inactive && context.guard_healthy => { + return BucketStatus::Inactive; + } + _ => {} + } + + match bucket { + "aw-dlp-incidents" | "aw-dlp-review" | "aw-dlp-rules" | "aw-session-events" => { + if age_sec < 86_400 { + BucketStatus::Fresh + } else { + BucketStatus::EventDriven + } + } + _ if age_sec < 3_600 => BucketStatus::Fresh, + _ if age_sec < 86_400 => BucketStatus::Stale, + _ => BucketStatus::Dead, + } +} + +fn classify_bucket_no_events(bucket: &str, context: &ContextState) -> BucketStatus { + match bucket { + "aw-watcher-window" if context.host_inactive => BucketStatus::Inactive, + "aw-dlp-endpoint-signals" if context.host_inactive && context.guard_healthy => { + BucketStatus::Inactive + } + "aw-dlp-incidents" | "aw-dlp-review" | "aw-dlp-rules" | "aw-session-events" => { + BucketStatus::EventDriven + } + _ => BucketStatus::Empty, + } +} + +fn summarize(rows: &[BucketRow]) -> Summary { + let mut summary = Summary::default(); + for row in rows { + match row.status { + BucketStatus::Fresh | BucketStatus::EventDriven | BucketStatus::Inactive => { + summary.fresh += 1 + } + BucketStatus::Stale => summary.stale += 1, + BucketStatus::Dead | BucketStatus::Empty | BucketStatus::Unknown => summary.dead += 1, + } + } + summary +} + +fn render_status(colors: &Colors, status: BucketStatus) -> String { + match status { + BucketStatus::Fresh => colors.paint(colors.green, "FRESH"), + BucketStatus::Stale => colors.paint(colors.yellow, "STALE"), + BucketStatus::Dead => colors.paint(colors.red, "DEAD"), + BucketStatus::Empty => colors.paint(colors.red, "EMPTY"), + BucketStatus::EventDriven => colors.paint(colors.cyan, "EVENT-DRIVEN"), + BucketStatus::Inactive => colors.paint(colors.cyan, "INACTIVE"), + BucketStatus::Unknown => colors.paint(colors.red, "?"), + } +} + +fn latest_event(client: &Client, server: &str, bucket: &str) -> Result> { + let url = format!("{server}/api/0/buckets/{bucket}/events?limit=1"); + let value = get_json(client, &url)?; + Ok(value.as_array().and_then(|items| items.first()).cloned()) +} + +fn get_json(client: &Client, url: &str) -> Result { + client + .get(url) + .header("Connection", "close") + .send() + .with_context(|| format!("GET {url}"))? + .error_for_status() + .with_context(|| format!("GET {url} status"))? + .json::() + .with_context(|| format!("decode JSON from {url}")) +} + +fn check_cors(client: &Client, server: &str) -> u16 { + let url = format!("{server}/api/0/settings/"); + client + .get(&url) + .header("Origin", "http://10.10.10.13:5600") + .send() + .map(|response| response.status().as_u16()) + .unwrap_or(0) +} + +fn tcp_open(host: &str, port: u16, timeout: Duration) -> bool { + let Ok(mut addrs) = (host, port).to_socket_addrs() else { + return false; + }; + addrs.any(|addr| TcpStream::connect_timeout(&addr, timeout).is_ok()) +} + +fn event_timestamp(event: &Value) -> Option> { + event + .get("timestamp") + .and_then(Value::as_str) + .and_then(parse_ts) +} + +fn parse_ts(value: &str) -> Option> { + DateTime::parse_from_rfc3339(&value.replace('Z', "+00:00")) + .map(|ts| ts.with_timezone(&Utc)) + .ok() +} + +fn format_age(age_sec: i64) -> String { + if age_sec < 3_600 { + format!("{}m", age_sec / 60) + } else if age_sec < 86_400 { + format!("{}h", age_sec / 3_600) + } else { + format!("{}d", age_sec / 86_400) + } +} + +fn json_value_to_string(value: &Value) -> String { + match value { + Value::String(value) => value.clone(), + other => other.to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn event_driven_bucket_is_not_dead_when_old() { + assert_eq!( + classify_bucket_age("aw-session-events", 100_000, &ContextState::default()), + BucketStatus::EventDriven + ); + } + + #[test] + fn inactive_context_suppresses_expected_idle_buckets() { + let context = ContextState { + host_inactive: true, + guard_healthy: true, + }; + assert_eq!( + classify_bucket_age("aw-watcher-window", 100_000, &context), + BucketStatus::Inactive + ); + assert_eq!( + classify_bucket_no_events("aw-dlp-endpoint-signals", &context), + BucketStatus::Inactive + ); + } + + #[test] + fn summary_treats_event_and_inactive_as_fresh_class() { + let rows = vec![ + BucketRow { + label: "a", + last_id: "0".to_string(), + age: "1m".to_string(), + status: BucketStatus::Fresh, + }, + BucketRow { + label: "b", + last_id: "0".to_string(), + age: "none".to_string(), + status: BucketStatus::EventDriven, + }, + BucketRow { + label: "c", + last_id: "0".to_string(), + age: "none".to_string(), + status: BucketStatus::Inactive, + }, + BucketRow { + label: "d", + last_id: "0".to_string(), + age: "none".to_string(), + status: BucketStatus::Empty, + }, + ]; + let summary = summarize(&rows); + assert_eq!(summary.fresh, 3); + assert_eq!(summary.stale, 0); + assert_eq!(summary.dead, 1); + } + + #[test] + fn formats_age_like_legacy_full_check() { + assert_eq!(format_age(59), "0m"); + assert_eq!(format_age(3_600), "1h"); + assert_eq!(format_age(86_400), "1d"); + } +} diff --git a/adk-rust/crates/check-install-kit-vs-repo/Cargo.toml b/adk-rust/crates/check-install-kit-vs-repo/Cargo.toml new file mode 100644 index 0000000..39e6c80 --- /dev/null +++ b/adk-rust/crates/check-install-kit-vs-repo/Cargo.toml @@ -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 diff --git a/adk-rust/crates/check-install-kit-vs-repo/src/main.rs b/adk-rust/crates/check-install-kit-vs-repo/src/main.rs new file mode 100644 index 0000000..93f016c --- /dev/null +++ b/adk-rust/crates/check-install-kit-vs-repo/src/main.rs @@ -0,0 +1,283 @@ +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"]; +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, + mismatches: Vec, + powershell_mismatches: Vec, +} + +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 { + 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 { + 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> { + let mut out = Vec::new(); + collect_files_inner(root, &mut out)?; + out.sort(); + Ok(out) +} + +fn collect_files_inner(path: &Path, out: &mut Vec) -> 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 { + 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::>() + .join("/") +} + +#[allow(dead_code)] +fn sorted_set(paths: &[String]) -> BTreeSet { + 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-192.168.100.18")).unwrap(); + fs::write( + kit.join("server-configs-192.168.100.18/config.deployment-config.json"), + "{}", + ) + .unwrap(); + + let report = compare_install_kit(root, &kit).unwrap(); + assert_eq!(report.missing_in_repo, vec!["unexpected.txt"]); + } +} diff --git a/adk-rust/crates/detmir-auto/Cargo.toml b/adk-rust/crates/detmir-auto/Cargo.toml new file mode 100644 index 0000000..2f847cd --- /dev/null +++ b/adk-rust/crates/detmir-auto/Cargo.toml @@ -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 diff --git a/adk-rust/crates/detmir-auto/src/main.rs b/adk-rust/crates/detmir-auto/src/main.rs new file mode 100644 index 0000000..bb52ee9 --- /dev/null +++ b/adk-rust/crates/detmir-auto/src/main.rs @@ -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, + + #[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, + #[serde(skip_serializing_if = "Option::is_none")] + detmir_summary: Option, + #[serde(skip_serializing_if = "Option::is_none")] + dlp_counts: Option, +} + +fn env_string(name: &str) -> Option { + std::env::var(name).ok().filter(|value| !value.is_empty()) +} + +fn env_path(name: &str) -> Option { + 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 { + 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> { + 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 { + 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 { + 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::>() + .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 { + 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()); + } +} diff --git a/adk-rust/crates/detmir-aw-client/Cargo.toml b/adk-rust/crates/detmir-aw-client/Cargo.toml new file mode 100644 index 0000000..0f9e41f --- /dev/null +++ b/adk-rust/crates/detmir-aw-client/Cargo.toml @@ -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 diff --git a/adk-rust/crates/detmir-aw-client/src/lib.rs b/adk-rust/crates/detmir-aw-client/src/lib.rs new file mode 100644 index 0000000..cf4ca07 --- /dev/null +++ b/adk-rust/crates/detmir-aw-client/src/lib.rs @@ -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, timeout: Duration) -> Result { + 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(&self, path: &str) -> Result { + 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> { + let path = format!("/buckets/{bucket_id}/events?limit=1"); + let mut events: Vec = 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> { + 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); + } +} diff --git a/adk-rust/crates/detmir-check/Cargo.toml b/adk-rust/crates/detmir-check/Cargo.toml new file mode 100644 index 0000000..ca6f5ef --- /dev/null +++ b/adk-rust/crates/detmir-check/Cargo.toml @@ -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 diff --git a/adk-rust/crates/detmir-check/src/main.rs b/adk-rust/crates/detmir-check/src/main.rs new file mode 100644 index 0000000..da5c51f --- /dev/null +++ b/adk-rust/crates/detmir-check/src/main.rs @@ -0,0 +1,522 @@ +use std::net::{SocketAddr, TcpStream}; +use std::time::Duration; + +use anyhow::{Context, Result}; +use chrono::{SecondsFormat, Utc}; +use clap::Parser; +use detmir_aw_client::ActivityWatchClient; +use detmir_core::{exit_codes, now_utc_rfc3339}; +use reqwest::blocking::Client; +use reqwest::header::{HeaderMap, HeaderName, HeaderValue}; +use serde::Serialize; +use serde_json::Value; + +const DEFAULT_AW_API: &str = "http://10.10.10.13:5600/api/0"; +const DEFAULT_WORKTIME_URL: &str = "http://10.10.10.13:5610"; +const DEFAULT_ONE_C_URL: &str = "http://10.10.10.2:8710"; +const DEFAULT_HOSTNAME: &str = "SHARKON2025"; +const DEFAULT_GATEWAY_HOST: &str = "dm.iri1968.dpdns.org"; + +#[derive(Debug, Parser)] +#[command(about = "Autonomous read-only DetMir contour check from Proxmox.")] +struct Cli { + #[arg(long)] + json: bool, + + #[arg(long, default_value = DEFAULT_AW_API)] + aw_api: String, + + #[arg(long, default_value = DEFAULT_WORKTIME_URL)] + worktime_url: String, + + #[arg(long, default_value = DEFAULT_ONE_C_URL)] + one_c_url: String, + + #[arg(long, default_value = DEFAULT_HOSTNAME)] + hostname: String, + + #[arg(long, default_value_t = 5)] + service_timeout_seconds: u64, + + #[arg(long, default_value_t = 8)] + bucket_timeout_seconds: u64, + + #[arg(long, default_value_t = 3.0)] + tcp_timeout_seconds: f64, +} + +#[derive(Debug, Clone, Copy)] +enum BucketMode { + Fresh, + InactiveOk, + EventDriven, +} + +impl BucketMode { + fn as_str(self) -> &'static str { + match self { + Self::Fresh => "fresh", + Self::InactiveOk => "inactive_ok", + Self::EventDriven => "event_driven", + } + } +} + +#[derive(Debug)] +struct BucketSpec { + label: &'static str, + bucket: String, + max_age_seconds: Option, + mode: BucketMode, +} + +#[derive(Debug, Serialize)] +struct CheckReport { + ok: bool, + #[serde(rename = "generatedAtUtc")] + generated_at_utc: String, + services: Vec, + buckets: Vec, + summary: CheckSummary, +} + +#[derive(Debug, Serialize)] +struct CheckSummary { + bucket_ok: usize, + bucket_stale: usize, + bucket_dead: usize, + service_failures: usize, + service_warnings: usize, +} + +#[derive(Debug, Serialize)] +struct ServiceCheck { + name: String, + required: bool, + ok: bool, + #[serde(skip_serializing_if = "Option::is_none")] + url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + payload: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + +#[derive(Debug, Serialize)] +struct BucketCheck { + label: String, + bucket: String, + mode: String, + status: String, + ok: bool, + #[serde(skip_serializing_if = "Option::is_none")] + event_count_sample: Option, + #[serde(skip_serializing_if = "Option::is_none")] + latest: Option, + #[serde(skip_serializing_if = "Option::is_none")] + age_seconds: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + +fn env_or_default(name: &str, default: &str) -> String { + std::env::var(name) + .ok() + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| default.to_string()) +} + +fn bucket_specs(hostname: &str) -> Vec { + vec![ + BucketSpec { + label: "AFK watcher", + bucket: format!("aw-watcher-afk_{hostname}"), + max_age_seconds: Some(15 * 60), + mode: BucketMode::Fresh, + }, + BucketSpec { + label: "Window watcher", + bucket: format!("aw-watcher-window_{hostname}"), + max_age_seconds: Some(2 * 60 * 60), + mode: BucketMode::InactiveOk, + }, + BucketSpec { + label: "Worktime sessions", + bucket: format!("aw-worktime-sessions_{hostname}"), + max_age_seconds: Some(5 * 60), + mode: BucketMode::Fresh, + }, + BucketSpec { + label: "Session events", + bucket: format!("aw-session-events_{hostname}"), + max_age_seconds: None, + mode: BucketMode::EventDriven, + }, + BucketSpec { + label: "DLP signals", + bucket: format!("aw-dlp-endpoint-signals_{hostname}"), + max_age_seconds: Some(10 * 60), + mode: BucketMode::Fresh, + }, + BucketSpec { + label: "DLP incidents", + bucket: format!("aw-dlp-incidents_{hostname}"), + max_age_seconds: None, + mode: BucketMode::EventDriven, + }, + BucketSpec { + label: "DLP review", + bucket: format!("aw-dlp-review_{hostname}"), + max_age_seconds: None, + mode: BucketMode::EventDriven, + }, + BucketSpec { + label: "DLP rules", + bucket: format!("aw-dlp-rules_{hostname}"), + max_age_seconds: None, + mode: BucketMode::EventDriven, + }, + ] +} + +fn build_headers(items: &[(&str, &str)]) -> Result { + let mut headers = HeaderMap::new(); + for (name, value) in items { + headers.insert( + HeaderName::from_bytes(name.as_bytes())?, + HeaderValue::from_str(value)?, + ); + } + Ok(headers) +} + +fn fetch_text( + url: &str, + timeout: Duration, + insecure: bool, + headers: HeaderMap, + attempts: usize, +) -> Result { + let client = Client::builder() + .timeout(timeout) + .danger_accept_invalid_certs(insecure) + .no_proxy() + .build() + .context("failed to build HTTP client")?; + let mut last_error = None; + for attempt in 0..attempts.max(1) { + match client.get(url).headers(headers.clone()).send() { + Ok(response) => match response.error_for_status() { + Ok(response) => { + return response.text().context("failed to read HTTP response body"); + } + Err(err) => last_error = Some(err.into()), + }, + Err(err) => last_error = Some(anyhow::anyhow!("{err:#}")), + } + if attempt + 1 < attempts.max(1) { + std::thread::sleep(Duration::from_millis(500)); + } + } + Err(last_error.unwrap_or_else(|| anyhow::anyhow!("HTTP request failed"))) +} + +fn service_checks(args: &Cli) -> Vec { + let timeout = Duration::from_secs(args.service_timeout_seconds); + let one_c_url = args.one_c_url.trim_end_matches('/'); + let services = [ + ( + "aw-info", + format!("{}/info", args.aw_api.trim_end_matches('/')), + false, + false, + HeaderMap::new(), + ), + ( + "worktime-today", + format!( + "{}/reports/worktime/today", + args.worktime_url.trim_end_matches('/') + ), + false, + false, + HeaderMap::new(), + ), + ( + "1c-api-health", + format!("{one_c_url}/api/health"), + false, + false, + HeaderMap::new(), + ), + ( + "gateway-healthz", + "https://127.0.0.1/healthz".to_string(), + true, + true, + build_headers(&[("Host", DEFAULT_GATEWAY_HOST)]).unwrap_or_default(), + ), + ]; + + let mut checks = Vec::new(); + for (name, url, insecure, required, headers) in services { + match fetch_text(&url, timeout, insecure, headers, 2) { + Ok(raw) => { + let payload = serde_json::from_str::(&raw) + .unwrap_or_else(|_| Value::String(raw.trim().to_string())); + checks.push(ServiceCheck { + name: name.to_string(), + required, + ok: true, + url: Some(url), + payload: Some(payload), + error: None, + }); + } + Err(err) => checks.push(ServiceCheck { + name: name.to_string(), + required, + ok: false, + url: Some(url), + payload: None, + error: Some(err.to_string()), + }), + } + } + + checks.push(tcp_check( + "192.168.100.18", + 5985, + args.tcp_timeout_seconds, + true, + )); + checks.push(tcp_check( + "192.168.100.18", + 22, + args.tcp_timeout_seconds, + true, + )); + checks +} + +fn tcp_check(host: &str, port: u16, timeout_seconds: f64, required: bool) -> ServiceCheck { + let name = format!("tcp:{host}:{port}"); + let timeout = Duration::from_secs_f64(timeout_seconds); + let addr = format!("{host}:{port}"); + match addr.parse::() { + Ok(addr) => match TcpStream::connect_timeout(&addr, timeout) { + Ok(_) => ServiceCheck { + name, + required, + ok: true, + url: None, + payload: None, + error: None, + }, + Err(err) => ServiceCheck { + name, + required, + ok: false, + url: None, + payload: None, + error: Some(err.to_string()), + }, + }, + Err(err) => ServiceCheck { + name, + required, + ok: false, + url: None, + payload: None, + error: Some(err.to_string()), + }, + } +} + +fn bucket_health(args: &Cli) -> Result> { + let client = ActivityWatchClient::new( + args.aw_api.trim_end_matches('/'), + Duration::from_secs(args.bucket_timeout_seconds), + )?; + let now = Utc::now(); + let mut out = Vec::new(); + + for spec in bucket_specs(&args.hostname) { + if matches!(spec.mode, BucketMode::EventDriven) { + out.push(BucketCheck { + label: spec.label.to_string(), + bucket: spec.bucket, + mode: spec.mode.as_str().to_string(), + status: "EVENT-DRIVEN".to_string(), + ok: true, + event_count_sample: None, + latest: None, + age_seconds: None, + error: None, + }); + continue; + } + + match client.latest_event(&spec.bucket) { + Ok(Some(event)) => { + let ts = event.timestamp_utc()?; + let age = (now - ts).num_seconds(); + let status = match spec.mode { + BucketMode::InactiveOk => { + if spec.max_age_seconds.is_some_and(|max_age| age <= max_age) { + "FRESH" + } else { + "INACTIVE" + } + } + BucketMode::Fresh => { + if spec.max_age_seconds.is_some_and(|max_age| age <= max_age) { + "FRESH" + } else { + "STALE" + } + } + BucketMode::EventDriven => "EVENT-DRIVEN", + }; + let ok = match spec.mode { + BucketMode::InactiveOk => true, + BucketMode::Fresh => status == "FRESH", + BucketMode::EventDriven => true, + }; + out.push(BucketCheck { + label: spec.label.to_string(), + bucket: spec.bucket, + mode: spec.mode.as_str().to_string(), + status: status.to_string(), + ok, + event_count_sample: Some(1), + latest: Some(ts.to_rfc3339_opts(SecondsFormat::Secs, true)), + age_seconds: Some(age), + error: None, + }); + } + Ok(None) => out.push(BucketCheck { + label: spec.label.to_string(), + bucket: spec.bucket, + mode: spec.mode.as_str().to_string(), + status: "DEAD".to_string(), + ok: false, + event_count_sample: Some(0), + latest: None, + age_seconds: None, + error: None, + }), + Err(err) => out.push(BucketCheck { + label: spec.label.to_string(), + bucket: spec.bucket, + mode: spec.mode.as_str().to_string(), + status: "DEAD".to_string(), + ok: false, + event_count_sample: None, + latest: None, + age_seconds: None, + error: Some(err.to_string()), + }), + } + } + Ok(out) +} + +fn build_report(args: &Cli) -> Result { + let services = service_checks(args); + let buckets = bucket_health(args)?; + let bucket_ok = buckets.iter().filter(|bucket| bucket.ok).count(); + let bucket_stale = buckets + .iter() + .filter(|bucket| !bucket.ok && bucket.status == "STALE") + .count(); + let bucket_dead = buckets + .iter() + .filter(|bucket| !bucket.ok && bucket.status == "DEAD") + .count(); + let service_failures = services + .iter() + .filter(|service| service.required && !service.ok) + .count(); + let service_warnings = services + .iter() + .filter(|service| !service.required && service.error.is_some()) + .count(); + let ok = service_failures == 0 && bucket_stale == 0 && bucket_dead == 0; + + Ok(CheckReport { + ok, + generated_at_utc: now_utc_rfc3339(), + services, + buckets, + summary: CheckSummary { + bucket_ok, + bucket_stale, + bucket_dead, + service_failures, + service_warnings, + }, + }) +} + +fn render_text(report: &CheckReport) -> String { + let mut lines = vec![ + "=== DetMir Autonomous Check ===".to_string(), + format!("OK: {}", if report.ok { "True" } else { "False" }), + String::new(), + "Services:".to_string(), + ]; + for service in &report.services { + let mark = if service.ok && service.error.is_none() { + "OK" + } else if service.required { + "FAIL" + } else { + "WARN" + }; + lines.push(format!(" {:<18} {}", service.name, mark)); + } + lines.push(String::new()); + lines.push(format!("{:<20} {:<13} {:>8}", "Bucket", "Status", "Age(s)")); + lines.push("-".repeat(44)); + for bucket in &report.buckets { + let age = bucket + .age_seconds + .map(|value| value.to_string()) + .unwrap_or_else(|| "-".to_string()); + lines.push(format!( + "{:<20} {:<13} {:>8}", + bucket.label, bucket.status, age + )); + } + lines.push(String::new()); + lines.push(format!( + "Summary: OK={} STALE={} DEAD={} SERVICE_FAIL={} SERVICE_WARN={}", + report.summary.bucket_ok, + report.summary.bucket_stale, + report.summary.bucket_dead, + report.summary.service_failures, + report.summary.service_warnings, + )); + lines.join("\n") +} + +fn main() -> Result<()> { + let mut args = Cli::parse(); + args.aw_api = env_or_default("DETMIR_AW_API", &args.aw_api); + args.worktime_url = env_or_default("DETMIR_WORKTIME_URL", &args.worktime_url); + args.one_c_url = env_or_default("DETMIR_ONE_C_URL", &args.one_c_url); + args.hostname = env_or_default("DETMIR_HOSTNAME", &args.hostname); + + let report = build_report(&args)?; + if args.json { + println!("{}", serde_json::to_string_pretty(&report)?); + } else { + println!("{}", render_text(&report)); + } + std::process::exit(if report.ok { + exit_codes::OK + } else { + exit_codes::CHECK_FAILED + }); +} diff --git a/adk-rust/crates/detmir-core/Cargo.toml b/adk-rust/crates/detmir-core/Cargo.toml new file mode 100644 index 0000000..3ec2ef3 --- /dev/null +++ b/adk-rust/crates/detmir-core/Cargo.toml @@ -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 diff --git a/adk-rust/crates/detmir-core/src/lib.rs b/adk-rust/crates/detmir-core/src/lib.rs new file mode 100644 index 0000000..a86c80d --- /dev/null +++ b/adk-rust/crates/detmir-core/src/lib.rs @@ -0,0 +1,88 @@ +use std::fmt; + +use anyhow::{Context, Result}; +use chrono::{DateTime, SecondsFormat, Utc}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "UPPERCASE")] +pub enum StatusLevel { + Ok, + Warn, + Fail, + Unknown, +} + +impl StatusLevel { + pub fn as_str(self) -> &'static str { + match self { + Self::Ok => "OK", + Self::Warn => "WARN", + Self::Fail => "FAIL", + Self::Unknown => "UNKNOWN", + } + } + + pub fn exit_code(self) -> i32 { + match self { + Self::Ok => exit_codes::OK, + Self::Warn | Self::Fail | Self::Unknown => exit_codes::CHECK_FAILED, + } + } +} + +impl fmt::Display for StatusLevel { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl From<&str> for StatusLevel { + fn from(value: &str) -> Self { + match value.trim().to_ascii_uppercase().as_str() { + "OK" => Self::Ok, + "WARN" | "WARNING" => Self::Warn, + "FAIL" | "FAILED" | "ERROR" => Self::Fail, + _ => Self::Unknown, + } + } +} + +pub mod exit_codes { + pub const OK: i32 = 0; + pub const ERROR: i32 = 1; + pub const CHECK_FAILED: i32 = 2; + pub const POLICY_DENIED: i32 = 3; +} + +pub fn now_utc_rfc3339() -> String { + Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true) +} + +pub fn parse_utc_rfc3339(value: &str) -> Result> { + DateTime::parse_from_rfc3339(value) + .with_context(|| format!("invalid RFC3339 timestamp: {value}")) + .map(|ts| ts.with_timezone(&Utc)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn status_level_maps_known_values() { + assert_eq!(StatusLevel::from("OK"), StatusLevel::Ok); + assert_eq!(StatusLevel::from("warn"), StatusLevel::Warn); + assert_eq!(StatusLevel::from("ERROR"), StatusLevel::Fail); + assert_eq!(StatusLevel::from("other"), StatusLevel::Unknown); + } + + #[test] + fn parses_zulu_timestamp() { + let ts = parse_utc_rfc3339("2026-05-31T10:20:30Z").unwrap(); + assert_eq!( + ts.to_rfc3339_opts(SecondsFormat::Secs, true), + "2026-05-31T10:20:30Z" + ); + } +} diff --git a/adk-rust/crates/detmir-dlp/Cargo.toml b/adk-rust/crates/detmir-dlp/Cargo.toml new file mode 100644 index 0000000..a8c03bf --- /dev/null +++ b/adk-rust/crates/detmir-dlp/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "detmir-dlp" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +publish.workspace = true + +[dependencies] +anyhow.workspace = true +clap.workspace = true diff --git a/adk-rust/crates/detmir-dlp/src/main.rs b/adk-rust/crates/detmir-dlp/src/main.rs new file mode 100644 index 0000000..6448e99 --- /dev/null +++ b/adk-rust/crates/detmir-dlp/src/main.rs @@ -0,0 +1,107 @@ +use std::io::{self, Write}; +use std::process::Command; + +use anyhow::{Context, Result}; +use clap::Parser; + +const DEFAULT_SSH_TARGET: &str = "igor@10.10.10.13"; +const DEFAULT_REMOTE_COMMAND: &str = "sudo -n /usr/local/bin/dlp-health-check --json"; + +#[derive(Debug, Parser)] +#[command(about = "Run the DetMir DLP health check on the AW server over SSH.")] +struct Cli { + #[arg(long, default_value = "ssh")] + ssh_bin: String, + + #[arg(long, default_value = DEFAULT_SSH_TARGET)] + ssh_target: String, + + #[arg(long, default_value_t = 10)] + connect_timeout_seconds: u64, + + #[arg(long, default_value = DEFAULT_REMOTE_COMMAND)] + remote_command: String, +} + +impl Cli { + fn apply_env(mut self) -> Self { + self.ssh_target = env_first( + &["DETMIR_DLP_SSH_TARGET", "DETMIR_AW_SSH_HOST"], + &self.ssh_target, + ); + self.remote_command = env_first(&["DETMIR_DLP_REMOTE_COMMAND"], &self.remote_command); + self.ssh_bin = env_first(&["DETMIR_SSH_BIN"], &self.ssh_bin); + self + } +} + +fn env_first(names: &[&str], fallback: &str) -> String { + names + .iter() + .find_map(|name| std::env::var(name).ok().filter(|value| !value.is_empty())) + .unwrap_or_else(|| fallback.to_string()) +} + +fn ssh_args(cli: &Cli) -> Vec { + vec![ + "-o".to_string(), + "BatchMode=yes".to_string(), + "-o".to_string(), + format!("ConnectTimeout={}", cli.connect_timeout_seconds), + "-o".to_string(), + "StrictHostKeyChecking=accept-new".to_string(), + cli.ssh_target.clone(), + cli.remote_command.clone(), + ] +} + +fn run(cli: Cli) -> Result { + let args = ssh_args(&cli); + let output = Command::new(&cli.ssh_bin) + .args(&args) + .output() + .with_context(|| format!("failed to execute {}", cli.ssh_bin))?; + + io::stdout() + .write_all(&output.stdout) + .context("failed to write DLP stdout")?; + io::stderr() + .write_all(&output.stderr) + .context("failed to write DLP stderr")?; + + Ok(output.status.code().unwrap_or(1)) +} + +fn main() -> Result<()> { + let cli = Cli::parse().apply_env(); + let code = run(cli)?; + std::process::exit(code); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn builds_legacy_ssh_args() { + let cli = Cli { + ssh_bin: "ssh".to_string(), + ssh_target: DEFAULT_SSH_TARGET.to_string(), + connect_timeout_seconds: 10, + remote_command: DEFAULT_REMOTE_COMMAND.to_string(), + }; + assert_eq!( + ssh_args(&cli), + vec![ + "-o", + "BatchMode=yes", + "-o", + "ConnectTimeout=10", + "-o", + "StrictHostKeyChecking=accept-new", + DEFAULT_SSH_TARGET, + DEFAULT_REMOTE_COMMAND, + ] + ); + } +} diff --git a/adk-rust/crates/detmir-heal-safe/Cargo.toml b/adk-rust/crates/detmir-heal-safe/Cargo.toml new file mode 100644 index 0000000..fc08c93 --- /dev/null +++ b/adk-rust/crates/detmir-heal-safe/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "detmir-heal-safe" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +publish.workspace = true + +[dependencies] +anyhow.workspace = true +clap.workspace = true +detmir-core.workspace = true +serde.workspace = true +serde_json.workspace = true diff --git a/adk-rust/crates/detmir-heal-safe/src/main.rs b/adk-rust/crates/detmir-heal-safe/src/main.rs new file mode 100644 index 0000000..6fa072a --- /dev/null +++ b/adk-rust/crates/detmir-heal-safe/src/main.rs @@ -0,0 +1,321 @@ +use std::io::Write; +use std::process::{Command, Stdio}; + +use anyhow::{Context, Result}; +use clap::Parser; +use detmir_core::{exit_codes, now_utc_rfc3339}; +use serde::Serialize; + +const DEFAULT_SSH_TARGET: &str = "igor@10.10.10.13"; +const REQUIRED_UNITS: &[&str] = &[ + "activitywatch-server.service", + "aw-worktime-api.service", + "aw-worktime-ui-bridge.timer", +]; +const OPTIONAL_UNITS: &[&str] = &["activitywatch-dlp-aggregator.timer"]; + +#[derive(Debug, Parser)] +#[command(about = "Safely heal allowlisted DetMir server-side services over SSH.")] +struct Cli { + #[arg(long, default_value = "ssh")] + ssh_bin: String, + + #[arg(long, default_value = DEFAULT_SSH_TARGET)] + ssh_target: String, + + #[arg(long, default_value_t = 10)] + connect_timeout_seconds: u64, + + #[arg(long)] + json: bool, + + #[arg(long)] + apply: bool, + + #[arg(long)] + start_optional: bool, +} + +impl Cli { + fn apply_env(mut self) -> Self { + self.ssh_target = env_first( + &["DETMIR_HEAL_SSH_TARGET", "DETMIR_AW_SSH_HOST"], + &self.ssh_target, + ); + self.ssh_bin = env_first(&["DETMIR_SSH_BIN"], &self.ssh_bin); + self + } +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +struct HealAction { + action: String, + unit: Option, + required: bool, + status: String, +} + +#[derive(Debug, Serialize)] +struct HealReport { + ok: bool, + dry_run: bool, + applied: bool, + generated_at_utc: String, + ssh_target: String, + actions: Vec, + raw_stdout: String, + raw_stderr: String, +} + +fn env_first(names: &[&str], fallback: &str) -> String { + names + .iter() + .find_map(|name| std::env::var(name).ok().filter(|value| !value.is_empty())) + .unwrap_or_else(|| fallback.to_string()) +} + +fn ssh_args(cli: &Cli) -> Vec { + vec![ + "-o".to_string(), + "BatchMode=yes".to_string(), + "-o".to_string(), + format!("ConnectTimeout={}", cli.connect_timeout_seconds), + "-o".to_string(), + "StrictHostKeyChecking=accept-new".to_string(), + cli.ssh_target.clone(), + "bash".to_string(), + "-s".to_string(), + "--".to_string(), + if cli.apply { "apply" } else { "dry-run" }.to_string(), + if cli.start_optional { + "start-optional" + } else { + "skip-optional" + } + .to_string(), + ] +} + +fn remote_script() -> String { + let required = REQUIRED_UNITS.join(" "); + let optional = OPTIONAL_UNITS.join(" "); + format!( + r#"set -euo pipefail +mode="${{1:-dry-run}}" +optional_mode="${{2:-skip-optional}}" +required_units="{required}" +optional_units="{optional}" + +emit() {{ + action="$1" + unit="${{2:-}}" + required="${{3:-false}}" + status="${{4:-ok}}" + printf '%s\t%s\t%s\t%s\n' "$action" "$unit" "$required" "$status" +}} + +if [ "$mode" = "apply" ]; then + sudo -n systemctl reset-failed $required_units $optional_units >/dev/null 2>&1 || true + emit reset-failed "" false applied +else + emit reset-failed "" false planned +fi + +for service in $required_units; do + if systemctl is-active --quiet "$service"; then + emit active "$service" true ok + elif [ "$mode" = "apply" ]; then + emit restart "$service" true started + sudo -n systemctl restart "$service" + emit active "$service" true ok + else + emit restart "$service" true planned + fi +done + +for service in $optional_units; do + if ! systemctl list-unit-files "$service" >/dev/null 2>&1; then + emit absent "$service" false skipped + elif systemctl is-active --quiet "$service"; then + emit active "$service" false ok + elif [ "$mode" = "apply" ]; then + if [ "$optional_mode" = "start-optional" ]; then + emit start "$service" false started + sudo -n systemctl start "$service" || true + else + emit inactive "$service" false skipped + fi + else + if [ "$optional_mode" = "start-optional" ]; then + emit start "$service" false planned + else + emit inactive "$service" false skipped + fi + fi +done + +if [ "$mode" = "apply" ]; then + sudo -n /usr/local/bin/dlp-health-check --json >/tmp/detmir-heal-dlp-health.json || true + emit dlp-health-check "/tmp/detmir-heal-dlp-health.json" false written +else + emit dlp-health-check "/tmp/detmir-heal-dlp-health.json" false planned +fi +"# + ) +} + +fn parse_actions(raw: &str) -> Vec { + raw.lines() + .filter_map(|line| { + let mut parts = line.split('\t'); + let action = parts.next()?; + let unit = parts.next().unwrap_or_default(); + let required = parts.next().unwrap_or("false") == "true"; + let status = parts.next().unwrap_or("unknown"); + Some(HealAction { + action: action.to_string(), + unit: if unit.is_empty() { + None + } else { + Some(unit.to_string()) + }, + required, + status: status.to_string(), + }) + }) + .collect() +} + +fn run(cli: Cli) -> Result { + let args = ssh_args(&cli); + let mut child = Command::new(&cli.ssh_bin) + .args(&args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .with_context(|| format!("failed to execute {}", cli.ssh_bin))?; + + child + .stdin + .as_mut() + .context("failed to open SSH stdin")? + .write_all(remote_script().as_bytes()) + .context("failed to write remote heal script")?; + + let output = child + .wait_with_output() + .context("SSH heal command failed")?; + let raw_stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let raw_stderr = String::from_utf8_lossy(&output.stderr).to_string(); + let code = output.status.code().unwrap_or(exit_codes::ERROR); + let actions = parse_actions(&raw_stdout); + let report = HealReport { + ok: code == exit_codes::OK, + dry_run: !cli.apply, + applied: cli.apply && code == exit_codes::OK, + generated_at_utc: now_utc_rfc3339(), + ssh_target: cli.ssh_target.clone(), + actions, + raw_stdout, + raw_stderr, + }; + + if cli.json { + println!("{}", serde_json::to_string_pretty(&report)?); + } else { + if report.dry_run { + println!("detmir-heal-safe: dry-run"); + } else { + println!("detmir-heal-safe: apply"); + } + for action in &report.actions { + match &action.unit { + Some(unit) => println!( + "{} {} required={} status={}", + action.action, unit, action.required, action.status + ), + None => println!("{} status={}", action.action, action.status), + } + } + if !report.raw_stderr.trim().is_empty() { + eprint!("{}", report.raw_stderr); + } + } + + Ok(code) +} + +fn main() -> Result<()> { + let cli = Cli::parse().apply_env(); + let code = run(cli)?; + std::process::exit(code); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn builds_dry_run_ssh_args() { + let cli = Cli { + ssh_bin: "ssh".to_string(), + ssh_target: DEFAULT_SSH_TARGET.to_string(), + connect_timeout_seconds: 10, + json: false, + apply: false, + start_optional: false, + }; + assert_eq!( + ssh_args(&cli), + vec![ + "-o", + "BatchMode=yes", + "-o", + "ConnectTimeout=10", + "-o", + "StrictHostKeyChecking=accept-new", + DEFAULT_SSH_TARGET, + "bash", + "-s", + "--", + "dry-run", + "skip-optional", + ] + ); + } + + #[test] + fn parses_remote_actions() { + let actions = parse_actions( + "reset-failed\t\tfalse\tplanned\nactive\tactivitywatch-server.service\ttrue\tok\n", + ); + assert_eq!( + actions, + vec![ + HealAction { + action: "reset-failed".to_string(), + unit: None, + required: false, + status: "planned".to_string(), + }, + HealAction { + action: "active".to_string(), + unit: Some("activitywatch-server.service".to_string()), + required: true, + status: "ok".to_string(), + }, + ] + ); + } + + #[test] + fn remote_script_uses_allowlisted_units() { + let script = remote_script(); + assert!(script.contains("activitywatch-server.service")); + assert!(script.contains("aw-worktime-api.service")); + assert!(script.contains("aw-worktime-ui-bridge.timer")); + assert!(script.contains("activitywatch-dlp-aggregator.timer")); + assert!(!script.contains("*")); + } +} diff --git a/adk-rust/crates/detmir-state/Cargo.toml b/adk-rust/crates/detmir-state/Cargo.toml new file mode 100644 index 0000000..373ab13 --- /dev/null +++ b/adk-rust/crates/detmir-state/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "detmir-state" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +publish.workspace = true + +[dependencies] +anyhow.workspace = true +detmir-core.workspace = true +serde.workspace = true +serde_json.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/adk-rust/crates/detmir-state/src/lib.rs b/adk-rust/crates/detmir-state/src/lib.rs new file mode 100644 index 0000000..e465b2f --- /dev/null +++ b/adk-rust/crates/detmir-state/src/lib.rs @@ -0,0 +1,209 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use detmir_core::StatusLevel; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +pub const DEFAULT_STATE_FILE: &str = "/var/lib/detmir-ai/latest-state.json"; + +#[derive(Debug, Deserialize)] +pub struct DetmirState { + pub severity: Option, + pub check_ok: Option, + pub dlp_ok: Option, + pub needs_heal: Option, + pub reasons: Option>, + pub detmir_summary: Option, + pub dlp_counts: Option, + pub check: Option, +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)] +pub struct DetmirSummary { + pub bucket_ok: Option, + pub bucket_stale: Option, + pub bucket_dead: Option, + pub service_failures: Option, + pub service_warnings: Option, +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)] +pub struct DlpCounts { + pub ok: Option, + pub warn: Option, + pub fail: Option, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct NormalizedStatus { + pub severity: String, + pub check_ok: bool, + pub dlp_ok: bool, + pub needs_heal: bool, + pub reasons: Vec, + pub detmir_summary: DetmirSummary, + pub dlp_counts: DlpCounts, + pub ok_for_operator: bool, +} + +impl NormalizedStatus { + pub fn level(&self) -> StatusLevel { + StatusLevel::from(self.severity.as_str()) + } + + pub fn exit_code(&self) -> i32 { + if self.ok_for_operator { + StatusLevel::Ok.exit_code() + } else { + StatusLevel::Fail.exit_code() + } + } +} + +impl DetmirState { + pub fn normalize(self) -> NormalizedStatus { + let summary = self.detmir_summary.or_else(|| { + self.check + .as_ref() + .and_then(|check| check.get("summary")) + .and_then(|summary| serde_json::from_value(summary.clone()).ok()) + }); + let summary = summary.unwrap_or_default(); + let dlp_counts = self.dlp_counts.unwrap_or_default(); + + let severity = self.severity.unwrap_or_else(|| "UNKNOWN".to_string()); + let check_ok = self + .check_ok + .or_else(|| { + self.check + .as_ref() + .and_then(|check| check.get("ok")) + .and_then(Value::as_bool) + }) + .unwrap_or(false); + let dlp_ok = self.dlp_ok.unwrap_or(false); + let needs_heal = self.needs_heal.unwrap_or(false); + let reasons = self.reasons.unwrap_or_default(); + + let ok_for_operator = severity == "OK" + && check_ok + && dlp_ok + && !needs_heal + && summary.bucket_stale.unwrap_or(0) == 0 + && summary.bucket_dead.unwrap_or(0) == 0 + && summary.service_failures.unwrap_or(0) == 0 + && dlp_counts.warn.unwrap_or(0) == 0 + && dlp_counts.fail.unwrap_or(0) == 0; + + NormalizedStatus { + severity, + check_ok, + dlp_ok, + needs_heal, + reasons, + detmir_summary: summary, + dlp_counts, + ok_for_operator, + } + } +} + +pub fn read_state(path: impl AsRef) -> Result { + let path = path.as_ref(); + let raw = fs::read_to_string(path) + .with_context(|| format!("failed to read DetMir state file {}", path.display()))?; + let state: DetmirState = serde_json::from_str(&raw) + .with_context(|| format!("failed to parse DetMir state JSON {}", path.display()))?; + Ok(state.normalize()) +} + +pub fn write_json_atomic(path: impl AsRef, value: &T) -> Result<()> { + let path = path.as_ref(); + let parent = path + .parent() + .with_context(|| format!("path has no parent: {}", path.display()))?; + fs::create_dir_all(parent) + .with_context(|| format!("failed to create state directory {}", parent.display()))?; + + let tmp_path = temp_path(path); + let mut payload = serde_json::to_vec_pretty(value)?; + payload.push(b'\n'); + fs::write(&tmp_path, payload) + .with_context(|| format!("failed to write temporary state {}", tmp_path.display()))?; + fs::rename(&tmp_path, path) + .with_context(|| format!("failed to atomically replace {}", path.display()))?; + Ok(()) +} + +fn temp_path(path: &Path) -> PathBuf { + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("state.json"); + path.with_file_name(format!(".{file_name}.{}.tmp", std::process::id())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalizes_current_state_shape() { + let raw = r#"{ + "severity": "OK", + "check_ok": true, + "dlp_ok": true, + "needs_heal": false, + "reasons": [], + "detmir_summary": { + "bucket_ok": 8, + "bucket_stale": 0, + "bucket_dead": 0, + "service_failures": 0, + "service_warnings": 0 + }, + "dlp_counts": {"ok": 22, "warn": 0, "fail": 0} + }"#; + let state: DetmirState = serde_json::from_str(raw).unwrap(); + let normalized = state.normalize(); + assert!(normalized.ok_for_operator); + assert_eq!(normalized.exit_code(), 0); + } + + #[test] + fn reads_legacy_check_summary_shape() { + let raw = r#"{ + "severity": "OK", + "dlp_ok": true, + "check": { + "ok": true, + "summary": { + "bucket_ok": 8, + "bucket_stale": 0, + "bucket_dead": 0, + "service_failures": 0, + "service_warnings": 0 + } + }, + "dlp_counts": {"ok": 1, "warn": 0, "fail": 0} + }"#; + let state: DetmirState = serde_json::from_str(raw).unwrap(); + assert!(state.normalize().ok_for_operator); + } + + #[test] + fn writes_json_atomically() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("state.json"); + let payload = DetmirSummary { + bucket_ok: Some(1), + ..Default::default() + }; + write_json_atomic(&path, &payload).unwrap(); + let stored: DetmirSummary = + serde_json::from_str(&fs::read_to_string(path).unwrap()).unwrap(); + assert_eq!(stored.bucket_ok, Some(1)); + } +} diff --git a/adk-rust/crates/detmir-status/.gitignore b/adk-rust/crates/detmir-status/.gitignore new file mode 100644 index 0000000..2f7896d --- /dev/null +++ b/adk-rust/crates/detmir-status/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/adk-rust/crates/detmir-status/Cargo.toml b/adk-rust/crates/detmir-status/Cargo.toml new file mode 100644 index 0000000..1cbf8cc --- /dev/null +++ b/adk-rust/crates/detmir-status/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "detmir-status" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +publish.workspace = true + +[[bin]] +name = "detmir-status" +path = "src/bin/detmir-status.rs" + +[[bin]] +name = "detmir-adk-status" +path = "src/bin/detmir-adk-status.rs" + +[dependencies] +adk-rust.workspace = true +anyhow.workspace = true +clap.workspace = true +detmir-state.workspace = true +serde.workspace = true +serde_json.workspace = true diff --git a/adk-rust/crates/detmir-status/README.md b/adk-rust/crates/detmir-status/README.md new file mode 100644 index 0000000..40142ea --- /dev/null +++ b/adk-rust/crates/detmir-status/README.md @@ -0,0 +1,19 @@ +# detmir-status + +Read-only DetMir status probe written in Rust as a small ADK-Rust adoption +spike. + +The command reads `/var/lib/detmir-ai/latest-state.json` and prints either a +compact operator summary, raw normalized JSON, or an ADK `Content` envelope that +can later be handed to an ADK runner/model/tool chain. + +It does not start recovery, modify services, or write state. + +```bash +cd adk-rust +cargo run -p detmir-status -- --json +cargo run -p detmir-status -- --adk-json +cargo run -p detmir-status -- status --json +``` + +The package also builds a compatibility binary named `detmir-adk-status`. diff --git a/adk-rust/crates/detmir-status/src/bin/detmir-adk-status.rs b/adk-rust/crates/detmir-status/src/bin/detmir-adk-status.rs new file mode 100644 index 0000000..f7ce994 --- /dev/null +++ b/adk-rust/crates/detmir-status/src/bin/detmir-adk-status.rs @@ -0,0 +1,3 @@ +fn main() -> anyhow::Result<()> { + detmir_status::main_entry() +} diff --git a/adk-rust/crates/detmir-status/src/bin/detmir-status.rs b/adk-rust/crates/detmir-status/src/bin/detmir-status.rs new file mode 100644 index 0000000..f7ce994 --- /dev/null +++ b/adk-rust/crates/detmir-status/src/bin/detmir-status.rs @@ -0,0 +1,3 @@ +fn main() -> anyhow::Result<()> { + detmir_status::main_entry() +} diff --git a/adk-rust/crates/detmir-status/src/lib.rs b/adk-rust/crates/detmir-status/src/lib.rs new file mode 100644 index 0000000..d70e26d --- /dev/null +++ b/adk-rust/crates/detmir-status/src/lib.rs @@ -0,0 +1,93 @@ +use std::path::PathBuf; + +use adk_rust::Content; +use anyhow::Result; +use clap::{Args, Parser, Subcommand}; +use detmir_state::{DEFAULT_STATE_FILE, NormalizedStatus, read_state}; +use serde_json::json; + +#[derive(Debug, Parser)] +#[command(about = "Read-only DetMir status probe with JSON and ADK output.")] +struct Cli { + #[command(flatten)] + status: StatusArgs, + + #[command(subcommand)] + command: Option, +} + +#[derive(Debug, Subcommand)] +enum Command { + Status(StatusArgs), +} + +#[derive(Debug, Args)] +struct StatusArgs { + #[arg(long, default_value = DEFAULT_STATE_FILE)] + state: PathBuf, + + #[arg(long)] + json: bool, + + #[arg(long)] + adk_json: bool, +} + +fn render_text(status: &NormalizedStatus) -> String { + let s = &status.detmir_summary; + let d = &status.dlp_counts; + format!( + "DetMir status: {severity}\n\ + check_ok={check_ok} dlp_ok={dlp_ok} needs_heal={needs_heal}\n\ + buckets: ok={bucket_ok} stale={bucket_stale} dead={bucket_dead}\n\ + services: fail={service_failures} warn={service_warnings}\n\ + dlp: ok={dlp_ok_count} warn={dlp_warn} fail={dlp_fail}\n\ + operator_ok={operator_ok}", + severity = status.severity, + check_ok = status.check_ok, + dlp_ok = status.dlp_ok, + needs_heal = status.needs_heal, + bucket_ok = s.bucket_ok.unwrap_or(0), + bucket_stale = s.bucket_stale.unwrap_or(0), + bucket_dead = s.bucket_dead.unwrap_or(0), + service_failures = s.service_failures.unwrap_or(0), + service_warnings = s.service_warnings.unwrap_or(0), + dlp_ok_count = d.ok.unwrap_or(0), + dlp_warn = d.warn.unwrap_or(0), + dlp_fail = d.fail.unwrap_or(0), + operator_ok = status.ok_for_operator, + ) +} + +fn render_adk_content_json(status: &NormalizedStatus) -> Result { + let text = render_text(status); + let content = Content::new("user").with_text(text); + let envelope = json!({ + "agent": "detmir-status-agent", + "mode": "read-only", + "adk_content": content, + "normalized": status, + }); + Ok(serde_json::to_string_pretty(&envelope)?) +} + +fn run_status(args: StatusArgs) -> Result { + let status = read_state(&args.state)?; + if args.adk_json { + println!("{}", render_adk_content_json(&status)?); + } else if args.json { + println!("{}", serde_json::to_string_pretty(&status)?); + } else { + println!("{}", render_text(&status)); + } + Ok(status.exit_code()) +} + +pub fn main_entry() -> Result<()> { + let cli = Cli::parse(); + let code = match cli.command { + Some(Command::Status(args)) => run_status(args)?, + None => run_status(cli.status)?, + }; + std::process::exit(code); +} diff --git a/adk-rust/crates/diag-and-manual-restart/Cargo.toml b/adk-rust/crates/diag-and-manual-restart/Cargo.toml new file mode 100644 index 0000000..dc39575 --- /dev/null +++ b/adk-rust/crates/diag-and-manual-restart/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "diag-and-manual-restart" +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 + +[dev-dependencies] +tempfile.workspace = true diff --git a/adk-rust/crates/diag-and-manual-restart/src/main.rs b/adk-rust/crates/diag-and-manual-restart/src/main.rs new file mode 100644 index 0000000..a814aec --- /dev/null +++ b/adk-rust/crates/diag-and-manual-restart/src/main.rs @@ -0,0 +1,250 @@ +use std::io::{self, Write}; +use std::process::Command; + +use anyhow::{Context, Result, bail}; +use chrono::Utc; +use clap::Parser; + +const SERVER_UNITS: &[&str] = &[ + "activitywatch-server", + "aw-worktime-api", + "aw-worktime-ui-bridge.timer", + "aw-dlp-policy-engine.service", + "aw-dlp-aggregator.timer", + "activitywatch-dlp-aggregator.timer", +]; + +#[derive(Debug, Parser)] +#[command(about = "Run AW/DLP diagnostics and optionally perform manual restart recovery")] +struct Cli { + #[arg(long)] + with_windows: bool, + + #[arg(long)] + yes: bool, + + #[arg(long, default_value = "ansible/inventory.ini")] + inventory: String, +} + +fn main() { + let code = match run() { + Ok(code) => code, + Err(err) => { + eprintln!("{err:#}"); + 1 + } + }; + std::process::exit(code); +} + +fn run() -> Result { + let cli = Cli::parse(); + require_command("ansible")?; + require_command("ansible-playbook")?; + if !std::path::Path::new(&cli.inventory).is_file() { + bail!("inventory not found: {}", cli.inventory); + } + + log("Running diagnostics on aw_server..."); + if run_health_check(&cli.inventory) { + log("Diagnostics: healthy. Restart not needed."); + return Ok(0); + } + + log("Diagnostics: FAILED."); + if !confirm_restart(cli.yes)? { + log("Restart declined."); + return Ok(1); + } + + restart_server_components(&cli.inventory); + if cli.with_windows { + restart_windows_collectors(&cli.inventory); + seed_windows_dlp_events(&cli.inventory); + } + seed_server_dlp_events(&cli.inventory)?; + + log("Waiting 15 seconds before re-check..."); + std::thread::sleep(std::time::Duration::from_secs(15)); + + log("Running post-restart diagnostics..."); + if run_health_check(&cli.inventory) { + log("Post-restart diagnostics: healthy."); + return Ok(0); + } + log("Post-restart diagnostics: still failing."); + Ok(1) +} + +fn log(message: &str) { + eprintln!("{} {}", Utc::now().format("%Y-%m-%d %H:%M:%S"), message); +} + +fn require_command(name: &str) -> Result<()> { + let status = Command::new("sh") + .arg("-c") + .arg(format!("command -v {name} >/dev/null 2>&1")) + .status() + .with_context(|| format!("check command {name}"))?; + if status.success() { + Ok(()) + } else { + bail!("{name} not found") + } +} + +fn run_health_check(inventory: &str) -> bool { + ansible_command( + inventory, + "aw_server", + "-b", + "ansible.builtin.command", + "/usr/local/bin/aw-health-check", + ) && ansible_command( + inventory, + "aw_server", + "-b", + "ansible.builtin.command", + "/usr/local/bin/dlp-health-check", + ) +} + +fn restart_server_components(inventory: &str) { + log("Restarting server components on aw_server..."); + for unit in SERVER_UNITS { + if ansible_command( + inventory, + "aw_server", + "-b", + "ansible.builtin.command", + &format!("systemctl status {unit}"), + ) { + let _ = ansible_command( + inventory, + "aw_server", + "-b", + "ansible.builtin.systemd", + &format!("name={unit} state=restarted enabled=true"), + ); + } + } +} + +fn seed_server_dlp_events(inventory: &str) -> Result<()> { + log("Seeding DLP freshness events on aw_server..."); + let ts = Utc::now().format("%Y-%m-%dT%H:%M:%SZ"); + let script = format!( + r#"cat >/tmp/aw-endpoint-seed.json <<'JSON' +{{"timestamp":"{ts}","duration":0.0,"data":{{"hostname":"SHARKON2025","signalType":"self_test","source":"diag_and_manual_restart","username":"system","queueDepth":0,"eventsEnqueued":0,"eventsFlushed":0,"sendFailures":0}}}} +JSON +cat >/tmp/aw-fileops-seed-host.json <<'JSON' +{{"timestamp":"{ts}","duration":0.0,"data":{{"hostname":"SHARKON2025","operation":"self_test","source":"diag_and_manual_restart"}}}} +JSON +cat >/tmp/aw-fileops-seed-server.json <<'JSON' +{{"timestamp":"{ts}","duration":0.0,"data":{{"hostname":"10.10.10.13","operation":"self_test","source":"diag_and_manual_restart"}}}} +JSON +curl -sS -X POST 'http://127.0.0.1:5600/api/0/buckets/aw-dlp-endpoint-signals_SHARKON2025' -H 'Content-Type: application/json' -d '{{"client":"aw-dlp-endpoint-signals","type":"aw.dlp.endpoint.signal","hostname":"SHARKON2025"}}' >/dev/null 2>&1 || true +curl -sS -X POST 'http://127.0.0.1:5600/api/0/buckets/aw-file-operations_SHARKON2025' -H 'Content-Type: application/json' -d '{{"client":"aw-file-operations","type":"aw.file.operation","hostname":"SHARKON2025"}}' >/dev/null 2>&1 || true +curl -sS -X POST 'http://127.0.0.1:5600/api/0/buckets/aw-file-operations_10.10.10.13' -H 'Content-Type: application/json' -d '{{"client":"aw-file-operations","type":"aw.file.operation","hostname":"10.10.10.13"}}' >/dev/null 2>&1 || true +curl -sS -X POST 'http://127.0.0.1:5600/api/0/buckets/aw-dlp-endpoint-signals_SHARKON2025/heartbeat?pulsetime=30' -H 'Content-Type: application/json' --data-binary @/tmp/aw-endpoint-seed.json >/dev/null +curl -sS -X POST 'http://127.0.0.1:5600/api/0/buckets/aw-file-operations_SHARKON2025/heartbeat?pulsetime=30' -H 'Content-Type: application/json' --data-binary @/tmp/aw-fileops-seed-host.json >/dev/null +curl -sS -X POST 'http://127.0.0.1:5600/api/0/buckets/aw-file-operations_10.10.10.13/heartbeat?pulsetime=30' -H 'Content-Type: application/json' --data-binary @/tmp/aw-fileops-seed-server.json >/dev/null +"# + ); + let _ = ansible_command( + inventory, + "aw_server", + "-b", + "ansible.builtin.shell", + &script, + ); + Ok(()) +} + +fn restart_windows_collectors(inventory: &str) { + log("Restarting Windows recovery/launch tasks on aw_windows..."); + let script = r#"powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; try { Start-ScheduledTask -TaskName 'ActivityWatch Recovery' -ErrorAction Stop | Out-Null } catch {}; Get-ScheduledTask | Where-Object TaskName -like 'ActivityWatch Launch *' | ForEach-Object { try { Start-ScheduledTask -TaskName $_.TaskName -ErrorAction Stop | Out-Null } catch {} }; Write-Output 'windows-tasks-restarted'""#; + let _ = ansible_windows_shell(inventory, script); +} + +fn seed_windows_dlp_events(inventory: &str) { + log("Seeding endpoint/file-ops events from aw_windows..."); + let script = r#"powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; $ts = (Get-Date).ToUniversalTime().ToString('o'); $api='http://10.10.10.13:5600/api/0'; $endpoint=@{timestamp=$ts;duration=0.0;data=@{hostname='SHARKON2025';signalType='self_test';source='diag_and_manual_restart';username=$env:USERNAME;queueDepth=0;eventsEnqueued=0;eventsFlushed=0;sendFailures=0}} | ConvertTo-Json -Depth 8 -Compress; $fileops=@{timestamp=$ts;duration=0.0;data=@{hostname='SHARKON2025';operation='self_test';source='diag_and_manual_restart';username=$env:USERNAME}} | ConvertTo-Json -Depth 8 -Compress; Invoke-RestMethod -Method Post -Uri $api'/buckets/aw-dlp-endpoint-signals_SHARKON2025' -ContentType 'application/json' -Body '{\"client\":\"aw-dlp-endpoint-signals\",\"type\":\"aw.dlp.endpoint.signal\",\"hostname\":\"SHARKON2025\"}' -TimeoutSec 15 -DisableKeepAlive -ErrorAction SilentlyContinue | Out-Null; Invoke-RestMethod -Method Post -Uri $api'/buckets/aw-file-operations_SHARKON2025' -ContentType 'application/json' -Body '{\"client\":\"aw-file-operations\",\"type\":\"aw.file.operation\",\"hostname\":\"SHARKON2025\"}' -TimeoutSec 15 -DisableKeepAlive -ErrorAction SilentlyContinue | Out-Null; Invoke-RestMethod -Method Post -Uri $api'/buckets/aw-dlp-endpoint-signals_SHARKON2025/heartbeat?pulsetime=30' -ContentType 'application/json' -Body $endpoint -TimeoutSec 15 -DisableKeepAlive | Out-Null; Invoke-RestMethod -Method Post -Uri $api'/buckets/aw-file-operations_SHARKON2025/heartbeat?pulsetime=30' -ContentType 'application/json' -Body $fileops -TimeoutSec 15 -DisableKeepAlive | Out-Null; Write-Output 'windows-dlp-seeded'""#; + let _ = ansible_windows_shell(inventory, script); +} + +fn confirm_restart(auto_yes: bool) -> Result { + if auto_yes { + return Ok(true); + } + eprint!("Diagnostics failed. Restart required components now? [y/N]: "); + io::stderr().flush().ok(); + let mut answer = String::new(); + io::stdin() + .read_line(&mut answer) + .context("read confirmation")?; + Ok(matches!(answer.trim(), "y" | "Y")) +} + +fn ansible_command( + inventory: &str, + group: &str, + become_flag: &str, + module: &str, + args: &str, +) -> bool { + run_command( + "ansible", + &[ + group, + "-i", + inventory, + become_flag, + "-m", + module, + "-a", + args, + ], + ) +} + +fn ansible_windows_shell(inventory: &str, args: &str) -> bool { + run_command( + "ansible", + &[ + "aw_windows", + "-i", + inventory, + "-m", + "ansible.windows.win_shell", + "-a", + args, + ], + ) +} + +fn run_command(cmd: &str, args: &[&str]) -> bool { + match Command::new(cmd).args(args).output() { + Ok(output) => { + print!("{}", String::from_utf8_lossy(&output.stdout)); + eprint!("{}", String::from_utf8_lossy(&output.stderr)); + output.status.success() + } + Err(err) => { + eprintln!("{cmd}: {err}"); + false + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn server_units_keep_legacy_order() { + assert_eq!(SERVER_UNITS[0], "activitywatch-server"); + assert!(SERVER_UNITS.contains(&"activitywatch-dlp-aggregator.timer")); + } +} diff --git a/adk-rust/crates/dlp-admin-cli/Cargo.toml b/adk-rust/crates/dlp-admin-cli/Cargo.toml new file mode 100644 index 0000000..c65990b --- /dev/null +++ b/adk-rust/crates/dlp-admin-cli/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "dlp-admin-cli" +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 +urlencoding.workspace = true diff --git a/adk-rust/crates/dlp-admin-cli/src/main.rs b/adk-rust/crates/dlp-admin-cli/src/main.rs new file mode 100644 index 0000000..349883f --- /dev/null +++ b/adk-rust/crates/dlp-admin-cli/src/main.rs @@ -0,0 +1,373 @@ +use std::time::Duration; + +use anyhow::{Context, Result, bail}; +use chrono::{DateTime, Utc}; +use clap::{Parser, Subcommand}; +use reqwest::blocking::Client; +use reqwest::header::{ACCEPT, CONTENT_TYPE}; +use serde_json::{Map, Value, json}; +use urlencoding::encode; + +#[derive(Debug, Parser)] +#[command(about = "AWatch DLP admin CLI")] +struct Cli { + #[arg(long, default_value = "http://127.0.0.1:5601")] + policy_server: String, + + #[arg(long, default_value = "http://127.0.0.1:5602")] + case_server: String, + + #[arg(long, default_value = "http://127.0.0.1:5600")] + aw_server: String, + + #[command(subcommand)] + command: Command, +} + +#[derive(Debug, Subcommand)] +enum Command { + Policies { + #[command(subcommand)] + command: PoliciesCommand, + }, + Incidents { + #[command(subcommand)] + command: IncidentsCommand, + }, + Cases { + #[command(subcommand)] + command: CasesCommand, + }, + Health { + #[command(subcommand)] + command: HealthCommand, + }, +} + +#[derive(Debug, Subcommand)] +enum PoliciesCommand { + List, + Active, +} + +#[derive(Debug, Subcommand)] +enum IncidentsCommand { + List { + #[arg(long)] + host: Option, + + #[arg(long)] + severity: Option, + + #[arg(long, default_value_t = 100)] + limit: usize, + + #[arg(long, default_value_t = 24)] + since_hours: i64, + }, +} + +#[derive(Debug, Subcommand)] +enum CasesCommand { + List { + #[arg(long)] + host: Option, + + #[arg(long)] + status: Option, + + #[arg(long, default_value_t = 100)] + limit: usize, + }, + Create { + #[arg(long)] + incident_id: String, + + #[arg(long)] + title: String, + + #[arg(long)] + host: Option, + + #[arg(long, default_value = "medium")] + severity: String, + }, +} + +#[derive(Debug, Subcommand)] +enum HealthCommand { + Check, +} + +fn main() { + if let Err(err) = run() { + eprintln!("{err:#}"); + std::process::exit(1); + } +} + +fn run() -> Result<()> { + let cli = Cli::parse(); + let client = Client::builder() + .timeout(Duration::from_secs(30)) + .no_proxy() + .build() + .context("build HTTP client")?; + + let output = match cli.command { + Command::Policies { command } => match command { + PoliciesCommand::List => get_json( + &client, + &format!("{}/api/0/dlp/policies", trim_url(&cli.policy_server)), + )?, + PoliciesCommand::Active => get_json( + &client, + &format!("{}/api/0/dlp/policies/active", trim_url(&cli.policy_server)), + )?, + }, + Command::Incidents { command } => match command { + IncidentsCommand::List { + host, + severity, + limit, + since_hours, + } => list_incidents(&client, &cli.aw_server, host, severity, limit, since_hours)?, + }, + Command::Cases { command } => match command { + CasesCommand::List { + host, + status, + limit, + } => list_cases(&client, &cli.case_server, host, status, limit)?, + CasesCommand::Create { + incident_id, + title, + host, + severity, + } => create_case( + &client, + &cli.case_server, + incident_id, + title, + host, + severity, + )?, + }, + Command::Health { command } => match command { + HealthCommand::Check => health_check( + &client, + &cli.policy_server, + &cli.case_server, + &cli.aw_server, + ), + }, + }; + + println!( + "{}", + serde_json::to_string_pretty(&output).context("serialize output")? + ); + Ok(()) +} + +fn get_json(client: &Client, url: &str) -> Result { + client + .get(url) + .header(ACCEPT, "application/json") + .send() + .with_context(|| format!("GET {url}"))? + .error_for_status() + .with_context(|| format!("GET {url} status"))? + .json::() + .with_context(|| format!("decode JSON from {url}")) +} + +fn send_json(client: &Client, url: &str, method: &str, payload: &Value) -> Result { + let request = match method { + "POST" => client.post(url), + "PUT" => client.put(url), + other => bail!("unsupported method: {other}"), + }; + let response = request + .header(ACCEPT, "application/json") + .header(CONTENT_TYPE, "application/json") + .json(payload) + .send() + .with_context(|| format!("{method} {url}"))? + .error_for_status() + .with_context(|| format!("{method} {url} status"))?; + let raw = response.text().context("read response body")?; + if raw.trim().is_empty() { + Ok(json!({})) + } else { + serde_json::from_str(&raw).with_context(|| format!("decode JSON from {url}")) + } +} + +fn list_incidents( + client: &Client, + aw_server: &str, + host: Option, + severity: Option, + limit: usize, + since_hours: i64, +) -> Result { + let aw_server = trim_url(aw_server); + let bucket_map = get_json(client, &format!("{aw_server}/api/0/buckets"))?; + let Some(buckets) = bucket_map.as_object() else { + return Ok(json!([])); + }; + + let mut bucket_ids: Vec = buckets + .keys() + .filter(|bucket_id| bucket_id.starts_with("aw-dlp-incidents_")) + .cloned() + .collect(); + if let Some(host) = host.as_deref() { + let suffix = format!("_{host}"); + bucket_ids.retain(|bucket_id| bucket_id.ends_with(&suffix)); + } + bucket_ids.sort(); + + let after = Utc::now() - chrono::Duration::hours(since_hours.max(1)); + let severity = severity.map(|value| value.to_lowercase()); + let mut rows = Vec::new(); + for bucket_id in bucket_ids { + let encoded = encode(&bucket_id); + let events_url = format!( + "{aw_server}/api/0/buckets/{encoded}/events?limit={}", + limit.max(1) + ); + let events = get_json(client, &events_url)?; + let Some(events) = events.as_array() else { + continue; + }; + for event in events { + let Some(ts) = event + .get("timestamp") + .and_then(Value::as_str) + .and_then(parse_iso) + else { + continue; + }; + if ts < after { + continue; + } + if let Some(expected) = severity.as_deref() { + let actual = event + .get("data") + .and_then(|data| data.get("severity")) + .and_then(Value::as_str) + .unwrap_or("") + .to_lowercase(); + if actual != expected { + continue; + } + } + rows.push(event.clone()); + } + } + Ok(Value::Array(rows)) +} + +fn list_cases( + client: &Client, + case_server: &str, + host: Option, + status: Option, + limit: usize, +) -> Result { + let mut query = Vec::new(); + if let Some(host) = host { + query.push(format!("host={}", encode(&host))); + } + if let Some(status) = status { + query.push(format!("status={}", encode(&status))); + } + query.push(format!("limit={}", limit.max(1))); + get_json( + client, + &format!( + "{}/api/0/dlp/cases?{}", + trim_url(case_server), + query.join("&") + ), + ) +} + +fn create_case( + client: &Client, + case_server: &str, + incident_id: String, + title: String, + host: Option, + severity: String, +) -> Result { + let payload = json!({ + "incident_id": incident_id, + "title": title, + "host": host, + "severity": severity, + "evidence": { + "source": "dlp-admin-cli" + } + }); + send_json( + client, + &format!("{}/api/0/dlp/cases", trim_url(case_server)), + "POST", + &payload, + ) +} + +fn health_check(client: &Client, policy_server: &str, case_server: &str, aw_server: &str) -> Value { + let mut out = Map::new(); + out.insert( + "policy".to_string(), + get_json(client, &format!("{}/healthz", trim_url(policy_server))) + .unwrap_or_else(error_payload), + ); + out.insert( + "cases".to_string(), + get_json(client, &format!("{}/health", trim_url(case_server))) + .unwrap_or_else(error_payload), + ); + out.insert( + "aw".to_string(), + get_json(client, &format!("{}/api/0/info", trim_url(aw_server))) + .unwrap_or_else(error_payload), + ); + Value::Object(out) +} + +fn parse_iso(value: &str) -> Option> { + DateTime::parse_from_rfc3339(value) + .map(|ts| ts.with_timezone(&Utc)) + .ok() +} + +fn trim_url(url: &str) -> String { + url.trim_end_matches('/').to_string() +} + +fn error_payload(err: anyhow::Error) -> Value { + json!({ + "status": "error", + "error": err.to_string() + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn trims_urls() { + assert_eq!(trim_url("http://127.0.0.1:5600/"), "http://127.0.0.1:5600"); + } + + #[test] + fn parses_rfc3339_z_timestamp() { + assert!(parse_iso("2026-06-01T10:11:12Z").is_some()); + } +} diff --git a/adk-rust/crates/dlp-aggregator/Cargo.toml b/adk-rust/crates/dlp-aggregator/Cargo.toml new file mode 100644 index 0000000..c9789b5 --- /dev/null +++ b/adk-rust/crates/dlp-aggregator/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "dlp-aggregator" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +publish = false + +[dependencies] +anyhow.workspace = true +chrono.workspace = true +clap.workspace = true +detmir-aw-client.workspace = true +rusqlite.workspace = true +serde.workspace = true +serde_json.workspace = true +tempfile.workspace = true +urlencoding.workspace = true diff --git a/adk-rust/crates/dlp-aggregator/src/main.rs b/adk-rust/crates/dlp-aggregator/src/main.rs new file mode 100644 index 0000000..1c1b90e --- /dev/null +++ b/adk-rust/crates/dlp-aggregator/src/main.rs @@ -0,0 +1,618 @@ +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use anyhow::{Context, Result, anyhow, bail}; +use chrono::{DateTime, SecondsFormat, TimeDelta, Utc}; +use clap::Parser; +use detmir_aw_client::ActivityWatchClient; +use rusqlite::{Connection, params}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +const DEFAULT_AW_URL: &str = "http://127.0.0.1:5600/api/0"; +const DEFAULT_SQLITE_PATH: &str = "data/dlp-events.sqlite3"; +const DEFAULT_STATE_PATH: &str = "data/dlp-aggregator-state.json"; +const DEFAULT_BUCKET_PREFIXES: &str = "aw-file-operations_,aw-dlp-incidents_"; + +#[derive(Debug, Parser)] +#[command(about = "Aggregate AWatch-rus DLP buckets into a local warehouse database.")] +struct Cli { + #[arg(long, default_value = DEFAULT_AW_URL)] + aw_url: String, + + #[arg(long)] + postgres_dsn: Option, + + #[arg(long, default_value = DEFAULT_SQLITE_PATH)] + sqlite_path: PathBuf, + + #[arg(long, default_value = DEFAULT_STATE_PATH)] + state_path: PathBuf, + + #[arg(long, default_value = DEFAULT_BUCKET_PREFIXES)] + bucket_prefixes: String, + + #[arg(long)] + since: Option, + + #[arg(long, default_value_t = 24)] + lookback_hours: i64, + + #[arg(long, default_value_t = 60)] + overlap_seconds: i64, + + #[arg(long, default_value_t = 10_000)] + limit: usize, + + #[arg(long, default_value_t = 15)] + timeout: u64, + + #[arg(long)] + dry_run: bool, +} + +impl Cli { + fn apply_env(mut self) -> Self { + self.aw_url = env_value("AW_URL", &self.aw_url); + self.postgres_dsn = self + .postgres_dsn + .or_else(|| std::env::var("DLP_AGGREGATOR_POSTGRES_DSN").ok()) + .filter(|value| !value.is_empty()); + self.sqlite_path = env_path("DLP_AGGREGATOR_SQLITE_PATH", self.sqlite_path); + self.state_path = env_path("DLP_AGGREGATOR_STATE_PATH", self.state_path); + self + } +} + +#[derive(Debug, Clone, Deserialize)] +struct Bucket { + #[serde(default)] + r#type: String, + #[serde(default)] + hostname: String, +} + +#[derive(Debug, Clone, Deserialize)] +struct RawAwEvent { + #[serde(default)] + id: Option, + timestamp: String, + #[serde(default)] + duration: f64, + #[serde(default)] + data: Value, +} + +#[derive(Debug, Clone)] +struct AwEvent { + bucket_id: String, + hostname: String, + stream_type: String, + event_id: String, + timestamp: String, + duration: f64, + data: Value, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize)] +struct State { + #[serde(default)] + last_end: Option, +} + +#[derive(Debug, Serialize)] +struct DryRunSummary { + aw_url: String, + start: String, + end: String, + selected_buckets: Vec, + fetched_events: usize, +} + +#[derive(Debug, Serialize)] +struct RunSummary { + aw_url: String, + target: String, + target_path: String, + state_path: String, + start: String, + end: String, + selected_buckets: usize, + fetched_events: usize, + inserted_events: usize, +} + +fn env_value(name: &str, fallback: &str) -> String { + std::env::var(name) + .ok() + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| fallback.to_string()) +} + +fn env_path(name: &str, fallback: PathBuf) -> PathBuf { + std::env::var(name) + .ok() + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .unwrap_or(fallback) +} + +fn parse_timestamp(value: &str) -> Result> { + let normalized = value.replace('Z', "+00:00"); + Ok(DateTime::parse_from_rfc3339(&normalized) + .with_context(|| format!("parse timestamp {value}"))? + .with_timezone(&Utc)) +} + +fn format_aw_timestamp(value: DateTime) -> String { + value.to_rfc3339_opts(SecondsFormat::Micros, true) +} + +fn load_state(path: &Path) -> Result { + if !path.exists() { + return Ok(State::default()); + } + let text = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?; + serde_json::from_str(&text).with_context(|| format!("parse {}", path.display())) +} + +fn save_state(path: &Path, state: &State) -> Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; + let mut temp = tempfile::NamedTempFile::new_in(parent) + .with_context(|| format!("create temp file in {}", parent.display()))?; + serde_json::to_writer_pretty(&mut temp, state)?; + std::io::Write::write_all(&mut temp, b"\n")?; + temp.persist(path) + .map_err(|err| anyhow!("persist {}: {}", path.display(), err))?; + return Ok(()); + } + fs::write(path, serde_json::to_string_pretty(state)? + "\n") + .with_context(|| format!("write {}", path.display())) +} + +fn get_start_time(cli: &Cli, state: &State) -> Result> { + if let Some(since) = &cli.since { + return parse_timestamp(since); + } + if let Some(last_end) = &state.last_end { + return Ok(parse_timestamp(last_end)? - TimeDelta::seconds(cli.overlap_seconds)); + } + Ok(Utc::now() - TimeDelta::hours(cli.lookback_hours)) +} + +fn parse_prefixes(value: &str) -> Result> { + let prefixes: Vec = value + .split(',') + .map(str::trim) + .filter(|item| !item.is_empty()) + .map(ToOwned::to_owned) + .collect(); + if prefixes.is_empty() { + bail!("at least one bucket prefix is required"); + } + Ok(prefixes) +} + +fn bucket_stream_type(bucket_id: &str, bucket: &Bucket) -> Option<&'static str> { + if bucket_id.starts_with("aw-file-operations_") || bucket.r#type == "aw.file.operation" { + return Some("file_operation"); + } + if bucket_id.starts_with("aw-dlp-incidents_") || bucket.r#type == "aw.dlp.incident" { + return Some("dlp_incident"); + } + None +} + +fn select_buckets( + buckets: &BTreeMap, + prefixes: &[String], +) -> Vec<(String, Bucket, &'static str)> { + buckets + .iter() + .filter_map(|(bucket_id, bucket)| { + let stream_type = bucket_stream_type(bucket_id, bucket)?; + if prefixes.iter().any(|prefix| bucket_id.starts_with(prefix)) { + Some((bucket_id.clone(), bucket.clone(), stream_type)) + } else { + None + } + }) + .collect() +} + +fn build_events_path( + bucket_id: &str, + start: DateTime, + end: DateTime, + limit: usize, +) -> String { + format!( + "/buckets/{}/events?start={}&end={}&limit={}", + urlencoding::encode(bucket_id), + urlencoding::encode(&format_aw_timestamp(start)), + urlencoding::encode(&format_aw_timestamp(end)), + limit + ) +} + +fn normalized_data(value: Value) -> Value { + if value.is_object() { + value + } else { + json!({ "raw": value }) + } +} + +fn event_key(bucket_id: &str, timestamp: &str, duration: f64, data: &Value) -> String { + let payload = serde_json::to_string(data).unwrap_or_else(|_| "{}".to_string()); + format!("{bucket_id}|{timestamp}|{duration}|{payload}") +} + +fn event_id(bucket_id: &str, event: &RawAwEvent, data: &Value) -> String { + match &event.id { + Some(Value::String(value)) if !value.is_empty() => value.clone(), + Some(Value::Number(value)) => value.to_string(), + Some(Value::Bool(value)) => value.to_string(), + _ => event_key(bucket_id, &event.timestamp, event.duration, data), + } +} + +fn fetch_bucket_events( + client: &ActivityWatchClient, + bucket_id: &str, + bucket: &Bucket, + stream_type: &str, + start: DateTime, + end: DateTime, + limit: usize, +) -> Result> { + let path = build_events_path(bucket_id, start, end, limit); + let raw_events: Vec = client.get_json(&path)?; + Ok(raw_events + .into_iter() + .map(|raw| { + let data = normalized_data(raw.data.clone()); + let hostname = if bucket.hostname.is_empty() { + first_string(&data, &["hostname"]).unwrap_or_default() + } else { + bucket.hostname.clone() + }; + AwEvent { + bucket_id: bucket_id.to_string(), + hostname, + stream_type: stream_type.to_string(), + event_id: event_id(bucket_id, &raw, &data), + timestamp: raw.timestamp, + duration: raw.duration, + data, + } + }) + .collect()) +} + +fn first_string(data: &Value, keys: &[&str]) -> Option { + let object = data.as_object()?; + for key in keys { + if let Some(value) = object.get(*key) { + match value { + Value::Null => {} + Value::String(s) if !s.is_empty() => return Some(s.clone()), + other => { + let rendered = other.to_string(); + if !rendered.is_empty() { + return Some(rendered); + } + } + } + } + } + None +} + +fn bool_as_int(value: Option<&Value>) -> i64 { + match value { + Some(Value::Bool(value)) => i64::from(*value), + Some(Value::String(value)) => i64::from(matches!( + value.to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "y" + )), + Some(Value::Number(value)) => i64::from(value.as_i64().unwrap_or(0) != 0), + Some(Value::Null) | None => 0, + Some(_) => 1, + } +} + +fn ensure_schema(connection: &Connection) -> Result<()> { + connection.execute_batch( + r#" + create table if not exists dlp_events ( + id integer primary key autoincrement, + bucket_id text not null, + event_id text not null, + stream_type text not null, + hostname text not null, + username text, + event_ts text not null, + duration real not null default 0, + operation text, + file_path text, + old_file_path text, + extension text, + archive_hint integer not null default 0, + rule_id text, + action text, + severity text, + signal_type text, + message text, + source text, + screenshot_path text, + raw_json text not null, + ingested_at text not null, + unique (bucket_id, event_id) + ); + create index if not exists idx_dlp_events_event_ts on dlp_events(event_ts); + create index if not exists idx_dlp_events_host_ts on dlp_events(hostname, event_ts); + create index if not exists idx_dlp_events_stream_ts on dlp_events(stream_type, event_ts); + create index if not exists idx_dlp_events_archive on dlp_events(archive_hint, event_ts); + create index if not exists idx_dlp_events_rule on dlp_events(rule_id, event_ts); + + create view if not exists dlp_file_operations as + select * + from dlp_events + where stream_type = 'file_operation'; + + create view if not exists dlp_incidents as + select * + from dlp_events + where stream_type = 'dlp_incident'; + "#, + )?; + Ok(()) +} + +fn connect_sqlite(path: &Path) -> Result { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; + } + let connection = Connection::open(path).with_context(|| format!("open {}", path.display()))?; + connection.execute_batch( + r#" + pragma journal_mode=WAL; + pragma synchronous=NORMAL; + pragma foreign_keys=ON; + "#, + )?; + Ok(connection) +} + +fn insert_events(connection: &mut Connection, events: &[AwEvent]) -> Result { + let ingested_at = format_aw_timestamp(Utc::now()); + let tx = connection.transaction()?; + let mut inserted = 0; + { + let mut statement = tx.prepare( + r#" + insert or ignore into dlp_events ( + bucket_id, + event_id, + stream_type, + hostname, + username, + event_ts, + duration, + operation, + file_path, + old_file_path, + extension, + archive_hint, + rule_id, + action, + severity, + signal_type, + message, + source, + screenshot_path, + raw_json, + ingested_at + ) + values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + "#, + )?; + for event in events { + let raw_json = serde_json::to_string(&event.data)?; + let archive_hint = bool_as_int(event.data.get("archiveHint")); + let row_count = statement.execute(params![ + event.bucket_id, + event.event_id, + event.stream_type, + event.hostname, + first_string(&event.data, &["username", "user"]), + event.timestamp, + event.duration, + first_string(&event.data, &["operation"]), + first_string(&event.data, &["path", "filePath"]), + first_string(&event.data, &["oldPath", "oldFilePath"]), + first_string(&event.data, &["extension"]), + archive_hint, + first_string(&event.data, &["ruleId", "rule"]), + first_string(&event.data, &["action"]), + first_string(&event.data, &["severity"]), + first_string(&event.data, &["signalType"]), + first_string(&event.data, &["message"]), + first_string(&event.data, &["source"]), + first_string( + &event.data, + &["screenshotPath", "capturePath", "artifactPath"] + ), + raw_json, + ingested_at, + ])?; + inserted += usize::from(row_count > 0); + } + } + tx.commit()?; + Ok(inserted) +} + +fn run(cli: &Cli) -> Result<()> { + if cli.postgres_dsn.is_some() { + bail!( + "PostgreSQL mode is not supported by the Rust DLP aggregator yet; use SQLite mode or the legacy Python script" + ); + } + let prefixes = parse_prefixes(&cli.bucket_prefixes)?; + let state = load_state(&cli.state_path)?; + let start = get_start_time(cli, &state)?; + let end = Utc::now(); + let client = ActivityWatchClient::new(&cli.aw_url, Duration::from_secs(cli.timeout))?; + let buckets: BTreeMap = client.get_json("/buckets")?; + let selected = select_buckets(&buckets, &prefixes); + let mut events = Vec::new(); + for (bucket_id, bucket, stream_type) in &selected { + events.extend(fetch_bucket_events( + &client, + bucket_id, + bucket, + stream_type, + start, + end, + cli.limit, + )?); + } + + if cli.dry_run { + let summary = DryRunSummary { + aw_url: cli.aw_url.clone(), + start: format_aw_timestamp(start), + end: format_aw_timestamp(end), + selected_buckets: selected + .iter() + .map(|(bucket_id, _bucket, _stream_type)| bucket_id.clone()) + .collect(), + fetched_events: events.len(), + }; + println!("{}", serde_json::to_string_pretty(&summary)?); + return Ok(()); + } + + let mut connection = connect_sqlite(&cli.sqlite_path)?; + ensure_schema(&connection)?; + let inserted = insert_events(&mut connection, &events)?; + save_state( + &cli.state_path, + &State { + last_end: Some(format_aw_timestamp(end)), + }, + )?; + let summary = RunSummary { + aw_url: cli.aw_url.clone(), + target: "sqlite".to_string(), + target_path: cli.sqlite_path.display().to_string(), + state_path: cli.state_path.display().to_string(), + start: format_aw_timestamp(start), + end: format_aw_timestamp(end), + selected_buckets: selected.len(), + fetched_events: events.len(), + inserted_events: inserted, + }; + println!("{}", serde_json::to_string_pretty(&summary)?); + Ok(()) +} + +fn main() -> Result<()> { + let cli = Cli::parse().apply_env(); + run(&cli) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn selects_expected_bucket_prefixes() { + let buckets = BTreeMap::from([ + ( + "aw-file-operations_HOST".to_string(), + Bucket { + r#type: "aw.file.operation".to_string(), + hostname: "HOST".to_string(), + }, + ), + ( + "aw-dlp-incidents_HOST".to_string(), + Bucket { + r#type: "aw.dlp.incident".to_string(), + hostname: "HOST".to_string(), + }, + ), + ( + "aw-watcher-window_HOST".to_string(), + Bucket { + r#type: "currentwindow".to_string(), + hostname: "HOST".to_string(), + }, + ), + ]); + let selected = select_buckets( + &buckets, + &parse_prefixes("aw-file-operations_,aw-dlp-incidents_").unwrap(), + ); + assert_eq!(selected.len(), 2); + assert_eq!(selected[0].2, "dlp_incident"); + assert_eq!(selected[1].2, "file_operation"); + } + + #[test] + fn event_row_helpers_match_python_contract() { + let data = json!({ + "username": "igor", + "path": "C:/x.zip", + "oldFilePath": "C:/x.tmp", + "archiveHint": "true", + "rule": "archive", + "signalType": "file_operation" + }); + assert_eq!( + first_string(&data, &["username", "user"]).as_deref(), + Some("igor") + ); + assert_eq!( + first_string(&data, &["oldPath", "oldFilePath"]).as_deref(), + Some("C:/x.tmp") + ); + assert_eq!(bool_as_int(data.get("archiveHint")), 1); + assert_eq!( + first_string(&data, &["ruleId", "rule"]).as_deref(), + Some("archive") + ); + } + + #[test] + fn inserts_events_once() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("dlp.sqlite"); + let mut connection = connect_sqlite(&path).unwrap(); + ensure_schema(&connection).unwrap(); + let event = AwEvent { + bucket_id: "aw-file-operations_HOST".to_string(), + hostname: "HOST".to_string(), + stream_type: "file_operation".to_string(), + event_id: "1".to_string(), + timestamp: "2026-05-31T10:00:00Z".to_string(), + duration: 0.0, + data: json!({"username":"igor","archiveHint":true}), + }; + assert_eq!( + insert_events(&mut connection, std::slice::from_ref(&event)).unwrap(), + 1 + ); + assert_eq!(insert_events(&mut connection, &[event]).unwrap(), 0); + let count: i64 = connection + .query_row("select count(*) from dlp_events", [], |row| row.get(0)) + .unwrap(); + assert_eq!(count, 1); + } +} diff --git a/adk-rust/crates/dlp-case-management/Cargo.toml b/adk-rust/crates/dlp-case-management/Cargo.toml new file mode 100644 index 0000000..2f1c927 --- /dev/null +++ b/adk-rust/crates/dlp-case-management/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "dlp-case-management" +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_json.workspace = true +sha2.workspace = true +tiny_http.workspace = true +urlencoding.workspace = true diff --git a/adk-rust/crates/dlp-case-management/src/main.rs b/adk-rust/crates/dlp-case-management/src/main.rs new file mode 100644 index 0000000..f9ed78d --- /dev/null +++ b/adk-rust/crates/dlp-case-management/src/main.rs @@ -0,0 +1,880 @@ +use std::collections::BTreeMap; +use std::fs; +use std::path::PathBuf; + +use anyhow::{Context, Result, anyhow, bail}; +use chrono::{SecondsFormat, Utc}; +use clap::Parser; +use rusqlite::{Connection, OptionalExtension, params}; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; +use tiny_http::{Header, Method, Request, Response, Server, StatusCode}; +use urlencoding::decode; + +#[derive(Debug, Parser)] +#[command(about = "AWatch DLP Case Management API")] +struct Cli { + #[arg(long, default_value = "127.0.0.1")] + bind_host: String, + + #[arg(long, default_value_t = 5602)] + port: u16, + + #[arg( + long, + default_value = "/opt/activitywatch/dlp-case-management/cases.db" + )] + db_path: PathBuf, +} + +#[derive(Clone)] +struct AppState { + db_path: PathBuf, +} + +fn main() { + if let Err(err) = run() { + eprintln!("{err:#}"); + std::process::exit(1); + } +} + +fn run() -> Result<()> { + let cli = apply_env(Cli::parse()); + let storage = CaseStorage::new(cli.db_path.clone())?; + storage.init_schema()?; + let state = AppState { + db_path: cli.db_path, + }; + let address = format!("{}:{}", cli.bind_host, cli.port); + let server = Server::http(&address).map_err(|err| anyhow!("bind {address}: {err}"))?; + eprintln!("aw-dlp-case-management 0.1.0 listening on {address}"); + for request in server.incoming_requests() { + if let Err(err) = handle_request(&state, request) { + eprintln!("request error: {err:#}"); + } + } + Ok(()) +} + +fn apply_env(mut cli: Cli) -> Cli { + if !cli_arg_present("--bind-host") { + if let Ok(value) = std::env::var("AW_DLP_CASE_BIND_HOST") { + if !value.is_empty() { + cli.bind_host = value; + } + } + } + if !cli_arg_present("--port") { + if let Ok(value) = std::env::var("AW_DLP_CASE_PORT") { + if let Ok(port) = value.parse() { + cli.port = port; + } + } + } + if !cli_arg_present("--db-path") { + if let Ok(value) = std::env::var("AW_DLP_CASE_DB_PATH") { + if !value.is_empty() { + cli.db_path = PathBuf::from(value); + } + } + } + cli +} + +fn cli_arg_present(flag: &str) -> bool { + std::env::args().any(|arg| arg == flag || arg.starts_with(&format!("{flag}="))) +} + +fn handle_request(state: &AppState, mut request: Request) -> Result<()> { + let method = request.method().clone(); + let url = request.url().to_string(); + let (path, query) = split_url(&url); + let segments = path_segments(&path); + let storage = CaseStorage::new(state.db_path.clone())?; + + let response = match (method, segments.as_slice()) { + (Method::Get, ["health"]) => json_response( + StatusCode(200), + json!({"ok": true, "db": state.db_path.to_string_lossy()}), + ), + (Method::Post, ["api", "0", "dlp", "cases"]) => { + let payload = read_json_body(&mut request)?; + if is_self_test_case( + payload.get("incident_id").and_then(Value::as_str), + payload.get("title").and_then(Value::as_str), + ) { + json_response( + StatusCode(422), + json!({"detail": "self_test cases are not allowed"}), + ) + } else { + match storage.create_case(&payload) { + Ok(item) => json_response(StatusCode(200), item), + Err(err) => json_response(StatusCode(422), json!({"detail": err.to_string()})), + } + } + } + (Method::Get, ["api", "0", "dlp", "cases"]) => { + let status = query.get("status").cloned(); + let host = query.get("host").cloned(); + let limit = query_limit(&query, 200, 2000); + json_response( + StatusCode(200), + Value::Array(storage.list_cases(status, host, limit)?), + ) + } + (Method::Get, ["api", "0", "dlp", "cases", case_id]) => { + let case_id = parse_id(case_id)?; + match storage.get_case_full(case_id)? { + Some(item) => json_response(StatusCode(200), item), + None => json_response(StatusCode(404), json!({"detail": "case not found"})), + } + } + (Method::Patch, ["api", "0", "dlp", "cases", case_id]) => { + let case_id = parse_id(case_id)?; + let payload = read_json_body(&mut request)?; + match storage.update_case(case_id, &payload) { + Ok(Some(item)) => json_response(StatusCode(200), item), + Ok(None) => json_response(StatusCode(404), json!({"detail": "case not found"})), + Err(err) => json_response(StatusCode(422), json!({"detail": err.to_string()})), + } + } + (Method::Post, ["api", "0", "dlp", "cases", case_id, "comments"]) => { + let case_id = parse_id(case_id)?; + let payload = read_json_body(&mut request)?; + if storage.get_case(case_id)?.is_none() { + json_response(StatusCode(404), json!({"detail": "case not found"})) + } else { + match storage.add_comment(case_id, &payload) { + Ok(item) => json_response(StatusCode(200), item), + Err(err) => json_response(StatusCode(422), json!({"detail": err.to_string()})), + } + } + } + (Method::Get, ["api", "0", "dlp", "cases", case_id, "comments"]) => { + let case_id = parse_id(case_id)?; + let limit = query_limit(&query, 200, 2000); + json_response( + StatusCode(200), + Value::Array(storage.list_comments(case_id, limit)?), + ) + } + (Method::Post, ["api", "0", "dlp", "cases", case_id, "forensics", "hayabusa"]) => { + let case_id = parse_id(case_id)?; + let payload = read_json_body(&mut request)?; + match storage.link_hayabusa(case_id, &payload) { + Ok(Some(item)) => json_response(StatusCode(200), item), + Ok(None) => json_response(StatusCode(404), json!({"detail": "case not found"})), + Err(CaseError::HostMismatch(text)) => { + json_response(StatusCode(409), json!({"detail": text})) + } + Err(CaseError::Other(err)) => { + json_response(StatusCode(422), json!({"detail": err.to_string()})) + } + } + } + _ => json_response(StatusCode(404), json!({"detail": "not found"})), + }; + + request.respond(response).context("send HTTP response") +} + +struct CaseStorage { + db_path: PathBuf, +} + +enum CaseError { + HostMismatch(String), + Other(anyhow::Error), +} + +impl From for CaseError { + fn from(value: anyhow::Error) -> Self { + Self::Other(value) + } +} + +impl From for CaseError { + fn from(value: rusqlite::Error) -> Self { + Self::Other(value.into()) + } +} + +impl From for CaseError { + fn from(value: serde_json::Error) -> Self { + Self::Other(value.into()) + } +} + +impl CaseStorage { + fn new(db_path: PathBuf) -> Result { + if let Some(parent) = db_path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("create db parent {}", parent.display()))?; + } + Ok(Self { db_path }) + } + + fn connect(&self) -> Result { + let conn = Connection::open(&self.db_path) + .with_context(|| format!("open sqlite {}", self.db_path.display()))?; + conn.pragma_update(None, "journal_mode", "WAL")?; + conn.pragma_update(None, "foreign_keys", "ON")?; + Ok(conn) + } + + fn init_schema(&self) -> Result<()> { + let conn = self.connect()?; + conn.execute_batch( + r#" + CREATE TABLE IF NOT EXISTS cases ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + incident_id TEXT NOT NULL, + host TEXT, + title TEXT NOT NULL, + severity TEXT NOT NULL DEFAULT 'medium', + assignee TEXT, + status TEXT NOT NULL DEFAULT 'open', + source_bucket TEXT, + source_event_ts TEXT, + evidence_json TEXT, + forensics_json TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_cases_incident_id ON cases(incident_id); + CREATE INDEX IF NOT EXISTS idx_cases_status ON cases(status); + + CREATE TABLE IF NOT EXISTS case_comments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + case_id INTEGER NOT NULL, + comment TEXT NOT NULL, + author TEXT, + created_at TEXT NOT NULL, + FOREIGN KEY(case_id) REFERENCES cases(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS case_audit ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + case_id INTEGER NOT NULL, + action TEXT NOT NULL, + actor TEXT, + details_json TEXT, + created_at TEXT NOT NULL, + FOREIGN KEY(case_id) REFERENCES cases(id) ON DELETE CASCADE + ); + "#, + )?; + ensure_column(&conn, "cases", "forensics_json", "TEXT")?; + Ok(()) + } + + fn create_case(&self, payload: &Value) -> Result { + let incident_id = required_string(payload.get("incident_id"), "incident_id")?; + validate_len("incident_id", &incident_id, 1, 256)?; + let title = required_string(payload.get("title"), "title")?; + validate_len("title", &title, 1, 512)?; + let host = optional_string(payload.get("host")); + validate_optional_len("host", host.as_deref(), 128)?; + let severity = + optional_string(payload.get("severity")).unwrap_or_else(|| "medium".to_string()); + validate_optional_len("severity", Some(&severity), 32)?; + let assignee = optional_string(payload.get("assignee")); + validate_optional_len("assignee", assignee.as_deref(), 128)?; + let source_bucket = optional_string(payload.get("source_bucket")); + validate_optional_len("source_bucket", source_bucket.as_deref(), 256)?; + let source_event_ts = optional_string(payload.get("source_event_ts")); + validate_optional_len("source_event_ts", source_event_ts.as_deref(), 64)?; + + let (normalized_evidence, evidence_digest) = match payload.get("evidence") { + Some(Value::Null) | None => (None, None), + Some(evidence) => { + let normalized = normalize_evidence_chain( + evidence, + source_bucket.as_deref(), + source_event_ts.as_deref(), + )?; + let digest = normalized + .get("latest_sha256") + .and_then(Value::as_str) + .map(ToOwned::to_owned) + .unwrap_or_else(|| evidence_sha256(evidence)); + (Some(normalized), Some(digest)) + } + }; + let now = utc_now(); + let conn = self.connect()?; + let tx = conn.unchecked_transaction()?; + let existing = tx + .query_row( + r#" + SELECT * FROM cases + WHERE incident_id = ? AND COALESCE(host, '') = COALESCE(?, '') + ORDER BY id DESC + LIMIT 1 + "#, + params![incident_id, host], + case_from_row, + ) + .optional()?; + if let Some(existing) = existing { + tx.commit()?; + return Ok(existing); + } + tx.execute( + r#" + INSERT INTO cases ( + incident_id, host, title, severity, assignee, status, + source_bucket, source_event_ts, evidence_json, forensics_json, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?) + "#, + params![ + incident_id, + host, + title, + severity, + assignee, + source_bucket, + source_event_ts, + normalized_evidence + .as_ref() + .map(serde_json::to_string) + .transpose()?, + Option::::None, + now, + now, + ], + )?; + let case_id = tx.last_insert_rowid(); + let mut fields = payload.as_object().cloned().unwrap_or_default(); + fields.remove("evidence"); + insert_audit( + &tx, + case_id, + "create", + Some("api"), + Some(json!({"fields": fields, "evidence_sha256": evidence_digest})), + )?; + tx.commit()?; + self.get_case(case_id)? + .ok_or_else(|| anyhow!("created case not found")) + } + + fn list_cases( + &self, + status: Option, + host: Option, + limit: i64, + ) -> Result> { + let conn = self.connect()?; + let mut query = "SELECT * FROM cases".to_string(); + let mut clauses = Vec::new(); + let mut values = Vec::new(); + if let Some(status) = status { + clauses.push("status = ?"); + values.push(status); + } + if let Some(host) = host { + clauses.push("host = ?"); + values.push(host); + } + if !clauses.is_empty() { + query.push_str(" WHERE "); + query.push_str(&clauses.join(" AND ")); + } + query.push_str(" ORDER BY id DESC LIMIT ?"); + let mut stmt = conn.prepare(&query)?; + let rows = match values.as_slice() { + [] => stmt.query_map([limit.to_string()], case_from_row)?, + [a] => stmt.query_map(params![a, limit], case_from_row)?, + [a, b] => stmt.query_map(params![a, b, limit], case_from_row)?, + _ => unreachable!(), + }; + rows.collect::, _>>() + .map_err(Into::into) + } + + fn get_case(&self, case_id: i64) -> Result> { + let conn = self.connect()?; + conn.query_row("SELECT * FROM cases WHERE id = ?", [case_id], case_from_row) + .optional() + .map_err(Into::into) + } + + fn get_case_full(&self, case_id: i64) -> Result> { + let Some(mut item) = self.get_case(case_id)? else { + return Ok(None); + }; + let comments = self.list_comments(case_id, 200)?; + let audit = self.list_audit(case_id, 200)?; + let object = item + .as_object_mut() + .ok_or_else(|| anyhow!("case row is not object"))?; + object.insert("comments".to_string(), Value::Array(comments)); + object.insert("audit".to_string(), Value::Array(audit)); + Ok(Some(item)) + } + + fn update_case(&self, case_id: i64, patch: &Value) -> Result> { + if self.get_case(case_id)?.is_none() { + return Ok(None); + } + let mut fields = Vec::new(); + let mut args = Vec::new(); + for key in ["status", "assignee", "title", "severity"] { + if let Some(value) = patch.get(key).filter(|value| !value.is_null()) { + if key == "status" { + let status = required_string(Some(value), "status")?; + if !matches!( + status.as_str(), + "open" | "investigating" | "resolved" | "closed" + ) { + bail!("invalid status"); + } + args.push(status); + } else { + args.push(required_string(Some(value), key)?); + } + fields.push(key); + } + } + if fields.is_empty() { + return self.get_case(case_id); + } + let now = utc_now(); + let conn = self.connect()?; + let tx = conn.unchecked_transaction()?; + let assignments = fields + .iter() + .map(|field| format!("{field} = ?")) + .collect::>() + .join(", "); + let sql = format!("UPDATE cases SET {assignments}, updated_at = ? WHERE id = ?"); + let mut params_values: Vec<&dyn rusqlite::ToSql> = args + .iter() + .map(|value| value as &dyn rusqlite::ToSql) + .collect(); + params_values.push(&now); + params_values.push(&case_id); + tx.execute(&sql, params_values.as_slice())?; + insert_audit(&tx, case_id, "update", Some("api"), Some(patch.clone()))?; + tx.commit()?; + self.get_case(case_id) + } + + fn add_comment(&self, case_id: i64, payload: &Value) -> Result { + let comment = required_string(payload.get("comment"), "comment")?; + validate_len("comment", &comment, 1, 2000)?; + let author = optional_string(payload.get("author")); + validate_optional_len("author", author.as_deref(), 128)?; + let now = utc_now(); + let conn = self.connect()?; + let tx = conn.unchecked_transaction()?; + tx.execute( + "INSERT INTO case_comments (case_id, comment, author, created_at) VALUES (?, ?, ?, ?)", + params![case_id, comment, author, now], + )?; + let comment_id = tx.last_insert_rowid(); + insert_audit( + &tx, + case_id, + "comment", + author.as_deref(), + Some(json!({"comment_id": comment_id})), + )?; + let row = tx.query_row( + "SELECT id, case_id, comment, author, created_at FROM case_comments WHERE id = ?", + [comment_id], + comment_from_row, + )?; + tx.commit()?; + Ok(row) + } + + fn list_comments(&self, case_id: i64, limit: i64) -> Result> { + let conn = self.connect()?; + let mut stmt = conn.prepare( + "SELECT id, case_id, comment, author, created_at FROM case_comments WHERE case_id = ? ORDER BY id DESC LIMIT ?", + )?; + let rows = stmt.query_map(params![case_id, limit], comment_from_row)?; + rows.collect::, _>>() + .map_err(Into::into) + } + + fn list_audit(&self, case_id: i64, limit: i64) -> Result> { + let conn = self.connect()?; + let mut stmt = conn.prepare( + "SELECT id, case_id, action, actor, details_json, created_at FROM case_audit WHERE case_id = ? ORDER BY id DESC LIMIT ?", + )?; + let rows = stmt.query_map(params![case_id, limit], audit_from_row)?; + rows.collect::, _>>() + .map_err(Into::into) + } + + fn link_hayabusa( + &self, + case_id: i64, + payload: &Value, + ) -> std::result::Result, CaseError> { + let existing = match self.get_case(case_id)? { + Some(item) => item, + None => return Ok(None), + }; + let case_host = normalize_host(existing.get("host").and_then(Value::as_str)); + let forensic_host = normalize_host(payload.get("host").and_then(Value::as_str)); + if !case_host.is_empty() && !forensic_host.is_empty() && case_host != forensic_host { + return Err(CaseError::HostMismatch(format!( + "hayabusa host mismatch: case host={} payload host={}", + existing.get("host").and_then(Value::as_str).unwrap_or(""), + payload.get("host").and_then(Value::as_str).unwrap_or("") + ))); + } + let host = required_string(payload.get("host"), "host")?; + validate_len("host", &host, 1, 128)?; + let mode = required_string(payload.get("mode"), "mode")?; + validate_len("mode", &mode, 1, 32)?; + let status = required_string(payload.get("status"), "status")?; + validate_len("status", &status, 1, 64)?; + let now = utc_now(); + let mut forensics = existing + .get("forensics") + .cloned() + .filter(|value| value.is_object()) + .unwrap_or_else(|| json!({})); + forensics.as_object_mut().expect("object").insert( + "hayabusa".to_string(), + json!({ + "tool": "hayabusa", + "host": host, + "mode": mode, + "status": status, + "intake_id": optional_json_string(payload.get("intake_id")), + "package_path": optional_json_string(payload.get("package_path")), + "sha256": optional_json_string(payload.get("sha256")), + "report_dir": optional_json_string(payload.get("report_dir")), + "summary_html": optional_json_string(payload.get("summary_html")), + "timeline_path": optional_json_string(payload.get("timeline_path")), + "manifest_path": optional_json_string(payload.get("manifest_path")), + "linked_at": optional_string(payload.get("linked_at")).unwrap_or_else(|| now.clone()), + "link_source": optional_string(payload.get("link_source")).unwrap_or_else(|| "api".to_string()), + }), + ); + let conn = self.connect()?; + let tx = conn.unchecked_transaction()?; + tx.execute( + "UPDATE cases SET forensics_json = ?, updated_at = ? WHERE id = ?", + params![serde_json::to_string(&forensics)?, now, case_id], + )?; + insert_audit( + &tx, + case_id, + "link_hayabusa", + Some("api"), + Some(json!({ + "host": payload.get("host").cloned().unwrap_or(Value::Null), + "mode": payload.get("mode").cloned().unwrap_or(Value::Null), + "status": payload.get("status").cloned().unwrap_or(Value::Null), + "intake_id": payload.get("intake_id").cloned().unwrap_or(Value::Null), + "report_dir": payload.get("report_dir").cloned().unwrap_or(Value::Null), + })), + )?; + tx.commit()?; + Ok(self.get_case(case_id)?) + } +} + +fn ensure_column(conn: &Connection, table: &str, column: &str, definition: &str) -> Result<()> { + let mut stmt = conn.prepare(&format!("PRAGMA table_info({table})"))?; + let columns = stmt + .query_map([], |row| row.get::<_, String>(1))? + .collect::, _>>()?; + if !columns.iter().any(|item| item == column) { + conn.execute( + &format!("ALTER TABLE {table} ADD COLUMN {column} {definition}"), + [], + )?; + } + Ok(()) +} + +fn case_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + let evidence = json_field(row.get::<_, Option>("evidence_json")?); + let forensics = json_field(row.get::<_, Option>("forensics_json")?); + Ok(json!({ + "id": row.get::<_, i64>("id")?, + "incident_id": row.get::<_, String>("incident_id")?, + "host": row.get::<_, Option>("host")?, + "title": row.get::<_, String>("title")?, + "severity": row.get::<_, String>("severity")?, + "assignee": row.get::<_, Option>("assignee")?, + "status": row.get::<_, String>("status")?, + "source_bucket": row.get::<_, Option>("source_bucket")?, + "source_event_ts": row.get::<_, Option>("source_event_ts")?, + "evidence": evidence, + "forensics": forensics, + "created_at": row.get::<_, String>("created_at")?, + "updated_at": row.get::<_, String>("updated_at")?, + })) +} + +fn comment_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(json!({ + "id": row.get::<_, i64>("id")?, + "case_id": row.get::<_, i64>("case_id")?, + "comment": row.get::<_, String>("comment")?, + "author": row.get::<_, Option>("author")?, + "created_at": row.get::<_, String>("created_at")?, + })) +} + +fn audit_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(json!({ + "id": row.get::<_, i64>("id")?, + "case_id": row.get::<_, i64>("case_id")?, + "action": row.get::<_, String>("action")?, + "actor": row.get::<_, Option>("actor")?, + "details": json_field(row.get::<_, Option>("details_json")?), + "created_at": row.get::<_, String>("created_at")?, + })) +} + +fn insert_audit( + conn: &Connection, + case_id: i64, + action: &str, + actor: Option<&str>, + details: Option, +) -> Result<()> { + conn.execute( + "INSERT INTO case_audit (case_id, action, actor, details_json, created_at) VALUES (?, ?, ?, ?, ?)", + params![ + case_id, + action, + actor, + details.as_ref().map(serde_json::to_string).transpose()?, + utc_now(), + ], + )?; + Ok(()) +} + +fn normalize_evidence_chain( + payload: &Value, + source_bucket: Option<&str>, + source_event_ts: Option<&str>, +) -> Result { + if payload.get("items").and_then(Value::as_array).is_some() { + return Ok(payload.clone()); + } + let digest = evidence_sha256(payload); + let record = json!({ + "recorded_at": utc_now(), + "source_bucket": source_bucket, + "source_event_ts": source_event_ts, + "sha256": digest, + "payload": payload, + }); + Ok(json!({ + "items": [record], + "latest_sha256": digest, + "chain_length": 1, + })) +} + +fn evidence_sha256(payload: &Value) -> String { + let mut hasher = Sha256::new(); + hasher.update( + canonical_json(payload) + .expect("evidence JSON serializable") + .as_bytes(), + ); + format!("{:x}", hasher.finalize()) +} + +fn canonical_json(value: &Value) -> Result { + match value { + Value::Null => Ok("null".to_string()), + Value::Bool(value) => Ok(value.to_string()), + Value::Number(value) => Ok(value.to_string()), + Value::String(value) => serde_json::to_string(value).map_err(Into::into), + Value::Array(items) => { + let mut out = String::from("["); + for (idx, item) in items.iter().enumerate() { + if idx > 0 { + out.push(','); + } + out.push_str(&canonical_json(item)?); + } + out.push(']'); + Ok(out) + } + Value::Object(map) => { + let mut out = String::from("{"); + let mut sorted = BTreeMap::new(); + for (key, value) in map { + sorted.insert(key, value); + } + for (idx, (key, value)) in sorted.into_iter().enumerate() { + if idx > 0 { + out.push(','); + } + out.push_str(&serde_json::to_string(key)?); + out.push(':'); + out.push_str(&canonical_json(value)?); + } + out.push('}'); + Ok(out) + } + } +} + +fn is_self_test_case(incident_id: Option<&str>, title: Option<&str>) -> bool { + incident_id + .unwrap_or("") + .to_lowercase() + .contains("|self_test|") + || title + .unwrap_or("") + .to_lowercase() + .starts_with("dlp self_test") +} + +fn split_url(url: &str) -> (String, BTreeMap) { + let (path, query) = url.split_once('?').unwrap_or((url, "")); + let mut params = BTreeMap::new(); + for pair in query.split('&').filter(|item| !item.is_empty()) { + let (key, value) = pair.split_once('=').unwrap_or((pair, "")); + params.insert(url_decode(key), url_decode(value)); + } + (path.to_string(), params) +} + +fn path_segments(path: &str) -> Vec<&str> { + path.trim_matches('/') + .split('/') + .filter(|segment| !segment.is_empty()) + .collect() +} + +fn query_limit(query: &BTreeMap, default: i64, max: i64) -> i64 { + query + .get("limit") + .and_then(|value| value.parse().ok()) + .unwrap_or(default) + .clamp(1, max) +} + +fn read_json_body(request: &mut Request) -> Result { + let mut body = String::new(); + request + .as_reader() + .read_to_string(&mut body) + .context("read request body")?; + if body.trim().is_empty() { + Ok(json!({})) + } else { + serde_json::from_str(&body).context("decode JSON body") + } +} + +fn json_response(status: StatusCode, payload: Value) -> Response>> { + let body = + serde_json::to_vec_pretty(&payload).unwrap_or_else(|_| b"{\"detail\":\"json\"}".to_vec()); + let mut response = Response::from_data(body).with_status_code(status); + for (key, value) in [ + ("Content-Type", "application/json; charset=utf-8"), + ("Access-Control-Allow-Origin", "*"), + ("Access-Control-Allow-Methods", "*"), + ("Access-Control-Allow-Headers", "*"), + ] { + response.add_header( + Header::from_bytes(key.as_bytes(), value.as_bytes()).expect("valid header"), + ); + } + response +} + +fn parse_id(value: &str) -> Result { + value + .parse::() + .with_context(|| format!("invalid case id: {value}")) +} + +fn required_string(value: Option<&Value>, field: &str) -> Result { + value + .and_then(Value::as_str) + .map(ToOwned::to_owned) + .ok_or_else(|| anyhow!("{field} is required")) +} + +fn optional_string(value: Option<&Value>) -> Option { + match value { + Some(Value::String(text)) => Some(text.clone()), + _ => None, + } +} + +fn optional_json_string(value: Option<&Value>) -> Value { + optional_string(value) + .map(Value::String) + .unwrap_or(Value::Null) +} + +fn validate_len(field: &str, value: &str, min: usize, max: usize) -> Result<()> { + let len = value.chars().count(); + if len < min || len > max { + bail!("{field} length must be {min}..{max}"); + } + Ok(()) +} + +fn validate_optional_len(field: &str, value: Option<&str>, max: usize) -> Result<()> { + if let Some(value) = value { + if value.chars().count() > max { + bail!("{field} length must be <= {max}"); + } + } + Ok(()) +} + +fn json_field(raw: Option) -> Value { + raw.as_deref() + .and_then(|text| serde_json::from_str(text).ok()) + .unwrap_or(Value::Null) +} + +fn utc_now() -> String { + Utc::now().to_rfc3339_opts(SecondsFormat::Micros, false) +} + +fn normalize_host(value: Option<&str>) -> String { + value.unwrap_or("").trim().to_lowercase() +} + +fn url_decode(value: &str) -> String { + decode(value) + .map(|value| value.into_owned()) + .unwrap_or_else(|_| value.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn self_test_detection_matches_python() { + assert!(is_self_test_case(Some("x|self_test|y"), Some("normal"))); + assert!(is_self_test_case(None, Some("DLP self_test probe"))); + assert!(!is_self_test_case(Some("incident"), Some("DLP incident"))); + } + + #[test] + fn evidence_checksum_matches_python_fixture() { + let value = json!({"b": 2, "a": "тест"}); + assert_eq!( + evidence_sha256(&value), + "350682d4ca349bdb90a337bf891720f43e85fad66e7811637a72f96b3f29984a" + ); + } +} diff --git a/adk-rust/crates/dlp-cef-exporter/Cargo.toml b/adk-rust/crates/dlp-cef-exporter/Cargo.toml new file mode 100644 index 0000000..db1b92e --- /dev/null +++ b/adk-rust/crates/dlp-cef-exporter/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "dlp-cef-exporter" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +publish = false + +[dependencies] +anyhow.workspace = true +chrono.workspace = true +clap.workspace = true +reqwest.workspace = true +serde.workspace = true +serde_json.workspace = true +tempfile.workspace = true diff --git a/adk-rust/crates/dlp-cef-exporter/src/main.rs b/adk-rust/crates/dlp-cef-exporter/src/main.rs new file mode 100644 index 0000000..6187d5d --- /dev/null +++ b/adk-rust/crates/dlp-cef-exporter/src/main.rs @@ -0,0 +1,505 @@ +use std::collections::BTreeMap; +use std::fs; +use std::io::Write; +use std::net::{TcpStream, UdpSocket}; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use anyhow::{Context, Result, anyhow}; +use chrono::{SecondsFormat, Utc}; +use clap::Parser; +use reqwest::blocking::Client; +use serde::Serialize; +use serde_json::{Value, json}; + +const DEFAULT_CONFIG: &str = "/opt/activitywatch/dlp-integrations/cef-config.yaml"; + +#[derive(Debug, Parser)] +#[command(about = "AWatch DLP CEF exporter")] +struct Cli { + #[arg(long, default_value = DEFAULT_CONFIG)] + config: PathBuf, + + #[arg(long)] + dry_run: bool, + + #[arg(long)] + json: bool, +} + +#[derive(Debug, Clone)] +struct Config { + aw_api_base: String, + state_path: PathBuf, + syslog_host: String, + syslog_port: u16, + syslog_proto: String, + per_bucket_limit: usize, + severity_mapping: BTreeMap, +} + +impl Default for Config { + fn default() -> Self { + Self { + aw_api_base: "http://127.0.0.1:5600/api/0".to_string(), + state_path: PathBuf::from("/var/lib/activitywatch/dlp-integrations/cef-state.json"), + syslog_host: "127.0.0.1".to_string(), + syslog_port: 514, + syslog_proto: "udp".to_string(), + per_bucket_limit: 300, + severity_mapping: BTreeMap::from([ + ("low".to_string(), 3), + ("medium".to_string(), 6), + ("high".to_string(), 10), + ]), + } + } +} + +#[derive(Debug, Serialize)] +struct RunSummary { + ok: bool, + sent: usize, + buckets: usize, + dry_run: bool, + state_saved: bool, + state_path: String, + target: String, + error: Option, +} + +fn load_config(path: &Path) -> Config { + let mut config = Config::default(); + let Ok(text) = fs::read_to_string(path) else { + return config; + }; + let mut in_severity_mapping = false; + for raw_line in text.lines() { + let line_without_comment = raw_line.split('#').next().unwrap_or(""); + let line = line_without_comment.trim(); + if line.is_empty() { + continue; + } + if line == "severity_mapping:" { + in_severity_mapping = true; + config.severity_mapping.clear(); + continue; + } + if in_severity_mapping && (raw_line.starts_with(' ') || raw_line.starts_with('\t')) { + if let Some((key, value)) = line.split_once(':') + && let Ok(score) = clean_scalar(value).parse::() + { + config + .severity_mapping + .insert(key.trim().to_ascii_lowercase(), score); + } + continue; + } + in_severity_mapping = false; + let Some((key, value)) = line.split_once(':') else { + continue; + }; + let key = key.trim(); + let value = clean_scalar(value); + match key { + "aw_api_base" if !value.is_empty() => config.aw_api_base = value, + "state_path" if !value.is_empty() => config.state_path = PathBuf::from(value), + "syslog_host" if !value.is_empty() => config.syslog_host = value, + "syslog_port" => config.syslog_port = value.parse().unwrap_or(config.syslog_port), + "syslog_proto" if !value.is_empty() => config.syslog_proto = value.to_ascii_lowercase(), + "per_bucket_limit" => { + config.per_bucket_limit = value.parse().unwrap_or(config.per_bucket_limit); + } + _ => {} + } + } + if config.severity_mapping.is_empty() { + config.severity_mapping = Config::default().severity_mapping; + } + config +} + +fn clean_scalar(value: &str) -> String { + value + .trim() + .trim_matches('"') + .trim_matches('\'') + .to_string() +} + +fn load_json(path: &Path) -> Value { + let Ok(text) = fs::read_to_string(path) else { + return json!({}); + }; + serde_json::from_str::(&text) + .ok() + .filter(Value::is_object) + .unwrap_or_else(|| json!({})) +} + +fn save_json(path: &Path, payload: &Value) -> Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; + } + fs::write(path, serde_json::to_string_pretty(payload)?) + .with_context(|| format!("write {}", path.display())) +} + +fn http_json(client: &Client, url: &str) -> Result { + client + .get(url) + .timeout(Duration::from_secs(15)) + .send() + .and_then(|resp| resp.error_for_status()) + .with_context(|| format!("GET {url}"))? + .json::() + .with_context(|| format!("parse JSON from {url}")) +} + +fn int_value(value: Option<&Value>) -> i64 { + match value { + Some(Value::Number(n)) => n + .as_i64() + .or_else(|| n.as_u64().map(|v| v as i64)) + .unwrap_or(0), + Some(Value::String(s)) => s.parse::().unwrap_or(0), + _ => 0, + } +} + +fn value_str(value: Option<&Value>, default: &str) -> String { + value.and_then(Value::as_str).unwrap_or(default).to_string() +} + +fn incident_bucket_ids(buckets: &Value) -> Vec { + let mut ids: Vec = buckets + .as_object() + .map(|map| { + map.keys() + .filter(|bucket_id| bucket_id.starts_with("aw-dlp-incidents_")) + .cloned() + .collect() + }) + .unwrap_or_default(); + ids.sort(); + ids +} + +fn iter_new_incidents( + client: &Client, + aw_base: &str, + state: &Value, + per_bucket_limit: usize, +) -> Result<(Vec, BTreeMap)> { + let base = aw_base.trim_end_matches('/'); + let buckets = http_json(client, &format!("{base}/buckets/"))?; + let bucket_ids = incident_bucket_ids(&buckets); + let last_ids = state.get("last_ids").and_then(Value::as_object); + let mut max_ids = BTreeMap::new(); + let mut out = Vec::new(); + for bucket_id in bucket_ids { + let events = match http_json( + client, + &format!("{base}/buckets/{bucket_id}/events?limit={per_bucket_limit}"), + ) { + Ok(Value::Array(items)) => items, + Ok(_) => continue, + Err(err) => return Err(err), + }; + let prev = last_ids + .and_then(|ids| ids.get(&bucket_id)) + .and_then(|v| match v { + Value::Number(n) => n.as_i64(), + Value::String(s) => s.parse().ok(), + _ => None, + }) + .unwrap_or(0); + let mut bucket_max = prev; + for event in events { + let event_id = int_value(event.get("id")); + if event_id <= prev { + continue; + } + if event_id > bucket_max { + bucket_max = event_id; + } + out.push(event); + } + max_ids.insert(bucket_id, bucket_max); + } + out.sort_by_key(|event| int_value(event.get("id"))); + Ok((out, max_ids)) +} + +fn escape_cef(value: Option<&Value>) -> String { + let text = match value { + Some(Value::String(s)) => s.clone(), + Some(Value::Number(n)) => n.to_string(), + Some(Value::Bool(v)) => v.to_string(), + Some(Value::Null) | None => String::new(), + Some(other) => other.to_string(), + }; + text.replace('\\', "\\\\") + .replace('|', "\\|") + .replace('=', "\\=") + .replace('\n', "\\n") + .replace('\r', "") +} + +fn map_severity(name: &str, mapping: &BTreeMap) -> i64 { + mapping + .get(&name.to_ascii_lowercase()) + .copied() + .unwrap_or(3) +} + +fn build_cef(event: &Value, mapping: &BTreeMap) -> String { + let data = event + .get("data") + .filter(|value| value.is_object()) + .unwrap_or(&Value::Null); + let sev_name = value_str(data.get("severity"), "low").to_ascii_lowercase(); + let sev_num = map_severity(&sev_name, mapping); + let rt = event + .get("timestamp") + .cloned() + .unwrap_or_else(|| json!(Utc::now().to_rfc3339_opts(SecondsFormat::Micros, false))); + let rule = data + .get("ruleId") + .cloned() + .unwrap_or_else(|| json!("dlp-incident")); + let msg = data + .get("message") + .cloned() + .unwrap_or_else(|| json!("AWatch DLP incident")); + let sig = data + .get("signalType") + .cloned() + .unwrap_or_else(|| json!("unknown")); + let host = data + .get("hostname") + .cloned() + .unwrap_or_else(|| json!("unknown")); + let user = data + .get("username") + .cloned() + .unwrap_or_else(|| json!("unknown")); + let action = data + .get("action") + .cloned() + .unwrap_or_else(|| json!("alert")); + let ext = format!( + "rt={} shost={} suser={} cs1Label=signalType cs1={} cs2Label=action cs2={} cs3Label=ruleId cs3={}", + escape_cef(Some(&rt)), + escape_cef(Some(&host)), + escape_cef(Some(&user)), + escape_cef(Some(&sig)), + escape_cef(Some(&action)), + escape_cef(Some(&rule)), + ); + format!( + "CEF:0|AWatch-rus|DLP|1.0|{}|{}|{}|{}", + escape_cef(Some(&rule)), + escape_cef(Some(&msg)), + sev_num, + ext + ) +} + +fn send_syslog(line: &str, host: &str, port: u16, proto: &str) -> Result<()> { + if proto.eq_ignore_ascii_case("tcp") { + let mut stream = TcpStream::connect((host, port)) + .with_context(|| format!("connect TCP syslog {host}:{port}"))?; + stream + .set_write_timeout(Some(Duration::from_secs(10))) + .context("set TCP write timeout")?; + stream + .write_all(format!("{line}\n").as_bytes()) + .context("write TCP syslog")?; + return Ok(()); + } + let socket = UdpSocket::bind("0.0.0.0:0").context("bind UDP syslog socket")?; + socket + .send_to(line.as_bytes(), (host, port)) + .with_context(|| format!("send UDP syslog {host}:{port}"))?; + Ok(()) +} + +fn run(cli: &Cli, client: &Client) -> RunSummary { + let cfg = load_config(&cli.config); + let state = load_json(&cfg.state_path); + let (incidents, max_ids) = + match iter_new_incidents(client, &cfg.aw_api_base, &state, cfg.per_bucket_limit) { + Ok(result) => result, + Err(err) => { + eprintln!("skip CEF exporter run: AW API unavailable: {err}"); + return RunSummary { + ok: true, + sent: 0, + buckets: 0, + dry_run: cli.dry_run, + state_saved: false, + state_path: cfg.state_path.to_string_lossy().to_string(), + target: format!( + "{}:{}/{}", + cfg.syslog_host, cfg.syslog_port, cfg.syslog_proto + ), + error: Some(err.to_string()), + }; + } + }; + + let mut sent = 0; + for event in &incidents { + let line = build_cef(event, &cfg.severity_mapping); + if !cli.dry_run + && let Err(err) = + send_syslog(&line, &cfg.syslog_host, cfg.syslog_port, &cfg.syslog_proto) + { + return RunSummary { + ok: false, + sent, + buckets: max_ids.len(), + dry_run: cli.dry_run, + state_saved: false, + state_path: cfg.state_path.to_string_lossy().to_string(), + target: format!( + "{}:{}/{}", + cfg.syslog_host, cfg.syslog_port, cfg.syslog_proto + ), + error: Some(err.to_string()), + }; + } + sent += 1; + } + + let mut next_state = state; + next_state["last_ids"] = json!(max_ids); + next_state["updated_at"] = json!(Utc::now().to_rfc3339_opts(SecondsFormat::Micros, false)); + let mut state_saved = false; + if !cli.dry_run { + if let Err(err) = save_json(&cfg.state_path, &next_state) { + return RunSummary { + ok: false, + sent, + buckets: next_state["last_ids"] + .as_object() + .map_or(0, serde_json::Map::len), + dry_run: false, + state_saved: false, + state_path: cfg.state_path.to_string_lossy().to_string(), + target: format!( + "{}:{}/{}", + cfg.syslog_host, cfg.syslog_port, cfg.syslog_proto + ), + error: Some(err.to_string()), + }; + } + state_saved = true; + } + + RunSummary { + ok: true, + sent, + buckets: next_state["last_ids"] + .as_object() + .map_or(0, serde_json::Map::len), + dry_run: cli.dry_run, + state_saved, + state_path: cfg.state_path.to_string_lossy().to_string(), + target: format!( + "{}:{}/{}", + cfg.syslog_host, cfg.syslog_port, cfg.syslog_proto + ), + error: None, + } +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + let client = Client::builder() + .no_proxy() + .build() + .context("build HTTP client")?; + let summary = run(&cli, &client); + if cli.json { + println!("{}", serde_json::to_string_pretty(&summary)?); + } else if let Some(err) = &summary.error { + eprintln!("{err}"); + } else { + println!( + "CEF exporter done: sent={} buckets={} target={}", + summary.sent, summary.buckets, summary.target + ); + } + if summary.ok { + Ok(()) + } else { + Err(anyhow!( + summary + .error + .unwrap_or_else(|| "CEF exporter failed".to_string()) + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn escapes_cef_fields_like_python_contract() { + assert_eq!( + escape_cef(Some(&json!("a\\b|c=d\nx\r"))), + "a\\\\b\\|c\\=d\\nx" + ); + assert_eq!(escape_cef(None), ""); + } + + #[test] + fn builds_cef_line() { + let event = json!({ + "timestamp": "2026-06-01T10:00:00Z", + "data": { + "severity": "high", + "ruleId": "usb|copy", + "message": "a=b", + "signalType": "dlp", + "hostname": "host", + "username": "user", + "action": "alert" + } + }); + let line = build_cef(&event, &Config::default().severity_mapping); + assert!(line.starts_with("CEF:0|AWatch-rus|DLP|1.0|usb\\|copy|a\\=b|10|")); + assert!(line.contains("shost=host")); + assert!(line.contains("cs3Label=ruleId cs3=usb\\|copy")); + } + + #[test] + fn parses_config_mapping() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("cef-config.yaml"); + fs::write( + &path, + r#" +aw_api_base: "http://127.0.0.1:5600/api/0" +state_path: "/tmp/cef-state.json" +syslog_host: "127.0.0.1" +syslog_port: 5514 +syslog_proto: "tcp" +per_bucket_limit: 10 +severity_mapping: + low: 1 + medium: 5 + high: 9 +"#, + ) + .unwrap(); + let cfg = load_config(&path); + assert_eq!(cfg.syslog_port, 5514); + assert_eq!(cfg.syslog_proto, "tcp"); + assert_eq!(cfg.per_bucket_limit, 10); + assert_eq!(cfg.severity_mapping["high"], 9); + } +} diff --git a/adk-rust/crates/dlp-compliance/Cargo.toml b/adk-rust/crates/dlp-compliance/Cargo.toml new file mode 100644 index 0000000..e131a9a --- /dev/null +++ b/adk-rust/crates/dlp-compliance/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "dlp-compliance" +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 diff --git a/adk-rust/crates/dlp-compliance/src/main.rs b/adk-rust/crates/dlp-compliance/src/main.rs new file mode 100644 index 0000000..fef81a8 --- /dev/null +++ b/adk-rust/crates/dlp-compliance/src/main.rs @@ -0,0 +1,414 @@ +use std::collections::BTreeMap; +use std::fs; +use std::path::PathBuf; +use std::time::Duration; + +use anyhow::{Context, Result, anyhow}; +use chrono::{DateTime, Datelike, SecondsFormat, TimeZone, Utc}; +use clap::Parser; +use reqwest::blocking::Client; +use reqwest::header::ACCEPT; +use serde::Serialize; +use serde_json::{Value, json}; +use urlencoding::encode; + +#[derive(Debug, Parser)] +#[command(about = "Generate DLP compliance reports from AW DLP incidents")] +struct Cli { + #[arg(long)] + month: Option, + + #[arg(long)] + profile: Option, + + #[arg(long, default_value = "152-fz,pci-dss")] + profiles: String, + + #[arg(long)] + stdout_json: bool, +} + +#[derive(Debug, Clone)] +struct Config { + aw_api_base: String, + output_dir: PathBuf, + base_dir: PathBuf, + template_152fz: PathBuf, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +struct ReportStats { + total_incidents: usize, + high: usize, + medium: usize, + low: usize, + by_host: BTreeMap, + channels: BTreeMap, +} + +#[derive(Debug, Serialize)] +struct Metadata { + profile: String, + period: String, + generated_at: String, + aw_api_base: String, + report_path: String, + stats: MetadataStats, +} + +#[derive(Debug, Serialize)] +struct MetadataStats { + total_incidents: usize, + high: usize, + medium: usize, + low: usize, +} + +fn main() { + if let Err(err) = run() { + eprintln!("{err:#}"); + std::process::exit(1); + } +} + +fn run() -> Result<()> { + let cli = Cli::parse(); + let config = Config::from_env()?; + let client = Client::builder() + .timeout(Duration::from_secs(30)) + .no_proxy() + .build() + .context("build HTTP client")?; + let profiles = if let Some(profile) = cli.profile.as_deref() { + vec![profile.to_string()] + } else { + cli.profiles + .split(',') + .map(str::trim) + .filter(|item| !item.is_empty()) + .map(ToOwned::to_owned) + .collect::>() + }; + let mut items = Vec::new(); + for profile in profiles { + items.push(generate_report( + &client, + &config, + cli.month.as_deref(), + &profile, + )?); + } + if cli.stdout_json { + if cli.profile.is_some() && items.len() == 1 { + println!("{}", serde_json::to_string(&items[0])?); + } else { + println!("{}", serde_json::to_string(&json!({"items": items}))?); + } + } + Ok(()) +} + +impl Config { + fn from_env() -> Result { + let aw_raw = env_string("AW_DLP_AW_API_BASE") + .or_else(|| env_string("AW_SERVER_URL")) + .unwrap_or_else(|| "http://127.0.0.1:5600".to_string()); + let aw_api_base = build_aw_api_base(&aw_raw); + let output_dir = env_path("AW_DLP_COMPLIANCE_REPORT_DIR") + .unwrap_or_else(|| PathBuf::from("/opt/activitywatch/dlp-compliance/reports")); + let base_dir = env_path("AW_DLP_COMPLIANCE_BASE_DIR") + .unwrap_or_else(|| PathBuf::from("/opt/activitywatch/dlp-compliance")); + let template_152fz = env_path("AW_DLP_COMPLIANCE_TEMPLATE") + .unwrap_or_else(|| base_dir.join("templates/152-fz-report.html")); + Ok(Self { + aw_api_base, + output_dir, + base_dir, + template_152fz, + }) + } +} + +fn generate_report( + client: &Client, + config: &Config, + month: Option<&str>, + profile: &str, +) -> Result { + let (start, end, period_label) = period_bounds(month)?; + let incidents = load_incidents(client, config, start, end)?; + let stats = build_stats(&incidents); + fs::create_dir_all(&config.output_dir) + .with_context(|| format!("create output dir {}", config.output_dir.display()))?; + let generated_at = Utc::now().to_rfc3339_opts(SecondsFormat::Micros, true); + let html_path = config + .output_dir + .join(format!("{profile}-{period_label}.html")); + let html = render_html(config, profile, &period_label, &stats, &generated_at)?; + fs::write(&html_path, html).with_context(|| format!("write {}", html_path.display()))?; + let metadata = Metadata { + profile: profile.to_string(), + period: period_label.clone(), + generated_at, + aw_api_base: config.aw_api_base.clone(), + report_path: html_path.to_string_lossy().to_string(), + stats: MetadataStats { + total_incidents: stats.total_incidents, + high: stats.high, + medium: stats.medium, + low: stats.low, + }, + }; + let json_path = config + .output_dir + .join(format!("{profile}-{period_label}.json")); + fs::write(&json_path, serde_json::to_string_pretty(&metadata)?) + .with_context(|| format!("write {}", json_path.display()))?; + Ok(metadata) +} + +fn build_aw_api_base(raw_url: &str) -> String { + let url = raw_url.trim().trim_end_matches('/').to_string(); + if url.ends_with("/api/0") { + url + } else { + format!("{url}/api/0") + } +} + +fn load_incidents( + client: &Client, + config: &Config, + start: DateTime, + end: DateTime, +) -> Result> { + let buckets = http_json(client, &format!("{}/buckets", config.aw_api_base))?; + let Some(map) = buckets.as_object() else { + return Ok(Vec::new()); + }; + let mut bucket_ids = map + .keys() + .filter(|bucket_id| bucket_id.starts_with("aw-dlp-incidents_")) + .cloned() + .collect::>(); + bucket_ids.sort(); + let mut incidents = Vec::new(); + for bucket_id in bucket_ids { + let encoded = encode(&bucket_id); + let url = format!("{}/buckets/{encoded}/events?limit=2000", config.aw_api_base); + let events = http_json(client, &url)?; + let Some(events) = events.as_array() else { + continue; + }; + for event in events { + let Some(ts) = event + .get("timestamp") + .and_then(Value::as_str) + .and_then(parse_ts) + else { + continue; + }; + if ts < start || ts > end { + continue; + } + incidents.push(event.clone()); + } + } + Ok(incidents) +} + +fn http_json(client: &Client, url: &str) -> Result { + client + .get(url) + .header(ACCEPT, "application/json") + .send() + .with_context(|| format!("GET {url}"))? + .error_for_status() + .with_context(|| format!("GET {url} status"))? + .json::() + .with_context(|| format!("decode JSON from {url}")) +} + +fn build_stats(incidents: &[Value]) -> ReportStats { + let mut by_host = BTreeMap::new(); + let mut channels = BTreeMap::new(); + let mut high = 0; + let mut medium = 0; + let mut low = 0; + for event in incidents { + let data = event.get("data").and_then(Value::as_object); + let host = data + .and_then(|item| item.get("hostname")) + .and_then(Value::as_str) + .unwrap_or("unknown") + .to_string(); + *by_host.entry(host).or_insert(0) += 1; + + match data + .and_then(|item| item.get("severity")) + .and_then(Value::as_str) + .unwrap_or("low") + .to_lowercase() + .as_str() + { + "high" => high += 1, + "medium" => medium += 1, + _ => low += 1, + } + let channel = data + .and_then(|item| item.get("signalType").or_else(|| item.get("source"))) + .and_then(Value::as_str) + .unwrap_or("unknown") + .to_string(); + *channels.entry(channel).or_insert(0) += 1; + } + ReportStats { + total_incidents: incidents.len(), + high, + medium, + low, + by_host: sort_counts_desc(by_host), + channels: sort_counts_desc(channels), + } +} + +fn sort_counts_desc(values: BTreeMap) -> BTreeMap { + let mut rows = values.into_iter().collect::>(); + rows.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))); + rows.into_iter().collect() +} + +fn render_html( + config: &Config, + profile: &str, + period_label: &str, + stats: &ReportStats, + generated_at: &str, +) -> Result { + let template_path = resolve_template_path(config, profile); + let template = fs::read_to_string(&template_path) + .with_context(|| format!("read template {}", template_path.display()))?; + Ok(template + .replace("{{PERIOD}}", period_label) + .replace("{{PROFILE}}", profile) + .replace("{{GENERATED_AT}}", generated_at) + .replace("{{TOTAL}}", &stats.total_incidents.to_string()) + .replace("{{HIGH}}", &stats.high.to_string()) + .replace("{{MEDIUM}}", &stats.medium.to_string()) + .replace("{{LOW}}", &stats.low.to_string()) + .replace( + "{{HOST_TABLE}}", + &render_table("Инциденты по хостам", &stats.by_host), + ) + .replace( + "{{CHANNEL_TABLE}}", + &render_table("Инциденты по каналам", &stats.channels), + )) +} + +fn resolve_template_path(config: &Config, profile: &str) -> PathBuf { + if profile == "152-fz" { + config.template_152fz.clone() + } else { + let specific = config + .base_dir + .join(format!("templates/{profile}-report.html")); + if specific.exists() { + specific + } else { + config.template_152fz.clone() + } + } +} + +fn render_table(title: &str, rows: &BTreeMap) -> String { + if rows.is_empty() { + return format!("

{title}

Нет данных

"); + } + let body = rows + .iter() + .map(|(name, count)| format!("{name}{count}")) + .collect::(); + format!( + "

{title}

{body}
ПараметрЗначение
" + ) +} + +fn period_bounds(month: Option<&str>) -> Result<(DateTime, DateTime, String)> { + let start = if let Some(month) = month { + let (year, month) = month + .split_once('-') + .ok_or_else(|| anyhow!("month must be YYYY-MM"))?; + Utc.with_ymd_and_hms(year.parse()?, month.parse()?, 1, 0, 0, 0) + .single() + .ok_or_else(|| anyhow!("invalid month"))? + } else { + let now = Utc::now(); + Utc.with_ymd_and_hms(now.year(), now.month(), 1, 0, 0, 0) + .single() + .ok_or_else(|| anyhow!("invalid current month"))? + }; + let end = if start.month() == 12 { + Utc.with_ymd_and_hms(start.year() + 1, 1, 1, 0, 0, 0) + .single() + .ok_or_else(|| anyhow!("invalid end month"))? + } else { + Utc.with_ymd_and_hms(start.year(), start.month() + 1, 1, 0, 0, 0) + .single() + .ok_or_else(|| anyhow!("invalid end month"))? + }; + Ok(( + start, + end, + format!("{:04}-{:02}", start.year(), start.month()), + )) +} + +fn parse_ts(value: &str) -> Option> { + DateTime::parse_from_rfc3339(&value.replace('Z', "+00:00")) + .map(|ts| ts.with_timezone(&Utc)) + .ok() +} + +fn env_string(name: &str) -> Option { + std::env::var(name).ok().filter(|value| !value.is_empty()) +} + +fn env_path(name: &str) -> Option { + env_string(name).map(PathBuf::from) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn aw_api_base_matches_python_contract() { + assert_eq!(build_aw_api_base("http://x"), "http://x/api/0"); + assert_eq!(build_aw_api_base("http://x/api/0"), "http://x/api/0"); + } + + #[test] + fn stats_match_expected_counts() { + let incidents = vec![ + json!({"data":{"hostname":"a","severity":"high","signalType":"usb"}}), + json!({"data":{"hostname":"a","severity":"medium","source":"clipboard"}}), + json!({"data":{"hostname":"b","severity":"low"}}), + ]; + let stats = build_stats(&incidents); + assert_eq!(stats.total_incidents, 3); + assert_eq!(stats.high, 1); + assert_eq!(stats.medium, 1); + assert_eq!(stats.low, 1); + assert_eq!(stats.by_host.get("a"), Some(&2)); + assert_eq!(stats.channels.get("unknown"), Some(&1)); + } + + #[test] + fn month_bounds_are_utc_month() { + let (start, end, label) = period_bounds(Some("2026-06")).unwrap(); + assert_eq!(label, "2026-06"); + assert_eq!(start.to_rfc3339(), "2026-06-01T00:00:00+00:00"); + assert_eq!(end.to_rfc3339(), "2026-07-01T00:00:00+00:00"); + } +} diff --git a/adk-rust/crates/dlp-content-analyzer/Cargo.toml b/adk-rust/crates/dlp-content-analyzer/Cargo.toml new file mode 100644 index 0000000..0fb6187 --- /dev/null +++ b/adk-rust/crates/dlp-content-analyzer/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "dlp-content-analyzer" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +publish.workspace = true + +[dependencies] +anyhow.workspace = true +clap.workspace = true +regex.workspace = true +serde.workspace = true +serde_json.workspace = true diff --git a/adk-rust/crates/dlp-content-analyzer/src/main.rs b/adk-rust/crates/dlp-content-analyzer/src/main.rs new file mode 100644 index 0000000..73d8e26 --- /dev/null +++ b/adk-rust/crates/dlp-content-analyzer/src/main.rs @@ -0,0 +1,387 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use anyhow::{Context, Result, bail}; +use clap::Parser; +use regex::Regex; +use serde::Serialize; +use serde_json::Value; + +const DEFAULT_BASE_DIR: &str = "/opt/activitywatch/dlp-content-analysis"; + +#[derive(Debug, Parser)] +#[command(about = "Analyze text or screenshot with DLP dictionaries/regex packs.")] +struct Cli { + #[arg(long)] + text: Option, + + #[arg(long)] + image: Option, + + #[arg(long)] + dictionary_pack: Option, + + #[arg(long)] + regex_pack: Option, + + #[arg(long, default_value = DEFAULT_BASE_DIR)] + base_dir: PathBuf, + + #[arg( + long, + default_value = "/opt/activitywatch/dlp-content-analysis/.venv/bin/python" + )] + legacy_python: PathBuf, + + #[arg( + long, + default_value = "/opt/activitywatch/dlp-content-analysis/content_analyzer.py" + )] + legacy_analyzer: PathBuf, +} + +#[derive(Debug, Serialize, PartialEq, Eq)] +struct MatchResult { + name: String, + description: String, + value: String, + start: usize, + end: usize, + #[serde(skip_serializing_if = "Option::is_none")] + severity: Option, +} + +#[derive(Debug, Serialize)] +struct Analysis { + text: String, + dictionary_pack: Option, + regex_pack: Option, + dictionary_matches: Vec, + regex_matches: Vec, + source: String, +} + +fn digits_only(value: &str) -> String { + value.chars().filter(|ch| ch.is_ascii_digit()).collect() +} + +fn validate_inn(value: &str) -> bool { + let digits = digits_only(value); + let nums = digits + .bytes() + .map(|byte| (byte - b'0') as u32) + .collect::>(); + match nums.len() { + 10 => { + let coef = [2, 4, 10, 3, 5, 9, 4, 6, 8]; + let chk = nums + .iter() + .take(9) + .zip(coef) + .map(|(digit, coef)| digit * coef) + .sum::() + % 11 + % 10; + chk == nums[9] + } + 12 => { + let c11 = [7, 2, 4, 10, 3, 5, 9, 4, 6, 8]; + let c12 = [3, 7, 2, 4, 10, 3, 5, 9, 4, 6, 8]; + let chk11 = nums + .iter() + .take(10) + .zip(c11) + .map(|(digit, coef)| digit * coef) + .sum::() + % 11 + % 10; + let chk12 = nums + .iter() + .take(11) + .zip(c12) + .map(|(digit, coef)| digit * coef) + .sum::() + % 11 + % 10; + chk11 == nums[10] && chk12 == nums[11] + } + _ => false, + } +} + +fn validate_snils(value: &str) -> bool { + let digits = digits_only(value); + if digits.len() != 11 { + return false; + } + let nums = digits + .bytes() + .map(|byte| (byte - b'0') as u32) + .collect::>(); + let checksum = nums[9] * 10 + nums[10]; + let sum = nums + .iter() + .take(9) + .enumerate() + .map(|(idx, digit)| digit * (9 - idx as u32)) + .sum::(); + let expected = match sum { + 0..=99 => sum, + 100 | 101 => 0, + _ => { + let value = sum % 101; + if value == 100 { 0 } else { value } + } + }; + checksum == expected +} + +fn validate_passport(value: &str) -> bool { + let digits = digits_only(value); + digits.len() == 10 + && digits != "0000000000" + && digits.chars().any(|ch| ch != digits.as_bytes()[0] as char) +} + +fn validate_checksum(kind: &str, value: &str) -> bool { + match kind { + "inn" => validate_inn(value), + "snils" => validate_snils(value), + "passport" => validate_passport(value), + _ => true, + } +} + +fn char_offset(text: &str, byte_offset: usize) -> usize { + text[..byte_offset].chars().count() +} + +fn load_json(path: &Path) -> Result { + let text = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?; + serde_json::from_str(&text).with_context(|| format!("parse {}", path.display())) +} + +fn resolve_pack(base_dir: &Path, kind: &str, name: Option<&str>) -> Option { + let name = name?; + let path = base_dir.join(kind).join(format!("{name}.json")); + path.exists().then_some(path) +} + +fn match_dictionary(text: &str, path: Option<&Path>) -> Result> { + let Some(path) = path else { + return Ok(Vec::new()); + }; + let rules = load_json(path)?; + let Some(rules) = rules.as_object() else { + return Ok(Vec::new()); + }; + let mut results = Vec::new(); + for (name, rule) in rules { + let Some(regex) = rule.get("regex").and_then(Value::as_str) else { + continue; + }; + let regex = + Regex::new(regex).with_context(|| format!("compile dictionary regex {name}"))?; + let checksum = rule + .get("checksum") + .and_then(Value::as_str) + .unwrap_or("none"); + let description = rule + .get("description") + .and_then(Value::as_str) + .unwrap_or(name) + .to_string(); + for capture in regex.find_iter(text) { + let value = capture.as_str(); + if validate_checksum(checksum, value) { + results.push(MatchResult { + name: name.to_string(), + description: description.clone(), + value: value.to_string(), + start: char_offset(text, capture.start()), + end: char_offset(text, capture.end()), + severity: None, + }); + } + } + } + results.sort_by_key(|result| (result.start, result.end, result.name.clone())); + Ok(results) +} + +fn regex_entries(pack: &Value) -> Vec<(String, String, String, String)> { + let mut entries = Vec::new(); + if let Some(rules) = pack.get("rules").and_then(Value::as_array) { + for rule in rules.iter().filter_map(Value::as_object) { + let Some(regex) = rule.get("regex").and_then(Value::as_str) else { + continue; + }; + let id = rule + .get("id") + .or_else(|| rule.get("name")) + .and_then(Value::as_str) + .unwrap_or("regex-rule"); + let description = rule + .get("description") + .and_then(Value::as_str) + .unwrap_or(id); + let severity = rule + .get("severity") + .and_then(Value::as_str) + .unwrap_or("medium"); + entries.push(( + id.to_string(), + description.to_string(), + regex.to_string(), + severity.to_string(), + )); + } + } else if let Some(patterns) = pack.get("patterns").and_then(Value::as_object) { + for (id, rule) in patterns { + let Some(rule) = rule.as_object() else { + continue; + }; + let Some(regex) = rule.get("regex").and_then(Value::as_str) else { + continue; + }; + let description = rule + .get("description") + .and_then(Value::as_str) + .unwrap_or(id); + let severity = rule + .get("severity") + .and_then(Value::as_str) + .unwrap_or("medium"); + entries.push(( + id.to_string(), + description.to_string(), + regex.to_string(), + severity.to_string(), + )); + } + } + entries +} + +fn match_regex_pack(text: &str, path: Option<&Path>) -> Result> { + let Some(path) = path else { + return Ok(Vec::new()); + }; + let pack = load_json(path)?; + let mut results = Vec::new(); + for (name, description, regex, severity) in regex_entries(&pack) { + let regex = + Regex::new(®ex).with_context(|| format!("compile regex pack rule {name}"))?; + for capture in regex.find_iter(text) { + results.push(MatchResult { + name: name.clone(), + description: description.clone(), + value: capture.as_str().to_string(), + start: char_offset(text, capture.start()), + end: char_offset(text, capture.end()), + severity: Some(severity.clone()), + }); + } + } + Ok(results) +} + +fn analyze_text(cli: &Cli) -> Result { + let text = cli.text.clone().unwrap_or_default(); + let dictionary_path = resolve_pack( + &cli.base_dir, + "dictionaries", + cli.dictionary_pack.as_deref(), + ); + let regex_pack_path = resolve_pack(&cli.base_dir, "regex-packs", cli.regex_pack.as_deref()); + Ok(Analysis { + dictionary_matches: match_dictionary(&text, dictionary_path.as_deref())?, + regex_matches: match_regex_pack(&text, regex_pack_path.as_deref())?, + text, + dictionary_pack: cli.dictionary_pack.clone(), + regex_pack: cli.regex_pack.clone(), + source: "text".to_string(), + }) +} + +fn run_legacy_image(cli: &Cli, image: &Path) -> Result { + if !cli.legacy_python.exists() || !cli.legacy_analyzer.exists() { + bail!( + "image OCR requires legacy analyzer: {} {}", + cli.legacy_python.display(), + cli.legacy_analyzer.display() + ); + } + let mut command = Command::new(&cli.legacy_python); + command.arg(&cli.legacy_analyzer).arg("--image").arg(image); + if let Some(pack) = &cli.dictionary_pack { + command.arg("--dictionary-pack").arg(pack); + } + if let Some(pack) = &cli.regex_pack { + command.arg("--regex-pack").arg(pack); + } + let output = command.output().context("run legacy image analyzer")?; + if !output.status.success() { + bail!( + "legacy image analyzer failed rc={:?}: {}{}", + output.status.code(), + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + serde_json::from_slice(&output.stdout).context("parse legacy image analyzer JSON") +} + +fn run(cli: &Cli) -> Result { + if let Some(image) = &cli.image { + return run_legacy_image(cli, image); + } + Ok(serde_json::to_value(analyze_text(cli)?)?) +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + let value = run(&cli)?; + println!("{}", serde_json::to_string_pretty(&value)?); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validates_inn_like_python() { + assert!(validate_inn("7707083893")); + assert!(validate_inn("500100732259")); + assert!(!validate_inn("7707083894")); + } + + #[test] + fn validates_snils_like_python() { + assert!(validate_snils("112-233-445 95")); + assert!(!validate_snils("112-233-445 96")); + } + + #[test] + fn validates_passport_like_python() { + assert!(validate_passport("1234 567890")); + assert!(!validate_passport("0000 000000")); + assert!(!validate_passport("1111 111111")); + } + + #[test] + fn regex_pack_patterns_shape_is_supported() { + let pack = serde_json::json!({"patterns": {"secret": {"regex": "token", "description": "Token", "severity": "high"}}}); + assert_eq!( + regex_entries(&pack), + vec![( + "secret".to_string(), + "Token".to_string(), + "token".to_string(), + "high".to_string() + )] + ); + } +} diff --git a/adk-rust/crates/dlp-health-check/Cargo.toml b/adk-rust/crates/dlp-health-check/Cargo.toml new file mode 100644 index 0000000..9b366b6 --- /dev/null +++ b/adk-rust/crates/dlp-health-check/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "dlp-health-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-core.workspace = true +reqwest.workspace = true +serde.workspace = true +serde_json.workspace = true +tempfile.workspace = true diff --git a/adk-rust/crates/dlp-health-check/src/main.rs b/adk-rust/crates/dlp-health-check/src/main.rs new file mode 100644 index 0000000..588dfd7 --- /dev/null +++ b/adk-rust/crates/dlp-health-check/src/main.rs @@ -0,0 +1,1523 @@ +use std::cmp::Reverse; +use std::collections::{BTreeMap, HashMap}; +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::thread::sleep; +use std::time::Duration; + +use anyhow::{Context, Result, anyhow}; +use chrono::{DateTime, SecondsFormat, Utc}; +use clap::Parser; +use detmir_core::parse_utc_rfc3339; +use reqwest::blocking::Client; +use serde::Serialize; +use serde_json::{Value, json}; + +#[derive(Debug, Parser)] +#[command(about = "AWatch DLP health check")] +struct Cli { + #[arg(long, default_value = "http://127.0.0.1:5600")] + aw_server: String, + + #[arg(long, default_value = "http://127.0.0.1:5601")] + policy_server: String, + + #[arg(long, default_value = "http://127.0.0.1:5602")] + case_server: String, + + #[arg(long, default_value_t = 900)] + max_age_seconds: i64, + + #[arg(long)] + strict_fileops: bool, + + #[arg(long, default_value_t = 100)] + endpoint_queue_warn_depth: i64, + + #[arg(long, default_value_t = 1)] + endpoint_send_failure_warn_count: i64, + + #[arg(long, default_value_t = 20)] + fileops_sample_limit: i64, + + #[arg(long, default_value_t = 100)] + fileops_queue_warn_depth: i64, + + #[arg(long, default_value_t = 1)] + fileops_send_failure_warn_count: i64, + + #[arg(long, default_value_t = 0)] + incident_sample_limit: i64, + + #[arg(long, default_value = "/var/lib/activitywatch/health")] + state_dir: PathBuf, + + #[arg(long, default_value = "/opt/activitywatch/dlp-compliance/reports")] + report_dir: PathBuf, + + #[arg(long, default_value = "152-fz,pci-dss")] + profiles: String, + + #[arg(long)] + json: bool, +} + +impl Cli { + fn apply_env(mut self) -> Self { + if !cli_arg_present("--aw-server") { + self.aw_server = env_string("AW_HEALTH_AW_SERVER").unwrap_or(self.aw_server); + } + if !cli_arg_present("--policy-server") { + self.policy_server = + env_string("AW_HEALTH_POLICY_SERVER").unwrap_or(self.policy_server); + } + if !cli_arg_present("--case-server") { + self.case_server = env_string("AW_HEALTH_CASE_SERVER").unwrap_or(self.case_server); + } + if !cli_arg_present("--max-age-seconds") { + self.max_age_seconds = env_i64("AW_HEALTH_MAX_AGE_SECONDS", self.max_age_seconds); + } + if !cli_arg_present("--strict-fileops") { + self.strict_fileops = env_bool("AW_HEALTH_STRICT_FILEOPS"); + } + if !cli_arg_present("--endpoint-queue-warn-depth") { + self.endpoint_queue_warn_depth = env_i64( + "AW_DLP_HEALTH_ENDPOINT_QUEUE_WARN_DEPTH", + self.endpoint_queue_warn_depth, + ); + } + if !cli_arg_present("--endpoint-send-failure-warn-count") { + self.endpoint_send_failure_warn_count = env_i64( + "AW_DLP_HEALTH_ENDPOINT_SEND_FAILURE_WARN_COUNT", + self.endpoint_send_failure_warn_count, + ); + } + if !cli_arg_present("--fileops-sample-limit") { + self.fileops_sample_limit = env_i64( + "AW_DLP_HEALTH_FILEOPS_SAMPLE_LIMIT", + self.fileops_sample_limit, + ); + } + if !cli_arg_present("--fileops-queue-warn-depth") { + self.fileops_queue_warn_depth = env_i64( + "AW_DLP_HEALTH_FILEOPS_QUEUE_WARN_DEPTH", + self.fileops_queue_warn_depth, + ); + } + if !cli_arg_present("--fileops-send-failure-warn-count") { + self.fileops_send_failure_warn_count = env_i64( + "AW_DLP_HEALTH_FILEOPS_SEND_FAILURE_WARN_COUNT", + self.fileops_send_failure_warn_count, + ); + } + if !cli_arg_present("--incident-sample-limit") { + self.incident_sample_limit = env_i64( + "AW_DLP_HEALTH_INCIDENT_SAMPLE_LIMIT", + self.incident_sample_limit, + ); + } + if !cli_arg_present("--state-dir") { + self.state_dir = + env_path("AW_DLP_HEALTH_STATE_DIR").unwrap_or_else(|| self.state_dir.clone()); + } + if !cli_arg_present("--report-dir") { + self.report_dir = + env_path("AW_DLP_COMPLIANCE_REPORT_DIR").unwrap_or_else(|| self.report_dir.clone()); + } + if !cli_arg_present("--profiles") { + self.profiles = env_string("AW_DLP_COMPLIANCE_PROFILES").unwrap_or(self.profiles); + } + self + } +} + +#[derive(Debug, Clone, Serialize)] +struct CheckResult { + name: String, + status: String, + summary: String, + details: Value, +} + +#[derive(Debug, Serialize)] +struct Counts { + ok: usize, + warn: usize, + fail: usize, +} + +#[derive(Debug, Serialize)] +struct HealthPayload { + ok: bool, + counts: Counts, + results: Vec, +} + +#[derive(Default)] +struct HealthReport { + results: Vec, +} + +impl HealthReport { + fn add( + &mut self, + name: impl Into, + status: &str, + summary: impl Into, + details: Value, + ) { + self.results.push(CheckResult { + name: name.into(), + status: status.to_string(), + summary: summary.into(), + details, + }); + } + + fn ok(&self) -> bool { + !self.results.iter().any(|item| item.status == "fail") + } + + fn payload(&self) -> HealthPayload { + let mut counts = Counts { + ok: 0, + warn: 0, + fail: 0, + }; + for item in &self.results { + match item.status.as_str() { + "ok" => counts.ok += 1, + "warn" => counts.warn += 1, + "fail" => counts.fail += 1, + _ => {} + } + } + HealthPayload { + ok: self.ok(), + counts, + results: self.results.clone(), + } + } + + fn render_text(&self) -> String { + let mut lines = vec![ + "=== DLP Health Check ===".to_string(), + format!("Timestamp: {}", utc_iso()), + String::new(), + ]; + for item in &self.results { + let icon = match item.status.as_str() { + "ok" => "OK", + "warn" => "WARN", + "fail" => "FAIL", + other => other, + }; + lines.push(format!("[{icon}] {}: {}", item.name, item.summary)); + if !item.details.as_object().is_none_or(|v| v.is_empty()) { + lines.push(format!( + " details: {}", + serde_json::to_string(&sort_json_value(&item.details)) + .unwrap_or_else(|_| "{}".to_string()) + )); + } + } + lines.push(String::new()); + lines.push(format!( + "Overall: {}", + if self.ok() { "OK" } else { "FAIL" } + )); + lines.join("\n") + } +} + +fn utc_iso() -> String { + Utc::now().to_rfc3339_opts(SecondsFormat::Micros, true) +} + +fn parse_ts(value: Option<&str>) -> Option> { + value.and_then(|text| parse_utc_rfc3339(text).ok()) +} + +fn age_seconds(ts: Option>, now: DateTime) -> Option { + ts.map(|ts| (now - ts).num_seconds().max(0)) +} + +fn int_or_zero(value: Option<&Value>) -> i64 { + match value { + Some(Value::Number(n)) => n + .as_i64() + .or_else(|| n.as_u64().map(|v| v as i64)) + .unwrap_or(0), + Some(Value::String(s)) => s.parse::().unwrap_or(0), + Some(Value::Bool(v)) => i64::from(*v), + _ => 0, + } +} + +fn value_str(value: Option<&Value>) -> String { + match value { + Some(Value::String(s)) => s.clone(), + Some(Value::Number(n)) => n.to_string(), + Some(Value::Bool(v)) => v.to_string(), + Some(Value::Null) | None => String::new(), + Some(other) => other.to_string(), + } +} + +fn path_tail(value: Option<&Value>, parts: usize) -> String { + let text = value_str(value).replace('\\', "/").trim().to_string(); + if text.is_empty() { + return String::new(); + } + let tokens: Vec<&str> = text.split('/').filter(|item| !item.is_empty()).collect(); + if tokens.is_empty() { + return text; + } + let start = tokens.len().saturating_sub(parts); + tokens[start..].join("/") +} + +fn text_excerpt(value: Option<&Value>, limit: usize) -> String { + let text = value_str(value) + .split_whitespace() + .collect::>() + .join(" "); + if text.chars().count() <= limit { + return text; + } + let prefix: String = text.chars().take(limit.saturating_sub(1)).collect(); + format!("{}…", prefix.trim_end()) +} + +fn http_json(client: &Client, url: &str, timeout_seconds: u64, attempts: usize) -> Result { + let mut last_error: Option = None; + for attempt in 0..attempts.max(1) { + match client + .get(url) + .timeout(Duration::from_secs(timeout_seconds)) + .send() + .and_then(|resp| resp.error_for_status()) + .with_context(|| format!("GET {url}")) + .and_then(|resp| resp.json::().context("parse JSON")) + { + Ok(value) => return Ok(value), + Err(err) => { + last_error = Some(err); + if attempt + 1 < attempts.max(1) { + sleep(Duration::from_millis(500 * (1 << attempt))); + } + } + } + } + Err(last_error.unwrap_or_else(|| anyhow!("HTTP request failed"))) +} + +fn check_http_endpoint(report: &mut HealthReport, client: &Client, name: &str, url: &str) { + match http_json(client, url, 15, 2) { + Ok(payload) => report.add( + name, + "ok", + "HTTP endpoint responded", + json!({"url": url, "payload": payload}), + ), + Err(err) => report.add( + name, + "fail", + format!("HTTP endpoint failed: {err}"), + json!({"url": url}), + ), + } +} + +fn run_systemctl(args: &[&str]) -> (i32, String) { + match Command::new("systemctl").args(args).output() { + Ok(output) => { + let mut text = String::from_utf8_lossy(&output.stdout).to_string(); + text.push_str(&String::from_utf8_lossy(&output.stderr)); + (output.status.code().unwrap_or(1), text.trim().to_string()) + } + Err(err) => (1, err.to_string()), + } +} + +fn check_systemd_unit(report: &mut HealthReport, unit: &str, kind: &str) { + let (active_rc, active_out) = run_systemctl(&["is-active", unit]); + let (enabled_rc, enabled_out) = run_systemctl(&["is-enabled", unit]); + let (exists_rc, _) = run_systemctl(&["status", unit]); + if exists_rc != 0 && active_rc != 0 && enabled_rc != 0 { + report.add( + format!("systemd:{unit}"), + "warn", + "unit not installed", + json!({"kind": kind}), + ); + return; + } + if active_rc == 0 && enabled_rc == 0 { + report.add( + format!("systemd:{unit}"), + "ok", + "active and enabled", + json!({"kind": kind}), + ); + return; + } + report.add( + format!("systemd:{unit}"), + "fail", + "unit is not active/enabled", + json!({ + "kind": kind, + "active": if active_out.is_empty() { active_rc.to_string() } else { active_out }, + "enabled": if enabled_out.is_empty() { enabled_rc.to_string() } else { enabled_out }, + }), + ); +} + +fn latest_bucket_ts( + client: &Client, + api_base: &str, + bucket_id: &str, + bucket_meta: &Value, +) -> Option> { + let meta_ts = bucket_meta + .get("metadata") + .and_then(|v| v.get("end")) + .and_then(Value::as_str); + if let Some(ts) = parse_ts(meta_ts) { + return Some(ts); + } + let url = format!("{api_base}/buckets/{bucket_id}/events?limit=1"); + let events = http_json(client, &url, 15, 2).ok()?; + events + .as_array() + .and_then(|items| items.first()) + .and_then(|event| event.get("timestamp")) + .and_then(Value::as_str) + .and_then(|text| parse_ts(Some(text))) +} + +fn bucket_suffix(bucket_id: &str, prefix: &str) -> String { + bucket_id + .strip_prefix(prefix) + .unwrap_or(bucket_id) + .to_string() +} + +fn bucket_keys_with_prefix(buckets: &BTreeMap, prefix: &str) -> Vec { + buckets + .keys() + .filter(|bucket_id| bucket_id.starts_with(prefix)) + .cloned() + .collect() +} + +fn check_incident_buckets( + report: &mut HealthReport, + client: &Client, + api_base: &str, + buckets: &BTreeMap, + max_age_seconds: i64, +) { + let now = Utc::now(); + let prefix = "aw-dlp-incidents_"; + let matched = bucket_keys_with_prefix(buckets, prefix); + if matched.is_empty() { + report.add( + "buckets:incidents", + "ok", + "no incident buckets yet", + json!({"prefix": prefix, "bucket_count": 0}), + ); + return; + } + let mut ages = Vec::new(); + let mut unknown = Vec::new(); + let mut stale = Vec::new(); + for bucket_id in &matched { + let ts = latest_bucket_ts( + client, + api_base, + bucket_id, + buckets.get(bucket_id).unwrap_or(&Value::Null), + ); + match age_seconds(ts, now) { + Some(age) => { + ages.push(age); + if age > max_age_seconds { + stale.push(json!({"bucket": bucket_id, "age_seconds": age})); + } + } + None => unknown.push(bucket_id.clone()), + } + } + if !stale.is_empty() && unknown.is_empty() { + report.add( + "buckets:incidents", + "ok", + "no recent incidents", + json!({ + "prefix": prefix, + "bucket_count": matched.len(), + "max_age_seconds": max_age_seconds, + "max_observed_age_seconds": ages.iter().max().copied(), + "stale": stale, + "unknown": [], + }), + ); + return; + } + let (status, summary) = if unknown.is_empty() { + ("ok", "incident buckets healthy".to_string()) + } else { + ( + "warn", + format!("{} incident buckets without timestamp", unknown.len()), + ) + }; + report.add( + "buckets:incidents", + status, + summary, + json!({ + "prefix": prefix, + "bucket_count": matched.len(), + "max_age_seconds": max_age_seconds, + "max_observed_age_seconds": ages.iter().max().copied(), + "stale": stale, + "unknown": unknown, + }), + ); +} + +struct HostBucketCheck<'a> { + check_name: &'a str, + prefix: &'a str, + max_age_seconds: i64, + strict: bool, +} + +fn worktime_activity_map( + client: &Client, + api_base: &str, + buckets: &BTreeMap, + max_age_seconds: i64, +) -> BTreeMap { + let now = Utc::now(); + let prefix = "aw-worktime-sessions_"; + let mut activity = BTreeMap::new(); + for bucket_id in bucket_keys_with_prefix(buckets, prefix) { + let host = bucket_suffix(&bucket_id, prefix); + let mut latest_ts: Option> = None; + let mut latest_active = false; + let url = format!("{api_base}/buckets/{bucket_id}/events?limit=20"); + if let Ok(events) = http_json(client, &url, 15, 2) + && let Some(items) = events.as_array() + { + for event in items { + let ts = event + .get("timestamp") + .and_then(Value::as_str) + .and_then(|text| parse_ts(Some(text))); + if let Some(ts) = ts + && latest_ts.is_none_or(|current| ts > current) + { + latest_ts = Some(ts); + latest_active = event + .get("data") + .and_then(|v| v.get("active")) + .and_then(Value::as_bool) + .unwrap_or(false); + } + } + } + let age = age_seconds(latest_ts, now); + activity.insert( + host, + json!({ + "active": latest_ts.is_some() && latest_active && age.unwrap_or(0) <= max_age_seconds, + "age_seconds": age, + "bucket": bucket_id, + }), + ); + } + activity +} + +fn check_host_bucket_freshness( + report: &mut HealthReport, + client: &Client, + api_base: &str, + buckets: &BTreeMap, + config: HostBucketCheck<'_>, +) { + let now = Utc::now(); + let matched = bucket_keys_with_prefix(buckets, config.prefix); + let worktime = worktime_activity_map(client, api_base, buckets, config.max_age_seconds); + let active_hosts: Vec = worktime + .iter() + .filter(|(_, meta)| meta.get("active").and_then(Value::as_bool).unwrap_or(false)) + .map(|(host, _)| host.clone()) + .collect(); + let matched_by_host: BTreeMap = matched + .iter() + .map(|bucket_id| (bucket_suffix(bucket_id, config.prefix), bucket_id.clone())) + .collect(); + let mut ignored_unmanaged = Vec::new(); + let mut ignored_inactive = Vec::new(); + let mut missing_active = Vec::new(); + let mut stale = Vec::new(); + let mut unknown = Vec::new(); + let mut fresh = Vec::new(); + + for (host, bucket_id) in &matched_by_host { + if !worktime.contains_key(host) { + ignored_unmanaged.push(bucket_id.clone()); + continue; + } + if !active_hosts.contains(host) { + ignored_inactive.push(bucket_id.clone()); + continue; + } + let ts = latest_bucket_ts( + client, + api_base, + bucket_id, + buckets.get(bucket_id).unwrap_or(&Value::Null), + ); + match age_seconds(ts, now) { + Some(age) if age > config.max_age_seconds => { + stale.push(json!({"bucket": bucket_id, "age_seconds": age})); + } + Some(_) => fresh.push(bucket_id.clone()), + None => unknown.push(bucket_id.clone()), + } + } + + for host in &active_hosts { + if !matched_by_host.contains_key(host) { + missing_active.push(host.clone()); + } + } + + if active_hosts.is_empty() { + report.add( + config.check_name, + "ok", + format!( + "no active managed hosts require {} freshness", + config.check_name.trim_start_matches("buckets:") + ), + json!({ + "active_hosts": [], + "ignored_unmanaged": ignored_unmanaged, + "ignored_inactive": ignored_inactive, + "worktime_hosts": worktime.keys().cloned().collect::>(), + }), + ); + return; + } + + let mut status = "ok"; + let mut summary = format!("{} active host buckets fresh", fresh.len()); + if !missing_active.is_empty() { + status = if config.strict { "fail" } else { "warn" }; + summary = format!( + "{} active hosts missing {} buckets", + missing_active.len(), + config.check_name.trim_start_matches("buckets:") + ); + } else if !stale.is_empty() { + status = if config.strict { "fail" } else { "warn" }; + summary = format!("{} active host buckets stale", stale.len()); + } else if !unknown.is_empty() { + status = "warn"; + summary = format!("{} active host buckets without timestamp", unknown.len()); + } + + report.add( + config.check_name, + status, + summary, + json!({ + "active_hosts": active_hosts, + "fresh": fresh, + "stale": stale, + "missing_active": missing_active, + "unknown": unknown, + "ignored_unmanaged": ignored_unmanaged, + "ignored_inactive": ignored_inactive, + }), + ); +} + +fn check_endpoint_signal_buckets( + report: &mut HealthReport, + client: &Client, + api_base: &str, + buckets: &BTreeMap, + max_age_seconds: i64, +) { + check_host_bucket_freshness( + report, + client, + api_base, + buckets, + HostBucketCheck { + check_name: "buckets:endpoint-signals", + prefix: "aw-dlp-endpoint-signals_", + max_age_seconds, + strict: true, + }, + ); +} + +fn check_file_operations_buckets( + report: &mut HealthReport, + client: &Client, + api_base: &str, + buckets: &BTreeMap, + max_age_seconds: i64, + strict: bool, +) { + check_host_bucket_freshness( + report, + client, + api_base, + buckets, + HostBucketCheck { + check_name: "buckets:file-operations", + prefix: "aw-file-operations_", + max_age_seconds, + strict, + }, + ); +} + +fn load_counter_state(path: &Path) -> Value { + let Ok(text) = fs::read_to_string(path) else { + return json!({"counters": {}}); + }; + let Ok(mut payload) = serde_json::from_str::(&text) else { + return json!({"counters": {}}); + }; + if !payload.is_object() { + return json!({"counters": {}}); + } + if !payload.get("counters").is_some_and(Value::is_object) { + payload["counters"] = json!({}); + } + payload +} + +fn save_counter_state(path: &Path, state: &Value) -> Option { + let content = match serde_json::to_string_pretty(&sort_json_value(state)) { + Ok(text) => text + "\n", + Err(err) => return Some(err.to_string()), + }; + if let Err(err) = write_atomic(path, &content) { + match fs::write(path, content) { + Ok(()) => None, + Err(_) => Some(err.to_string()), + } + } else { + None + } +} + +fn counter_delta( + counter_state: Option<&mut Value>, + key: &str, + current_value: i64, +) -> (Option, i64) { + let Some(state) = counter_state else { + return (None, current_value); + }; + if !state.get("counters").is_some_and(Value::is_object) { + state["counters"] = json!({}); + } + let counters = state + .get_mut("counters") + .and_then(Value::as_object_mut) + .unwrap(); + let previous = counters.get(key).and_then(|v| match v { + Value::Number(n) => n.as_i64(), + Value::String(s) => s.parse().ok(), + _ => None, + }); + counters.insert(key.to_string(), json!(current_value)); + if previous.is_none() || current_value < previous.unwrap_or(0) { + (previous, 0) + } else { + (previous, current_value - previous.unwrap_or(0)) + } +} + +#[derive(Clone, Copy)] +struct RuntimeThresholds { + sample_limit: i64, + queue_warn_depth: i64, + send_failure_warn_count: i64, +} + +fn check_file_operations_runtime( + report: &mut HealthReport, + client: &Client, + api_base: &str, + buckets: &BTreeMap, + thresholds: RuntimeThresholds, + mut counter_state: Option<&mut Value>, +) { + let now = Utc::now(); + let prefix = "aw-file-operations_"; + let matched = bucket_keys_with_prefix(buckets, prefix); + if matched.is_empty() { + report.add( + "file-operations-runtime", + "warn", + "no file-operations buckets to sample", + json!({"bucket_count": 0}), + ); + return; + } + + let mut sampled = Vec::new(); + let mut latest_operations = Vec::new(); + let mut latest_health = Vec::new(); + let mut warnings = Vec::new(); + let mut read_failed = Vec::new(); + + for bucket_id in &matched { + let url = format!( + "{api_base}/buckets/{bucket_id}/events?limit={}", + thresholds.sample_limit + ); + let events = match http_json(client, &url, 15, 2) { + Ok(Value::Array(items)) => items, + Ok(_) => { + read_failed + .push(json!({"bucket": bucket_id, "error": "events response is not a list"})); + continue; + } + Err(err) => { + read_failed.push(json!({"bucket": bucket_id, "error": err.to_string()})); + continue; + } + }; + let mut operation_counts: BTreeMap = BTreeMap::new(); + let mut latest_health_event: Option = None; + let mut latest_health_ts: Option> = None; + for event in &events { + let data = event + .get("data") + .filter(|v| v.is_object()) + .unwrap_or(&Value::Null); + let ts = event + .get("timestamp") + .and_then(Value::as_str) + .and_then(|text| parse_ts(Some(text))); + let signal_type = value_str(data.get("signalType")); + let operation = value_str(data.get("operation")); + if signal_type == "collector_health" { + if latest_health_event.is_none() + || ts.is_some_and(|ts| latest_health_ts.is_none_or(|current| ts > current)) + { + latest_health_event = Some(event.clone()); + latest_health_ts = ts; + } + continue; + } + if !operation.is_empty() { + *operation_counts.entry(operation.clone()).or_insert(0) += 1; + latest_operations.push(json!({ + "bucket": bucket_id, + "timestamp": event.get("timestamp").cloned().unwrap_or(Value::Null), + "age_seconds": age_seconds(ts, now), + "operation": operation, + "username": value_str(data.get("username")), + "hostname": value_str(data.get("hostname")), + "extension": value_str(data.get("extension")), + "archiveHint": data.get("archiveHint").and_then(Value::as_bool).unwrap_or(false), + "path_tail": path_tail(data.get("path"), 2), + "size": int_or_zero(data.get("size")), + })); + } + } + sampled.push(json!({ + "bucket": bucket_id, + "sampled_events": events.len(), + "operation_counts": operation_counts, + })); + let Some(health_event) = latest_health_event else { + warnings.push(json!({"bucket": bucket_id, "metric": "collector_health", "value": "missing_in_sample"})); + continue; + }; + let health_data = health_event.get("data").unwrap_or(&Value::Null); + let send_failures = int_or_zero(health_data.get("sendFailures")); + let (previous, delta) = counter_delta( + counter_state.as_deref_mut(), + &format!("file-operations:{bucket_id}:sendFailures"), + send_failures, + ); + let health_item = json!({ + "bucket": bucket_id, + "timestamp": health_event.get("timestamp").cloned().unwrap_or(Value::Null), + "age_seconds": age_seconds(latest_health_ts, now), + "queueDepth": int_or_zero(health_data.get("queueDepth")), + "eventsEnqueued": int_or_zero(health_data.get("eventsEnqueued")), + "eventsFlushed": int_or_zero(health_data.get("eventsFlushed")), + "sendFailures": send_failures, + "sendFailuresPrevious": previous, + "sendFailuresDelta": delta, + "username": value_str(health_data.get("username")), + "hostname": value_str(health_data.get("hostname")), + "sessionId": int_or_zero(health_data.get("sessionId")), + }); + if health_item["queueDepth"].as_i64().unwrap_or(0) > thresholds.queue_warn_depth { + warnings.push(json!({"bucket": bucket_id, "metric": "queueDepth", "value": health_item["queueDepth"], "threshold": thresholds.queue_warn_depth})); + } + if thresholds.send_failure_warn_count > 0 && delta >= thresholds.send_failure_warn_count { + warnings.push(json!({ + "bucket": bucket_id, + "metric": "sendFailuresDelta", + "value": delta, + "current": send_failures, + "previous": previous, + "threshold": thresholds.send_failure_warn_count, + })); + } + latest_health.push(health_item); + } + + latest_operations.sort_by_key(|item| Reverse(value_str(item.get("timestamp")))); + let mut status = "ok"; + let mut summary = format!("{} file-operations buckets sampled", matched.len()); + if !read_failed.is_empty() { + status = "warn"; + summary = format!( + "{} file-operations buckets failed to sample", + read_failed.len() + ); + } else if !warnings.is_empty() { + status = "warn"; + summary = "file-operations runtime counters outside expectations".to_string(); + } + + report.add( + "file-operations-runtime", + status, + summary, + json!({ + "bucket_count": matched.len(), + "sample_limit": thresholds.sample_limit, + "sampled": sampled, + "latest_health": latest_health, + "latest_operations": latest_operations.into_iter().take(5).collect::>(), + "warnings": warnings, + "read_failed": read_failed, + "thresholds": {"queueDepth": thresholds.queue_warn_depth, "sendFailures": thresholds.send_failure_warn_count}, + }), + ); +} + +fn check_endpoint_self_test_metrics( + report: &mut HealthReport, + client: &Client, + api_base: &str, + buckets: &BTreeMap, + queue_warn_depth: i64, + send_failure_warn_count: i64, + mut counter_state: Option<&mut Value>, +) { + let now = Utc::now(); + let mut missing = Vec::new(); + let mut latest_self_tests = Vec::new(); + let mut warnings = Vec::new(); + for bucket_id in bucket_keys_with_prefix(buckets, "aw-dlp-endpoint-signals_") { + let url = format!("{api_base}/buckets/{bucket_id}/events?limit=20"); + let events = match http_json(client, &url, 15, 2) { + Ok(Value::Array(items)) => items, + Ok(_) => { + report.add( + format!("endpoint-self-test:{bucket_id}"), + "warn", + "failed to read events: events response is not a list", + json!({"bucket": bucket_id}), + ); + continue; + } + Err(err) => { + report.add( + format!("endpoint-self-test:{bucket_id}"), + "warn", + format!("failed to read events: {err}"), + json!({"bucket": bucket_id}), + ); + continue; + } + }; + let mut latest_event: Option = None; + let mut latest_ts: Option> = None; + for event in &events { + let data = event + .get("data") + .filter(|v| v.is_object()) + .unwrap_or(&Value::Null); + let is_self_test = data.get("signalType").and_then(Value::as_str) == Some("self_test"); + let has_expected = [ + "queueDepth", + "eventsEnqueued", + "eventsFlushed", + "sendFailures", + ] + .iter() + .all(|key| data.get(*key).is_some()); + if !is_self_test || !has_expected { + continue; + } + let ts = event + .get("timestamp") + .and_then(Value::as_str) + .and_then(|text| parse_ts(Some(text))); + if latest_event.is_none() + || ts.is_some_and(|ts| latest_ts.is_none_or(|current| ts > current)) + { + latest_event = Some(event.clone()); + latest_ts = ts; + } + } + let Some(latest_event) = latest_event else { + missing.push(bucket_id); + continue; + }; + let data = latest_event.get("data").unwrap_or(&Value::Null); + let send_failures = int_or_zero(data.get("sendFailures")); + let (previous, delta) = counter_delta( + counter_state.as_deref_mut(), + &format!("endpoint-self-test:{bucket_id}:sendFailures"), + send_failures, + ); + let item = json!({ + "bucket": bucket_id, + "timestamp": latest_event.get("timestamp").cloned().unwrap_or(Value::Null), + "age_seconds": age_seconds(latest_ts, now), + "queueDepth": int_or_zero(data.get("queueDepth")), + "eventsEnqueued": int_or_zero(data.get("eventsEnqueued")), + "eventsFlushed": int_or_zero(data.get("eventsFlushed")), + "sendFailures": send_failures, + "sendFailuresPrevious": previous, + "sendFailuresDelta": delta, + }); + if item["queueDepth"].as_i64().unwrap_or(0) > queue_warn_depth { + warnings.push(json!({"bucket": item["bucket"], "metric": "queueDepth", "value": item["queueDepth"], "threshold": queue_warn_depth})); + } + if send_failure_warn_count > 0 && delta >= send_failure_warn_count { + warnings.push(json!({ + "bucket": item["bucket"], + "metric": "sendFailuresDelta", + "value": delta, + "current": send_failures, + "previous": previous, + "threshold": send_failure_warn_count, + })); + } + latest_self_tests.push(item); + } + let thresholds = + json!({"queueDepth": queue_warn_depth, "sendFailures": send_failure_warn_count}); + if !missing.is_empty() { + report.add( + "endpoint-self-test-metrics", + "warn", + "missing transport metrics in sampled self_test events", + json!({"buckets": missing, "latest_self_tests": latest_self_tests, "thresholds": thresholds}), + ); + } else if !warnings.is_empty() { + report.add( + "endpoint-self-test-metrics", + "warn", + "endpoint transport counters outside thresholds", + json!({"latest_self_tests": latest_self_tests, "warnings": warnings, "thresholds": thresholds}), + ); + } else { + report.add( + "endpoint-self-test-metrics", + "ok", + "self_test transport metrics present", + json!({"latest_self_tests": latest_self_tests, "thresholds": thresholds}), + ); + } +} + +fn check_incident_runtime( + report: &mut HealthReport, + client: &Client, + api_base: &str, + buckets: &BTreeMap, + sample_limit: i64, +) { + let now = Utc::now(); + let prefix = "aw-dlp-incidents_"; + let matched = bucket_keys_with_prefix(buckets, prefix); + if matched.is_empty() { + report.add( + "incident-runtime", + "ok", + "no incident buckets to sample", + json!({"bucket_count": 0}), + ); + return; + } + if sample_limit <= 0 { + let metadata: Vec = matched + .iter() + .map(|bucket_id| { + let ts = latest_bucket_ts( + client, + api_base, + bucket_id, + buckets.get(bucket_id).unwrap_or(&Value::Null), + ); + json!({ + "bucket": bucket_id, + "end": ts.map(|ts| ts.to_rfc3339_opts(SecondsFormat::Secs, true)), + "age_seconds": age_seconds(ts, now), + }) + }) + .collect(); + report.add( + "incident-runtime", + "ok", + "incident event sampling disabled", + json!({"bucket_count": matched.len(), "sample_limit": sample_limit, "metadata": metadata}), + ); + return; + } + + let mut sampled = Vec::new(); + let mut latest_incidents = Vec::new(); + let mut read_failed = Vec::new(); + let mut totals = BTreeMap::from([ + ("sampled_events".to_string(), 0_i64), + ("real_incidents".to_string(), 0_i64), + ("self_tests".to_string(), 0_i64), + ]); + let mut severity_counts: BTreeMap = BTreeMap::new(); + let mut action_counts: BTreeMap = BTreeMap::new(); + let mut rule_counts: HashMap = HashMap::new(); + + for bucket_id in &matched { + let url = format!("{api_base}/buckets/{bucket_id}/events?limit={sample_limit}"); + let events = match http_json(client, &url, 5, 1) { + Ok(Value::Array(items)) => items, + Ok(_) => { + read_failed + .push(json!({"bucket": bucket_id, "error": "events response is not a list"})); + continue; + } + Err(err) => { + read_failed.push(json!({"bucket": bucket_id, "error": err.to_string()})); + continue; + } + }; + let mut bucket_summary = BTreeMap::from([ + ("bucket".to_string(), json!(bucket_id)), + ("sampled_events".to_string(), json!(events.len())), + ("real_incidents".to_string(), json!(0)), + ("self_tests".to_string(), json!(0)), + ]); + *totals.get_mut("sampled_events").unwrap() += events.len() as i64; + for event in &events { + let data = event + .get("data") + .filter(|v| v.is_object()) + .unwrap_or(&Value::Null); + let signal_type = value_str(data.get("signalType")); + let source = value_str(data.get("source")); + let rule_id = value_str(data.get("ruleId")).trim().to_string(); + let rule_id = if rule_id.is_empty() { + value_str(data.get("rule_id")).trim().to_string() + } else { + rule_id + }; + let is_self_test = signal_type == "self_test" + || source == "self-test" + || rule_id.starts_with("selftest-"); + if is_self_test { + *bucket_summary.get_mut("self_tests").unwrap() = + json!(bucket_summary["self_tests"].as_i64().unwrap_or(0) + 1); + *totals.get_mut("self_tests").unwrap() += 1; + continue; + } + let ts = event + .get("timestamp") + .and_then(Value::as_str) + .and_then(|text| parse_ts(Some(text))); + let severity = nonempty_lower(data.get("severity"), "unknown"); + let action = nonempty_lower(data.get("action"), "unknown"); + let rule_key = if rule_id.is_empty() { + "unknown".to_string() + } else { + rule_id.clone() + }; + *severity_counts.entry(severity.clone()).or_insert(0) += 1; + *action_counts.entry(action.clone()).or_insert(0) += 1; + *rule_counts.entry(rule_key).or_insert(0) += 1; + *bucket_summary.get_mut("real_incidents").unwrap() = + json!(bucket_summary["real_incidents"].as_i64().unwrap_or(0) + 1); + *totals.get_mut("real_incidents").unwrap() += 1; + latest_incidents.push(json!({ + "bucket": bucket_id, + "timestamp": event.get("timestamp").cloned().unwrap_or(Value::Null), + "age_seconds": age_seconds(ts, now), + "ruleId": rule_id, + "severity": severity, + "action": action, + "username": value_str(data.get("username")), + "hostname": value_str(data.get("hostname")), + "source": source, + "message_excerpt": text_excerpt(data.get("message"), 120), + })); + } + sampled.push(json!(bucket_summary)); + } + + latest_incidents.sort_by_key(|item| Reverse(value_str(item.get("timestamp")))); + let mut sorted_rules: Vec<(String, i64)> = rule_counts.into_iter().collect(); + sorted_rules.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))); + let rule_counts_top: BTreeMap = sorted_rules.into_iter().take(10).collect(); + let mut status = "ok"; + let mut summary = format!( + "{} real incidents in sampled events", + totals["real_incidents"] + ); + if !read_failed.is_empty() { + status = "warn"; + summary = format!("{} incident buckets failed to sample", read_failed.len()); + } else if totals["real_incidents"] == 0 { + summary = "no real incidents in sampled events".to_string(); + } + report.add( + "incident-runtime", + status, + summary, + json!({ + "bucket_count": matched.len(), + "sample_limit": sample_limit, + "totals": totals, + "sampled": sampled, + "severity_counts": severity_counts, + "action_counts": action_counts, + "rule_counts": rule_counts_top, + "latest_incidents": latest_incidents.into_iter().take(5).collect::>(), + "read_failed": read_failed, + }), + ); +} + +fn nonempty_lower(value: Option<&Value>, default: &str) -> String { + let text = value_str(value).trim().to_ascii_lowercase(); + if text.is_empty() { + default.to_string() + } else { + text + } +} + +fn check_compliance_reports( + report: &mut HealthReport, + report_dir: &Path, + profiles: &[String], + month: &str, +) { + let mut missing = Vec::new(); + let mut present = Vec::new(); + for profile in profiles { + for suffix in ["html", "json"] { + let path = report_dir.join(format!("{profile}-{month}.{suffix}")); + if path.exists() { + present.push(path.to_string_lossy().to_string()); + } else { + missing.push(path.to_string_lossy().to_string()); + } + } + } + if missing.is_empty() { + report.add( + "compliance-reports", + "ok", + "all expected compliance artifacts exist", + json!({"present": present}), + ); + } else { + report.add( + "compliance-reports", + "fail", + "missing expected compliance report artifacts", + json!({"present": present, "missing": missing}), + ); + } +} + +fn build_report(cli: &Cli, client: &Client) -> HealthReport { + let mut report = HealthReport::default(); + let aw_api_base = format!("{}/api/0", cli.aw_server.trim_end_matches('/')); + let counter_state_path = cli.state_dir.join("dlp-health-check-counters.json"); + let mut counter_state = load_counter_state(&counter_state_path); + + check_http_endpoint( + &mut report, + client, + "http:aw", + &format!("{aw_api_base}/info"), + ); + check_http_endpoint( + &mut report, + client, + "http:policy", + &format!("{}/healthz", cli.policy_server.trim_end_matches('/')), + ); + check_http_endpoint( + &mut report, + client, + "http:cases", + &format!("{}/health", cli.case_server.trim_end_matches('/')), + ); + + for unit in [ + "activitywatch-server", + "aw-dlp-policy-engine.service", + "aw-dlp-case-management.service", + "aw-worktime-api.service", + ] { + check_systemd_unit(&mut report, unit, "service"); + } + for unit in [ + "aw-dlp-report-scheduler.timer", + "aw-dlp-syslog-forwarder.timer", + "aw-dlp-webhook-sender.timer", + "aw-dlp-cef-exporter.timer", + "activitywatch-dlp-aggregator.timer", + "aw-dlp-ioc-refresh.timer", + "aw-worktime-ui-bridge.timer", + ] { + check_systemd_unit(&mut report, unit, "timer"); + } + + match http_json(client, &format!("{aw_api_base}/buckets"), 15, 2) { + Ok(Value::Object(map)) => { + let buckets: BTreeMap = map.into_iter().collect(); + report.add( + "aw:buckets-index", + "ok", + "bucket index loaded", + json!({"total": buckets.len()}), + ); + check_endpoint_signal_buckets( + &mut report, + client, + &aw_api_base, + &buckets, + cli.max_age_seconds, + ); + check_file_operations_buckets( + &mut report, + client, + &aw_api_base, + &buckets, + cli.max_age_seconds, + cli.strict_fileops, + ); + check_file_operations_runtime( + &mut report, + client, + &aw_api_base, + &buckets, + RuntimeThresholds { + sample_limit: cli.fileops_sample_limit, + queue_warn_depth: cli.fileops_queue_warn_depth, + send_failure_warn_count: cli.fileops_send_failure_warn_count, + }, + Some(&mut counter_state), + ); + check_incident_buckets( + &mut report, + client, + &aw_api_base, + &buckets, + cli.max_age_seconds * 24, + ); + check_incident_runtime( + &mut report, + client, + &aw_api_base, + &buckets, + cli.incident_sample_limit, + ); + check_endpoint_self_test_metrics( + &mut report, + client, + &aw_api_base, + &buckets, + cli.endpoint_queue_warn_depth, + cli.endpoint_send_failure_warn_count, + Some(&mut counter_state), + ); + } + Ok(_) => report.add( + "aw:buckets-index", + "fail", + "failed to inspect bucket index: bucket list is not a dict", + json!({}), + ), + Err(err) => report.add( + "aw:buckets-index", + "fail", + format!("failed to inspect bucket index: {err}"), + json!({}), + ), + } + + if let Some(state_error) = save_counter_state(&counter_state_path, &counter_state) { + report.add( + "state:counters", + "warn", + format!("failed to save counter baseline: {state_error}"), + json!({"path": counter_state_path}), + ); + } + let month = Utc::now().format("%Y-%m").to_string(); + let profiles: Vec = cli + .profiles + .split(',') + .map(str::trim) + .filter(|item| !item.is_empty()) + .map(str::to_string) + .collect(); + check_compliance_reports(&mut report, &cli.report_dir, &profiles, &month); + report +} + +fn write_atomic(path: &Path, content: &str) -> Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; + } + let mut tmp = tempfile::NamedTempFile::new_in(path.parent().unwrap_or_else(|| Path::new(".")))?; + tmp.write_all(content.as_bytes())?; + tmp.flush()?; + tmp.persist(path) + .map(|_| ()) + .map_err(|err| anyhow!(err.error)) + .with_context(|| format!("persist {}", path.display())) +} + +fn sort_json_value(value: &Value) -> Value { + match value { + Value::Object(map) => { + let sorted: serde_json::Map = map + .iter() + .map(|(key, value)| (key.clone(), sort_json_value(value))) + .collect(); + Value::Object(sorted) + } + Value::Array(items) => Value::Array(items.iter().map(sort_json_value).collect()), + other => other.clone(), + } +} + +fn cli_arg_present(name: &str) -> bool { + std::env::args_os().skip(1).any(|arg| { + let Some(value) = arg.to_str() else { + return false; + }; + value == name + || value + .strip_prefix(name) + .is_some_and(|rest| rest.starts_with('=')) + }) +} + +fn env_string(name: &str) -> Option { + std::env::var(name).ok().filter(|value| !value.is_empty()) +} + +fn env_path(name: &str) -> Option { + env_string(name).map(PathBuf::from) +} + +fn env_i64(name: &str, default: i64) -> i64 { + env_string(name) + .and_then(|value| value.parse::().ok()) + .unwrap_or(default) +} + +fn env_bool(name: &str) -> bool { + env_string(name) + .map(|value| { + matches!( + value.to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "on" + ) + }) + .unwrap_or(false) +} + +fn main() -> Result<()> { + let cli = Cli::parse().apply_env(); + let client = Client::builder() + .no_proxy() + .build() + .context("build HTTP client")?; + let report = build_report(&cli, &client); + let payload = report.payload(); + if cli.json { + println!("{}", serde_json::to_string_pretty(&payload)?); + } else { + println!("{}", report.render_text()); + } + std::process::exit(if payload.ok { 0 } else { 1 }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn path_tail_keeps_last_two_parts() { + assert_eq!( + path_tail(Some(&json!("C:\\Users\\USER1\\Downloads\\report.zip")), 2), + "Downloads/report.zip" + ); + } + + #[test] + fn counter_delta_uses_baseline() { + let mut state = json!({"counters": {}}); + assert_eq!(counter_delta(Some(&mut state), "k", 12), (None, 0)); + assert_eq!(counter_delta(Some(&mut state), "k", 12), (Some(12), 0)); + assert_eq!(counter_delta(Some(&mut state), "k", 13), (Some(12), 1)); + assert_eq!(counter_delta(Some(&mut state), "k", 1), (Some(13), 0)); + } + + #[test] + fn text_excerpt_truncates_like_python() { + let text = json!("one two three"); + assert_eq!(text_excerpt(Some(&text), 20), "one two three"); + let long = json!("abcdef"); + assert_eq!(text_excerpt(Some(&long), 4), "abc…"); + } + + #[test] + fn report_ok_ignores_warnings() { + let mut report = HealthReport::default(); + report.add("a", "ok", "ok", json!({})); + report.add("b", "warn", "warn", json!({})); + let payload = report.payload(); + assert!(payload.ok); + assert_eq!(payload.counts.ok, 1); + assert_eq!(payload.counts.warn, 1); + assert_eq!(payload.counts.fail, 0); + } +} diff --git a/adk-rust/crates/dlp-influx-exporter/Cargo.toml b/adk-rust/crates/dlp-influx-exporter/Cargo.toml new file mode 100644 index 0000000..8f2d9c9 --- /dev/null +++ b/adk-rust/crates/dlp-influx-exporter/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "dlp-influx-exporter" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +publish = false + +[dependencies] +anyhow.workspace = true +chrono.workspace = true +clap.workspace = true +reqwest.workspace = true +serde.workspace = true +serde_json.workspace = true +urlencoding.workspace = true diff --git a/adk-rust/crates/dlp-influx-exporter/src/main.rs b/adk-rust/crates/dlp-influx-exporter/src/main.rs new file mode 100644 index 0000000..aacefc1 --- /dev/null +++ b/adk-rust/crates/dlp-influx-exporter/src/main.rs @@ -0,0 +1,1096 @@ +use std::{thread, time::Duration}; + +use anyhow::{Context, Result, anyhow, bail}; +use chrono::{DateTime, TimeDelta, Utc}; +use clap::Parser; +use reqwest::blocking::Client; +use serde::Serialize; +use serde_json::Value; + +const DEFAULT_AW_API_BASE: &str = "http://127.0.0.1:5600/api/0"; +const DEFAULT_CASE_API_BASE: &str = "http://127.0.0.1:5602/api/0/dlp/cases"; +const DEFAULT_INFLUX_ORG: &str = "proxmox"; +const DEFAULT_INFLUX_BUCKET: &str = "aw_metrics"; +const DEFAULT_HOSTS: &str = "SHARKON2025"; + +#[derive(Debug, Parser)] +#[command(about = "AW DLP InfluxDB exporter")] +struct Cli { + #[arg(long)] + dry_run: bool, + + #[arg(long)] + json: bool, + + #[arg(long, default_value_t = 30)] + timeout_seconds: u64, +} + +#[derive(Debug, Clone)] +struct Config { + aw_api_base: String, + case_api_base: String, + influx_url: String, + influx_org: String, + influx_bucket: String, + influx_token: String, + influx_enabled: bool, + hosts: Vec, + lookback_days: i64, + event_limit: usize, + case_limit: usize, +} + +#[derive(Debug, Serialize)] +struct RunSummary { + ok: bool, + enabled: bool, + dry_run: bool, + hosts: Vec, + lookback_days: i64, + lines: usize, + written: usize, + bucket: String, + error: Option, +} + +#[derive(Debug, Clone)] +enum FieldValue { + Bool(bool), + Int(i64), + String(String), +} + +fn env(name: &str, fallback: &str) -> String { + std::env::var(name) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| fallback.to_string()) +} + +fn env_bool(name: &str, fallback: bool) -> bool { + match std::env::var(name) + .ok() + .map(|value| value.trim().to_ascii_lowercase()) + { + Some(value) if !value.is_empty() => matches!(value.as_str(), "1" | "true" | "yes" | "on"), + _ => fallback, + } +} + +fn env_int(name: &str, fallback: i64) -> i64 { + std::env::var(name) + .ok() + .and_then(|value| value.trim().parse().ok()) + .unwrap_or(fallback) +} + +fn env_usize(name: &str, fallback: usize) -> usize { + std::env::var(name) + .ok() + .and_then(|value| value.trim().parse().ok()) + .unwrap_or(fallback) +} + +fn load_config() -> Config { + Config { + aw_api_base: env("AW_DLP_AW_API_BASE", DEFAULT_AW_API_BASE) + .trim_end_matches('/') + .to_string(), + case_api_base: env("AW_DLP_CASE_API_BASE", DEFAULT_CASE_API_BASE) + .trim_end_matches('/') + .to_string(), + influx_url: env("AW_DLP_INFLUX_URL", "") + .trim_end_matches('/') + .to_string(), + influx_org: env("AW_DLP_INFLUX_ORG", DEFAULT_INFLUX_ORG), + influx_bucket: env("AW_DLP_INFLUX_BUCKET", DEFAULT_INFLUX_BUCKET), + influx_token: env("AW_DLP_INFLUX_TOKEN", ""), + influx_enabled: env_bool("AW_DLP_INFLUX_ENABLED", false), + hosts: env("AW_DLP_INFLUX_HOSTS", DEFAULT_HOSTS) + .split(',') + .map(str::trim) + .filter(|item| !item.is_empty()) + .map(ToOwned::to_owned) + .collect(), + lookback_days: env_int("AW_DLP_INFLUX_LOOKBACK_DAYS", 30), + event_limit: env_usize("AW_DLP_INFLUX_EVENT_LIMIT", 2000), + case_limit: env_usize("AW_DLP_CASE_LIMIT", 500), + } +} + +fn utc_now() -> DateTime { + Utc::now() +} + +fn pts(value: Option<&str>) -> DateTime { + let Some(value) = value.filter(|value| !value.is_empty()) else { + return utc_now(); + }; + let normalized = value.replace('Z', "+00:00"); + DateTime::parse_from_rfc3339(&normalized) + .map(|parsed| parsed.with_timezone(&Utc)) + .unwrap_or_else(|_| utc_now()) +} + +fn escape_tag(value: &str) -> String { + value + .replace('\\', "\\\\") + .replace(' ', "\\ ") + .replace(',', "\\,") + .replace('=', "\\=") +} + +fn timestamp_ns(dt: DateTime) -> i64 { + dt.timestamp_nanos_opt() + .unwrap_or_else(|| dt.timestamp() * 1_000_000_000) +} + +fn line( + measurement: &str, + tags: Vec<(&str, String)>, + fields: Vec<(&str, FieldValue)>, + timestamp_ns: i64, +) -> Option { + let mut tag_items: Vec<(&str, String)> = tags + .into_iter() + .filter(|(_key, value)| !value.is_empty()) + .collect(); + tag_items.sort_by(|left, right| left.0.cmp(right.0)); + let tag_part = tag_items + .iter() + .map(|(key, value)| format!("{key}={}", escape_tag(value))) + .collect::>() + .join(","); + + let field_parts = fields + .into_iter() + .map(|(key, value)| match value { + FieldValue::Bool(value) => format!("{key}={}", if value { "true" } else { "false" }), + FieldValue::Int(value) => format!("{key}={value}i"), + FieldValue::String(value) => { + let text = value.replace('\\', "\\\\").replace('"', "\\\""); + format!("{key}=\"{text}\"") + } + }) + .collect::>(); + if field_parts.is_empty() { + return None; + } + if tag_part.is_empty() { + Some(format!( + "{measurement} {} {timestamp_ns}", + field_parts.join(",") + )) + } else { + Some(format!( + "{measurement},{tag_part} {} {timestamp_ns}", + field_parts.join(",") + )) + } +} + +fn get_json(client: &Client, url: &str) -> Result { + let mut last_error = None; + for attempt in 1..=6 { + match client + .get(url) + .header("Accept", "application/json") + .send() + .and_then(|resp| resp.error_for_status()) + .and_then(|resp| resp.json()) + { + Ok(payload) => return Ok(payload), + Err(err) => { + last_error = Some(err); + if attempt < 6 { + thread::sleep(Duration::from_millis(250 * attempt)); + } + } + } + } + Err(anyhow!( + "GET {url}: {}", + last_error + .map(|err| err.to_string()) + .unwrap_or_else(|| "unknown error".to_string()) + )) +} + +fn format_aw_time(value: DateTime) -> String { + value.to_rfc3339_opts(chrono::SecondsFormat::Secs, true) +} + +fn fetch_bucket_events( + client: &Client, + config: &Config, + bucket_id: &str, + start: DateTime, + end: DateTime, + limit: usize, +) -> Result> { + let query = format!( + "start={}&end={}&limit={}", + urlencoding::encode(&format_aw_time(start)), + urlencoding::encode(&format_aw_time(end)), + limit + ); + let url = format!( + "{}/buckets/{}/events?{}", + config.aw_api_base, + urlencoding::encode(bucket_id), + query + ); + let payload = get_json(client, &url)?; + Ok(payload.as_array().cloned().unwrap_or_default()) +} + +fn fetch_cases(client: &Client, config: &Config, host: &str) -> Result> { + let url = format!( + "{}?host={}&limit={}", + config.case_api_base, + urlencoding::encode(host), + config.case_limit + ); + let payload = get_json(client, &url)?; + Ok(payload.as_array().cloned().unwrap_or_default()) +} + +fn data_object(item: &Value) -> &serde_json::Map { + match item.get("data").and_then(Value::as_object) { + Some(data) => data, + None => empty_object(), + } +} + +fn empty_object() -> &'static serde_json::Map { + static EMPTY: std::sync::OnceLock> = std::sync::OnceLock::new(); + EMPTY.get_or_init(serde_json::Map::new) +} + +fn s(value: Option<&Value>) -> String { + match value { + Some(Value::Null) | None => String::new(), + Some(Value::String(value)) => value.trim().to_string(), + Some(Value::Bool(value)) => value.to_string(), + Some(Value::Number(value)) => value.to_string(), + Some(other) => other.to_string(), + } +} + +fn first_nonempty(values: &[Option<&Value>], default: &str) -> String { + values + .iter() + .map(|value| s(*value)) + .find(|value| !value.is_empty()) + .unwrap_or_else(|| default.to_string()) +} + +fn int_value(value: Option<&Value>) -> i64 { + match value { + Some(Value::Number(value)) => value + .as_i64() + .or_else(|| value.as_u64().map(|v| v as i64)) + .unwrap_or(0), + Some(Value::String(value)) => value.parse().unwrap_or(0), + Some(Value::Bool(value)) => i64::from(*value), + _ => 0, + } +} + +fn bool_value(value: Option<&Value>) -> bool { + match value { + Some(Value::Bool(value)) => *value, + Some(Value::Number(value)) => value.as_i64().unwrap_or(0) != 0, + Some(Value::String(value)) => matches!( + value.to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "on" + ), + _ => false, + } +} + +fn event_id(item: &Value, fallback: String) -> String { + first_nonempty(&[item.get("id")], &fallback) +} + +#[derive(Debug)] +struct NormalizedIncident { + host: String, + signal_type: String, + username: String, + severity: String, + action: String, + message: String, + rule_id: String, + source: String, + document_name: String, + printer_name: String, + incident_status: String, + incident_verdict: String, + regex_matches: usize, + dictionary_matches: usize, + ocr_requested: bool, +} + +fn normalize_incident(event: &Value, default_host: &str) -> NormalizedIncident { + let empty = empty_object(); + let data = event + .get("data") + .and_then(Value::as_object) + .unwrap_or(empty); + let source_event = data + .get("sourceEvent") + .and_then(Value::as_object) + .unwrap_or(empty); + let source_data = source_event + .get("data") + .and_then(Value::as_object) + .unwrap_or(empty); + let nested = data + .get("incident") + .and_then(Value::as_object) + .unwrap_or(empty); + + NormalizedIncident { + host: first_nonempty( + &[ + data.get("hostname"), + data.get("host"), + source_data.get("hostname"), + ], + default_host, + ), + signal_type: first_nonempty( + &[data.get("signalType"), source_data.get("signalType")], + "unknown", + ), + username: first_nonempty( + &[ + data.get("username"), + source_data.get("username"), + source_data.get("owner"), + source_data.get("host"), + ], + "unknown", + ), + severity: first_nonempty(&[data.get("severity"), nested.get("severity")], "unknown"), + action: first_nonempty(&[data.get("action"), nested.get("verdict")], "incident"), + message: first_nonempty( + &[ + data.get("message"), + source_data.get("documentName"), + source_data.get("documentNameOriginal"), + nested.get("comment"), + ], + "", + ), + rule_id: first_nonempty(&[data.get("ruleId")], ""), + source: first_nonempty( + &[ + data.get("source"), + source_data.get("source"), + data.get("sourceBucket"), + ], + "", + ), + document_name: first_nonempty( + &[ + source_data.get("documentName"), + source_data.get("documentNameOriginal"), + ], + "", + ), + printer_name: first_nonempty(&[source_data.get("printerName")], ""), + incident_status: first_nonempty(&[nested.get("status")], ""), + incident_verdict: first_nonempty(&[nested.get("verdict")], ""), + regex_matches: data + .get("regexMatches") + .and_then(Value::as_array) + .map(Vec::len) + .unwrap_or(0), + dictionary_matches: data + .get("dictionaryMatches") + .and_then(Value::as_array) + .map(Vec::len) + .unwrap_or(0), + ocr_requested: bool_value(data.get("ocrRequested")), + } +} + +fn build_endpoint_lines(host: &str, events: &[Value]) -> Vec { + let mut lines = Vec::new(); + for item in events { + let data = data_object(item); + let signal_type = first_nonempty(&[data.get("signalType")], "unknown"); + let ts = timestamp_ns(pts(item.get("timestamp").and_then(Value::as_str))); + let id = event_id(item, format!("{signal_type}-{ts}")); + let username = first_nonempty(&[data.get("username"), data.get("owner")], "unknown"); + if signal_type == "self_test" { + if let Some(line) = line( + "aw_dlp_endpoint_self_test", + vec![ + ("host", first_nonempty(&[data.get("hostname")], host)), + ("event_id", id), + ("username", username), + ( + "policy_mode", + first_nonempty(&[data.get("policyMode")], "unknown"), + ), + ( + "policy_source", + first_nonempty(&[data.get("policySource")], "unknown"), + ), + ], + vec![ + ("count", FieldValue::Int(1)), + ( + "queue_depth", + FieldValue::Int(int_value(data.get("queueDepth"))), + ), + ( + "events_enqueued", + FieldValue::Int(int_value(data.get("eventsEnqueued"))), + ), + ( + "events_flushed", + FieldValue::Int(int_value(data.get("eventsFlushed"))), + ), + ( + "send_failures", + FieldValue::Int(int_value(data.get("sendFailures"))), + ), + ( + "policy_enabled", + FieldValue::Bool(bool_value(data.get("policyEnabled"))), + ), + ], + ts, + ) { + lines.push(line); + } + continue; + } + if let Some(line) = line( + "aw_dlp_signal", + vec![ + ("host", first_nonempty(&[data.get("hostname")], host)), + ("event_id", id), + ("signal_type", signal_type), + ("username", username), + ("source", first_nonempty(&[data.get("source")], "unknown")), + ], + vec![ + ("count", FieldValue::Int(1)), + ( + "document_name", + FieldValue::String(first_nonempty( + &[data.get("documentName"), data.get("documentNameOriginal")], + "", + )), + ), + ( + "printer_name", + FieldValue::String(first_nonempty(&[data.get("printerName")], "")), + ), + ( + "owner", + FieldValue::String(first_nonempty(&[data.get("owner")], "")), + ), + ( + "session_id", + FieldValue::Int(int_value(data.get("sessionId"))), + ), + ], + ts, + ) { + lines.push(line); + } + } + lines +} + +fn build_incident_lines(host: &str, events: &[Value]) -> Vec { + let mut lines = Vec::new(); + for item in events { + let normalized = normalize_incident(item, host); + let ts = timestamp_ns(pts(item.get("timestamp").and_then(Value::as_str))); + let id = event_id(item, format!("incident-{ts}")); + if let Some(line) = line( + "aw_dlp_incident", + vec![ + ("host", normalized.host), + ("event_id", id), + ("signal_type", normalized.signal_type), + ("severity", normalized.severity), + ("action", normalized.action), + ("username", normalized.username), + ("source", normalized.source), + ], + vec![ + ("count", FieldValue::Int(1)), + ("message", FieldValue::String(normalized.message)), + ("rule_id", FieldValue::String(normalized.rule_id)), + ( + "document_name", + FieldValue::String(normalized.document_name), + ), + ("printer_name", FieldValue::String(normalized.printer_name)), + ( + "incident_status", + FieldValue::String(normalized.incident_status), + ), + ( + "incident_verdict", + FieldValue::String(normalized.incident_verdict), + ), + ( + "regex_matches", + FieldValue::Int(normalized.regex_matches as i64), + ), + ( + "dictionary_matches", + FieldValue::Int(normalized.dictionary_matches as i64), + ), + ("ocr_requested", FieldValue::Bool(normalized.ocr_requested)), + ], + ts, + ) { + lines.push(line); + } + } + lines +} + +fn build_review_lines(host: &str, events: &[Value]) -> Vec { + let mut lines = Vec::new(); + for item in events { + let empty = empty_object(); + let data = item.get("data").and_then(Value::as_object).unwrap_or(empty); + let review = data + .get("review") + .and_then(Value::as_object) + .unwrap_or(empty); + let source_data = data + .get("sourceEvent") + .and_then(Value::as_object) + .and_then(|value| value.get("data")) + .and_then(Value::as_object) + .unwrap_or(empty); + let ts = timestamp_ns(pts(item.get("timestamp").and_then(Value::as_str))); + let review_id = first_nonempty(&[review.get("reviewId")], &format!("review-{ts}")); + if let Some(line) = line( + "aw_dlp_review", + vec![ + ( + "host", + first_nonempty(&[data.get("host"), source_data.get("hostname")], host), + ), + ("review_id", review_id), + ( + "verdict", + first_nonempty(&[review.get("verdict")], "unknown"), + ), + ( + "signal_type", + first_nonempty(&[source_data.get("signalType")], "unknown"), + ), + ( + "username", + first_nonempty( + &[source_data.get("username"), source_data.get("owner")], + "unknown", + ), + ), + ], + vec![ + ("count", FieldValue::Int(1)), + ( + "archived", + FieldValue::Bool(bool_value(review.get("archived"))), + ), + ( + "comment", + FieldValue::String(first_nonempty(&[review.get("comment")], "")), + ), + ( + "category", + FieldValue::String(first_nonempty(&[review.get("category")], "")), + ), + ( + "document_name", + FieldValue::String(first_nonempty( + &[ + source_data.get("documentName"), + source_data.get("documentNameOriginal"), + ], + "", + )), + ), + ( + "printer_name", + FieldValue::String(first_nonempty(&[source_data.get("printerName")], "")), + ), + ], + ts, + ) { + lines.push(line); + } + } + lines +} + +fn build_rule_lines(host: &str, events: &[Value]) -> Vec { + let mut lines = Vec::new(); + for item in events { + let empty = empty_object(); + let data = item.get("data").and_then(Value::as_object).unwrap_or(empty); + let match_data = data + .get("match") + .and_then(Value::as_object) + .unwrap_or(empty); + let ts = timestamp_ns(pts(item.get("timestamp").and_then(Value::as_str))); + let rule_id = first_nonempty(&[data.get("ruleId")], &format!("rule-{ts}")); + let enabled = data + .get("enabled") + .is_none_or(|value| bool_value(Some(value))); + if let Some(line) = line( + "aw_dlp_rule", + vec![ + ( + "host", + first_nonempty(&[data.get("host"), match_data.get("hostname")], host), + ), + ("rule_id", rule_id), + ("action", first_nonempty(&[data.get("action")], "unknown")), + ( + "signal_type", + first_nonempty(&[match_data.get("signalType")], "unknown"), + ), + ( + "username", + first_nonempty( + &[match_data.get("username"), match_data.get("owner")], + "unknown", + ), + ), + ( + "enabled", + if enabled { + "true".to_string() + } else { + "false".to_string() + }, + ), + ], + vec![ + ("count", FieldValue::Int(1)), + ( + "category", + FieldValue::String(first_nonempty(&[data.get("category")], "")), + ), + ( + "comment", + FieldValue::String(first_nonempty(&[data.get("comment")], "")), + ), + ( + "document_name", + FieldValue::String(first_nonempty(&[match_data.get("documentName")], "")), + ), + ( + "printer_name", + FieldValue::String(first_nonempty(&[match_data.get("printerName")], "")), + ), + ], + ts, + ) { + lines.push(line); + } + } + lines +} + +fn build_fileops_lines(host: &str, events: &[Value]) -> Vec { + let mut lines = Vec::new(); + for item in events { + let data = data_object(item); + let signal_type = first_nonempty(&[data.get("signalType")], "unknown"); + if signal_type != "collector_health" { + continue; + } + let ts = timestamp_ns(pts(item.get("timestamp").and_then(Value::as_str))); + let id = event_id(item, format!("fileops-{ts}")); + if let Some(line) = line( + "aw_dlp_fileops_health", + vec![ + ("host", first_nonempty(&[data.get("hostname")], host)), + ("event_id", id), + ( + "username", + first_nonempty(&[data.get("username")], "unknown"), + ), + ], + vec![ + ("count", FieldValue::Int(1)), + ( + "queue_depth", + FieldValue::Int(int_value(data.get("queueDepth"))), + ), + ( + "events_enqueued", + FieldValue::Int(int_value(data.get("eventsEnqueued"))), + ), + ( + "events_flushed", + FieldValue::Int(int_value(data.get("eventsFlushed"))), + ), + ( + "send_failures", + FieldValue::Int(int_value(data.get("sendFailures"))), + ), + ( + "session_id", + FieldValue::Int(int_value(data.get("sessionId"))), + ), + ], + ts, + ) { + lines.push(line); + } + } + lines +} + +fn build_case_lines(host: &str, cases: &[Value]) -> Vec { + let mut lines = Vec::new(); + for item in cases { + let empty = empty_object(); + let object = item.as_object().unwrap_or(empty); + let evidence = object + .get("evidence") + .and_then(Value::as_object) + .unwrap_or(empty); + let evidence_items = evidence + .get("items") + .and_then(Value::as_array) + .map(Vec::len) + .unwrap_or(0); + let ts = timestamp_ns(pts(object + .get("updated_at") + .or_else(|| object.get("created_at")) + .and_then(Value::as_str))); + if let Some(line) = line( + "aw_dlp_case", + vec![ + ("host", first_nonempty(&[object.get("host")], host)), + ("case_id", first_nonempty(&[object.get("id")], "")), + ("status", first_nonempty(&[object.get("status")], "unknown")), + ( + "severity", + first_nonempty(&[object.get("severity")], "unknown"), + ), + ( + "assignee", + first_nonempty(&[object.get("assignee")], "unassigned"), + ), + ], + vec![ + ("count", FieldValue::Int(1)), + ( + "title", + FieldValue::String(first_nonempty(&[object.get("title")], "")), + ), + ( + "incident_id", + FieldValue::String(first_nonempty(&[object.get("incident_id")], "")), + ), + ( + "has_forensics", + FieldValue::Bool(!matches!(object.get("forensics"), None | Some(Value::Null))), + ), + ("evidence_items", FieldValue::Int(evidence_items as i64)), + ( + "chain_length", + FieldValue::Int(int_value(evidence.get("chain_length"))), + ), + ], + ts, + ) { + lines.push(line); + } + } + lines +} + +fn build_lines_for_host( + client: &Client, + config: &Config, + host: &str, + start: DateTime, + end: DateTime, +) -> Result> { + let mut lines = Vec::new(); + lines.extend(build_endpoint_lines( + host, + &fetch_bucket_events( + client, + config, + &format!("aw-dlp-endpoint-signals_{host}"), + start, + end, + config.event_limit, + )?, + )); + lines.extend(build_incident_lines( + host, + &fetch_bucket_events( + client, + config, + &format!("aw-dlp-incidents_{host}"), + start, + end, + config.event_limit, + )?, + )); + lines.extend(build_review_lines( + host, + &fetch_bucket_events( + client, + config, + &format!("aw-dlp-review_{host}"), + start, + end, + config.event_limit, + )?, + )); + lines.extend(build_rule_lines( + host, + &fetch_bucket_events( + client, + config, + &format!("aw-dlp-rules_{host}"), + start, + end, + config.event_limit, + )?, + )); + lines.extend(build_fileops_lines( + host, + &fetch_bucket_events( + client, + config, + &format!("aw-file-operations_{host}"), + start, + end, + config.event_limit, + )?, + )); + lines.extend(build_case_lines(host, &fetch_cases(client, config, host)?)); + Ok(lines) +} + +fn write_lines(client: &Client, config: &Config, lines: &[String]) -> Result { + if lines.is_empty() { + return Ok(0); + } + if config.influx_url.is_empty() || config.influx_token.is_empty() { + bail!("InfluxDB destination is not configured"); + } + let url = format!( + "{}/api/v2/write?org={}&bucket={}&precision=ns", + config.influx_url, + urlencoding::encode(&config.influx_org), + urlencoding::encode(&config.influx_bucket) + ); + let payload = format!("{}\n", lines.join("\n")); + client + .post(url) + .header("Authorization", format!("Token {}", config.influx_token)) + .header("Content-Type", "text/plain; charset=utf-8") + .body(payload) + .send() + .and_then(|resp| resp.error_for_status()) + .context("InfluxDB write failed")?; + Ok(lines.len()) +} + +fn run(cli: &Cli) -> Result { + let config = load_config(); + if !config.influx_enabled && !cli.dry_run { + return Ok(RunSummary { + ok: true, + enabled: false, + dry_run: false, + hosts: config.hosts, + lookback_days: config.lookback_days, + lines: 0, + written: 0, + bucket: config.influx_bucket, + error: None, + }); + } + let client = Client::builder() + .timeout(Duration::from_secs(cli.timeout_seconds)) + .no_proxy() + .build() + .context("build HTTP client")?; + let end = utc_now(); + let start = end - TimeDelta::days(config.lookback_days); + let mut lines = Vec::new(); + for host in &config.hosts { + lines.extend(build_lines_for_host(&client, &config, host, start, end)?); + } + let written = if cli.dry_run { + 0 + } else { + write_lines(&client, &config, &lines)? + }; + Ok(RunSummary { + ok: true, + enabled: config.influx_enabled, + dry_run: cli.dry_run, + hosts: config.hosts, + lookback_days: config.lookback_days, + lines: lines.len(), + written, + bucket: config.influx_bucket, + error: None, + }) +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + match run(&cli) { + Ok(summary) => { + if cli.json || cli.dry_run { + println!("{}", serde_json::to_string_pretty(&summary)?); + } else if !summary.enabled { + eprintln!("[aw-dlp-influx-exporter] disabled by AW_DLP_INFLUX_ENABLED"); + } else { + eprintln!( + "[aw-dlp-influx-exporter] wrote {} points to {}", + summary.written, summary.bucket + ); + } + Ok(()) + } + Err(err) => { + if cli.json { + println!( + "{}", + serde_json::to_string_pretty(&RunSummary { + ok: false, + enabled: false, + dry_run: cli.dry_run, + hosts: Vec::new(), + lookback_days: 0, + lines: 0, + written: 0, + bucket: String::new(), + error: Some(err.to_string()), + })? + ); + } + Err(err) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn endpoint_lines_emit_self_test_and_signal() { + let events = vec![ + json!({ + "id": 10, + "timestamp": "2026-05-15T10:00:00Z", + "data": { + "hostname": "SHARKON2025", + "username": "Администратор", + "signalType": "self_test", + "policyMode": "server", + "policySource": "local-fallback", + "queueDepth": 2, + "eventsEnqueued": 100, + "eventsFlushed": 99, + "sendFailures": 1, + "policyEnabled": true + } + }), + json!({ + "id": 11, + "timestamp": "2026-05-15T10:01:00Z", + "data": { + "hostname": "SHARKON2025", + "username": "Администратор", + "signalType": "print_job", + "source": "endpoint-signals-phase2", + "documentName": "Документ.docx", + "printerName": "HP LaserJet" + } + }), + ]; + let lines = build_endpoint_lines("SHARKON2025", &events); + assert!( + lines + .iter() + .any(|line| line.starts_with("aw_dlp_endpoint_self_test,")) + ); + assert!(lines.iter().any(|line| line.starts_with("aw_dlp_signal,"))); + } + + #[test] + fn normalize_incident_handles_nested_source_event() { + let item = json!({ + "timestamp": "2026-05-15T10:02:00Z", + "data": { + "host": "SHARKON2025", + "incident": {"status": "open", "verdict": "incident"}, + "sourceBucket": "aw-dlp-endpoint-signals_SHARKON2025", + "sourceEvent": { + "data": { + "signalType": "print_job", + "hostname": "SHARKON2025", + "username": "Администратор", + "documentName": "Письмо", + "printerName": "HP", + "source": "endpoint-signals-phase2" + } + } + } + }); + let normalized = normalize_incident(&item, "SHARKON2025"); + assert_eq!(normalized.signal_type, "print_job"); + assert_eq!(normalized.username, "Администратор"); + assert_eq!(normalized.action, "incident"); + assert_eq!(normalized.incident_status, "open"); + } + + #[test] + fn case_lines_emit_case_state() { + let cases = vec![json!({ + "id": 28, + "host": "SHARKON2025", + "status": "open", + "severity": "medium", + "assignee": null, + "title": "DLP print_job · Администратор", + "incident_id": "case-1", + "evidence": {"items": [1], "chain_length": 1}, + "forensics": null, + "updated_at": "2026-05-15T10:03:00+00:00" + })]; + let lines = build_case_lines("SHARKON2025", &cases); + assert_eq!(lines.len(), 1); + assert!(lines[0].starts_with("aw_dlp_case,")); + } + + #[test] + fn timestamp_parser_accepts_zulu() { + assert_eq!(pts(Some("2026-05-15T10:00:00Z")).timestamp(), 1_778_839_200); + } +} diff --git a/adk-rust/crates/dlp-policy-engine/Cargo.toml b/adk-rust/crates/dlp-policy-engine/Cargo.toml new file mode 100644 index 0000000..e3241ed --- /dev/null +++ b/adk-rust/crates/dlp-policy-engine/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "dlp-policy-engine" +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_json.workspace = true +sha2.workspace = true +tiny_http.workspace = true +urlencoding.workspace = true diff --git a/adk-rust/crates/dlp-policy-engine/src/main.rs b/adk-rust/crates/dlp-policy-engine/src/main.rs new file mode 100644 index 0000000..c442b88 --- /dev/null +++ b/adk-rust/crates/dlp-policy-engine/src/main.rs @@ -0,0 +1,1089 @@ +use std::collections::{BTreeMap, HashMap}; +use std::fs; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; + +use anyhow::{Context, Result, anyhow, bail}; +use chrono::{SecondsFormat, Utc}; +use clap::Parser; +use rusqlite::{Connection, OptionalExtension, params}; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; +use tiny_http::{Header, Method, Request, Response, Server, StatusCode}; +use urlencoding::decode; + +const APP_NAME: &str = "aw-dlp-policy-engine"; +const APP_VERSION: &str = "0.1.0"; + +#[derive(Debug, Parser)] +#[command(about = "AW DLP Policy Engine")] +struct Cli { + #[arg(long, default_value = "127.0.0.1")] + bind_host: String, + + #[arg(long, default_value_t = 5601)] + port: u16, + + #[arg( + long, + default_value = "/var/lib/activitywatch/dlp-policy-engine.sqlite" + )] + db_path: PathBuf, +} + +#[derive(Clone)] +struct AppState { + db_path: PathBuf, + agents: Arc>>, +} + +fn main() { + if let Err(err) = run() { + eprintln!("{err:#}"); + std::process::exit(1); + } +} + +fn run() -> Result<()> { + let cli = apply_env(Cli::parse()); + let storage = PolicyStorage::new(cli.db_path.clone())?; + storage.init_schema()?; + let state = AppState { + db_path: cli.db_path, + agents: Arc::new(Mutex::new(HashMap::new())), + }; + let address = format!("{}:{}", cli.bind_host, cli.port); + let server = Server::http(&address).map_err(|err| anyhow!("bind {address}: {err}"))?; + eprintln!("{APP_NAME} {APP_VERSION} listening on {address}"); + for request in server.incoming_requests() { + if let Err(err) = handle_request(&state, request) { + eprintln!("request error: {err:#}"); + } + } + Ok(()) +} + +fn apply_env(mut cli: Cli) -> Cli { + if !cli_arg_present("--bind-host") { + if let Ok(value) = std::env::var("AW_DLP_POLICY_ENGINE_BIND_HOST") { + if !value.is_empty() { + cli.bind_host = value; + } + } + } + if !cli_arg_present("--port") { + if let Ok(value) = std::env::var("AW_DLP_POLICY_ENGINE_PORT") { + if let Ok(port) = value.parse() { + cli.port = port; + } + } + } + if !cli_arg_present("--db-path") { + if let Ok(value) = std::env::var("AW_DLP_POLICY_ENGINE_DB_PATH") { + if !value.is_empty() { + cli.db_path = PathBuf::from(value); + } + } + } + cli +} + +fn cli_arg_present(flag: &str) -> bool { + std::env::args().any(|arg| arg == flag || arg.starts_with(&format!("{flag}="))) +} + +fn handle_request(state: &AppState, mut request: Request) -> Result<()> { + let method = request.method().clone(); + let url = request.url().to_string(); + let (path, query) = split_url(&url); + let segments = path_segments(&path); + let storage = PolicyStorage::new(state.db_path.clone())?; + + let response = match (method, segments.as_slice()) { + (Method::Get, ["healthz"]) => json_response( + StatusCode(200), + json!({ + "status": "ok", + "service": APP_NAME, + "db_path": state.db_path.to_string_lossy(), + "db_exists": state.db_path.exists().to_string(), + }), + ), + (Method::Get, ["api", "0", "dlp", "policies"]) => { + json_response(StatusCode(200), json!({"items": storage.list_policies()?})) + } + (Method::Post, ["api", "0", "dlp", "policies"]) => { + let payload = read_json_body(&mut request)?; + match storage.create_policy(&payload) { + Ok(item) => json_response(StatusCode(201), json!({"item": item})), + Err(err) => json_response(StatusCode(400), json!({"detail": err.to_string()})), + } + } + (Method::Get, ["api", "0", "dlp", "policies", "active"]) => { + match storage.get_active_policy()? { + Some(item) => json_response(StatusCode(200), build_policy_bundle(&item)), + None => json_response( + StatusCode(404), + json!({"detail": "no active policy configured"}), + ), + } + } + (Method::Get, ["api", "0", "dlp", "policies", "active", "version"]) => { + match storage.get_active_policy()? { + Some(item) => json_response( + StatusCode(200), + json!({ + "active": true, + "policyId": item.get("id").cloned().unwrap_or(Value::Null), + "version": item.get("current_version").cloned().unwrap_or(Value::Null), + "checksum": item.get("checksum").cloned().unwrap_or(Value::Null), + "updatedAtUtc": item.get("updated_at").cloned().unwrap_or(Value::Null), + }), + ), + None => json_response( + StatusCode(404), + json!({"detail": "no active policy configured"}), + ), + } + } + ( + Method::Post, + [ + "api", + "0", + "dlp", + "policies", + "agents", + agent_id, + "heartbeat", + ], + ) => { + let payload = read_json_body(&mut request)?; + let agent = agent_heartbeat(agent_id, &payload); + state + .agents + .lock() + .map_err(|_| anyhow!("agent state lock poisoned"))? + .insert(agent_id.to_string(), agent.clone()); + json_response(StatusCode(200), json!({"ok": true, "agent": agent})) + } + (Method::Get, ["api", "0", "dlp", "policies", "agents", agent_id, "desired"]) => { + match storage.get_active_policy()? { + Some(item) => { + let current = state + .agents + .lock() + .map_err(|_| anyhow!("agent state lock poisoned"))? + .get(*agent_id) + .cloned() + .unwrap_or_else(|| json!({})); + json_response(StatusCode(200), desired_policy(agent_id, ¤t, &item)) + } + None => json_response( + StatusCode(404), + json!({"detail": "no active policy configured"}), + ), + } + } + (Method::Post, ["api", "0", "dlp", "policies", "rollback"]) => { + let payload = read_json_body(&mut request)?; + match storage.rollback_active_policy(actor_from(&payload))? { + Some(item) => json_response(StatusCode(200), json!({"item": item})), + None => json_response( + StatusCode(404), + json!({"detail": "no active policy configured"}), + ), + } + } + (Method::Get, ["api", "0", "dlp", "policies", "audit"]) => { + let limit = query_limit(&query); + json_response( + StatusCode(200), + json!({"items": storage.list_audit(None, limit)?}), + ) + } + (Method::Get, ["api", "0", "dlp", "policies", policy_id]) => { + match parse_id(policy_id).and_then(|id| storage.get_policy(id))? { + Some(item) => json_response(StatusCode(200), json!({"item": item})), + None => json_response(StatusCode(404), json!({"detail": "policy not found"})), + } + } + (Method::Put, ["api", "0", "dlp", "policies", policy_id]) => { + let payload = read_json_body(&mut request)?; + let policy_id = parse_id(policy_id)?; + match storage.update_policy(policy_id, &payload) { + Ok(Some(item)) => json_response(StatusCode(200), json!({"item": item})), + Ok(None) => json_response(StatusCode(404), json!({"detail": "policy not found"})), + Err(err) => json_response(StatusCode(400), json!({"detail": err.to_string()})), + } + } + (Method::Post, ["api", "0", "dlp", "policies", policy_id, "activate"]) => { + let payload = read_json_body(&mut request)?; + let policy_id = parse_id(policy_id)?; + match storage.activate_policy(policy_id, actor_from(&payload)) { + Ok(Some(item)) => json_response(StatusCode(200), json!({"item": item})), + Ok(None) => json_response(StatusCode(404), json!({"detail": "policy not found"})), + Err(err) => json_response(StatusCode(400), json!({"detail": err.to_string()})), + } + } + (Method::Post, ["api", "0", "dlp", "policies", policy_id, action]) + if matches!(*action, "submit" | "approve" | "draft") => + { + let payload = read_json_body(&mut request)?; + let policy_id = parse_id(policy_id)?; + let status = match *action { + "submit" => "pending_approval", + "approve" => "approved", + "draft" => "draft", + _ => unreachable!(), + }; + match storage.set_policy_status( + policy_id, + status, + actor_from(&payload), + optional_string(payload.get("comment")), + ) { + Ok(Some(item)) => json_response(StatusCode(200), json!({"item": item})), + Ok(None) => json_response(StatusCode(404), json!({"detail": "policy not found"})), + Err(err) => json_response(StatusCode(400), json!({"detail": err.to_string()})), + } + } + (Method::Delete, ["api", "0", "dlp", "policies", policy_id]) => { + let policy_id = parse_id(policy_id)?; + match storage.delete_policy(policy_id) { + Ok(true) => json_response(StatusCode(200), json!({"deleted": true})), + Ok(false) => json_response(StatusCode(404), json!({"detail": "policy not found"})), + Err(err) => json_response(StatusCode(409), json!({"detail": err.to_string()})), + } + } + (Method::Get, ["api", "0", "dlp", "policies", policy_id, "audit"]) => { + let policy_id = parse_id(policy_id)?; + if storage.get_policy(policy_id)?.is_none() { + json_response(StatusCode(404), json!({"detail": "policy not found"})) + } else { + let limit = query_limit(&query); + json_response( + StatusCode(200), + json!({"items": storage.list_audit(Some(policy_id), limit)?}), + ) + } + } + _ => json_response(StatusCode(404), json!({"detail": "not found"})), + }; + request.respond(response).context("send HTTP response") +} + +struct PolicyStorage { + db_path: PathBuf, +} + +impl PolicyStorage { + fn new(db_path: PathBuf) -> Result { + if let Some(parent) = db_path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("create db parent {}", parent.display()))?; + } + Ok(Self { db_path }) + } + + fn connect(&self) -> Result { + let conn = Connection::open(&self.db_path) + .with_context(|| format!("open sqlite {}", self.db_path.display()))?; + conn.pragma_update(None, "journal_mode", "WAL")?; + Ok(conn) + } + + fn init_schema(&self) -> Result<()> { + let conn = self.connect()?; + conn.execute_batch( + r#" + CREATE TABLE IF NOT EXISTS policies ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + description TEXT, + status TEXT NOT NULL DEFAULT 'draft', + is_active INTEGER NOT NULL DEFAULT 0, + current_version INTEGER NOT NULL DEFAULT 1, + checksum TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS policy_versions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + policy_id INTEGER NOT NULL, + version INTEGER NOT NULL, + policy_json TEXT NOT NULL, + checksum TEXT NOT NULL, + created_at TEXT NOT NULL, + created_by TEXT, + rollback_of_version INTEGER, + FOREIGN KEY(policy_id) REFERENCES policies(id), + UNIQUE(policy_id, version) + ); + + CREATE TABLE IF NOT EXISTS policy_audit ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + policy_id INTEGER, + action TEXT NOT NULL, + actor TEXT, + comment TEXT, + details_json TEXT, + created_at TEXT NOT NULL, + FOREIGN KEY(policy_id) REFERENCES policies(id) + ); + + CREATE INDEX IF NOT EXISTS idx_policies_active ON policies(is_active); + CREATE INDEX IF NOT EXISTS idx_policy_versions_policy ON policy_versions(policy_id, version DESC); + CREATE INDEX IF NOT EXISTS idx_policy_audit_policy ON policy_audit(policy_id, id DESC); + "#, + )?; + let has_status = { + let mut stmt = conn.prepare("PRAGMA table_info(policies)")?; + let rows = stmt.query_map([], |row| row.get::<_, String>(1))?; + let mut found = false; + for row in rows { + if row? == "status" { + found = true; + break; + } + } + found + }; + if !has_status { + conn.execute( + "ALTER TABLE policies ADD COLUMN status TEXT NOT NULL DEFAULT 'draft'", + [], + )?; + } + Ok(()) + } + + fn list_policies(&self) -> Result> { + let conn = self.connect()?; + let mut stmt = conn.prepare( + r#" + SELECT id, name, description, status, is_active, current_version, checksum, created_at, updated_at + FROM policies + ORDER BY is_active DESC, updated_at DESC, id DESC + "#, + )?; + let rows = stmt.query_map([], policy_summary_from_row)?; + rows.collect::, _>>() + .map_err(Into::into) + } + + fn get_policy(&self, policy_id: i64) -> Result> { + let conn = self.connect()?; + let policy = conn + .query_row( + r#" + SELECT id, name, description, status, is_active, current_version, checksum, created_at, updated_at + FROM policies + WHERE id = ? + "#, + [policy_id], + policy_summary_from_row, + ) + .optional()?; + let Some(mut policy) = policy else { + return Ok(None); + }; + let current_version = policy + .get("current_version") + .and_then(Value::as_i64) + .ok_or_else(|| anyhow!("policy current_version missing"))?; + let version = conn + .query_row( + r#" + SELECT version, policy_json, checksum, created_at, created_by + FROM policy_versions + WHERE policy_id = ? AND version = ? + "#, + params![policy_id, current_version], + |row| { + Ok(( + row.get::<_, String>(1)?, + row.get::<_, String>(3)?, + row.get::<_, Option>(4)?, + )) + }, + ) + .optional()?; + let Some((policy_json, version_created_at, version_created_by)) = version else { + return Ok(None); + }; + let policy_doc: Value = serde_json::from_str(&policy_json).context("decode policy_json")?; + let obj = policy + .as_object_mut() + .ok_or_else(|| anyhow!("policy row is not object"))?; + obj.insert("policy".to_string(), policy_doc); + obj.insert( + "version_created_at".to_string(), + Value::String(version_created_at), + ); + obj.insert( + "version_created_by".to_string(), + version_created_by.map(Value::String).unwrap_or(Value::Null), + ); + Ok(Some(policy)) + } + + fn get_active_policy(&self) -> Result> { + let conn = self.connect()?; + let id = conn + .query_row( + "SELECT id FROM policies WHERE is_active = 1 ORDER BY updated_at DESC LIMIT 1", + [], + |row| row.get::<_, i64>(0), + ) + .optional()?; + match id { + Some(id) => self.get_policy(id), + None => Ok(None), + } + } + + fn create_policy(&self, payload: &Value) -> Result { + let name = required_string(payload.get("name"), "name")?; + validate_name(&name)?; + let description = optional_string(payload.get("description")); + validate_description(description.as_deref())?; + let policy = payload + .get("policy") + .cloned() + .ok_or_else(|| anyhow!("policy is required"))?; + let activate = payload + .get("activate") + .and_then(Value::as_bool) + .unwrap_or(false); + let actor = actor_from(payload); + let checksum = checksum_policy(&policy); + let policy_json = canonical_json(&policy)?; + let now = utc_now(); + let status = if activate { "deployed" } else { "draft" }; + let conn = self.connect()?; + let tx = conn.unchecked_transaction()?; + if activate { + tx.execute("UPDATE policies SET is_active = 0", [])?; + } + tx.execute( + r#" + INSERT INTO policies(name, description, status, is_active, current_version, checksum, created_at, updated_at) + VALUES(?, ?, ?, ?, 1, ?, ?, ?) + "#, + params![ + name, + description, + status, + if activate { 1 } else { 0 }, + checksum, + now, + now + ], + )?; + let policy_id = tx.last_insert_rowid(); + tx.execute( + r#" + INSERT INTO policy_versions(policy_id, version, policy_json, checksum, created_at, created_by, rollback_of_version) + VALUES(?, 1, ?, ?, ?, ?, NULL) + "#, + params![policy_id, policy_json, checksum, now, actor], + )?; + audit( + &tx, + Some(policy_id), + "create", + actor.as_deref(), + None, + Some(json!({"activate": activate, "status": status})), + )?; + tx.commit()?; + self.get_policy(policy_id)? + .ok_or_else(|| anyhow!("created policy not found")) + } + + fn update_policy(&self, policy_id: i64, payload: &Value) -> Result> { + let current = match self.get_policy(policy_id)? { + Some(item) => item, + None => return Ok(None), + }; + let name = match payload.get("name") { + Some(Value::Null) | None => current + .get("name") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + Some(value) => { + let name = required_string(Some(value), "name")?; + validate_name(&name)?; + name + } + }; + let description = match payload.get("description") { + Some(value) => optional_string(Some(value)), + None => optional_string(current.get("description")), + }; + validate_description(description.as_deref())?; + let activate = payload + .get("activate") + .and_then(Value::as_bool) + .unwrap_or(false); + let actor = actor_from(payload); + let mut new_version = current + .get("current_version") + .and_then(Value::as_i64) + .ok_or_else(|| anyhow!("current_version missing"))?; + let mut new_checksum = required_string(current.get("checksum"), "checksum")?; + let mut new_status = current + .get("status") + .and_then(Value::as_str) + .unwrap_or("draft") + .to_string(); + let conn = self.connect()?; + let tx = conn.unchecked_transaction()?; + if let Some(policy) = payload.get("policy").filter(|value| !value.is_null()) { + new_version += 1; + new_checksum = checksum_policy(policy); + new_status = "draft".to_string(); + tx.execute( + r#" + INSERT INTO policy_versions(policy_id, version, policy_json, checksum, created_at, created_by, rollback_of_version) + VALUES(?, ?, ?, ?, ?, ?, NULL) + "#, + params![ + policy_id, + new_version, + canonical_json(policy)?, + new_checksum, + utc_now(), + actor + ], + )?; + } + if activate { + tx.execute("UPDATE policies SET is_active = 0", [])?; + new_status = "deployed".to_string(); + } + tx.execute( + r#" + UPDATE policies + SET name = ?, description = ?, status = ?, is_active = ?, current_version = ?, checksum = ?, updated_at = ? + WHERE id = ? + "#, + params![ + name, + description, + new_status, + if activate { + 1 + } else { + current + .get("is_active") + .and_then(Value::as_i64) + .unwrap_or(0) + }, + new_version, + new_checksum, + utc_now(), + policy_id, + ], + )?; + audit( + &tx, + Some(policy_id), + "update", + actor.as_deref(), + None, + Some(json!({"activate": activate, "status": new_status})), + )?; + tx.commit()?; + self.get_policy(policy_id) + } + + fn activate_policy(&self, policy_id: i64, actor: Option) -> Result> { + let current = match self.get_policy(policy_id)? { + Some(item) => item, + None => return Ok(None), + }; + if current.get("status").and_then(Value::as_str) != Some("approved") { + bail!("policy must be approved before deploy"); + } + let conn = self.connect()?; + let tx = conn.unchecked_transaction()?; + tx.execute("UPDATE policies SET is_active = 0", [])?; + tx.execute( + "UPDATE policies SET status = 'deployed', is_active = 1, updated_at = ? WHERE id = ?", + params![utc_now(), policy_id], + )?; + audit(&tx, Some(policy_id), "deploy", actor.as_deref(), None, None)?; + tx.commit()?; + self.get_policy(policy_id) + } + + fn rollback_active_policy(&self, actor: Option) -> Result> { + let active = match self.get_active_policy()? { + Some(item) => item, + None => return Ok(None), + }; + let policy_id = active + .get("id") + .and_then(Value::as_i64) + .ok_or_else(|| anyhow!("active policy id missing"))?; + let current_version = active + .get("current_version") + .and_then(Value::as_i64) + .ok_or_else(|| anyhow!("active current_version missing"))?; + let conn = self.connect()?; + let rows = { + let mut stmt = conn.prepare( + r#" + SELECT version, policy_json + FROM policy_versions + WHERE policy_id = ? + ORDER BY version DESC + LIMIT 2 + "#, + )?; + stmt.query_map([policy_id], |row| { + Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)) + })? + .collect::, _>>()? + }; + if rows.len() < 2 { + return Ok(Some(active)); + } + let previous_version = rows[1].0; + let previous_policy: Value = serde_json::from_str(&rows[1].1)?; + let rollback_version = current_version + 1; + let rollback_checksum = checksum_policy(&previous_policy); + let now = utc_now(); + let tx = conn.unchecked_transaction()?; + tx.execute( + r#" + INSERT INTO policy_versions(policy_id, version, policy_json, checksum, created_at, created_by, rollback_of_version) + VALUES(?, ?, ?, ?, ?, ?, ?) + "#, + params![ + policy_id, + rollback_version, + canonical_json(&previous_policy)?, + rollback_checksum, + now, + actor, + previous_version + ], + )?; + tx.execute( + r#" + UPDATE policies + SET status = 'draft', current_version = ?, checksum = ?, updated_at = ? + WHERE id = ? + "#, + params![rollback_version, rollback_checksum, now, policy_id], + )?; + audit( + &tx, + Some(policy_id), + "rollback", + actor.as_deref(), + None, + Some(json!({"rollback_to": previous_version})), + )?; + tx.commit()?; + self.get_policy(policy_id) + } + + fn delete_policy(&self, policy_id: i64) -> Result { + let current = match self.get_policy(policy_id)? { + Some(item) => item, + None => return Ok(false), + }; + if current + .get("is_active") + .and_then(Value::as_i64) + .unwrap_or(0) + != 0 + { + bail!("cannot delete active policy"); + } + let conn = self.connect()?; + let tx = conn.unchecked_transaction()?; + audit(&tx, Some(policy_id), "delete", None, None, None)?; + tx.execute( + "DELETE FROM policy_versions WHERE policy_id = ?", + [policy_id], + )?; + tx.execute("DELETE FROM policies WHERE id = ?", [policy_id])?; + tx.commit()?; + Ok(true) + } + + fn set_policy_status( + &self, + policy_id: i64, + status: &str, + actor: Option, + comment: Option, + ) -> Result> { + if !matches!( + status, + "draft" | "pending_approval" | "approved" | "deployed" + ) { + bail!("unsupported status: {status}"); + } + if self.get_policy(policy_id)?.is_none() { + return Ok(None); + } + validate_description(comment.as_deref())?; + let conn = self.connect()?; + let tx = conn.unchecked_transaction()?; + tx.execute( + "UPDATE policies SET status = ?, updated_at = ? WHERE id = ?", + params![status, utc_now(), policy_id], + )?; + audit( + &tx, + Some(policy_id), + "status_change", + actor.as_deref(), + comment.as_deref(), + Some(json!({"status": status})), + )?; + tx.commit()?; + self.get_policy(policy_id) + } + + fn list_audit(&self, policy_id: Option, limit: i64) -> Result> { + let conn = self.connect()?; + let limit = limit.clamp(1, 1000); + let mut items = Vec::new(); + if let Some(policy_id) = policy_id { + let mut stmt = conn.prepare( + r#" + SELECT id, policy_id, action, actor, comment, details_json, created_at + FROM policy_audit + WHERE policy_id = ? + ORDER BY id DESC + LIMIT ? + "#, + )?; + let rows = stmt.query_map(params![policy_id, limit], audit_from_row)?; + for row in rows { + items.push(row?); + } + } else { + let mut stmt = conn.prepare( + r#" + SELECT id, policy_id, action, actor, comment, details_json, created_at + FROM policy_audit + ORDER BY id DESC + LIMIT ? + "#, + )?; + let rows = stmt.query_map([limit], audit_from_row)?; + for row in rows { + items.push(row?); + } + } + Ok(items) + } +} + +fn audit( + conn: &Connection, + policy_id: Option, + action: &str, + actor: Option<&str>, + comment: Option<&str>, + details: Option, +) -> Result<()> { + let details_json = details + .as_ref() + .map(canonical_json) + .transpose() + .context("serialize audit details")?; + conn.execute( + r#" + INSERT INTO policy_audit(policy_id, action, actor, comment, details_json, created_at) + VALUES(?, ?, ?, ?, ?, ?) + "#, + params![policy_id, action, actor, comment, details_json, utc_now()], + )?; + Ok(()) +} + +fn policy_summary_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(json!({ + "id": row.get::<_, i64>(0)?, + "name": row.get::<_, String>(1)?, + "description": row.get::<_, Option>(2)?, + "status": row.get::<_, String>(3)?, + "is_active": row.get::<_, i64>(4)?, + "current_version": row.get::<_, i64>(5)?, + "checksum": row.get::<_, String>(6)?, + "created_at": row.get::<_, String>(7)?, + "updated_at": row.get::<_, String>(8)?, + })) +} + +fn audit_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + let details_json: Option = row.get(5)?; + let details = details_json + .as_deref() + .and_then(|text| serde_json::from_str(text).ok()) + .unwrap_or(Value::Null); + Ok(json!({ + "id": row.get::<_, i64>(0)?, + "policy_id": row.get::<_, Option>(1)?, + "action": row.get::<_, String>(2)?, + "actor": row.get::<_, Option>(3)?, + "comment": row.get::<_, Option>(4)?, + "created_at": row.get::<_, String>(6)?, + "details": details, + })) +} + +fn build_policy_bundle(record: &Value) -> Value { + json!({ + "active": true, + "policyId": record.get("id").cloned().unwrap_or(Value::Null), + "name": record.get("name").cloned().unwrap_or(Value::Null), + "version": record.get("current_version").cloned().unwrap_or(Value::Null), + "checksum": record.get("checksum").cloned().unwrap_or(Value::Null), + "updatedAtUtc": record.get("updated_at").cloned().unwrap_or(Value::Null), + "policy": record.get("policy").cloned().unwrap_or(Value::Null), + }) +} + +fn desired_policy(agent_id: &str, current: &Value, active: &Value) -> Value { + let current_version = current.get("version").cloned().unwrap_or(Value::Null); + let current_checksum = current.get("checksum").cloned().unwrap_or(Value::Null); + let desired_version = active + .get("current_version") + .cloned() + .unwrap_or(Value::Null); + let desired_checksum = active.get("checksum").cloned().unwrap_or(Value::Null); + let refresh_now = value_to_string(¤t_version) != value_to_string(&desired_version) + || value_to_string(¤t_checksum) != value_to_string(&desired_checksum); + json!({ + "agentId": agent_id, + "refreshNow": refresh_now, + "reason": if refresh_now { "mismatch" } else { "up-to-date" }, + "current": { + "version": current_version, + "checksum": current_checksum, + }, + "desired": { + "policyId": active.get("id").cloned().unwrap_or(Value::Null), + "version": desired_version, + "checksum": desired_checksum, + "updatedAtUtc": active.get("updated_at").cloned().unwrap_or(Value::Null), + }, + }) +} + +fn agent_heartbeat(agent_id: &str, payload: &Value) -> Value { + json!({ + "agentId": agent_id, + "hostname": payload.get("hostname").and_then(Value::as_str).unwrap_or(agent_id), + "version": payload.get("version").cloned().unwrap_or(Value::Null), + "checksum": payload.get("checksum").cloned().unwrap_or(Value::Null), + "updatedAtUtc": payload.get("updatedAtUtc").cloned().unwrap_or(Value::Null), + }) +} + +fn split_url(url: &str) -> (String, BTreeMap) { + let (path, query) = url.split_once('?').unwrap_or((url, "")); + let mut params = BTreeMap::new(); + for pair in query.split('&').filter(|item| !item.is_empty()) { + let (key, value) = pair.split_once('=').unwrap_or((pair, "")); + params.insert(url_decode(key), url_decode(value)); + } + (path.to_string(), params) +} + +fn path_segments(path: &str) -> Vec<&str> { + path.trim_matches('/') + .split('/') + .filter(|segment| !segment.is_empty()) + .collect() +} + +fn query_limit(query: &BTreeMap) -> i64 { + query + .get("limit") + .and_then(|value| value.parse().ok()) + .unwrap_or(200) + .clamp(1, 1000) +} + +fn read_json_body(request: &mut Request) -> Result { + let mut body = String::new(); + request + .as_reader() + .read_to_string(&mut body) + .context("read request body")?; + if body.trim().is_empty() { + Ok(json!({})) + } else { + serde_json::from_str(&body).context("decode JSON body") + } +} + +fn json_response(status: StatusCode, payload: Value) -> Response>> { + let body = + serde_json::to_vec_pretty(&payload).unwrap_or_else(|_| b"{\"detail\":\"json\"}".to_vec()); + let mut response = Response::from_data(body).with_status_code(status); + response.add_header( + Header::from_bytes( + &b"Content-Type"[..], + &b"application/json; charset=utf-8"[..], + ) + .expect("valid header"), + ); + response +} + +fn parse_id(value: &str) -> Result { + value + .parse::() + .with_context(|| format!("invalid policy id: {value}")) +} + +fn actor_from(payload: &Value) -> Option { + match payload.get("actor") { + None => Some("api".to_string()), + Some(Value::Null) => None, + Some(value) => value.as_str().map(ToOwned::to_owned), + } +} + +fn required_string(value: Option<&Value>, field: &str) -> Result { + value + .and_then(Value::as_str) + .map(ToOwned::to_owned) + .ok_or_else(|| anyhow!("{field} is required")) +} + +fn optional_string(value: Option<&Value>) -> Option { + match value { + Some(Value::String(text)) => Some(text.clone()), + _ => None, + } +} + +fn validate_name(value: &str) -> Result<()> { + if value.is_empty() || value.chars().count() > 128 { + bail!("name length must be 1..128"); + } + Ok(()) +} + +fn validate_description(value: Option<&str>) -> Result<()> { + if value.is_some_and(|text| text.chars().count() > 2048) { + bail!("description/comment length must be <= 2048"); + } + Ok(()) +} + +fn utc_now() -> String { + Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true) +} + +fn checksum_policy(policy: &Value) -> String { + let mut hasher = Sha256::new(); + hasher.update( + canonical_json(policy) + .expect("policy JSON serializable") + .as_bytes(), + ); + format!("{:x}", hasher.finalize()) +} + +fn canonical_json(value: &Value) -> Result { + match value { + Value::Null => Ok("null".to_string()), + Value::Bool(value) => Ok(value.to_string()), + Value::Number(value) => Ok(value.to_string()), + Value::String(value) => serde_json::to_string(value).map_err(Into::into), + Value::Array(items) => { + let mut out = String::from("["); + for (idx, item) in items.iter().enumerate() { + if idx > 0 { + out.push(','); + } + out.push_str(&canonical_json(item)?); + } + out.push(']'); + Ok(out) + } + Value::Object(map) => { + let mut out = String::from("{"); + let mut first = true; + let mut sorted = BTreeMap::new(); + for (key, value) in map { + sorted.insert(key, value); + } + for (key, value) in sorted { + if !first { + out.push(','); + } + first = false; + out.push_str(&serde_json::to_string(key)?); + out.push(':'); + out.push_str(&canonical_json(value)?); + } + out.push('}'); + Ok(out) + } + } +} + +fn value_to_string(value: &Value) -> String { + match value { + Value::Null => "None".to_string(), + Value::String(text) => text.clone(), + other => other.to_string(), + } +} + +fn url_decode(value: &str) -> String { + decode(value) + .map(|value| value.into_owned()) + .unwrap_or_else(|_| value.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn canonical_json_sorts_keys_without_spaces() { + let value = json!({"b": 2, "a": {"z": true, "m": "тест"}}); + assert_eq!( + canonical_json(&value).unwrap(), + r#"{"a":{"m":"тест","z":true},"b":2}"# + ); + } + + #[test] + fn checksum_matches_python_contract_fixture() { + let value = json!({ + "version": 1, + "defaults": {"enabled": true, "action": "alert"}, + "endpoint": {"clipboard": []} + }); + assert_eq!( + checksum_policy(&value), + "88d9966b1517b45b009dbf8aca7260cf67b64201ea91bc85f743ab8ba88da0cf" + ); + } + + #[test] + fn desired_policy_detects_string_equivalent_version() { + let current = json!({"version": "6", "checksum": "abc"}); + let active = json!({"id": 1, "current_version": 6, "checksum": "abc", "updated_at": "now"}); + assert_eq!(desired_policy("a", ¤t, &active)["refreshNow"], false); + } +} diff --git a/adk-rust/crates/dlp-syslog-forwarder/Cargo.toml b/adk-rust/crates/dlp-syslog-forwarder/Cargo.toml new file mode 100644 index 0000000..3581c67 --- /dev/null +++ b/adk-rust/crates/dlp-syslog-forwarder/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "dlp-syslog-forwarder" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +publish = false + +[dependencies] +anyhow.workspace = true +chrono.workspace = true +clap.workspace = true +reqwest.workspace = true +serde.workspace = true +serde_json.workspace = true diff --git a/adk-rust/crates/dlp-syslog-forwarder/src/main.rs b/adk-rust/crates/dlp-syslog-forwarder/src/main.rs new file mode 100644 index 0000000..297e6ee --- /dev/null +++ b/adk-rust/crates/dlp-syslog-forwarder/src/main.rs @@ -0,0 +1,446 @@ +use std::collections::BTreeMap; +use std::fs; +use std::io::Write; +use std::net::{TcpStream, UdpSocket}; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use anyhow::{Context, Result, anyhow}; +use chrono::{SecondsFormat, Utc}; +use clap::Parser; +use reqwest::blocking::Client; +use serde::Serialize; +use serde_json::{Value, json}; + +const DEFAULT_CONFIG: &str = "/opt/activitywatch/dlp-integrations/syslog-forwarder-config.yaml"; + +#[derive(Debug, Parser)] +#[command(about = "AWatch DLP syslog forwarder")] +struct Cli { + #[arg(long, default_value = DEFAULT_CONFIG)] + config: PathBuf, + + #[arg(long)] + dry_run: bool, + + #[arg(long)] + json: bool, +} + +#[derive(Debug, Clone)] +struct Config { + aw_api_base: String, + state_path: PathBuf, + syslog_host: String, + syslog_port: u16, + syslog_proto: String, + facility: i64, + app_name: String, + per_bucket_limit: usize, +} + +impl Default for Config { + fn default() -> Self { + Self { + aw_api_base: default_aw_api_base(), + state_path: default_state_path(), + syslog_host: default_syslog_host(), + syslog_port: default_syslog_port(), + syslog_proto: default_syslog_proto(), + facility: default_facility(), + app_name: default_app_name(), + per_bucket_limit: default_per_bucket_limit(), + } + } +} + +#[derive(Debug, Serialize)] +struct RunSummary { + ok: bool, + sent: usize, + buckets: usize, + dry_run: bool, + state_saved: bool, + state_path: String, + error: Option, +} + +fn default_aw_api_base() -> String { + "http://127.0.0.1:5600/api/0".to_string() +} + +fn default_state_path() -> PathBuf { + PathBuf::from("/var/lib/activitywatch/dlp-integrations/syslog-forwarder-state.json") +} + +fn default_syslog_host() -> String { + "127.0.0.1".to_string() +} + +fn default_syslog_port() -> u16 { + 514 +} + +fn default_syslog_proto() -> String { + "udp".to_string() +} + +fn default_facility() -> i64 { + 16 +} + +fn default_app_name() -> String { + "aw-dlp".to_string() +} + +fn default_per_bucket_limit() -> usize { + 300 +} + +fn load_config(path: &Path) -> Config { + if !path.exists() { + return Config::default(); + } + let Ok(text) = fs::read_to_string(path) else { + return Config::default(); + }; + let mut config = Config::default(); + for raw_line in text.lines() { + let line = raw_line.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('"').trim_matches('\''); + match key { + "aw_api_base" if !value.is_empty() => config.aw_api_base = value.to_string(), + "state_path" if !value.is_empty() => config.state_path = PathBuf::from(value), + "syslog_host" if !value.is_empty() => config.syslog_host = value.to_string(), + "syslog_port" => { + if let Ok(port) = value.parse::() { + config.syslog_port = port; + } + } + "syslog_proto" if !value.is_empty() => config.syslog_proto = value.to_string(), + "facility" => { + if let Ok(facility) = value.parse::() { + config.facility = facility; + } + } + "app_name" if !value.is_empty() => config.app_name = value.to_string(), + "per_bucket_limit" => { + if let Ok(limit) = value.parse::() { + config.per_bucket_limit = limit; + } + } + _ => {} + } + } + config +} + +fn load_json(path: &Path) -> Value { + let Ok(text) = fs::read_to_string(path) else { + return json!({}); + }; + serde_json::from_str::(&text) + .ok() + .filter(Value::is_object) + .unwrap_or_else(|| json!({})) +} + +fn save_json(path: &Path, payload: &Value) -> Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; + } + fs::write(path, serde_json::to_string_pretty(payload)?) + .with_context(|| format!("write {}", path.display())) +} + +fn http_json(client: &Client, url: &str) -> Result { + client + .get(url) + .timeout(Duration::from_secs(15)) + .send() + .and_then(|resp| resp.error_for_status()) + .with_context(|| format!("GET {url}"))? + .json::() + .with_context(|| format!("parse JSON from {url}")) +} + +fn int_value(value: Option<&Value>) -> i64 { + match value { + Some(Value::Number(n)) => n + .as_i64() + .or_else(|| n.as_u64().map(|v| v as i64)) + .unwrap_or(0), + Some(Value::String(s)) => s.parse::().unwrap_or(0), + _ => 0, + } +} + +fn incident_bucket_ids(buckets: &Value) -> Vec { + let mut ids: Vec = buckets + .as_object() + .map(|map| { + map.keys() + .filter(|bucket_id| bucket_id.starts_with("aw-dlp-incidents_")) + .cloned() + .collect() + }) + .unwrap_or_default(); + ids.sort(); + ids +} + +fn iter_new_incidents( + client: &Client, + aw_base: &str, + state: &Value, + per_bucket_limit: usize, +) -> Result<(Vec, BTreeMap)> { + let buckets = http_json( + client, + &format!("{}/buckets/", aw_base.trim_end_matches('/')), + )?; + let bucket_ids = incident_bucket_ids(&buckets); + let last_ids = state.get("last_ids").and_then(Value::as_object); + let mut max_ids = BTreeMap::new(); + let mut out = Vec::new(); + for bucket_id in bucket_ids { + let url = format!( + "{}/buckets/{bucket_id}/events?limit={}", + aw_base.trim_end_matches('/'), + per_bucket_limit + ); + let events = match http_json(client, &url) { + Ok(Value::Array(items)) => items, + Ok(_) => { + eprintln!("skip bucket {bucket_id}: events response is not a list"); + continue; + } + Err(err) => { + eprintln!("skip bucket {bucket_id}: {err}"); + continue; + } + }; + let prev = last_ids + .and_then(|ids| ids.get(&bucket_id)) + .and_then(|v| match v { + Value::Number(n) => n.as_i64(), + Value::String(s) => s.parse().ok(), + _ => None, + }) + .unwrap_or(0); + let mut bucket_max = prev; + for event in events { + let event_id = int_value(event.get("id")); + if event_id <= prev { + continue; + } + if event_id > bucket_max { + bucket_max = event_id; + } + out.push(event); + } + max_ids.insert(bucket_id, bucket_max); + } + out.sort_by_key(|event| int_value(event.get("id"))); + Ok((out, max_ids)) +} + +fn build_message(event: &Value, app_name: &str, facility: i64) -> String { + let pri = facility * 8 + 6; + let ts = Utc::now().to_rfc3339_opts(SecondsFormat::Micros, true); + let data = event + .get("data") + .filter(|value| value.is_object()) + .unwrap_or(&Value::Null); + let host = data + .get("hostname") + .and_then(Value::as_str) + .unwrap_or("unknown") + .to_string(); + let payload = json!({ + "event_id": event.get("id").cloned().unwrap_or(Value::Null), + "timestamp": event.get("timestamp").cloned().unwrap_or(Value::Null), + "host": host, + "severity": data.get("severity").cloned().unwrap_or(Value::Null), + "signalType": data.get("signalType").cloned().unwrap_or(Value::Null), + "username": data.get("username").cloned().unwrap_or(Value::Null), + "action": data.get("action").cloned().unwrap_or(Value::Null), + "message": data.get("message").cloned().unwrap_or(Value::Null), + "data": data, + }); + let payload_text = serde_json::to_string(&payload).unwrap_or_else(|_| "{}".to_string()); + format!("<{pri}>1 {ts} {host} {app_name} - - - {payload_text}") +} + +fn send_syslog(line: &str, host: &str, port: u16, proto: &str) -> Result<()> { + if proto.eq_ignore_ascii_case("tcp") { + let mut stream = TcpStream::connect((host, port)) + .with_context(|| format!("connect TCP syslog {host}:{port}"))?; + stream + .set_write_timeout(Some(Duration::from_secs(10))) + .context("set TCP write timeout")?; + stream + .write_all(format!("{line}\n").as_bytes()) + .context("write TCP syslog")?; + return Ok(()); + } + let socket = UdpSocket::bind("0.0.0.0:0").context("bind UDP syslog socket")?; + socket + .send_to(line.as_bytes(), (host, port)) + .with_context(|| format!("send UDP syslog {host}:{port}"))?; + Ok(()) +} + +fn run(cli: &Cli, client: &Client) -> RunSummary { + let cfg = load_config(&cli.config); + let state = load_json(&cfg.state_path); + let (incidents, max_ids) = + match iter_new_incidents(client, &cfg.aw_api_base, &state, cfg.per_bucket_limit) { + Ok(result) => result, + Err(err) => { + eprintln!("skip syslog forwarder run: AW API unavailable: {err}"); + return RunSummary { + ok: true, + sent: 0, + buckets: 0, + dry_run: cli.dry_run, + state_saved: false, + state_path: cfg.state_path.to_string_lossy().to_string(), + error: Some(err.to_string()), + }; + } + }; + + let mut sent = 0; + for event in &incidents { + let line = build_message(event, &cfg.app_name, cfg.facility); + if !cli.dry_run + && let Err(err) = + send_syslog(&line, &cfg.syslog_host, cfg.syslog_port, &cfg.syslog_proto) + { + return RunSummary { + ok: false, + sent, + buckets: max_ids.len(), + dry_run: cli.dry_run, + state_saved: false, + state_path: cfg.state_path.to_string_lossy().to_string(), + error: Some(err.to_string()), + }; + } + sent += 1; + } + + let mut payload = json!({"last_ids": max_ids}); + let mut state_saved = false; + if !cli.dry_run { + if let Err(err) = save_json(&cfg.state_path, &payload) { + return RunSummary { + ok: false, + sent, + buckets: payload["last_ids"] + .as_object() + .map_or(0, serde_json::Map::len), + dry_run: cli.dry_run, + state_saved: false, + state_path: cfg.state_path.to_string_lossy().to_string(), + error: Some(err.to_string()), + }; + } + state_saved = true; + } else { + payload["dry_run"] = json!(true); + } + + RunSummary { + ok: true, + sent, + buckets: payload["last_ids"] + .as_object() + .map_or(0, serde_json::Map::len), + dry_run: cli.dry_run, + state_saved, + state_path: cfg.state_path.to_string_lossy().to_string(), + error: None, + } +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + let client = Client::builder() + .no_proxy() + .build() + .context("build HTTP client")?; + let summary = run(&cli, &client); + if cli.json { + println!("{}", serde_json::to_string_pretty(&summary)?); + } else if let Some(err) = &summary.error { + eprintln!("{err}"); + } else { + println!( + "syslog forwarder sent={} buckets={}", + summary.sent, summary.buckets + ); + } + if summary.ok { + Ok(()) + } else { + Err(anyhow!( + summary + .error + .unwrap_or_else(|| "syslog forwarder failed".to_string()) + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn selects_only_new_incidents_by_id() { + let state = json!({"last_ids": {"aw-dlp-incidents_HOST": 10}}); + let last_ids = state.get("last_ids").and_then(Value::as_object).unwrap(); + assert_eq!(last_ids["aw-dlp-incidents_HOST"], 10); + } + + #[test] + fn builds_rfc5424_like_message() { + let event = json!({ + "id": 42, + "timestamp": "2026-05-31T12:00:00Z", + "data": { + "hostname": "HOST1", + "severity": "high", + "signalType": "dlp_incident", + "username": "user", + "action": "alert", + "message": "test" + } + }); + let msg = build_message(&event, "aw-dlp", 16); + assert!(msg.starts_with("<134>1 ")); + assert!(msg.contains(" HOST1 aw-dlp - - - ")); + assert!(msg.contains("\"event_id\":42")); + } + + #[test] + fn incident_bucket_filter_is_sorted() { + let buckets = json!({ + "other": {}, + "aw-dlp-incidents_B": {}, + "aw-dlp-incidents_A": {} + }); + assert_eq!( + incident_bucket_ids(&buckets), + vec!["aw-dlp-incidents_A", "aw-dlp-incidents_B"] + ); + } +} diff --git a/adk-rust/crates/dlp-webhook-sender/Cargo.toml b/adk-rust/crates/dlp-webhook-sender/Cargo.toml new file mode 100644 index 0000000..cca5751 --- /dev/null +++ b/adk-rust/crates/dlp-webhook-sender/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "dlp-webhook-sender" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +publish = false + +[dependencies] +anyhow.workspace = true +chrono.workspace = true +clap.workspace = true +reqwest.workspace = true +serde.workspace = true +serde_json.workspace = true +tempfile.workspace = true diff --git a/adk-rust/crates/dlp-webhook-sender/src/main.rs b/adk-rust/crates/dlp-webhook-sender/src/main.rs new file mode 100644 index 0000000..869835b --- /dev/null +++ b/adk-rust/crates/dlp-webhook-sender/src/main.rs @@ -0,0 +1,504 @@ +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; +use std::thread::sleep; +use std::time::Duration; + +use anyhow::{Context, Result, anyhow}; +use chrono::{SecondsFormat, Utc}; +use clap::Parser; +use reqwest::blocking::Client; +use reqwest::header::CONTENT_TYPE; +use serde::Serialize; +use serde_json::{Value, json}; + +const DEFAULT_CONFIG: &str = "/opt/activitywatch/dlp-integrations/webhook-config.yaml"; + +#[derive(Debug, Parser)] +#[command(about = "AWatch DLP webhook sender")] +struct Cli { + #[arg(long, default_value = DEFAULT_CONFIG)] + config: PathBuf, + + #[arg(long)] + dry_run: bool, + + #[arg(long)] + json: bool, +} + +#[derive(Debug, Clone)] +struct HookConfig { + url: String, + severity: Vec, +} + +#[derive(Debug, Clone)] +struct Config { + aw_api_base: String, + state_path: PathBuf, + retries: usize, + timeout_sec: u64, + backoff_base: f64, + per_bucket_limit: usize, + critical_webhooks: Vec, +} + +impl Default for Config { + fn default() -> Self { + Self { + aw_api_base: "http://127.0.0.1:5600/api/0".to_string(), + state_path: PathBuf::from("/var/lib/activitywatch/dlp-integrations/webhook-state.json"), + retries: 4, + timeout_sec: 15, + backoff_base: 2.0, + per_bucket_limit: 300, + critical_webhooks: Vec::new(), + } + } +} + +#[derive(Debug, Serialize)] +struct RunSummary { + ok: bool, + delivered: usize, + incidents_seen: usize, + buckets: usize, + dry_run: bool, + state_saved: bool, + state_path: String, + error: Option, +} + +fn load_config(path: &Path) -> Config { + let mut config = Config::default(); + let Ok(text) = fs::read_to_string(path) else { + return config; + }; + let mut current_hook: Option = None; + for raw_line in text.lines() { + let line_without_comment = raw_line.split('#').next().unwrap_or(""); + let line = line_without_comment.trim(); + if line.is_empty() || line == "critical_webhooks:" { + continue; + } + if line.starts_with("- ") { + if let Some(hook) = current_hook.take() { + config.critical_webhooks.push(hook); + } + let mut hook = HookConfig { + url: String::new(), + severity: vec!["high".to_string()], + }; + parse_hook_field(line.trim_start_matches("- ").trim(), &mut hook); + current_hook = Some(hook); + continue; + } + if raw_line.starts_with(' ') || raw_line.starts_with('\t') { + if let Some(hook) = current_hook.as_mut() { + parse_hook_field(line, hook); + } + continue; + } + if let Some(hook) = current_hook.take() { + config.critical_webhooks.push(hook); + } + let Some((key, value)) = line.split_once(':') else { + continue; + }; + let key = key.trim(); + let value = clean_scalar(value); + match key { + "aw_api_base" if !value.is_empty() => config.aw_api_base = value, + "state_path" if !value.is_empty() => config.state_path = PathBuf::from(value), + "retries" => config.retries = value.parse().unwrap_or(config.retries), + "timeout_sec" => config.timeout_sec = value.parse().unwrap_or(config.timeout_sec), + "backoff_base" => config.backoff_base = value.parse().unwrap_or(config.backoff_base), + "per_bucket_limit" => { + config.per_bucket_limit = value.parse().unwrap_or(config.per_bucket_limit); + } + _ => {} + } + } + if let Some(hook) = current_hook { + config.critical_webhooks.push(hook); + } + config +} + +fn parse_hook_field(line: &str, hook: &mut HookConfig) { + let Some((key, value)) = line.split_once(':') else { + return; + }; + let key = key.trim(); + let value = clean_scalar(value); + match key { + "url" => hook.url = value, + "severity" => hook.severity = parse_list_or_scalar(&value), + _ => {} + } +} + +fn clean_scalar(value: &str) -> String { + value + .trim() + .trim_matches('"') + .trim_matches('\'') + .to_string() +} + +fn parse_list_or_scalar(value: &str) -> Vec { + let text = value.trim(); + if text.starts_with('[') && text.ends_with(']') { + return text + .trim_start_matches('[') + .trim_end_matches(']') + .split(',') + .map(clean_scalar) + .filter(|item| !item.is_empty()) + .collect(); + } + if text.is_empty() { + Vec::new() + } else { + vec![clean_scalar(text)] + } +} + +fn load_json(path: &Path) -> Value { + let Ok(text) = fs::read_to_string(path) else { + return json!({}); + }; + serde_json::from_str::(&text) + .ok() + .filter(Value::is_object) + .unwrap_or_else(|| json!({})) +} + +fn save_json(path: &Path, payload: &Value) -> Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; + } + fs::write(path, serde_json::to_string_pretty(payload)?) + .with_context(|| format!("write {}", path.display())) +} + +fn http_json(client: &Client, url: &str, timeout_sec: u64) -> Result { + client + .get(url) + .timeout(Duration::from_secs(timeout_sec)) + .send() + .and_then(|resp| resp.error_for_status()) + .with_context(|| format!("GET {url}"))? + .json::() + .with_context(|| format!("parse JSON from {url}")) +} + +fn int_value(value: Option<&Value>) -> i64 { + match value { + Some(Value::Number(n)) => n + .as_i64() + .or_else(|| n.as_u64().map(|v| v as i64)) + .unwrap_or(0), + Some(Value::String(s)) => s.parse::().unwrap_or(0), + _ => 0, + } +} + +fn incident_bucket_ids(buckets: &Value) -> Vec { + let mut ids: Vec = buckets + .as_object() + .map(|map| { + map.keys() + .filter(|bucket_id| bucket_id.starts_with("aw-dlp-incidents_")) + .cloned() + .collect() + }) + .unwrap_or_default(); + ids.sort(); + ids +} + +fn iter_new_incidents( + client: &Client, + aw_base: &str, + state: &Value, + per_bucket_limit: usize, + timeout_sec: u64, +) -> Result<(Vec, BTreeMap)> { + let base = aw_base.trim_end_matches('/'); + let buckets = http_json(client, &format!("{base}/buckets/"), timeout_sec)?; + let bucket_ids = incident_bucket_ids(&buckets); + let last_ids = state.get("last_ids").and_then(Value::as_object); + let mut max_ids = BTreeMap::new(); + let mut out = Vec::new(); + for bucket_id in bucket_ids { + let events = match http_json( + client, + &format!("{base}/buckets/{bucket_id}/events?limit={per_bucket_limit}"), + timeout_sec, + ) { + Ok(Value::Array(items)) => items, + Ok(_) => continue, + Err(err) => return Err(err), + }; + let prev = last_ids + .and_then(|ids| ids.get(&bucket_id)) + .and_then(|v| match v { + Value::Number(n) => n.as_i64(), + Value::String(s) => s.parse().ok(), + _ => None, + }) + .unwrap_or(0); + let mut bucket_max = prev; + for event in events { + let event_id = int_value(event.get("id")); + if event_id <= prev { + continue; + } + if event_id > bucket_max { + bucket_max = event_id; + } + out.push(event); + } + max_ids.insert(bucket_id, bucket_max); + } + out.sort_by_key(|event| int_value(event.get("id"))); + Ok((out, max_ids)) +} + +fn should_send(severity: &str, allowed: &[String]) -> bool { + let severity = severity.to_ascii_lowercase(); + allowed + .iter() + .any(|item| item.to_ascii_lowercase() == severity) +} + +fn value_str(value: Option<&Value>, default: &str) -> String { + value.and_then(Value::as_str).unwrap_or(default).to_string() +} + +fn build_payload(event: &Value) -> Value { + let data = event + .get("data") + .filter(|value| value.is_object()) + .unwrap_or(&Value::Null); + let severity = value_str(data.get("severity"), "low"); + json!({ + "source": "AWatch-rus DLP", + "timestamp": event.get("timestamp").cloned().unwrap_or(Value::Null), + "event_id": event.get("id").cloned().unwrap_or(Value::Null), + "severity": severity, + "message": data.get("message").cloned().unwrap_or(Value::Null), + "ruleId": data.get("ruleId").cloned().unwrap_or(Value::Null), + "signalType": data.get("signalType").cloned().unwrap_or(Value::Null), + "hostname": data.get("hostname").cloned().unwrap_or(Value::Null), + "username": data.get("username").cloned().unwrap_or(Value::Null), + "action": data.get("action").cloned().unwrap_or(Value::Null), + "raw": data, + }) +} + +fn post_with_retry(client: &Client, url: &str, payload: &Value, cfg: &Config) -> bool { + let body = match serde_json::to_vec(payload) { + Ok(body) => body, + Err(err) => { + eprintln!("webhook payload serialization error url={url}: {err}"); + return false; + } + }; + for attempt in 1..=cfg.retries.max(1) { + let result = client + .post(url) + .timeout(Duration::from_secs(cfg.timeout_sec)) + .header(CONTENT_TYPE, "application/json; charset=utf-8") + .body(body.clone()) + .send(); + match result { + Ok(resp) if resp.status().is_success() => return true, + Ok(resp) => eprintln!( + "webhook http error url={url} code={} attempt={attempt}/{}", + resp.status(), + cfg.retries.max(1) + ), + Err(err) => eprintln!( + "webhook transport error url={url} err={err} attempt={attempt}/{}", + cfg.retries.max(1) + ), + } + if attempt < cfg.retries.max(1) { + sleep(Duration::from_secs_f64( + cfg.backoff_base.powi((attempt - 1) as i32), + )); + } + } + false +} + +fn run(cli: &Cli, client: &Client) -> RunSummary { + let cfg = load_config(&cli.config); + let state = load_json(&cfg.state_path); + let (incidents, max_ids) = match iter_new_incidents( + client, + &cfg.aw_api_base, + &state, + cfg.per_bucket_limit, + cfg.timeout_sec, + ) { + Ok(result) => result, + Err(err) => { + eprintln!("skip webhook sender run: AW API unavailable: {err}"); + return RunSummary { + ok: true, + delivered: 0, + incidents_seen: 0, + buckets: 0, + dry_run: cli.dry_run, + state_saved: false, + state_path: cfg.state_path.to_string_lossy().to_string(), + error: Some(err.to_string()), + }; + } + }; + + let mut delivered = 0; + for event in &incidents { + let data = event + .get("data") + .filter(|value| value.is_object()) + .unwrap_or(&Value::Null); + let severity = value_str(data.get("severity"), "low"); + for hook in &cfg.critical_webhooks { + if hook.url.trim().is_empty() || !should_send(&severity, &hook.severity) { + continue; + } + if cli.dry_run || post_with_retry(client, &hook.url, &build_payload(event), &cfg) { + delivered += 1; + } + } + } + + let mut next_state = state; + next_state["last_ids"] = json!(max_ids); + next_state["updated_at"] = json!(Utc::now().to_rfc3339_opts(SecondsFormat::Micros, false)); + let mut state_saved = false; + if !cli.dry_run { + if let Err(err) = save_json(&cfg.state_path, &next_state) { + return RunSummary { + ok: false, + delivered, + incidents_seen: incidents.len(), + buckets: next_state["last_ids"] + .as_object() + .map_or(0, serde_json::Map::len), + dry_run: false, + state_saved: false, + state_path: cfg.state_path.to_string_lossy().to_string(), + error: Some(err.to_string()), + }; + } + state_saved = true; + } + + RunSummary { + ok: true, + delivered, + incidents_seen: incidents.len(), + buckets: next_state["last_ids"] + .as_object() + .map_or(0, serde_json::Map::len), + dry_run: cli.dry_run, + state_saved, + state_path: cfg.state_path.to_string_lossy().to_string(), + error: None, + } +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + let client = Client::builder() + .no_proxy() + .build() + .context("build HTTP client")?; + let summary = run(&cli, &client); + if cli.json { + println!("{}", serde_json::to_string_pretty(&summary)?); + } else if let Some(err) = &summary.error { + eprintln!("{err}"); + } else { + println!( + "Webhook sender done: delivered={} incidents_seen={}", + summary.delivered, summary.incidents_seen + ); + } + if summary.ok { + Ok(()) + } else { + Err(anyhow!( + summary + .error + .unwrap_or_else(|| "webhook sender failed".to_string()) + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_simple_config_with_hook() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("webhook-config.yaml"); + fs::write( + &path, + r#" +aw_api_base: "http://127.0.0.1:5600/api/0" +state_path: "/tmp/webhook-state.json" +retries: 2 +timeout_sec: 3 +backoff_base: 1.5 +per_bucket_limit: 10 +critical_webhooks: + - url: "https://example.invalid/hook" + severity: ["high", "medium"] +"#, + ) + .unwrap(); + let cfg = load_config(&path); + assert_eq!(cfg.retries, 2); + assert_eq!(cfg.timeout_sec, 3); + assert_eq!(cfg.per_bucket_limit, 10); + assert_eq!(cfg.critical_webhooks.len(), 1); + assert_eq!(cfg.critical_webhooks[0].url, "https://example.invalid/hook"); + assert_eq!(cfg.critical_webhooks[0].severity, ["high", "medium"]); + } + + #[test] + fn severity_matching_is_case_insensitive() { + assert!(should_send("HIGH", &[String::from("high")])); + assert!(!should_send("low", &[String::from("high")])); + } + + #[test] + fn payload_shape_matches_python_contract() { + let event = json!({ + "id": 7, + "timestamp": "2026-06-01T10:00:00Z", + "data": { + "severity": "high", + "message": "m", + "ruleId": "r", + "signalType": "dlp_incident", + "hostname": "h", + "username": "u", + "action": "alert" + } + }); + let payload = build_payload(&event); + assert_eq!(payload["source"], "AWatch-rus DLP"); + assert_eq!(payload["event_id"], 7); + assert_eq!(payload["severity"], "high"); + assert_eq!(payload["raw"]["ruleId"], "r"); + } +} diff --git a/adk-rust/crates/extract-ioc-from-sigma/Cargo.toml b/adk-rust/crates/extract-ioc-from-sigma/Cargo.toml new file mode 100644 index 0000000..d0b8e5a --- /dev/null +++ b/adk-rust/crates/extract-ioc-from-sigma/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "extract-ioc-from-sigma" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +publish.workspace = true + +[dependencies] +anyhow.workspace = true +clap.workspace = true +regex.workspace = true +serde.workspace = true +serde_json.workspace = true +serde_yaml.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/adk-rust/crates/extract-ioc-from-sigma/src/main.rs b/adk-rust/crates/extract-ioc-from-sigma/src/main.rs new file mode 100644 index 0000000..17afdb1 --- /dev/null +++ b/adk-rust/crates/extract-ioc-from-sigma/src/main.rs @@ -0,0 +1,499 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, bail}; +use clap::Parser; +use regex::Regex; +use serde::Serialize; +use serde_yaml::Value; + +const OUTPUT_JSON: &str = "ioc_blacklist.json"; +const OUTPUT_CSV: &str = "ioc_blacklist.csv"; +const OUTPUT_SQL: &str = "ioc_blacklist.sql"; +const CSV_FIELDS: &[&str] = &[ + "ioc_type", + "ioc_value", + "field", + "rule_id", + "rule_title", + "source_file", +]; + +#[derive(Debug, Parser)] +#[command(about = "Extract IOC-like Sigma values for DLP preload")] +struct Cli { + #[arg(long, default_value = "rules")] + rules_root: PathBuf, + + #[arg(long, default_value = "ioc_export")] + out_dir: PathBuf, + + #[arg(long, default_value = "dlp_blacklist_ioc")] + table_name: String, +} + +#[derive(Debug, Clone, Eq, PartialEq, Serialize)] +struct IocRow { + ioc_type: String, + ioc_value: String, + field: String, + rule_id: String, + rule_title: String, + source_file: String, +} + +fn main() { + let code = match run() { + Ok(()) => 0, + Err(err) => { + eprintln!("{err:#}"); + 1 + } + }; + std::process::exit(code); +} + +fn run() -> Result<()> { + let cli = Cli::parse(); + extract_to_outputs(&cli.rules_root, &cli.out_dir, &cli.table_name) +} + +fn extract_to_outputs(rules_root: &Path, out_dir: &Path, table_name: &str) -> Result<()> { + if !rules_root.exists() { + bail!("rules root not found: {}", rules_root.display()); + } + if !rules_root.is_dir() { + bail!("rules root is not a directory: {}", rules_root.display()); + } + + let yaml_files = collect_yaml_files(rules_root)?; + let mut rows = Vec::new(); + for path in &yaml_files { + rows.extend(extract_from_yaml(path)?); + } + let rows = dedupe_and_sort(rows); + + fs::create_dir_all(out_dir).with_context(|| format!("create {}", out_dir.display()))?; + write_json(&out_dir.join(OUTPUT_JSON), &rows)?; + write_csv(&out_dir.join(OUTPUT_CSV), &rows)?; + write_sql(&out_dir.join(OUTPUT_SQL), &rows, table_name)?; + + let mut counts: BTreeMap<&str, usize> = BTreeMap::new(); + for row in &rows { + *counts.entry(row.ioc_type.as_str()).or_default() += 1; + } + + println!("rules_scanned={}", yaml_files.len()); + println!("iocs_extracted={}", rows.len()); + for (kind, count) in counts { + println!("{kind}={count}"); + } + println!("json={}", out_dir.join(OUTPUT_JSON).display()); + println!("csv={}", out_dir.join(OUTPUT_CSV).display()); + println!("sql={}", out_dir.join(OUTPUT_SQL).display()); + Ok(()) +} + +fn collect_yaml_files(root: &Path) -> Result> { + let mut out = Vec::new(); + collect_yaml_files_inner(root, &mut out)?; + out.sort(); + Ok(out) +} + +fn collect_yaml_files_inner(path: &Path, out: &mut Vec) -> 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 path = entry.path(); + let file_type = entry + .file_type() + .with_context(|| format!("read file type {}", path.display()))?; + if file_type.is_dir() { + collect_yaml_files_inner(&path, out)?; + } else if file_type.is_file() && is_yaml_path(&path) { + out.push(path); + } + } + Ok(()) +} + +fn is_yaml_path(path: &Path) -> bool { + path.extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| matches!(ext.to_ascii_lowercase().as_str(), "yml" | "yaml")) +} + +fn extract_from_yaml(path: &Path) -> Result> { + let text = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?; + let doc: Value = match serde_yaml::from_str(&text) { + Ok(doc) => doc, + Err(_) => return Ok(Vec::new()), + }; + let Some(mapping) = doc.as_mapping() else { + return Ok(Vec::new()); + }; + let Some(detection) = mapping_get(mapping, "detection") else { + return Ok(Vec::new()); + }; + + let rule_id = mapping_get(mapping, "id") + .map(scalar_to_string) + .unwrap_or_default(); + let rule_title = mapping_get(mapping, "title") + .map(scalar_to_string) + .unwrap_or_default(); + let mut rows = Vec::new(); + let sha256_re = Regex::new(r"\b[a-fA-F0-9]{64}\b").expect("valid sha256 regex"); + walk( + detection, + &rule_id, + &rule_title, + &path.display().to_string(), + &sha256_re, + &mut rows, + ); + Ok(rows) +} + +fn mapping_get<'a>(mapping: &'a serde_yaml::Mapping, key: &str) -> Option<&'a Value> { + mapping.get(Value::String(key.to_string())) +} + +fn split_key(key: &str) -> (String, Vec) { + let parts: Vec<_> = key + .split('|') + .map(str::trim) + .filter(|part| !part.is_empty()) + .collect(); + if parts.is_empty() { + return (String::new(), Vec::new()); + } + ( + parts[0].to_ascii_lowercase(), + parts[1..] + .iter() + .map(|part| part.to_ascii_lowercase()) + .collect(), + ) +} + +fn walk( + node: &Value, + rule_id: &str, + rule_title: &str, + source_file: &str, + sha256_re: &Regex, + out: &mut Vec, +) { + match node { + Value::Mapping(mapping) => { + for (key, value) in mapping { + let key_string = scalar_to_string(key); + let (base, ops) = split_key(&key_string); + if is_base_field(&base) { + for raw in flatten_values(value) { + let Some(ioc_type) = detect_ioc_type(&base, &ops, &raw, sha256_re) else { + continue; + }; + if ioc_type == "sha256" { + for hash in parse_sha256(&raw, sha256_re) { + out.push(IocRow { + ioc_type: "sha256".to_string(), + ioc_value: hash, + field: key_string.clone(), + rule_id: rule_id.to_string(), + rule_title: rule_title.to_string(), + source_file: source_file.to_string(), + }); + } + } else { + out.push(IocRow { + ioc_type: ioc_type.to_string(), + ioc_value: raw, + field: key_string.clone(), + rule_id: rule_id.to_string(), + rule_title: rule_title.to_string(), + source_file: source_file.to_string(), + }); + } + } + } + walk(value, rule_id, rule_title, source_file, sha256_re, out); + } + } + Value::Sequence(items) => { + for item in items { + walk(item, rule_id, rule_title, source_file, sha256_re, out); + } + } + _ => {} + } +} + +fn is_base_field(base: &str) -> bool { + matches!( + base, + "image" | "commandline" | "originalfilename" | "hashes" + ) +} + +fn flatten_values(value: &Value) -> Vec { + match value { + Value::Null => Vec::new(), + Value::Bool(v) => vec![py_bool_string(*v).to_string()], + Value::Number(v) => vec![v.to_string()], + Value::String(v) => { + let trimmed = v.trim(); + if trimmed.is_empty() { + Vec::new() + } else { + vec![pyyaml_scalar_string(trimmed)] + } + } + Value::Sequence(items) => items.iter().flat_map(flatten_values).collect(), + Value::Mapping(mapping) => { + let mut out = Vec::new(); + for (key, value) in mapping { + let key = scalar_to_string(key); + for item in flatten_values(value) { + out.push(format!("{key}:{item}")); + } + } + out + } + Value::Tagged(tagged) => flatten_values(&tagged.value), + } +} + +fn scalar_to_string(value: &Value) -> String { + match value { + Value::Null => String::new(), + Value::Bool(v) => py_bool_string(*v).to_string(), + Value::Number(v) => v.to_string(), + Value::String(v) => v.clone(), + Value::Sequence(_) | Value::Mapping(_) | Value::Tagged(_) => serde_yaml::to_string(value) + .unwrap_or_default() + .trim() + .to_string(), + } +} + +fn pyyaml_scalar_string(value: &str) -> String { + match value.to_ascii_lowercase().as_str() { + "yes" | "true" | "on" => "True".to_string(), + "no" | "false" | "off" => "False".to_string(), + _ => value.to_string(), + } +} + +fn py_bool_string(value: bool) -> &'static str { + if value { "True" } else { "False" } +} + +fn detect_ioc_type<'a>( + base: &str, + ops: &[String], + raw: &str, + sha256_re: &Regex, +) -> Option<&'a str> { + if base == "image" && ops.iter().any(|op| op == "endswith") { + return Some("process_image_endswith"); + } + if base == "commandline" && ops.iter().any(|op| op == "contains") { + return Some("commandline_contains"); + } + if base == "originalfilename" { + return Some("original_filename"); + } + if base == "hashes" && (ops.iter().any(|op| op == "sha256") || sha256_re.is_match(raw)) { + return Some("sha256"); + } + None +} + +fn parse_sha256(raw: &str, sha256_re: &Regex) -> Vec { + let mut seen = BTreeSet::new(); + let mut out = Vec::new(); + for hit in sha256_re.find_iter(raw) { + let value = hit.as_str().to_ascii_lowercase(); + if seen.insert(value.clone()) { + out.push(value); + } + } + out +} + +fn dedupe_and_sort(rows: Vec) -> Vec { + let mut seen = BTreeSet::new(); + let mut out = Vec::new(); + for row in rows { + let key = ( + row.ioc_type.clone(), + row.ioc_value.to_lowercase(), + row.field.clone(), + ); + if seen.insert(key) { + out.push(row); + } + } + out.sort_by(|a, b| { + ( + a.ioc_type.as_str(), + a.ioc_value.to_lowercase(), + a.field.as_str(), + ) + .cmp(&( + b.ioc_type.as_str(), + b.ioc_value.to_lowercase(), + b.field.as_str(), + )) + }); + out +} + +fn write_json(path: &Path, rows: &[IocRow]) -> Result<()> { + fs::write(path, serde_json::to_string_pretty(rows)?) + .with_context(|| format!("write {}", path.display()))?; + Ok(()) +} + +fn write_csv(path: &Path, rows: &[IocRow]) -> Result<()> { + let mut out = String::new(); + out.push_str(&CSV_FIELDS.join(",")); + out.push('\n'); + for row in rows { + out.push_str(&csv_escape(&row.ioc_type)); + out.push(','); + out.push_str(&csv_escape(&row.ioc_value)); + out.push(','); + out.push_str(&csv_escape(&row.field)); + out.push(','); + out.push_str(&csv_escape(&row.rule_id)); + out.push(','); + out.push_str(&csv_escape(&row.rule_title)); + out.push(','); + out.push_str(&csv_escape(&row.source_file)); + out.push('\n'); + } + fs::write(path, out).with_context(|| format!("write {}", path.display()))?; + Ok(()) +} + +fn csv_escape(value: &str) -> String { + if value.contains([',', '"', '\n', '\r']) { + format!("\"{}\"", value.replace('"', "\"\"")) + } else { + value.to_string() + } +} + +fn write_sql(path: &Path, rows: &[IocRow], table_name: &str) -> Result<()> { + let mut out = format!( + "CREATE TABLE IF NOT EXISTS {table_name} (\n\ + \x20 id INTEGER PRIMARY KEY AUTOINCREMENT,\n\ + \x20 ioc_type TEXT NOT NULL,\n\ + \x20 ioc_value TEXT NOT NULL,\n\ + \x20 field TEXT,\n\ + \x20 rule_id TEXT,\n\ + \x20 rule_title TEXT,\n\ + \x20 source_file TEXT\n\ + );\n\n" + ); + for row in rows { + out.push_str(&format!( + "INSERT INTO {table_name} (ioc_type, ioc_value, field, rule_id, rule_title, source_file) VALUES \ + ('{}','{}','{}','{}','{}','{}');\n", + sql_escape(&row.ioc_type), + sql_escape(&row.ioc_value), + sql_escape(&row.field), + sql_escape(&row.rule_id), + sql_escape(&row.rule_title), + sql_escape(&row.source_file), + )); + } + fs::write(path, out).with_context(|| format!("write {}", path.display()))?; + Ok(()) +} + +fn sql_escape(value: &str) -> String { + value.replace('\'', "''") +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn extracts_supported_iocs_and_dedupes() { + let tmp = tempdir().unwrap(); + let rule = tmp.path().join("rule.yml"); + fs::write( + &rule, + r#" +id: test-rule +title: Test Rule +detection: + selection: + Image|endswith: + - '\bad.exe' + - '\bad.exe' + CommandLine|contains: ['--dump', 'sekret'] + OriginalFileName: evil.exe + Hashes|SHA256: + - 'SHA256=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + - 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' + condition: selection +"#, + ) + .unwrap(); + + let rows = dedupe_and_sort(extract_from_yaml(&rule).unwrap()); + assert_eq!(rows.len(), 5); + assert_eq!(rows[0].ioc_type, "commandline_contains"); + assert!(rows.iter().any(|row| row.ioc_type == "sha256")); + assert_eq!( + rows.iter() + .filter(|row| row.ioc_type == "process_image_endswith") + .count(), + 1 + ); + } + + #[test] + fn writes_expected_outputs() { + let tmp = tempdir().unwrap(); + let rules = tmp.path().join("rules"); + let out = tmp.path().join("out"); + fs::create_dir_all(&rules).unwrap(); + fs::write( + rules.join("rule.yml"), + r#" +id: write-rule +title: "CSV, SQL Rule" +detection: + selection: + CommandLine|contains: "a,b" + condition: selection +"#, + ) + .unwrap(); + + extract_to_outputs(&rules, &out, "dlp_blacklist_ioc").unwrap(); + let json = fs::read_to_string(out.join(OUTPUT_JSON)).unwrap(); + let csv = fs::read_to_string(out.join(OUTPUT_CSV)).unwrap(); + let sql = fs::read_to_string(out.join(OUTPUT_SQL)).unwrap(); + assert!(json.contains("commandline_contains")); + assert!(csv.contains("\"a,b\"")); + assert!(sql.contains("CREATE TABLE IF NOT EXISTS dlp_blacklist_ioc")); + } + + #[test] + fn skips_invalid_yaml() { + let tmp = tempdir().unwrap(); + let rule = tmp.path().join("bad.yml"); + fs::write(&rule, ": not yaml: :").unwrap(); + assert!(extract_from_yaml(&rule).unwrap().is_empty()); + } +} diff --git a/adk-rust/crates/hayabusa-tools/Cargo.toml b/adk-rust/crates/hayabusa-tools/Cargo.toml new file mode 100644 index 0000000..3e527cc --- /dev/null +++ b/adk-rust/crates/hayabusa-tools/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "hayabusa-tools" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +publish.workspace = true + +[[bin]] +name = "aw-hayabusa-case-alert-rust" +path = "src/bin/case_alert.rs" + +[[bin]] +name = "aw-hayabusa-link-case-rust" +path = "src/bin/link_case.rs" + +[[bin]] +name = "aw-hayabusa-from-windows-rust" +path = "src/bin/from_windows.rs" + +[[bin]] +name = "aw-hayabusa-autoprocess-rust" +path = "src/bin/autoprocess.rs" + +[dependencies] +anyhow.workspace = true +chrono.workspace = true +clap.workspace = true +fs2.workspace = true +regex.workspace = true +reqwest.workspace = true +serde.workspace = true +serde_json.workspace = true +urlencoding.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/adk-rust/crates/hayabusa-tools/src/bin/autoprocess.rs b/adk-rust/crates/hayabusa-tools/src/bin/autoprocess.rs new file mode 100644 index 0000000..015083b --- /dev/null +++ b/adk-rust/crates/hayabusa-tools/src/bin/autoprocess.rs @@ -0,0 +1,300 @@ +use std::fs::{self, File}; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use anyhow::{Context, Result, bail}; +use clap::Parser; +use fs2::FileExt; +use hayabusa_tools::{guess_host_from_filename, read_json_file}; +use serde_json::{Value, json}; + +const LOCK_PATH: &str = "/opt/hayabusa/state/aw-hayabusa-autoprocess.lock"; +const WRAPPER: &str = "/usr/local/bin/aw-hayabusa"; +const LINKER: &str = "/usr/local/bin/aw-hayabusa-link-case"; +const CASE_ALERT: &str = "/usr/local/bin/aw-hayabusa-case-alert"; +const LATEST_INTAKE: &str = "/opt/hayabusa/state/latest-intake.json"; + +#[derive(Debug, Parser)] +#[command(about = "Auto-process Hayabusa zip packages dropped onto aw-rus server")] +struct Cli { + #[arg(long, default_value = "/opt/activitywatch/aw-rus-ops/drop")] + drop_dir: PathBuf, + + #[arg(long, default_value_t = true)] + once: bool, +} + +#[derive(Debug)] +struct Sidecars { + case_id: Option, + host: Option, + mode: String, + link_source: String, + caseid_path: PathBuf, + meta_path: PathBuf, +} + +fn main() { + let code = match run() { + Ok(code) => code, + Err(err) => { + eprintln!("{err:#}"); + 1 + } + }; + std::process::exit(code); +} + +fn run() -> Result { + let cli = Cli::parse(); + let _once = cli.once; + fs::create_dir_all(&cli.drop_dir) + .with_context(|| format!("create {}", cli.drop_dir.display()))?; + let lock_path = Path::new(LOCK_PATH); + if let Some(parent) = lock_path.parent() { + fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; + } + let lock = File::create(lock_path).with_context(|| format!("open {}", lock_path.display()))?; + if lock.try_lock_exclusive().is_err() { + eprintln!("autoprocess already running"); + return Ok(0); + } + + let zips = list_zips(&cli.drop_dir)?; + if zips.is_empty() { + println!("no zip packages in drop dir"); + return Ok(0); + } + for zip_path in zips { + let result = process_one(&zip_path)?; + println!( + "{}", + serde_json::to_string_pretty(&json!({ + "processed": zip_path.display().to_string(), + "latest_intake": result.latest_intake, + "case_alert": result.case_alert, + }))? + ); + } + Ok(0) +} + +struct ProcessResult { + latest_intake: Value, + case_alert: Option, +} + +fn list_zips(drop_dir: &Path) -> Result> { + let mut zips = Vec::new(); + for entry in fs::read_dir(drop_dir).with_context(|| format!("read {}", drop_dir.display()))? { + let path = entry?.path(); + if path.extension().and_then(|ext| ext.to_str()) == Some("zip") { + zips.push(path); + } + } + zips.sort(); + Ok(zips) +} + +fn process_one(zip_path: &Path) -> Result { + let sidecars = load_sidecars(zip_path)?; + let host = guess_host(zip_path, &sidecars); + let mode = if sidecars.mode.is_empty() { + "incident".to_string() + } else { + sidecars.mode.clone() + }; + + let mut accept_cmd = vec![ + "accept".to_string(), + "--package".to_string(), + zip_path.display().to_string(), + ]; + if let Some(host) = host { + accept_cmd.extend(["--host".to_string(), host]); + } + run_checked(Path::new(WRAPPER), &accept_cmd)?; + run_checked( + Path::new(WRAPPER), + &[ + "process-inbox".to_string(), + "--mode".to_string(), + mode.clone(), + "--limit".to_string(), + "1".to_string(), + ], + )?; + let latest = read_json_file(Path::new(LATEST_INTAKE))?; + let report_dir = PathBuf::from( + latest + .get("report_dir") + .and_then(Value::as_str) + .context("latest intake report_dir missing")?, + ); + let mut case_alert = None; + if Path::new(CASE_ALERT).is_file() { + let mut alert_cmd = vec![ + "--mode".to_string(), + mode.clone(), + "--link-source".to_string(), + sidecars.link_source.clone(), + ]; + if let Some(case_id) = sidecars.case_id { + alert_cmd.extend(["--case-id".to_string(), case_id.to_string()]); + } + let output = run_capture(Path::new(CASE_ALERT), &alert_cmd)?; + case_alert = Some(json!({ + "returncode": output.returncode, + "stdout": output.stdout.trim(), + "stderr": output.stderr.trim(), + })); + } + archive_sidecars(&report_dir, &sidecars)?; + archive_drop_package(&report_dir, zip_path)?; + if sidecars.case_id.is_some() && !Path::new(CASE_ALERT).is_file() { + run_checked( + Path::new(LINKER), + &[ + "--case-id".to_string(), + sidecars.case_id.unwrap().to_string(), + "--mode".to_string(), + mode, + "--link-source".to_string(), + sidecars.link_source, + ], + )?; + } + Ok(ProcessResult { + latest_intake: latest, + case_alert, + }) +} + +fn load_sidecars(zip_path: &Path) -> Result { + let base = zip_path.with_extension(""); + let caseid_path = base.with_extension("caseid"); + let meta_path = base.with_extension("meta.json"); + let meta = if meta_path.is_file() { + read_json_file(&meta_path)? + } else { + json!({}) + }; + let mut case_id = meta.get("case_id").and_then(Value::as_i64); + if case_id.is_none() && caseid_path.is_file() { + let raw = fs::read_to_string(&caseid_path) + .with_context(|| format!("read {}", caseid_path.display()))?; + let trimmed = raw.trim(); + if !trimmed.is_empty() { + case_id = Some( + trimmed + .parse::() + .with_context(|| format!("parse {}", caseid_path.display()))?, + ); + } + } + Ok(Sidecars { + case_id, + host: meta.get("host").and_then(Value::as_str).map(str::to_string), + mode: meta + .get("mode") + .and_then(Value::as_str) + .unwrap_or("incident") + .to_string(), + link_source: meta + .get("link_source") + .and_then(Value::as_str) + .unwrap_or("aw-rus-drop-autoprocess") + .to_string(), + caseid_path, + meta_path, + }) +} + +fn archive_sidecars(report_dir: &Path, sidecars: &Sidecars) -> Result<()> { + let target_dir = report_dir.join("input-sidecars"); + fs::create_dir_all(&target_dir).with_context(|| format!("create {}", target_dir.display()))?; + for path in [&sidecars.caseid_path, &sidecars.meta_path] { + if path.is_file() { + fs::rename( + path, + target_dir.join(path.file_name().context("sidecar file name")?), + ) + .with_context(|| format!("move {}", path.display()))?; + } + } + Ok(()) +} + +fn archive_drop_package(report_dir: &Path, zip_path: &Path) -> Result<()> { + let target_dir = report_dir.join("input-drop"); + fs::create_dir_all(&target_dir).with_context(|| format!("create {}", target_dir.display()))?; + let target_path = target_dir.join(zip_path.file_name().context("zip file name")?); + if target_path.exists() { + fs::remove_file(&target_path) + .with_context(|| format!("remove {}", target_path.display()))?; + } + fs::rename(zip_path, &target_path).with_context(|| format!("move {}", zip_path.display()))?; + Ok(()) +} + +fn guess_host(zip_path: &Path, sidecars: &Sidecars) -> Option { + if let Some(host) = &sidecars.host { + if !host.is_empty() { + return Some(host.clone()); + } + } + let name = zip_path + .file_stem() + .and_then(|name| name.to_str()) + .map(guess_host_from_filename)?; + (!name.is_empty()).then_some(name) +} + +fn run_checked(program: &Path, args: &[String]) -> Result<()> { + println!( + "RUN {} {}", + program.display(), + args.iter() + .map(String::as_str) + .collect::>() + .join(" ") + ); + let status = Command::new(program) + .args(args) + .status() + .with_context(|| format!("run {}", program.display()))?; + if !status.success() { + bail!( + "{} failed with status {}", + program.display(), + status.code().unwrap_or(1) + ); + } + Ok(()) +} + +struct Captured { + returncode: i32, + stdout: String, + stderr: String, +} + +fn run_capture(program: &Path, args: &[String]) -> Result { + println!( + "RUN {} {}", + program.display(), + args.iter() + .map(String::as_str) + .collect::>() + .join(" ") + ); + let output = Command::new(program) + .args(args) + .output() + .with_context(|| format!("run {}", program.display()))?; + Ok(Captured { + returncode: output.status.code().unwrap_or(1), + stdout: String::from_utf8_lossy(&output.stdout).to_string(), + stderr: String::from_utf8_lossy(&output.stderr).to_string(), + }) +} diff --git a/adk-rust/crates/hayabusa-tools/src/bin/case_alert.rs b/adk-rust/crates/hayabusa-tools/src/bin/case_alert.rs new file mode 100644 index 0000000..8115eec --- /dev/null +++ b/adk-rust/crates/hayabusa-tools/src/bin/case_alert.rs @@ -0,0 +1,184 @@ +use std::path::PathBuf; + +use anyhow::Result; +use clap::Parser; +use hayabusa_tools::{ + analyze_report, build_case_payload, build_comment, build_hayabusa_payload, build_telegram_text, + env_bool, env_string, http_client, normalize_case_api_base, patch_json, post_json, + read_json_file, required_str, severity_meets, +}; +use reqwest::blocking::Client; +use serde_json::{Value, json}; + +#[derive(Debug, Parser)] +#[command( + about = "Auto-create/update AW-rus case, compute Hayabusa severity, and send Telegram alerts" +)] +struct Cli { + #[arg(long)] + case_id: Option, + + #[arg(long, default_value = "/opt/hayabusa/state/latest-intake.json")] + intake_json: PathBuf, + + #[arg(long)] + case_api_base: Option, + + #[arg(long, default_value = "incident")] + mode: String, + + #[arg(long, default_value = "aw-rus-drop-autoprocess")] + link_source: String, + + #[arg(long, default_value_t = false)] + auto_create: bool, + + #[arg(long)] + auto_create_min_severity: Option, + + #[arg(long, default_value_t = false)] + telegram_enabled: bool, + + #[arg(long)] + telegram_min_severity: Option, + + #[arg(long)] + telegram_bot_token: Option, + + #[arg(long)] + telegram_chat_ids: Option, +} + +fn main() { + let code = match run() { + Ok(code) => code, + Err(err) => { + eprintln!("{err:#}"); + 1 + } + }; + std::process::exit(code); +} + +fn run() -> Result { + let cli = Cli::parse(); + let intake = read_json_file(&cli.intake_json)?; + let summary = analyze_report(PathBuf::from(required_str(&intake, "report_dir")?).as_path())?; + let case_api_base = normalize_case_api_base(cli.case_api_base.as_deref().unwrap_or( + &env_string("AW_HAYABUSA_CASE_API_BASE", "http://127.0.0.1:5602"), + )); + let auto_create = cli.auto_create || env_bool("AW_HAYABUSA_AUTO_CASE_ENABLED", true); + let auto_create_min_severity = cli + .auto_create_min_severity + .unwrap_or_else(|| env_string("AW_HAYABUSA_AUTO_CASE_MIN_SEVERITY", "medium")); + let telegram_enabled = cli.telegram_enabled || env_bool("AW_HAYABUSA_TELEGRAM_ENABLED", false); + let telegram_min_severity = cli + .telegram_min_severity + .unwrap_or_else(|| env_string("AW_HAYABUSA_TELEGRAM_MIN_SEVERITY", "high")); + let telegram_bot_token = cli + .telegram_bot_token + .unwrap_or_else(|| env_string("AW_HAYABUSA_TELEGRAM_BOT_TOKEN", "")); + let telegram_chat_ids = cli + .telegram_chat_ids + .unwrap_or_else(|| env_string("AW_HAYABUSA_TELEGRAM_CHAT_IDS", "")); + let client = http_client()?; + + let mut case_id = cli.case_id; + let mut created_case = Value::Null; + let mut case_error: Option = None; + let mut linked = false; + let mut comment_added = false; + + if let Err(err) = (|| -> Result<()> { + if case_id.is_none() + && auto_create + && severity_meets(&summary.severity, &auto_create_min_severity) + { + created_case = post_json( + &client, + &format!("{case_api_base}/api/0/dlp/cases"), + &build_case_payload(&intake, &summary)?, + )?; + case_id = created_case.get("id").and_then(Value::as_i64); + } + if let Some(id) = case_id { + patch_json( + &client, + &format!("{case_api_base}/api/0/dlp/cases/{id}"), + &json!({"severity": summary.severity}), + )?; + post_json( + &client, + &format!("{case_api_base}/api/0/dlp/cases/{id}/forensics/hayabusa"), + &build_hayabusa_payload(&intake, &cli.mode, &cli.link_source)?, + )?; + linked = true; + post_json( + &client, + &format!("{case_api_base}/api/0/dlp/cases/{id}/comments"), + &json!({"comment": build_comment(&summary, &intake)?, "author": "aw-hayabusa-auto"}), + )?; + comment_added = true; + } + Ok(()) + })() { + case_error = Some(err.to_string()); + } + + let telegram_results = if telegram_enabled + && !telegram_bot_token.is_empty() + && severity_meets(&summary.severity, &telegram_min_severity) + { + let chat_ids = telegram_chat_ids + .split(',') + .map(str::trim) + .filter(|item| !item.is_empty()) + .collect::>(); + send_telegram( + &client, + &telegram_bot_token, + &chat_ids, + &build_telegram_text(case_id, &intake, &summary)?, + ) + } else { + Vec::new() + }; + + let result = json!({ + "summary": summary, + "case_id": case_id, + "case_created": if created_case.is_null() { Value::Null } else { created_case }, + "case_linked": linked, + "case_comment_added": comment_added, + "case_error": case_error, + "telegram_results": telegram_results, + }); + println!("{}", serde_json::to_string_pretty(&result)?); + Ok(if result.get("case_error").is_some_and(|v| !v.is_null()) { + 1 + } else { + 0 + }) +} + +fn send_telegram(client: &Client, bot_token: &str, chat_ids: &[&str], text: &str) -> Vec { + let mut results = Vec::new(); + for chat_id in chat_ids { + let url = format!("https://api.telegram.org/bot{bot_token}/sendMessage"); + let response = client + .post(&url) + .form(&[("chat_id", *chat_id), ("text", text)]) + .send(); + match response { + Ok(resp) => match resp.json::() { + Ok(body) => results.push(json!({"chat_id": chat_id, "ok": true, "response": body})), + Err(err) => { + results.push(json!({"chat_id": chat_id, "ok": false, "error": err.to_string()})) + } + }, + Err(_) => results + .push(json!({"chat_id": chat_id, "ok": false, "error": "telegram request failed"})), + } + } + results +} diff --git a/adk-rust/crates/hayabusa-tools/src/bin/from_windows.rs b/adk-rust/crates/hayabusa-tools/src/bin/from_windows.rs new file mode 100644 index 0000000..38abf75 --- /dev/null +++ b/adk-rust/crates/hayabusa-tools/src/bin/from_windows.rs @@ -0,0 +1,243 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use anyhow::{Context, Result, bail}; +use clap::Parser; +use hayabusa_tools::{guess_host_from_filename, windows_filename}; +use regex::Regex; +use serde_json::Value; + +const WINDOWS_EXPORT_CMD: &str = r"powershell.exe -ExecutionPolicy Bypass -File C:\ProgramData\AWatch-rus\export-evtx-for-hayabusa.ps1 -DaysBack {days_back} | ConvertTo-Json -Depth 8 -Compress"; +const WINDOWS_LATEST_ZIP_CMD: &str = r"Get-ChildItem 'C:\ProgramData\AWatch-rus\forensics\evtx-exports' -File -Filter '*.zip' | Sort-Object LastWriteTime -Descending | Select-Object -First 1 FullName,Length,LastWriteTime | ConvertTo-Json -Compress"; + +#[derive(Debug, Parser)] +#[command( + about = "Run Windows EVTX export and Hayabusa intake directly from aw-server, without the laptop" +)] +struct Cli { + #[arg( + long, + default_value = "/opt/activitywatch/aw-rus-ops/ansible/inventory.ini" + )] + inventory: PathBuf, + + #[arg(long, default_value = "/opt/activitywatch/aw-rus-ops/venv/bin/ansible")] + ansible_bin: PathBuf, + + #[arg(long, default_value = "/opt/activitywatch/aw-rus-ops/drop")] + drop_dir: PathBuf, + + #[arg(long, default_value_t = 1)] + days_back: i64, + + #[arg(long, default_value = "incident", value_parser = ["quick", "incident", "full"])] + mode: String, + + #[arg(long)] + case_id: Option, + + #[arg(long, default_value = "aw-rus-ops-from-windows")] + link_source: String, + + #[arg(long, default_value = "aw_windows")] + windows_group: String, + + #[arg(long, default_value = "/usr/local/bin/aw-hayabusa")] + wrapper: PathBuf, + + #[arg(long, default_value = "/usr/local/bin/aw-hayabusa-link-case")] + linker: PathBuf, +} + +fn main() { + if let Err(err) = run() { + eprintln!("{err:#}"); + std::process::exit(1); + } +} + +fn run() -> Result<()> { + let cli = Cli::parse(); + ensure_file(&cli.inventory, "inventory")?; + ensure_file(&cli.ansible_bin, "ansible binary")?; + ensure_file(&cli.wrapper, "wrapper")?; + fs::create_dir_all(&cli.drop_dir) + .with_context(|| format!("create {}", cli.drop_dir.display()))?; + + let export_arg = WINDOWS_EXPORT_CMD.replace("{days_back}", &cli.days_back.to_string()); + let export_cmd = vec![ + cli.windows_group.as_str(), + "-i", + path_str(&cli.inventory)?, + "-m", + "win_shell", + "-a", + export_arg.as_str(), + ]; + print_run("RUN_EXPORT", &cli.ansible_bin, &export_cmd); + let export_out = run_capture(&cli.ansible_bin, &export_cmd)?; + let export_json = extract_json_blob(&export_out)?; + + let list_cmd = vec![ + cli.windows_group.as_str(), + "-i", + path_str(&cli.inventory)?, + "-m", + "win_shell", + "-a", + WINDOWS_LATEST_ZIP_CMD, + ]; + print_run("RUN_LIST", &cli.ansible_bin, &list_cmd); + let latest_out = run_capture(&cli.ansible_bin, &list_cmd)?; + let latest = normalize_latest_zip(extract_json_blob(&latest_out)?)?; + let remote_zip = latest + .get("FullName") + .and_then(Value::as_str) + .context("latest zip FullName missing")?; + let filename = windows_filename(remote_zip); + let local_zip = cli.drop_dir.join(&filename); + let remote_zip_posix = remote_zip.replace('\\', "/"); + let fetch_arg = format!( + "src={} dest={}/ flat=yes", + remote_zip_posix, + cli.drop_dir.display() + ); + let fetch_cmd = vec![ + cli.windows_group.as_str(), + "-i", + path_str(&cli.inventory)?, + "-m", + "fetch", + "-a", + fetch_arg.as_str(), + ]; + print_run("RUN_FETCH", &cli.ansible_bin, &fetch_cmd); + run_checked(&cli.ansible_bin, &fetch_cmd)?; + if !local_zip.is_file() { + bail!("fetched zip not found: {}", local_zip.display()); + } + + let host = export_json + .get("hostname") + .and_then(Value::as_str) + .map(str::to_string) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| guess_host_from_filename(&filename)); + let mut accept_args = vec![ + "accept".to_string(), + "--package".to_string(), + local_zip.display().to_string(), + ]; + if !host.is_empty() { + accept_args.extend(["--host".to_string(), host]); + } + print_run_owned("RUN_ACCEPT", &cli.wrapper, &accept_args); + run_checked_owned(&cli.wrapper, &accept_args)?; + + let process_args = vec![ + "process-inbox".to_string(), + "--mode".to_string(), + cli.mode.clone(), + "--limit".to_string(), + "1".to_string(), + ]; + print_run_owned("RUN_PROCESS", &cli.wrapper, &process_args); + run_checked_owned(&cli.wrapper, &process_args)?; + + if let Some(case_id) = cli.case_id { + ensure_file(&cli.linker, "linker")?; + let link_args = vec![ + "--case-id".to_string(), + case_id.to_string(), + "--mode".to_string(), + cli.mode, + "--link-source".to_string(), + cli.link_source, + ]; + print_run_owned("RUN_LINK", &cli.linker, &link_args); + run_checked_owned(&cli.linker, &link_args)?; + } + + let latest_intake = Path::new("/opt/hayabusa/state/latest-intake.json"); + println!("LATEST_INTAKE"); + println!( + "{}", + fs::read_to_string(latest_intake).context("read latest intake")? + ); + Ok(()) +} + +fn ensure_file(path: &Path, label: &str) -> Result<()> { + if !path.is_file() { + bail!("{label} not found: {}", path.display()); + } + Ok(()) +} + +fn path_str(path: &Path) -> Result<&str> { + path.to_str() + .with_context(|| format!("path is not UTF-8: {}", path.display())) +} + +fn run_capture(program: &Path, args: &[&str]) -> Result { + let output = Command::new(program) + .args(args) + .output() + .with_context(|| format!("run {}", program.display()))?; + if !output.status.success() { + print!("{}", String::from_utf8_lossy(&output.stdout)); + eprint!("{}", String::from_utf8_lossy(&output.stderr)); + std::process::exit(output.status.code().unwrap_or(1)); + } + Ok(String::from_utf8_lossy(&output.stdout).to_string()) +} + +fn run_checked(program: &Path, args: &[&str]) -> Result<()> { + let status = Command::new(program) + .args(args) + .status() + .with_context(|| format!("run {}", program.display()))?; + if !status.success() { + std::process::exit(status.code().unwrap_or(1)); + } + Ok(()) +} + +fn run_checked_owned(program: &Path, args: &[String]) -> Result<()> { + let status = Command::new(program) + .args(args) + .status() + .with_context(|| format!("run {}", program.display()))?; + if !status.success() { + std::process::exit(status.code().unwrap_or(1)); + } + Ok(()) +} + +fn print_run(label: &str, program: &Path, args: &[&str]) { + println!("{label} {} {}", program.display(), args.join(" ")); +} + +fn print_run_owned(label: &str, program: &Path, args: &[String]) { + println!("{label} {} {}", program.display(), args.join(" ")); +} + +fn extract_json_blob(text: &str) -> Result { + let re = Regex::new(r"(?s)(\{.*\}|\[.*\])").context("compile JSON extractor")?; + let matches = re.find_iter(text).collect::>(); + for candidate in matches.iter().rev().map(|item| item.as_str()) { + if let Ok(value) = serde_json::from_str::(candidate) { + return Ok(value); + } + } + bail!("cannot parse JSON from ansible output:\n{text}") +} + +fn normalize_latest_zip(value: Value) -> Result { + if let Some(items) = value.as_array() { + items.first().cloned().context("latest zip list is empty") + } else { + Ok(value) + } +} diff --git a/adk-rust/crates/hayabusa-tools/src/bin/link_case.rs b/adk-rust/crates/hayabusa-tools/src/bin/link_case.rs new file mode 100644 index 0000000..b9e7c65 --- /dev/null +++ b/adk-rust/crates/hayabusa-tools/src/bin/link_case.rs @@ -0,0 +1,55 @@ +use anyhow::Result; +use clap::Parser; +use hayabusa_tools::{build_hayabusa_payload, http_client, link_hayabusa_to_case, read_json_file}; +use serde_json::json; +use std::path::PathBuf; + +#[derive(Debug, Parser)] +#[command(about = "Link Hayabusa intake metadata to AW-rus case management")] +struct Cli { + #[arg(long)] + case_id: i64, + + #[arg(long, default_value = "/opt/hayabusa/state/latest-intake.json")] + intake_json: PathBuf, + + #[arg(long, default_value = "http://127.0.0.1:5602")] + case_api_base: String, + + #[arg(long, default_value = "incident")] + mode: String, + + #[arg(long, default_value = "aw-rus-ops")] + link_source: String, +} + +fn main() { + if let Err(err) = run() { + eprintln!("{err:#}"); + std::process::exit(1); + } +} + +fn run() -> Result<()> { + let cli = Cli::parse(); + let intake = read_json_file(&cli.intake_json)?; + let client = http_client()?; + let case = link_hayabusa_to_case( + &client, + &cli.case_api_base, + cli.case_id, + &intake, + &cli.mode, + &cli.link_source, + )?; + let _payload = build_hayabusa_payload(&intake, &cli.mode, &cli.link_source)?; + println!( + "{}", + serde_json::to_string_pretty(&json!({ + "case_id": cli.case_id, + "intake": intake, + "forensics": case.get("forensics").cloned().unwrap_or(serde_json::Value::Null), + }))? + ); + Ok(()) +} diff --git a/adk-rust/crates/hayabusa-tools/src/lib.rs b/adk-rust/crates/hayabusa-tools/src/lib.rs new file mode 100644 index 0000000..8dc3618 --- /dev/null +++ b/adk-rust/crates/hayabusa-tools/src/lib.rs @@ -0,0 +1,526 @@ +use std::collections::{BTreeMap, HashMap}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use anyhow::{Context, Result, anyhow, bail}; +use chrono::{DateTime, Utc}; +use reqwest::blocking::Client; +use reqwest::header::{ACCEPT, CONTENT_TYPE}; +use serde::Serialize; +use serde_json::{Value, json}; + +pub const SEVERITY_ORDER: &[(&str, i64)] = + &[("low", 1), ("medium", 2), ("high", 3), ("critical", 4)]; + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] +pub struct Summary { + pub severity: String, + pub score: i64, + pub events_total: usize, + pub level_counts: BTreeMap, + pub top_rules: Vec, + pub first_timestamp: Option, + pub last_timestamp: Option, + pub failed_logon_rows: usize, + pub successful_logon_rows: usize, + pub suspicious_pwsh: usize, + pub credential_events: usize, + pub timestomp_events: usize, + pub logon_failure_events: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct TopRule { + pub title: String, + pub count: usize, +} + +pub fn http_client() -> Result { + Client::builder() + .timeout(Duration::from_secs(30)) + .no_proxy() + .build() + .context("build HTTP client") +} + +pub fn get_json(client: &Client, url: &str) -> Result { + client + .get(url) + .header(ACCEPT, "application/json") + .send() + .with_context(|| format!("GET {url}"))? + .error_for_status() + .with_context(|| format!("GET {url} status"))? + .json::() + .with_context(|| format!("decode JSON from {url}")) +} + +pub fn post_json(client: &Client, url: &str, payload: &Value) -> Result { + let response = client + .post(url) + .header(ACCEPT, "application/json") + .header(CONTENT_TYPE, "application/json") + .json(payload) + .send() + .with_context(|| format!("POST {url}"))? + .error_for_status() + .with_context(|| format!("POST {url} status"))?; + decode_optional_json(response.text().context("read POST response")?) +} + +pub fn patch_json(client: &Client, url: &str, payload: &Value) -> Result { + let response = client + .patch(url) + .header(ACCEPT, "application/json") + .header(CONTENT_TYPE, "application/json") + .json(payload) + .send() + .with_context(|| format!("PATCH {url}"))? + .error_for_status() + .with_context(|| format!("PATCH {url} status"))?; + decode_optional_json(response.text().context("read PATCH response")?) +} + +fn decode_optional_json(body: String) -> Result { + if body.trim().is_empty() { + Ok(json!({})) + } else { + serde_json::from_str(&body).context("decode JSON response") + } +} + +pub fn read_json_file(path: &Path) -> Result { + let text = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?; + serde_json::from_str(&text).with_context(|| format!("decode {}", path.display())) +} + +pub fn write_json_pretty(value: &Value) -> Result { + serde_json::to_string_pretty(value).context("encode JSON") +} + +pub fn normalize_host(value: Option<&str>) -> String { + value.unwrap_or("").trim().to_lowercase() +} + +pub fn normalize_case_api_base(raw: &str) -> String { + let mut base = raw.trim().trim_end_matches('/').to_string(); + if base.ends_with("/api/0/dlp/cases") { + let cut = base.len() - "/api/0/dlp/cases".len(); + base.truncate(cut); + } + base +} + +pub fn build_hayabusa_payload(intake: &Value, mode: &str, link_source: &str) -> Result { + let report_dir = required_str(intake, "report_dir")?; + let report_dir_path = PathBuf::from(report_dir); + Ok(json!({ + "tool": "hayabusa", + "host": required_str(intake, "host")?, + "mode": mode, + "status": required_str(intake, "status")?, + "intake_id": required_str(intake, "intake_id")?, + "package_path": required_str(intake, "package_path")?, + "sha256": required_str(intake, "sha256")?, + "report_dir": report_dir, + "summary_html": report_dir_path.join("summary.html").to_string_lossy(), + "timeline_path": report_dir_path.join("timeline.jsonl").to_string_lossy(), + "manifest_path": report_dir_path.join("manifest.json").to_string_lossy(), + "link_source": link_source, + })) +} + +pub fn link_hayabusa_to_case( + client: &Client, + case_api_base: &str, + case_id: i64, + intake: &Value, + mode: &str, + link_source: &str, +) -> Result { + let case_api_base = normalize_case_api_base(case_api_base); + let case = get_json( + client, + &format!("{case_api_base}/api/0/dlp/cases/{case_id}"), + )?; + let case_host = normalize_host(case.get("host").and_then(Value::as_str)); + let intake_host = normalize_host(intake.get("host").and_then(Value::as_str)); + if !case_host.is_empty() && !intake_host.is_empty() && case_host != intake_host { + bail!( + "hayabusa host mismatch: case host={} intake host={}", + case.get("host").and_then(Value::as_str).unwrap_or(""), + intake.get("host").and_then(Value::as_str).unwrap_or("") + ); + } + let payload = build_hayabusa_payload(intake, mode, link_source)?; + post_json( + client, + &format!("{case_api_base}/api/0/dlp/cases/{case_id}/forensics/hayabusa"), + &payload, + )?; + get_json( + client, + &format!("{case_api_base}/api/0/dlp/cases/{case_id}"), + ) +} + +pub fn analyze_report(report_dir: &Path) -> Result { + let timeline_path = report_dir.join("timeline.jsonl"); + let mut level_counts: HashMap = HashMap::new(); + let mut title_counts: HashMap = HashMap::new(); + let mut first_ts: Option> = None; + let mut last_ts: Option> = None; + let mut total_events = 0usize; + + if timeline_path.is_file() { + let text = fs::read_to_string(&timeline_path) + .with_context(|| format!("read {}", timeline_path.display()))?; + for raw_line in text.lines() { + let line = raw_line.trim(); + if line.is_empty() { + continue; + } + let Ok(event) = serde_json::from_str::(line) else { + continue; + }; + total_events += 1; + let level = normalize_level(event.get("Level").and_then(Value::as_str)); + *level_counts.entry(level).or_insert(0) += 1; + let title = event + .get("RuleTitle") + .and_then(Value::as_str) + .map(str::trim) + .filter(|title| !title.is_empty()) + .unwrap_or("Unknown rule") + .to_string(); + let next_index = title_counts.len(); + let entry = title_counts.entry(title).or_insert((0, next_index)); + entry.0 += 1; + if let Some(ts) = event + .get("Timestamp") + .and_then(Value::as_str) + .and_then(parse_ts) + { + first_ts = Some(first_ts.map_or(ts, |old| old.min(ts))); + last_ts = Some(last_ts.map_or(ts, |old| old.max(ts))); + } + } + } + + let failed_logons = read_csv_rows(&report_dir.join("logon-summary-failed.csv"))?; + let successful_logons = read_csv_rows(&report_dir.join("logon-summary-successful.csv"))?; + let suspicious_pwsh = title_counts + .iter() + .filter(|(title, _)| { + let text = title.to_lowercase(); + text.contains("pwsh") || text.contains("powershell") || text.contains("obfuscation") + }) + .map(|(_, (count, _))| *count) + .sum(); + let credential_events = title_counts + .iter() + .filter(|(title, _)| title.to_lowercase().contains("credential")) + .map(|(_, (count, _))| *count) + .sum(); + let timestomp_events = title_counts + .iter() + .filter(|(title, _)| title.to_lowercase().contains("timestomp")) + .map(|(_, (count, _))| *count) + .sum(); + let logon_failure_events = title_counts + .iter() + .filter(|(title, _)| title.to_lowercase().contains("logon failure")) + .map(|(_, (count, _))| *count) + .sum(); + let score = level_counts + .iter() + .map(|(level, count)| level_weight(level) * (*count as i64)) + .sum::() + + (failed_logons.min(200) as i64) * 2 + + (suspicious_pwsh as i64) * 6 + + (credential_events as i64) * 8 + + (timestomp_events as i64) * 12 + + (logon_failure_events as i64) * 2; + + let crit_count = *level_counts.get("crit").unwrap_or(&0); + let high_count = *level_counts.get("high").unwrap_or(&0); + let med_count = *level_counts.get("med").unwrap_or(&0); + let severity = + if crit_count >= 1 || score >= 240 || (high_count >= 4 && suspicious_pwsh >= 4) { + "critical" + } else if high_count >= 1 || score >= 120 || suspicious_pwsh >= 8 || credential_events >= 5 + { + "high" + } else if med_count >= 1 || score >= 40 || failed_logons >= 10 { + "medium" + } else { + "low" + } + .to_string(); + + Ok(Summary { + severity, + score, + events_total: total_events, + level_counts: sort_map(level_counts), + top_rules: top_rules(title_counts), + first_timestamp: first_ts.map(format_ts), + last_timestamp: last_ts.map(format_ts), + failed_logon_rows: failed_logons, + successful_logon_rows: successful_logons, + suspicious_pwsh, + credential_events, + timestomp_events, + logon_failure_events, + }) +} + +pub fn build_case_payload(intake: &Value, summary: &Summary) -> Result { + Ok(json!({ + "incident_id": format!("hayabusa:{}:{}", required_str(intake, "host")?, required_str(intake, "intake_id")?), + "host": required_str(intake, "host")?, + "title": build_case_title(required_str(intake, "host")?, summary), + "severity": summary.severity, + "evidence": { + "hayabusa": { + "intake_id": required_str(intake, "intake_id")?, + "package_path": required_str(intake, "package_path")?, + "sha256": required_str(intake, "sha256")?, + "report_dir": required_str(intake, "report_dir")?, + "summary": serde_json::to_value(summary)?, + } + } + })) +} + +pub fn build_case_title(host: &str, summary: &Summary) -> String { + let suffix = summary + .top_rules + .first() + .map(|item| item.title.as_str()) + .unwrap_or("No dominant rule"); + format!( + "Hayabusa {} · {host} · {suffix}", + summary.severity.to_uppercase() + ) +} + +pub fn build_comment(summary: &Summary, intake: &Value) -> Result { + let top = summary + .top_rules + .iter() + .take(3) + .map(|item| format!("{} ({})", item.title, item.count)) + .collect::>() + .join(", "); + Ok(format!( + "Hayabusa auto-summary\nSeverity: {} (score={})\nHost: {}\nIntake: {}\nEvents: {}, failed_logons={}, suspicious_pwsh={}, credential_events={}\nTop rules: {}\nReport: {}", + summary.severity, + summary.score, + required_str(intake, "host")?, + required_str(intake, "intake_id")?, + summary.events_total, + summary.failed_logon_rows, + summary.suspicious_pwsh, + summary.credential_events, + if top.is_empty() { + "n/a".to_string() + } else { + top + }, + required_str(intake, "report_dir")?, + )) +} + +pub fn build_telegram_text( + case_id: Option, + intake: &Value, + summary: &Summary, +) -> Result { + let severity_label = match summary.severity.as_str() { + "critical" => "критичное событие", + "high" => "опасное событие", + "medium" => "подозрительное событие", + "low" => "слабый сигнал", + other => other, + }; + let top_rule = summary + .top_rules + .first() + .map(|item| item.title.as_str()) + .unwrap_or("нет явного доминирующего правила"); + let top_count = summary + .top_rules + .first() + .map(|item| item.count) + .unwrap_or(0); + let mut lines = vec![ + format!("Hayabusa: {severity_label}"), + String::new(), + format!("Хост: {}", required_str(intake, "host")?), + ]; + if let Some(case_id) = case_id { + lines.push(format!("Кейс: {case_id}")); + } + lines.extend([ + format!("Уровень: {}", summary.severity), + String::new(), + "Что найдено:".to_string(), + format!("- {top_rule}: {top_count}"), + format!("- подозрительный PowerShell: {}", summary.suspicious_pwsh), + format!("- ошибок входа: {}", summary.logon_failure_events), + format!("- событий по учётным данным: {}", summary.credential_events), + ]); + if summary.timestomp_events > 0 { + lines.push(format!( + "- timestomp-подобных событий: {}", + summary.timestomp_events + )); + } + lines.extend([ + String::new(), + "Главный риск:".to_string(), + "возможная активность вокруг учётных данных и PowerShell".to_string(), + String::new(), + "Отчёт:".to_string(), + required_str(intake, "report_dir")?.to_string(), + ]); + Ok(lines.join("\n")) +} + +pub fn severity_meets(actual: &str, threshold: &str) -> bool { + severity_order(actual) >= severity_order(threshold) +} + +pub fn env_bool(name: &str, default: bool) -> bool { + match std::env::var(name) { + Ok(value) => matches!( + value.trim().to_lowercase().as_str(), + "1" | "true" | "yes" | "on" + ), + Err(_) => default, + } +} + +pub fn env_string(name: &str, default: &str) -> String { + std::env::var(name) + .ok() + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| default.to_string()) +} + +pub fn required_str<'a>(value: &'a Value, key: &str) -> Result<&'a str> { + value + .get(key) + .and_then(Value::as_str) + .ok_or_else(|| anyhow!("{key} missing")) +} + +pub fn windows_filename(path: &str) -> String { + path.rsplit(['\\', '/']) + .next() + .filter(|name| !name.is_empty()) + .unwrap_or(path) + .to_string() +} + +pub fn guess_host_from_filename(filename: &str) -> String { + let stem = filename.strip_suffix(".zip").unwrap_or(filename); + stem.split_once('-') + .map(|(host, _)| host) + .unwrap_or(stem) + .to_string() +} + +fn read_csv_rows(path: &Path) -> Result { + if !path.is_file() { + return Ok(0); + } + let text = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?; + let rows = text.lines().count(); + Ok(rows.saturating_sub(1)) +} + +fn normalize_level(level: Option<&str>) -> String { + match level.unwrap_or("").trim().to_lowercase().as_str() { + "" => "info", + "informational" | "info" => "info", + "low" => "low", + "med" | "medium" => "med", + "high" => "high", + "crit" | "critical" => "crit", + other => other, + } + .to_string() +} + +fn level_weight(level: &str) -> i64 { + match level { + "info" => 1, + "low" => 4, + "med" => 12, + "high" => 40, + "crit" => 100, + _ => 0, + } +} + +fn severity_order(value: &str) -> i64 { + SEVERITY_ORDER + .iter() + .find_map(|(name, order)| (*name == value).then_some(*order)) + .unwrap_or(0) +} + +fn parse_ts(value: &str) -> Option> { + DateTime::parse_from_rfc3339(&value.replace('Z', "+00:00")) + .map(|ts| ts.with_timezone(&Utc)) + .ok() +} + +fn format_ts(value: DateTime) -> String { + value.to_rfc3339_opts(chrono::SecondsFormat::AutoSi, true) +} + +fn sort_map(values: HashMap) -> BTreeMap { + values.into_iter().collect() +} + +fn top_rules(values: HashMap) -> Vec { + let mut rows = values.into_iter().collect::>(); + rows.sort_by(|a, b| b.1.0.cmp(&a.1.0).then_with(|| a.1.1.cmp(&b.1.1))); + rows.into_iter() + .take(5) + .map(|(title, (count, _))| TopRule { title, count }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn severity_rules_match_expected_thresholds() { + assert!(severity_meets("high", "medium")); + assert!(!severity_meets("low", "medium")); + } + + #[test] + fn analyzes_timeline_and_csv_counts() { + let dir = tempdir().unwrap(); + fs::write( + dir.path().join("timeline.jsonl"), + r#"{"Level":"high","RuleTitle":"PowerShell Credential Dump","Timestamp":"2026-06-01T00:00:00Z"}"#, + ) + .unwrap(); + fs::write(dir.path().join("logon-summary-failed.csv"), "h\n1\n2\n").unwrap(); + let summary = analyze_report(dir.path()).unwrap(); + assert_eq!(summary.severity, "high"); + assert_eq!(summary.events_total, 1); + assert_eq!(summary.failed_logon_rows, 2); + assert_eq!(summary.credential_events, 1); + assert_eq!(summary.suspicious_pwsh, 1); + } +} diff --git a/adk-rust/crates/merge-aw-server-dbs/Cargo.toml b/adk-rust/crates/merge-aw-server-dbs/Cargo.toml new file mode 100644 index 0000000..a0cfe70 --- /dev/null +++ b/adk-rust/crates/merge-aw-server-dbs/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "merge-aw-server-dbs" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +publish.workspace = true + +[dependencies] +anyhow.workspace = true +clap.workspace = true +rusqlite = { workspace = true, features = ["backup"] } +serde_json.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/adk-rust/crates/merge-aw-server-dbs/src/main.rs b/adk-rust/crates/merge-aw-server-dbs/src/main.rs new file mode 100644 index 0000000..1a13c3a --- /dev/null +++ b/adk-rust/crates/merge-aw-server-dbs/src/main.rs @@ -0,0 +1,376 @@ +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, bail}; +use clap::Parser; +use rusqlite::{Connection, DatabaseName, OptionalExtension, Row, params}; +use serde_json::json; + +#[derive(Debug, Parser)] +#[command(about = "Merge ActivityWatch aw-server-rust SQLite databases")] +struct Cli { + #[arg(long)] + base: PathBuf, + + #[arg(long)] + output: PathBuf, + + #[arg(long)] + overlay: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct BucketKey { + name: String, + type_: String, + client: String, + hostname: String, +} + +#[derive(Debug, Clone)] +struct Bucket { + rowid: i64, + id: Option, + key: BucketKey, + created: i64, + data_deprecated: Option, + data: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct EventKey { + starttime: i64, + endtime: i64, + data: String, +} + +#[derive(Debug, Default)] +struct MergeStats { + inserted_buckets: usize, + inserted_events: usize, +} + +fn main() { + let code = match run() { + Ok(()) => 0, + Err(err) => { + eprintln!("{err:#}"); + 1 + } + }; + std::process::exit(code); +} + +fn run() -> Result<()> { + let cli = Cli::parse(); + if !cli.base.is_file() { + bail!("Base DB not found: {}", cli.base.display()); + } + ensure_parent(&cli.output)?; + let tmp_output = tmp_output_path(&cli.output); + if tmp_output.exists() { + fs::remove_file(&tmp_output) + .with_context(|| format!("remove stale tmp output {}", tmp_output.display()))?; + } + copy_sqlite_via_backup(&cli.base, &tmp_output)?; + + let mut stats = MergeStats::default(); + if let Some(overlay) = &cli.overlay { + if overlay.exists() { + stats = merge_overlay(&tmp_output, overlay)?; + } + } + + fs::rename(&tmp_output, &cli.output) + .with_context(|| format!("move {} to {}", tmp_output.display(), cli.output.display()))?; + println!( + "{}", + json!({ + "base": cli.base.to_string_lossy(), + "overlay": cli.overlay.as_ref().map(|p| p.to_string_lossy().to_string()), + "output": cli.output.to_string_lossy(), + "inserted_buckets": stats.inserted_buckets, + "inserted_events": stats.inserted_events, + }) + ); + Ok(()) +} + +fn copy_sqlite_via_backup(src: &Path, dst: &Path) -> Result<()> { + ensure_parent(dst)?; + let source = + Connection::open(src).with_context(|| format!("open base DB {}", src.display()))?; + source + .backup(DatabaseName::Main, dst, None) + .with_context(|| format!("backup {} to {}", src.display(), dst.display())) +} + +fn merge_overlay(dest_path: &Path, overlay_path: &Path) -> Result { + let dest = + connect(dest_path).with_context(|| format!("open output DB {}", dest_path.display()))?; + let source = connect(overlay_path) + .with_context(|| format!("open overlay DB {}", overlay_path.display()))?; + + let mut stats = MergeStats::default(); + let source_buckets = load_buckets(&source)?; + let mut dest_bucket_map = load_dest_bucket_key_map(&dest)?; + let mut dest_id_map = load_dest_bucket_id_map(&dest)?; + + for src_bucket in source_buckets { + let dest_rowid = if let Some(src_id) = &src_bucket.id { + dest_id_map.get(src_id).copied() + } else { + None + } + .or_else(|| dest_bucket_map.get(&src_bucket.key).copied()) + .or_else(|| { + find_bucket_by_name(&dest, &src_bucket.key.name) + .ok() + .flatten() + }); + + let dest_rowid = match dest_rowid { + Some(rowid) => { + update_bucket_by_rowid(&dest, rowid, &src_bucket)?; + rowid + } + None => { + stats.inserted_buckets += 1; + insert_bucket(&dest, &src_bucket)? + } + }; + + dest_bucket_map.insert(src_bucket.key.clone(), dest_rowid); + if let Some(id) = &src_bucket.id { + dest_id_map.insert(id.clone(), dest_rowid); + } + + let mut existing_events = load_existing_events(&dest, dest_rowid)?; + let source_events = load_source_events(&source, src_bucket.rowid)?; + for event in source_events { + if existing_events.contains(&event) { + continue; + } + dest.execute( + "insert into events (bucketrow, starttime, endtime, data) values (?1, ?2, ?3, ?4)", + params![dest_rowid, event.starttime, event.endtime, event.data], + ) + .with_context(|| format!("insert event into bucketrow {dest_rowid}"))?; + existing_events.insert(event); + stats.inserted_events += 1; + } + } + dest.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);") + .context("checkpoint output DB")?; + Ok(stats) +} + +fn connect(path: &Path) -> Result { + let connection = Connection::open(path)?; + connection.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")?; + Ok(connection) +} + +fn load_buckets(connection: &Connection) -> Result> { + let mut stmt = connection.prepare( + "select rowid as bucketrow, id, name, type, client, hostname, created, data_deprecated, data from buckets order by rowid", + )?; + let rows = stmt.query_map([], bucket_from_row)?; + rows.collect::>>() + .context("load buckets") +} + +fn bucket_from_row(row: &Row<'_>) -> rusqlite::Result { + Ok(Bucket { + rowid: row.get("bucketrow")?, + id: row.get("id")?, + key: BucketKey { + name: row.get("name")?, + type_: row.get("type")?, + client: row.get("client")?, + hostname: row.get("hostname")?, + }, + created: row.get("created")?, + data_deprecated: row.get("data_deprecated")?, + data: row.get("data")?, + }) +} + +fn load_dest_bucket_key_map(connection: &Connection) -> Result> { + Ok(load_buckets(connection)? + .into_iter() + .map(|bucket| (bucket.key, bucket.rowid)) + .collect()) +} + +fn load_dest_bucket_id_map(connection: &Connection) -> Result> { + Ok(load_buckets(connection)? + .into_iter() + .filter_map(|bucket| bucket.id.map(|id| (id, bucket.rowid))) + .collect()) +} + +fn find_bucket_by_name(connection: &Connection, name: &str) -> Result> { + connection + .query_row( + "select rowid as bucketrow from buckets where name = ?1 order by rowid limit 1", + [name], + |row| row.get::<_, i64>("bucketrow"), + ) + .optional() + .context("find bucket by name") +} + +fn insert_bucket(connection: &Connection, bucket: &Bucket) -> Result { + connection.execute( + "insert into buckets (id, name, type, client, hostname, created, data_deprecated, data) values (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + params![ + bucket.id, + bucket.key.name, + bucket.key.type_, + bucket.key.client, + bucket.key.hostname, + bucket.created, + bucket.data_deprecated, + bucket.data + ], + )?; + Ok(connection.last_insert_rowid()) +} + +fn update_bucket_by_rowid(connection: &Connection, rowid: i64, bucket: &Bucket) -> Result<()> { + connection.execute( + "update buckets set type = ?1, client = ?2, hostname = ?3, created = ?4, data_deprecated = ?5, data = ?6 where rowid = ?7", + params![ + bucket.key.type_, + bucket.key.client, + bucket.key.hostname, + bucket.created, + bucket.data_deprecated, + bucket.data, + rowid + ], + )?; + Ok(()) +} + +fn load_existing_events(connection: &Connection, bucketrow: i64) -> Result> { + let mut stmt = + connection.prepare("select starttime, endtime, data from events where bucketrow = ?1")?; + let rows = stmt.query_map([bucketrow], |row| { + Ok(EventKey { + starttime: row.get(0)?, + endtime: row.get(1)?, + data: row.get(2)?, + }) + })?; + Ok(rows.collect::>>()?) +} + +fn load_source_events(connection: &Connection, bucketrow: i64) -> Result> { + let mut stmt = connection + .prepare("select starttime, endtime, data from events where bucketrow = ?1 order by id")?; + let rows = stmt.query_map([bucketrow], |row| { + Ok(EventKey { + starttime: row.get(0)?, + endtime: row.get(1)?, + data: row.get(2)?, + }) + })?; + Ok(rows.collect::>>()?) +} + +fn ensure_parent(path: &Path) -> Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("create parent directory {}", parent.display()))?; + } + Ok(()) +} + +fn tmp_output_path(output: &Path) -> PathBuf { + let mut os = output.as_os_str().to_os_string(); + os.push(".tmp"); + PathBuf::from(os) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tmp_output_appends_tmp_like_python_suffix() { + assert_eq!( + tmp_output_path(Path::new("/tmp/sqlite.db")), + PathBuf::from("/tmp/sqlite.db.tmp") + ); + } + + #[test] + fn merge_inserts_new_bucket_and_skips_duplicate_event() { + let dir = tempfile::tempdir().unwrap(); + let base = dir.path().join("base.db"); + let overlay = dir.path().join("overlay.db"); + let output = dir.path().join("out.db"); + create_fixture_db(&base, &[("bucket-a", "id-a")], &[(1, 10, 20, r#"{"a":1}"#)]); + create_fixture_db( + &overlay, + &[("bucket-a", "id-a"), ("bucket-b", "id-b")], + &[(1, 10, 20, r#"{"a":1}"#), (2, 30, 40, r#"{"b":1}"#)], + ); + + copy_sqlite_via_backup(&base, &output).unwrap(); + let stats = merge_overlay(&output, &overlay).unwrap(); + assert_eq!(stats.inserted_buckets, 1); + assert_eq!(stats.inserted_events, 1); + + let conn = Connection::open(&output).unwrap(); + let bucket_count: i64 = conn + .query_row("select count(*) from buckets", [], |row| row.get(0)) + .unwrap(); + let event_count: i64 = conn + .query_row("select count(*) from events", [], |row| row.get(0)) + .unwrap(); + assert_eq!(bucket_count, 2); + assert_eq!(event_count, 2); + } + + fn create_fixture_db(path: &Path, buckets: &[(&str, &str)], events: &[(i64, i64, i64, &str)]) { + let conn = Connection::open(path).unwrap(); + conn.execute_batch( + "create table buckets ( + id text, + name text unique, + type text, + client text, + hostname text, + created integer, + data_deprecated text, + data text + ); + create table events ( + id integer primary key autoincrement, + bucketrow integer, + starttime integer, + endtime integer, + data text + );", + ) + .unwrap(); + for (name, id) in buckets { + conn.execute( + "insert into buckets (id, name, type, client, hostname, created, data_deprecated, data) values (?1, ?2, 'type', 'client', 'host', 1, null, '{}')", + params![id, name], + ) + .unwrap(); + } + for (bucketrow, starttime, endtime, data) in events { + conn.execute( + "insert into events (bucketrow, starttime, endtime, data) values (?1, ?2, ?3, ?4)", + params![bucketrow, starttime, endtime, data], + ) + .unwrap(); + } + } +} diff --git a/adk-rust/crates/prod-backup-restore/Cargo.toml b/adk-rust/crates/prod-backup-restore/Cargo.toml new file mode 100644 index 0000000..2d08c0e --- /dev/null +++ b/adk-rust/crates/prod-backup-restore/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "prod-backup-restore" +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 +serde.workspace = true +serde_json.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/adk-rust/crates/prod-backup-restore/src/main.rs b/adk-rust/crates/prod-backup-restore/src/main.rs new file mode 100644 index 0000000..1d4a00f --- /dev/null +++ b/adk-rust/crates/prod-backup-restore/src/main.rs @@ -0,0 +1,476 @@ +use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use anyhow::{Context, Result, bail}; +use chrono::Local; +use clap::Parser; +use serde::Serialize; + +const DEFAULT_SERVER_HOST: &str = "10.10.10.13"; +const DEFAULT_SERVER_USER: &str = "igor"; +const DEFAULT_LEGACY_DB: &str = "/root/.local/share/activitywatch/aw-server-rust/sqlite.db"; +const DEFAULT_TARGET_DB: &str = + "/var/lib/activitywatch/.local/share/activitywatch/aw-server-rust/sqlite.db"; +const DEFAULT_REMOTE_MERGE_SCRIPT: &str = "/tmp/merge_aw_server_dbs.py"; + +#[derive(Debug, Parser)] +#[command(about = "Safe planner/checker for the destructive prod backup restore flow")] +struct Cli { + #[arg(long, default_value = ".")] + root: PathBuf, + + #[arg(long)] + check_inputs: bool, + + #[arg(long)] + json: bool, + + #[arg(long)] + timestamp: Option, + + #[arg(long)] + server_host: Option, + + #[arg(long)] + server_user: Option, + + #[arg(long, default_value = "ansible/inventory.ini")] + inventory: PathBuf, + + #[arg(long, default_value = DEFAULT_LEGACY_DB)] + legacy_db: String, + + #[arg(long, default_value = DEFAULT_TARGET_DB)] + target_db: String, + + #[arg(long, default_value = DEFAULT_REMOTE_MERGE_SCRIPT)] + remote_merge_script: String, + + #[arg(long)] + apply: bool, +} + +#[derive(Debug, Serialize)] +struct Plan { + mode: &'static str, + root: String, + env_file: String, + server_host: String, + server_user: String, + timestamp: String, + remote_backup_dir: String, + legacy_db: String, + target_db: String, + remote_merge_script: String, + required_env: Vec, + required_commands: Vec, + required_files: Vec, + steps: Vec, + missing_count: usize, +} + +#[derive(Debug, Serialize)] +struct Requirement { + name: String, + ok: bool, + detail: String, +} + +#[derive(Debug, Serialize)] +struct Step { + order: usize, + kind: &'static str, + command: String, + destructive: bool, +} + +fn main() { + let code = match run() { + Ok(code) => code, + Err(err) => { + eprintln!("{err:#}"); + 1 + } + }; + std::process::exit(code); +} + +fn run() -> Result { + let cli = Cli::parse(); + if cli.apply { + bail!( + "Rust apply is intentionally disabled for this stage; use scripts/prod_backup_restore.sh --apply-legacy only for explicit legacy restore" + ); + } + let root = cli + .root + .canonicalize() + .with_context(|| format!("canonicalize root {}", cli.root.display()))?; + let env_file = root.join("secrets/runtime.env"); + let env_values = read_env_file(&env_file).unwrap_or_default(); + let timestamp = cli + .timestamp + .clone() + .unwrap_or_else(|| Local::now().format("%Y%m%d-%H%M%S").to_string()); + let server_host = cli + .server_host + .or_else(|| env_value("AW_SERVER_HOST", &env_values)) + .unwrap_or_else(|| DEFAULT_SERVER_HOST.to_string()); + let server_user = cli + .server_user + .or_else(|| env_value("AW_SERVER_USER", &env_values)) + .unwrap_or_else(|| DEFAULT_SERVER_USER.to_string()); + let remote_backup_dir = format!("/var/lib/activitywatch/backups/prod-restore-{timestamp}"); + let inventory = if cli.inventory.is_absolute() { + cli.inventory.clone() + } else { + root.join(&cli.inventory) + }; + let merge_script = root.join("scripts/merge_aw_server_dbs.py"); + let plan = build_plan( + &root, + &env_file, + &env_values, + &server_host, + &server_user, + ×tamp, + &remote_backup_dir, + &cli.legacy_db, + &cli.target_db, + &cli.remote_merge_script, + &inventory, + &merge_script, + ); + + if cli.json { + println!("{}", serde_json::to_string_pretty(&plan)?); + } else { + print_plan(&plan); + } + + if cli.check_inputs && plan.missing_count > 0 { + Ok(2) + } else { + Ok(0) + } +} + +#[allow(clippy::too_many_arguments)] +fn build_plan( + root: &Path, + env_file: &Path, + env_values: &HashMap, + server_host: &str, + server_user: &str, + timestamp: &str, + remote_backup_dir: &str, + legacy_db: &str, + target_db: &str, + remote_merge_script: &str, + inventory: &Path, + merge_script: &Path, +) -> Plan { + let required_env = ["AW_SSH_PASSWORD", "AW_WINRM_PASSWORD"] + .into_iter() + .map(|name| { + let ok = env_value(name, env_values).is_some(); + Requirement { + name: name.to_string(), + ok, + detail: if ok { + "present (value hidden)".to_string() + } else { + "missing".to_string() + }, + } + }) + .collect::>(); + let required_commands = ["sshpass", "ansible-playbook"] + .into_iter() + .map(|name| Requirement { + name: name.to_string(), + ok: command_exists(name), + detail: if command_exists(name) { + "found in PATH".to_string() + } else { + "missing in PATH".to_string() + }, + }) + .collect::>(); + let required_files = [ + ("inventory", inventory), + ("merge_aw_server_dbs.py", merge_script), + ] + .into_iter() + .map(|(name, path)| Requirement { + name: name.to_string(), + ok: path.is_file(), + detail: path.display().to_string(), + }) + .collect::>(); + + let mut steps = Vec::new(); + push_step( + &mut steps, + "scp", + format!( + "sshpass scp scripts/merge_aw_server_dbs.py {server_user}@{server_host}:{remote_merge_script}" + ), + false, + ); + push_step( + &mut steps, + "ssh", + format!( + "sudo mkdir -p '{remote_backup_dir}' && sudo chown root:root '{remote_backup_dir}'" + ), + false, + ); + push_step( + &mut steps, + "ssh", + format!("sudo test -f '{legacy_db}'"), + false, + ); + push_step( + &mut steps, + "ssh", + format!("sudo test -f '{target_db}'"), + false, + ); + push_step( + &mut steps, + "ssh", + format!( + "sudo cp -a '{legacy_db}' '{remote_backup_dir}/legacy-root-sqlite.db' && sudo cp -a '{target_db}' '{remote_backup_dir}/target-before-merge-sqlite.db'" + ), + false, + ); + push_step( + &mut steps, + "ssh", + "sudo systemctl stop activitywatch-server.service || true".to_string(), + true, + ); + push_step( + &mut steps, + "ssh", + format!( + "sudo python3 '{remote_merge_script}' --base '{legacy_db}' --overlay '{target_db}' --output '{remote_backup_dir}/sqlite.merged.db'" + ), + true, + ); + push_step( + &mut steps, + "ssh", + format!( + "sudo install -o activitywatch -g activitywatch -m 0644 '{remote_backup_dir}/sqlite.merged.db' '{target_db}'" + ), + true, + ); + for playbook in [ + "ansible/deploy_aw_server.yml", + "ansible/deploy_aw_windows.yml", + "ansible/post_validate_aw_windows.yml", + ] { + push_step( + &mut steps, + "ansible-playbook", + format!("ansible-playbook -i {} {playbook}", inventory.display()), + true, + ); + } + push_step( + &mut steps, + "validate", + "query AW historical window data for 2026-04-29 and settings always_active_pattern" + .to_string(), + false, + ); + + let missing_count = required_env + .iter() + .chain(required_commands.iter()) + .chain(required_files.iter()) + .filter(|item| !item.ok) + .count(); + + Plan { + mode: "plan-only", + root: root.display().to_string(), + env_file: env_file.display().to_string(), + server_host: server_host.to_string(), + server_user: server_user.to_string(), + timestamp: timestamp.to_string(), + remote_backup_dir: remote_backup_dir.to_string(), + legacy_db: legacy_db.to_string(), + target_db: target_db.to_string(), + remote_merge_script: remote_merge_script.to_string(), + required_env, + required_commands, + required_files, + steps, + missing_count, + } +} + +fn push_step(steps: &mut Vec, kind: &'static str, command: String, destructive: bool) { + steps.push(Step { + order: steps.len() + 1, + kind, + command, + destructive, + }); +} + +fn print_plan(plan: &Plan) { + println!("prod-backup-restore: {}", plan.mode); + println!("root: {}", plan.root); + println!("server: {}@{}", plan.server_user, plan.server_host); + println!("remote_backup_dir: {}", plan.remote_backup_dir); + println!("missing_inputs: {}", plan.missing_count); + println!(); + println!("Required env:"); + for item in &plan.required_env { + println!(" [{}] {} - {}", ok_mark(item.ok), item.name, item.detail); + } + println!("Required commands:"); + for item in &plan.required_commands { + println!(" [{}] {} - {}", ok_mark(item.ok), item.name, item.detail); + } + println!("Required files:"); + for item in &plan.required_files { + println!(" [{}] {} - {}", ok_mark(item.ok), item.name, item.detail); + } + println!(); + println!("Planned steps, not executed:"); + for step in &plan.steps { + let risk = if step.destructive { + "MUTATION" + } else { + "read/prepare" + }; + println!( + " {:02}. {:<16} {:<12} {}", + step.order, step.kind, risk, step.command + ); + } +} + +fn ok_mark(ok: bool) -> &'static str { + if ok { "OK" } else { "MISS" } +} + +fn env_value(name: &str, env_values: &HashMap) -> Option { + std::env::var(name) + .ok() + .filter(|value| !value.trim().is_empty()) + .or_else(|| env_values.get(name).cloned()) + .filter(|value| !value.trim().is_empty()) +} + +fn read_env_file(path: &Path) -> Result> { + let text = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?; + let mut values = HashMap::new(); + for raw_line in text.lines() { + let line = raw_line.trim(); + if line.is_empty() || line.starts_with('#') || !line.contains('=') { + continue; + } + let mut parts = line.splitn(2, '='); + let Some(key) = parts.next().map(str::trim) else { + continue; + }; + let Some(value) = parts.next().map(str::trim) else { + continue; + }; + if key.is_empty() || key.starts_with("export ") { + continue; + } + values.insert(key.to_string(), strip_shell_quotes(value).to_string()); + } + Ok(values) +} + +fn strip_shell_quotes(value: &str) -> &str { + if value.len() >= 2 + && ((value.starts_with('"') && value.ends_with('"')) + || (value.starts_with('\'') && value.ends_with('\''))) + { + &value[1..value.len() - 1] + } else { + value + } +} + +fn command_exists(name: &str) -> bool { + Command::new("sh") + .arg("-c") + .arg(format!("command -v {name} >/dev/null 2>&1")) + .status() + .map(|status| status.success()) + .unwrap_or(false) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn strips_simple_shell_quotes() { + assert_eq!(strip_shell_quotes("'secret'"), "secret"); + assert_eq!(strip_shell_quotes("\"secret\""), "secret"); + assert_eq!(strip_shell_quotes("plain"), "plain"); + } + + #[test] + fn reads_simple_env_file_without_exposing_values() { + let dir = tempfile::tempdir().unwrap(); + let env = dir.path().join("runtime.env"); + fs::write( + &env, + "AW_SSH_PASSWORD='one'\nAW_WINRM_PASSWORD=\"two\"\n# ignored\n", + ) + .unwrap(); + let values = read_env_file(&env).unwrap(); + assert_eq!(values.get("AW_SSH_PASSWORD").unwrap(), "one"); + assert_eq!(values.get("AW_WINRM_PASSWORD").unwrap(), "two"); + } + + #[test] + fn plan_marks_destructive_steps() { + let dir = tempfile::tempdir().unwrap(); + let inventory = dir.path().join("ansible/inventory.ini"); + let merge = dir.path().join("scripts/merge_aw_server_dbs.py"); + fs::create_dir_all(inventory.parent().unwrap()).unwrap(); + fs::create_dir_all(merge.parent().unwrap()).unwrap(); + fs::write(&inventory, "").unwrap(); + fs::write(&merge, "").unwrap(); + let mut env = HashMap::new(); + env.insert("AW_SSH_PASSWORD".to_string(), "hidden".to_string()); + env.insert("AW_WINRM_PASSWORD".to_string(), "hidden".to_string()); + let plan = build_plan( + dir.path(), + &dir.path().join("secrets/runtime.env"), + &env, + "10.10.10.13", + "igor", + "20260602-000000", + "/var/lib/activitywatch/backups/prod-restore-20260602-000000", + DEFAULT_LEGACY_DB, + DEFAULT_TARGET_DB, + DEFAULT_REMOTE_MERGE_SCRIPT, + &inventory, + &merge, + ); + assert!(plan.steps.iter().any(|step| step.destructive)); + assert!( + plan.steps + .iter() + .any(|step| step.command.contains("systemctl stop activitywatch-server")) + ); + assert_eq!(plan.required_env.iter().filter(|item| item.ok).count(), 2); + } +} diff --git a/adk-rust/crates/prod-rollout/Cargo.toml b/adk-rust/crates/prod-rollout/Cargo.toml new file mode 100644 index 0000000..1ead670 --- /dev/null +++ b/adk-rust/crates/prod-rollout/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "prod-rollout" +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 +serde.workspace = true +serde_json.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/adk-rust/crates/prod-rollout/src/main.rs b/adk-rust/crates/prod-rollout/src/main.rs new file mode 100644 index 0000000..eb1844e --- /dev/null +++ b/adk-rust/crates/prod-rollout/src/main.rs @@ -0,0 +1,662 @@ +use std::collections::HashMap; +use std::fs::{self, OpenOptions}; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use anyhow::{Context, Result, bail}; +use chrono::Local; +use clap::Parser; +use serde::Serialize; + +#[derive(Debug, Parser)] +#[command(about = "Safe planner/orchestrator for the production ActivityWatch rollout")] +struct Cli { + #[arg(long, default_value = ".")] + root: PathBuf, + + #[arg(long, default_value = "ansible/inventory.ini")] + inventory: PathBuf, + + #[arg(long)] + check_inputs: bool, + + #[arg(long)] + json: bool, + + #[arg(long)] + apply: bool, + + #[arg(long)] + skip_quality_gate: bool, + + #[arg(long)] + timestamp: Option, +} + +#[derive(Debug, Clone, Serialize)] +struct Requirement { + name: String, + ok: bool, + detail: String, +} + +#[derive(Debug, Clone, Serialize)] +struct Step { + order: usize, + name: String, + command: Vec, + log_file: String, + mutation: bool, +} + +#[derive(Debug, Serialize)] +struct Plan { + mode: &'static str, + root: String, + branch: String, + env_file: String, + inventory: String, + log_dir: String, + required_env: Vec, + required_commands: Vec, + required_files: Vec, + steps: Vec, + missing_count: usize, +} + +#[derive(Debug, Serialize)] +struct StepResult { + order: usize, + name: String, + status: &'static str, + exit_code: Option, + log_file: String, +} + +#[derive(Debug, Serialize)] +struct ApplyReport { + plan: Plan, + results: Vec, +} + +fn main() { + let code = match run() { + Ok(code) => code, + Err(err) => { + eprintln!("{err:#}"); + 1 + } + }; + std::process::exit(code); +} + +fn run() -> Result { + let cli = Cli::parse(); + let root = cli + .root + .canonicalize() + .with_context(|| format!("canonicalize root {}", cli.root.display()))?; + let timestamp = cli + .timestamp + .clone() + .unwrap_or_else(|| Local::now().format("%Y%m%d-%H%M%S").to_string()); + let env_file = root.join("secrets/runtime.env"); + let env_values = read_env_file(&env_file).unwrap_or_default(); + let inventory = absolute_path(&root, &cli.inventory); + let log_dir = root.join(".rollout-logs").join(×tamp); + let plan = build_plan( + &root, + &env_file, + &env_values, + &inventory, + &log_dir, + cli.apply, + cli.skip_quality_gate, + ); + + if !cli.apply { + if cli.json { + println!("{}", serde_json::to_string_pretty(&plan)?); + } else { + print_plan(&plan); + } + return if cli.check_inputs && plan.missing_count > 0 { + Ok(2) + } else { + Ok(0) + }; + } + + if plan.missing_count > 0 { + if cli.json { + println!("{}", serde_json::to_string_pretty(&plan)?); + } else { + print_plan(&plan); + } + bail!("refusing --apply because required inputs are missing"); + } + + let report = apply_plan(plan, env_values)?; + if cli.json { + println!("{}", serde_json::to_string_pretty(&report)?); + } else { + print_apply_report(&report); + } + Ok(if report.results.iter().all(|item| item.status == "ok") { + 0 + } else { + 1 + }) +} + +fn absolute_path(root: &Path, path: &Path) -> PathBuf { + if path.is_absolute() { + path.to_path_buf() + } else { + root.join(path) + } +} + +fn build_plan( + root: &Path, + env_file: &Path, + env_values: &HashMap, + inventory: &Path, + log_dir: &Path, + apply: bool, + skip_quality_gate: bool, +) -> Plan { + let required_env = ["AW_SSH_PASSWORD", "AW_WINRM_PASSWORD"] + .into_iter() + .map(|name| { + let ok = env_value(name, env_values).is_some(); + Requirement { + name: name.to_string(), + ok, + detail: if ok { + "present (value hidden)".to_string() + } else { + "missing".to_string() + }, + } + }) + .collect::>(); + + let required_commands = ["git", "ansible", "ansible-playbook"] + .into_iter() + .map(|name| { + let ok = command_exists(name); + Requirement { + name: name.to_string(), + ok, + detail: if ok { + "found in PATH".to_string() + } else { + "missing in PATH".to_string() + }, + } + }) + .collect::>(); + + let quality_gate = root.join("scripts/quality-gate.sh"); + let deploy_aw_server = root.join("ansible/deploy_aw_server.yml"); + let deploy_aw_windows = root.join("ansible/deploy_aw_windows.yml"); + let post_validate_aw_windows = root.join("ansible/post_validate_aw_windows.yml"); + let required_files = [ + ("inventory", inventory), + ("scripts/quality-gate.sh", &quality_gate), + ("ansible/deploy_aw_server.yml", &deploy_aw_server), + ("ansible/deploy_aw_windows.yml", &deploy_aw_windows), + ( + "ansible/post_validate_aw_windows.yml", + &post_validate_aw_windows, + ), + ] + .into_iter() + .map(|(name, path)| Requirement { + name: name.to_string(), + ok: path.is_file(), + detail: path.display().to_string(), + }) + .collect::>(); + + let mut steps = Vec::new(); + if !skip_quality_gate { + push_step( + &mut steps, + "quality-gate", + vec!["./scripts/quality-gate.sh".to_string()], + "quality-gate.log", + false, + ); + } + push_step( + &mut steps, + "ping-aw-server", + vec![ + "ansible".to_string(), + "-i".to_string(), + "ansible/inventory.ini".to_string(), + "aw_server".to_string(), + "-m".to_string(), + "ping".to_string(), + ], + "ping_aw_server.log", + false, + ); + push_step( + &mut steps, + "ping-aw-windows", + vec![ + "ansible".to_string(), + "-i".to_string(), + "ansible/inventory.ini".to_string(), + "aw_windows".to_string(), + "-m".to_string(), + "win_ping".to_string(), + ], + "ping_aw_windows.log", + false, + ); + push_step( + &mut steps, + "check-aw-server", + vec![ + "ansible-playbook".to_string(), + "-i".to_string(), + "ansible/inventory.ini".to_string(), + "ansible/deploy_aw_server.yml".to_string(), + "--check".to_string(), + "--diff".to_string(), + ], + "check_aw_server.log", + false, + ); + push_step( + &mut steps, + "deploy-aw-server", + vec![ + "ansible-playbook".to_string(), + "-i".to_string(), + "ansible/inventory.ini".to_string(), + "ansible/deploy_aw_server.yml".to_string(), + ], + "deploy_aw_server.log", + true, + ); + push_step( + &mut steps, + "check-aw-windows", + vec![ + "ansible-playbook".to_string(), + "-i".to_string(), + "ansible/inventory.ini".to_string(), + "ansible/deploy_aw_windows.yml".to_string(), + "--check".to_string(), + "--diff".to_string(), + ], + "check_aw_windows.log", + false, + ); + push_step( + &mut steps, + "deploy-aw-windows", + vec![ + "ansible-playbook".to_string(), + "-i".to_string(), + "ansible/inventory.ini".to_string(), + "ansible/deploy_aw_windows.yml".to_string(), + ], + "deploy_aw_windows.log", + true, + ); + push_step( + &mut steps, + "post-validate-aw-windows", + vec![ + "ansible-playbook".to_string(), + "-i".to_string(), + "ansible/inventory.ini".to_string(), + "ansible/post_validate_aw_windows.yml".to_string(), + ], + "post_validate_aw_windows.log", + false, + ); + + let missing_count = required_env + .iter() + .chain(required_commands.iter()) + .chain(required_files.iter()) + .filter(|item| !item.ok) + .count(); + + Plan { + mode: if apply { "apply" } else { "plan-only" }, + root: root.display().to_string(), + branch: git_branch(root).unwrap_or_else(|| "unknown".to_string()), + env_file: env_file.display().to_string(), + inventory: inventory.display().to_string(), + log_dir: log_dir.display().to_string(), + required_env, + required_commands, + required_files, + steps, + missing_count, + } +} + +fn push_step( + steps: &mut Vec, + name: &str, + command: Vec, + log_file: &str, + mutation: bool, +) { + steps.push(Step { + order: steps.len() + 1, + name: name.to_string(), + command, + log_file: log_file.to_string(), + mutation, + }); +} + +fn apply_plan(plan: Plan, env_values: HashMap) -> Result { + let log_dir = PathBuf::from(&plan.log_dir); + fs::create_dir_all(&log_dir).with_context(|| format!("create {}", log_dir.display()))?; + let mut results = Vec::new(); + for step in &plan.steps { + log_line(&log_dir, &format!("START {} {}", step.order, step.name))?; + let result = run_step(&plan, step, &env_values)?; + log_line( + &log_dir, + &format!( + "END {} {} status={} exit={:?}", + step.order, step.name, result.status, result.exit_code + ), + )?; + let ok = result.status == "ok"; + results.push(result); + if !ok { + break; + } + } + Ok(ApplyReport { plan, results }) +} + +fn run_step(plan: &Plan, step: &Step, env_values: &HashMap) -> Result { + let Some(program) = step.command.first() else { + bail!("empty command for step {}", step.name); + }; + let args = &step.command[1..]; + let output = Command::new(program) + .args(args) + .current_dir(&plan.root) + .envs(env_values) + .output() + .with_context(|| format!("run {}", shell_join(&step.command)))?; + let log_file = PathBuf::from(&plan.log_dir).join(&step.log_file); + let mut file = OpenOptions::new() + .create(true) + .append(true) + .open(&log_file) + .with_context(|| format!("open {}", log_file.display()))?; + file.write_all(&output.stdout)?; + file.write_all(&output.stderr)?; + std::io::stdout().write_all(&output.stdout)?; + std::io::stderr().write_all(&output.stderr)?; + Ok(StepResult { + order: step.order, + name: step.name.clone(), + status: if output.status.success() { + "ok" + } else { + "failed" + }, + exit_code: output.status.code(), + log_file: log_file.display().to_string(), + }) +} + +fn print_plan(plan: &Plan) { + println!("prod-rollout: {}", plan.mode); + println!("root: {}", plan.root); + println!("branch: {}", plan.branch); + println!("inventory: {}", plan.inventory); + println!("log_dir: {}", plan.log_dir); + println!("missing_inputs: {}", plan.missing_count); + println!(); + println!("Required env:"); + for item in &plan.required_env { + println!(" [{}] {} - {}", ok_mark(item.ok), item.name, item.detail); + } + println!("Required commands:"); + for item in &plan.required_commands { + println!(" [{}] {} - {}", ok_mark(item.ok), item.name, item.detail); + } + println!("Required files:"); + for item in &plan.required_files { + println!(" [{}] {} - {}", ok_mark(item.ok), item.name, item.detail); + } + println!(); + println!("Planned steps:"); + for step in &plan.steps { + let risk = if step.mutation { "MUTATION" } else { "check" }; + println!( + " {:02}. {:<24} {:<8} {}", + step.order, + step.name, + risk, + shell_join(&step.command) + ); + } + if plan.mode == "plan-only" { + println!(); + println!("No steps executed. Use --apply for explicit rollout execution."); + } +} + +fn print_apply_report(report: &ApplyReport) { + println!("prod-rollout apply result:"); + println!("log_dir: {}", report.plan.log_dir); + for item in &report.results { + println!( + " {:02}. {:<24} {:<6} exit={:?} log={}", + item.order, item.name, item.status, item.exit_code, item.log_file + ); + } +} + +fn log_line(log_dir: &Path, line: &str) -> Result<()> { + fs::create_dir_all(log_dir).with_context(|| format!("create {}", log_dir.display()))?; + let path = log_dir.join("rollout.log"); + let mut file = OpenOptions::new() + .create(true) + .append(true) + .open(&path) + .with_context(|| format!("open {}", path.display()))?; + writeln!( + file, + "{} {}", + Local::now().format("%Y-%m-%d %H:%M:%S"), + line + )?; + Ok(()) +} + +fn ok_mark(ok: bool) -> &'static str { + if ok { "OK" } else { "MISS" } +} + +fn env_value(name: &str, env_values: &HashMap) -> Option { + std::env::var(name) + .ok() + .filter(|value| !value.trim().is_empty()) + .or_else(|| env_values.get(name).cloned()) + .filter(|value| !value.trim().is_empty()) +} + +fn read_env_file(path: &Path) -> Result> { + let text = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?; + let mut values = HashMap::new(); + for raw_line in text.lines() { + let line = raw_line.trim(); + if line.is_empty() || line.starts_with('#') || !line.contains('=') { + continue; + } + let mut parts = line.splitn(2, '='); + let Some(key) = parts.next().map(str::trim) else { + continue; + }; + let Some(value) = parts.next().map(str::trim) else { + continue; + }; + if key.is_empty() { + continue; + } + let key = key.strip_prefix("export ").unwrap_or(key).trim(); + if key.is_empty() { + continue; + } + values.insert(key.to_string(), strip_shell_quotes(value).to_string()); + } + Ok(values) +} + +fn strip_shell_quotes(value: &str) -> &str { + if value.len() >= 2 + && ((value.starts_with('"') && value.ends_with('"')) + || (value.starts_with('\'') && value.ends_with('\''))) + { + &value[1..value.len() - 1] + } else { + value + } +} + +fn command_exists(name: &str) -> bool { + Command::new("sh") + .arg("-c") + .arg(format!("command -v {name} >/dev/null 2>&1")) + .status() + .map(|status| status.success()) + .unwrap_or(false) +} + +fn git_branch(root: &Path) -> Option { + let output = Command::new("git") + .args(["branch", "--show-current"]) + .current_dir(root) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let branch = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if branch.is_empty() { + None + } else { + Some(branch) + } +} + +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::>() + .join(" ") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reads_exported_env_and_strips_quotes() { + let dir = tempfile::tempdir().unwrap(); + let env = dir.path().join("runtime.env"); + fs::write( + &env, + "export AW_SSH_PASSWORD='one'\nAW_WINRM_PASSWORD=\"two\"\n", + ) + .unwrap(); + let values = read_env_file(&env).unwrap(); + assert_eq!(values.get("AW_SSH_PASSWORD").unwrap(), "one"); + assert_eq!(values.get("AW_WINRM_PASSWORD").unwrap(), "two"); + } + + #[test] + fn plan_marks_rollout_mutations() { + let dir = tempfile::tempdir().unwrap(); + create_file(dir.path().join("scripts/quality-gate.sh")); + create_file(dir.path().join("ansible/inventory.ini")); + create_file(dir.path().join("ansible/deploy_aw_server.yml")); + create_file(dir.path().join("ansible/deploy_aw_windows.yml")); + create_file(dir.path().join("ansible/post_validate_aw_windows.yml")); + let mut env = HashMap::new(); + env.insert("AW_SSH_PASSWORD".to_string(), "hidden".to_string()); + env.insert("AW_WINRM_PASSWORD".to_string(), "hidden".to_string()); + let plan = build_plan( + dir.path(), + &dir.path().join("secrets/runtime.env"), + &env, + &dir.path().join("ansible/inventory.ini"), + &dir.path().join(".rollout-logs/test"), + false, + false, + ); + assert!( + plan.steps + .iter() + .any(|step| step.name == "deploy-aw-server") + ); + assert!( + plan.steps + .iter() + .any(|step| step.name == "deploy-aw-windows") + ); + assert!( + plan.steps + .iter() + .filter(|step| step.mutation) + .all(|step| step.name.starts_with("deploy-")) + ); + assert_eq!(plan.required_env.iter().filter(|item| item.ok).count(), 2); + } + + #[test] + fn skip_quality_gate_removes_first_step() { + let dir = tempfile::tempdir().unwrap(); + create_file(dir.path().join("scripts/quality-gate.sh")); + create_file(dir.path().join("ansible/inventory.ini")); + create_file(dir.path().join("ansible/deploy_aw_server.yml")); + create_file(dir.path().join("ansible/deploy_aw_windows.yml")); + create_file(dir.path().join("ansible/post_validate_aw_windows.yml")); + let plan = build_plan( + dir.path(), + &dir.path().join("secrets/runtime.env"), + &HashMap::new(), + &dir.path().join("ansible/inventory.ini"), + &dir.path().join(".rollout-logs/test"), + false, + true, + ); + assert_ne!(plan.steps.first().unwrap().name, "quality-gate"); + } + + fn create_file(path: PathBuf) { + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, "").unwrap(); + } +} diff --git a/adk-rust/crates/quality-gate/Cargo.toml b/adk-rust/crates/quality-gate/Cargo.toml new file mode 100644 index 0000000..0ddf0ba --- /dev/null +++ b/adk-rust/crates/quality-gate/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "quality-gate" +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 diff --git a/adk-rust/crates/quality-gate/src/main.rs b/adk-rust/crates/quality-gate/src/main.rs new file mode 100644 index 0000000..f7d0fc1 --- /dev/null +++ b/adk-rust/crates/quality-gate/src/main.rs @@ -0,0 +1,236 @@ +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +use anyhow::{Context, Result, bail}; +use clap::Parser; + +#[derive(Debug, Parser)] +#[command(about = "Run ActivityWatch-Russian repository quality gates")] +struct Cli { + #[arg(long, default_value = ".")] + root: PathBuf, +} + +fn main() { + let code = match run() { + Ok(()) => 0, + Err(err) => { + eprintln!("{err:#}"); + 1 + } + }; + std::process::exit(code); +} + +fn run() -> Result<()> { + let cli = Cli::parse(); + let root = cli + .root + .canonicalize() + .with_context(|| format!("canonicalize root {}", cli.root.display()))?; + quality_gate(&root) +} + +fn quality_gate(root: &Path) -> Result<()> { + println!("[1/5] Bash syntax check"); + for file in collect_by_extension(root, &["aw-server", "proxmox", "scripts"], "sh")? { + run_status(Command::new("bash").arg("-n").arg(&file), false)?; + } + + println!("[2/5] Shellcheck (if available)"); + if command_exists("shellcheck") { + let mut files = collect_by_extension(root, &["aw-server", "proxmox"], "sh")?; + files.push(root.join("scripts/aw-webui-browser-smoke.sh")); + let mut command = Command::new("shellcheck"); + command.arg("-e").arg("SC1007,SC1090,SC2016"); + for file in files.into_iter().filter(|path| path.is_file()) { + command.arg(file); + } + run_status(&mut command, false)?; + } else { + println!("shellcheck not found, skipping."); + } + + println!("[3/5] Node syntax check (if node available)"); + if command_exists("node") { + run_status( + Command::new("node") + .arg("--check") + .arg(root.join("scripts/aw-webui-browser-smoke.mjs")) + .stdout(Stdio::null()), + false, + )?; + } else { + println!("node not found, skipping."); + } + + println!("[4/5] PowerShell parse check (if pwsh available)"); + if command_exists("pwsh") { + let ps = r#" +$ErrorActionPreference = "Stop" +Get-ChildItem windows -Filter *.ps1 | ForEach-Object { + $tokens = $null + $errors = $null + [void][System.Management.Automation.Language.Parser]::ParseFile($_.FullName,[ref]$tokens,[ref]$errors) + if ($errors.Count) { $errors | ForEach-Object { Write-Error $_.Message }; exit 1 } +} +foreach ($path in @("windows/ActivityWatch.Windows.Common.psm1", "windows/ActivityWatch.Windows.Common.psd1")) { + $tokens = $null + $errors = $null + [void][System.Management.Automation.Language.Parser]::ParseFile((Resolve-Path $path),[ref]$tokens,[ref]$errors) + if ($errors.Count) { $errors | ForEach-Object { Write-Error $_.Message }; exit 1 } +} +"#; + run_status( + Command::new("pwsh") + .arg("-NoLogo") + .arg("-NoProfile") + .arg("-Command") + .arg(ps) + .current_dir(root), + false, + )?; + run_status( + Command::new("pwsh") + .arg("-NoLogo") + .arg("-NoProfile") + .arg("-File") + .arg("windows/aw-collector-guard.ps1") + .arg("-SelfTest") + .current_dir(root) + .stdout(Stdio::null()), + false, + )?; + } else { + println!("pwsh not found, skipping."); + } + + println!("[5/5] Ansible syntax check (if ansible-playbook available)"); + if command_exists("ansible-playbook") { + for playbook in collect_top_level_yml(&root.join("ansible"))? { + run_status( + Command::new("ansible-playbook") + .arg("--syntax-check") + .arg(&playbook) + .arg("-i") + .arg(root.join("ansible/inventory.example.ini")) + .stdout(Stdio::null()), + false, + )?; + } + } else { + println!("ansible-playbook not found, skipping."); + } + + println!("quality-gate: OK"); + Ok(()) +} + +fn run_status(command: &mut Command, quiet: bool) -> Result<()> { + let program = command.get_program().to_string_lossy().to_string(); + if quiet { + command.stdout(Stdio::null()).stderr(Stdio::null()); + } + let status = command.status().with_context(|| format!("run {program}"))?; + if !status.success() { + bail!("{program} failed with status {status}"); + } + Ok(()) +} + +fn command_exists(name: &str) -> bool { + let Some(path) = env::var_os("PATH") else { + return false; + }; + env::split_paths(&path).any(|dir| dir.join(name).is_file()) +} + +fn collect_by_extension(root: &Path, dirs: &[&str], extension: &str) -> Result> { + let mut out = Vec::new(); + for dir in dirs { + let path = root.join(dir); + if path.is_dir() { + collect_by_extension_inner(&path, extension, &mut out)?; + } + } + out.sort(); + Ok(out) +} + +fn collect_by_extension_inner(path: &Path, extension: &str, out: &mut Vec) -> 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_by_extension_inner(&entry_path, extension, out)?; + } else if entry_path + .extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| ext == extension) + { + out.push(entry_path); + } + } + Ok(()) +} + +fn collect_top_level_yml(dir: &Path) -> Result> { + let mut out = Vec::new(); + if !dir.is_dir() { + return Ok(out); + } + for entry in fs::read_dir(dir).with_context(|| format!("read dir {}", dir.display()))? { + let entry = entry.with_context(|| format!("read dir entry {}", dir.display()))?; + let path = entry.path(); + if path.is_file() + && path + .extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| ext == "yml") + { + out.push(path); + } + } + out.sort(); + Ok(out) +} + +#[cfg(test)] +mod tests { + use std::fs; + + use tempfile::tempdir; + + use super::{collect_by_extension, collect_top_level_yml}; + + #[test] + fn collects_recursive_shell_files_sorted() { + let tmp = tempdir().unwrap(); + fs::create_dir_all(tmp.path().join("scripts/nested")).unwrap(); + fs::write(tmp.path().join("scripts/b.sh"), "").unwrap(); + fs::write(tmp.path().join("scripts/nested/a.sh"), "").unwrap(); + fs::write(tmp.path().join("scripts/skip.py"), "").unwrap(); + let files = collect_by_extension(tmp.path(), &["scripts"], "sh").unwrap(); + let names: Vec<_> = files + .iter() + .map(|path| path.file_name().unwrap().to_string_lossy().to_string()) + .collect(); + assert_eq!(names, vec!["b.sh", "a.sh"]); + } + + #[test] + fn collects_top_level_yml_only() { + let tmp = tempdir().unwrap(); + fs::create_dir_all(tmp.path().join("ansible/nested")).unwrap(); + fs::write(tmp.path().join("ansible/a.yml"), "").unwrap(); + fs::write(tmp.path().join("ansible/nested/b.yml"), "").unwrap(); + let files = collect_top_level_yml(&tmp.path().join("ansible")).unwrap(); + assert_eq!(files.len(), 1); + assert_eq!(files[0].file_name().unwrap(), "a.yml"); + } +} diff --git a/adk-rust/crates/rdp-worktime-report/Cargo.toml b/adk-rust/crates/rdp-worktime-report/Cargo.toml new file mode 100644 index 0000000..00a387f --- /dev/null +++ b/adk-rust/crates/rdp-worktime-report/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "rdp-worktime-report" +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 + +[dev-dependencies] +tempfile.workspace = true diff --git a/adk-rust/crates/rdp-worktime-report/src/main.rs b/adk-rust/crates/rdp-worktime-report/src/main.rs new file mode 100644 index 0000000..3649e6a --- /dev/null +++ b/adk-rust/crates/rdp-worktime-report/src/main.rs @@ -0,0 +1,615 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use anyhow::{Context, Result, bail}; +use chrono::{DateTime, Days, Local, NaiveDate, TimeDelta, TimeZone, Utc}; +use clap::Parser; +use reqwest::blocking::Client; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +const DEFAULT_AW_BASE_URL: &str = "http://10.10.10.13:5600/api/0"; +const DEFAULT_HOST: &str = "SHARKON2025"; +const DEFAULT_SAMPLE_SECONDS: f64 = 30.0; +const DEFAULT_MAX_SAMPLE_SECONDS: f64 = 300.0; +const DEFAULT_OUT_DIR: &str = "reports"; + +#[derive(Debug, Parser)] +#[command(about = "Build per-user RDP worktime CSV/JSON report from AW session samples")] +struct Cli { + #[arg(long)] + day: Option, + + #[arg(long)] + from: Option, + + #[arg(long)] + to: Option, +} + +#[derive(Debug, Clone)] +struct Config { + aw_base_url: String, + host: String, + default_sample_seconds: f64, + max_sample_seconds: f64, + out_dir: PathBuf, +} + +#[derive(Debug, Clone, Deserialize)] +struct AwEvent { + timestamp: Option, + #[serde(default)] + duration: Option, + #[serde(default)] + data: Value, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +struct ReportRow { + user: String, + user_id: String, + active_seconds: i64, + active_hhmm: String, + first_activity: String, + last_activity: String, + idle_seconds: i64, + sessions_count: usize, + samples_count: i64, + active_samples: i64, +} + +#[derive(Debug, Serialize)] +struct JsonReport { + host: String, + bucket_id: String, + from: String, + to: String, + generated_at_utc: String, + rows: Vec, +} + +#[derive(Debug, Default)] +struct UserAggregate { + user: String, + user_id: String, + sessions: BTreeSet, + samples_count: i64, + active_samples: i64, + intervals: Vec<(DateTime, DateTime)>, +} + +#[derive(Debug, Clone)] +struct Sample { + ts: DateTime, + duration: Option, + data: Value, +} + +fn main() { + let code = match run() { + Ok(()) => 0, + Err(err) => { + eprintln!("{err:#}"); + 1 + } + }; + std::process::exit(code); +} + +fn run() -> Result<()> { + let cli = Cli::parse(); + let (from, to) = resolve_range(&cli)?; + let config = load_config(); + fs::create_dir_all(&config.out_dir) + .with_context(|| format!("create {}", config.out_dir.display()))?; + + let bucket_id = format!("aw-worktime-sessions_{}", config.host); + let client = Client::builder() + .no_proxy() + .timeout(Duration::from_secs(30)) + .build() + .context("build HTTP client")?; + + get_json::( + &client, + &format!("{}/buckets/{bucket_id}", config.aw_base_url), + ) + .with_context(|| format!("Bucket not found: {bucket_id}"))?; + let events: Vec = get_json( + &client, + &format!( + "{}/buckets/{bucket_id}/events?limit=50000", + config.aw_base_url + ), + )?; + + let rows = build_rows( + events, + &config.host, + from, + to, + config.default_sample_seconds, + config.max_sample_seconds, + )?; + let csv_out = config + .out_dir + .join(format!("rdp-worktime-{}_{}.csv", from, to)); + let json_out = config + .out_dir + .join(format!("rdp-worktime-{}_{}.json", from, to)); + write_csv(&csv_out, &rows)?; + write_json(&json_out, &config.host, &bucket_id, from, to, &rows)?; + + println!("{}", csv_out.display()); + println!("{}", json_out.display()); + println!("CSV: {}", csv_out.display()); + println!("JSON: {}", json_out.display()); + Ok(()) +} + +fn load_config() -> Config { + let default_sample_seconds = + env_f64("AW_WORKTIME_DEFAULT_SAMPLE_SECONDS", DEFAULT_SAMPLE_SECONDS).max(1.0); + let max_sample_seconds = env_f64("AW_WORKTIME_MAX_SAMPLE_SECONDS", DEFAULT_MAX_SAMPLE_SECONDS) + .max(default_sample_seconds); + Config { + aw_base_url: normalize_aw_base(&env_string("AW_BASE_URL", DEFAULT_AW_BASE_URL)), + host: env_string("AW_WORKTIME_HOST", DEFAULT_HOST), + default_sample_seconds, + max_sample_seconds, + out_dir: PathBuf::from(env_string("OUT_DIR", DEFAULT_OUT_DIR)), + } +} + +fn env_string(name: &str, fallback: &str) -> String { + std::env::var(name) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| fallback.to_string()) +} + +fn env_f64(name: &str, fallback: f64) -> f64 { + std::env::var(name) + .ok() + .and_then(|value| value.trim().parse().ok()) + .unwrap_or(fallback) +} + +fn normalize_aw_base(raw: &str) -> String { + let base = raw.trim().trim_end_matches('/'); + if base.ends_with("/api/0") { + base.to_string() + } else { + format!("{base}/api/0") + } +} + +fn resolve_range(cli: &Cli) -> Result<(NaiveDate, NaiveDate)> { + if let Some(day) = &cli.day { + let today = Local::now().date_naive(); + return match day.as_str() { + "today" => Ok((today, today)), + "yesterday" => { + let yesterday = today + .checked_sub_days(Days::new(1)) + .context("calculate yesterday")?; + Ok((yesterday, yesterday)) + } + _ => bail!("Invalid --day: {day}"), + }; + } + + let Some(from) = cli.from else { + bail!( + "Usage: rdp-worktime-report --day today|yesterday OR --from YYYY-MM-DD --to YYYY-MM-DD" + ); + }; + let Some(to) = cli.to else { + bail!( + "Usage: rdp-worktime-report --day today|yesterday OR --from YYYY-MM-DD --to YYYY-MM-DD" + ); + }; + Ok((from, to)) +} + +fn get_json(client: &Client, url: &str) -> Result { + let response = client + .get(url) + .send() + .with_context(|| format!("GET {url}"))? + .error_for_status() + .with_context(|| format!("GET {url} status"))?; + response + .json() + .with_context(|| format!("decode JSON from {url}")) +} + +fn build_rows( + events: Vec, + host: &str, + from: NaiveDate, + to: NaiveDate, + default_sample_seconds: f64, + max_sample_seconds: f64, +) -> Result> { + let start = day_start_utc(from)?; + let end = day_end_utc(to)?; + let mut by_identity: BTreeMap<(String, String), Vec> = BTreeMap::new(); + + for event in events { + let Some(ts) = event.timestamp.as_deref().and_then(parse_ts) else { + continue; + }; + if ts < start || ts > end { + continue; + } + let user = value_string(&event.data, "username").trim().to_string(); + if user.is_empty() { + continue; + } + let session_id = value_string(&event.data, "sessionId"); + let session_id = if session_id.trim().is_empty() { + "unknown".to_string() + } else { + session_id.trim().to_string() + }; + by_identity + .entry((user, session_id)) + .or_default() + .push(Sample { + ts, + duration: event.duration, + data: event.data, + }); + } + + let full_range = (end - start).num_seconds() + 1; + let mut by_user: BTreeMap = BTreeMap::new(); + for ((user, session_id), mut samples) in by_identity { + samples.sort_by_key(|sample| sample.ts); + for idx in 0..samples.len() { + let sample = &samples[idx]; + let rec = by_user + .entry(user.clone()) + .or_insert_with(|| UserAggregate { + user: user.clone(), + user_id: normalize_user_id(&sample.data, host, &user), + ..UserAggregate::default() + }); + rec.sessions.insert(session_id.clone()); + rec.samples_count += 1; + if !is_active(&sample.data) { + continue; + } + rec.active_samples += 1; + let sample_seconds = sample_seconds( + sample, + samples.get(idx + 1).map(|next| next.ts), + default_sample_seconds, + max_sample_seconds, + ); + let interval_end = std::cmp::min( + sample.ts + TimeDelta::milliseconds((sample_seconds * 1000.0).round() as i64), + end + TimeDelta::seconds(1), + ); + if interval_end > sample.ts { + rec.intervals.push((sample.ts, interval_end)); + } + } + } + + let mut rows = Vec::new(); + for (_, rec) in by_user { + let merged = merge_intervals(rec.intervals); + let mut active: i64 = merged + .iter() + .map(|(begin, finish)| (*finish - *begin).num_seconds()) + .sum(); + active = active.min(full_range); + let idle_seconds = (full_range - active).max(0); + rows.push(ReportRow { + user: rec.user, + user_id: rec.user_id, + active_seconds: active, + active_hhmm: format!("{:02}:{:02}", active / 3600, (active % 3600) / 60), + first_activity: merged + .first() + .map(|(begin, _)| format_ts(*begin)) + .unwrap_or_default(), + last_activity: merged + .last() + .map(|(_, finish)| format_ts(*finish)) + .unwrap_or_default(), + idle_seconds, + sessions_count: rec.sessions.len(), + samples_count: rec.samples_count, + active_samples: rec.active_samples, + }); + } + Ok(rows) +} + +fn day_start_utc(day: NaiveDate) -> Result> { + Utc.from_local_datetime( + &day.and_hms_opt(0, 0, 0) + .with_context(|| format!("invalid start date {day}"))?, + ) + .single() + .context("resolve UTC start") +} + +fn day_end_utc(day: NaiveDate) -> Result> { + Utc.from_local_datetime( + &day.and_hms_opt(23, 59, 59) + .with_context(|| format!("invalid end date {day}"))?, + ) + .single() + .context("resolve UTC end") +} + +fn parse_ts(value: &str) -> Option> { + DateTime::parse_from_rfc3339(&value.replace('Z', "+00:00")) + .ok() + .map(|dt| dt.with_timezone(&Utc)) +} + +fn value_string(data: &Value, key: &str) -> String { + match data.get(key) { + Some(Value::String(value)) => value.clone(), + Some(Value::Number(value)) => value.to_string(), + Some(Value::Bool(value)) => value.to_string(), + _ => String::new(), + } +} + +fn is_active(data: &Value) -> bool { + if data.get("active").and_then(Value::as_bool) == Some(true) { + return true; + } + let state = value_string(data, "state").trim().to_lowercase(); + state.contains("актив") || state == "active" +} + +fn normalize_user_id(data: &Value, host: &str, username: &str) -> String { + let raw = value_string(data, "userId").trim().to_string(); + if let Some((_, right)) = raw.split_once('\\') { + return format!("{host}\\{right}"); + } + if !raw.is_empty() { + return raw; + } + format!("{host}\\{username}") +} + +fn sample_seconds( + sample: &Sample, + next_ts: Option>, + default_sample_seconds: f64, + max_sample_seconds: f64, +) -> f64 { + for key in ["sampleSeconds", "pollSeconds"] { + if let Some(value) = value_f64(&sample.data, key).filter(|value| *value > 0.0) { + return clamp_seconds(value, default_sample_seconds, max_sample_seconds); + } + } + if let Some(duration) = sample.duration.filter(|value| *value > 0.0) { + return clamp_seconds(duration, default_sample_seconds, max_sample_seconds); + } + if let Some(next_ts) = next_ts { + return clamp_seconds( + (next_ts - sample.ts).num_milliseconds() as f64 / 1000.0, + default_sample_seconds, + max_sample_seconds, + ); + } + clamp_seconds( + default_sample_seconds, + default_sample_seconds, + max_sample_seconds, + ) +} + +fn value_f64(data: &Value, key: &str) -> Option { + match data.get(key) { + Some(Value::Number(value)) => value.as_f64(), + Some(Value::String(value)) => value.parse().ok(), + _ => None, + } +} + +fn clamp_seconds(value: f64, fallback: f64, max_sample_seconds: f64) -> f64 { + let seconds = if value <= 0.0 { fallback } else { value }; + seconds.min(max_sample_seconds) +} + +fn merge_intervals( + mut intervals: Vec<(DateTime, DateTime)>, +) -> Vec<(DateTime, DateTime)> { + if intervals.is_empty() { + return Vec::new(); + } + intervals.sort_by_key(|(start, _)| *start); + let mut merged = vec![intervals[0]]; + for (start, end) in intervals.into_iter().skip(1) { + let last = merged.last_mut().expect("merged non-empty"); + if start <= last.1 { + if end > last.1 { + last.1 = end; + } + } else { + merged.push((start, end)); + } + } + merged +} + +fn write_csv(path: &Path, rows: &[ReportRow]) -> Result<()> { + let fields = [ + "user", + "user_id", + "active_seconds", + "active_hhmm", + "first_activity", + "last_activity", + "idle_seconds", + "sessions_count", + "samples_count", + "active_samples", + ]; + let mut out = String::new(); + out.push_str(&fields.join(",")); + out.push('\n'); + for row in rows { + out.push_str(&csv_escape(&row.user)); + out.push(','); + out.push_str(&csv_escape(&row.user_id)); + out.push(','); + out.push_str(&row.active_seconds.to_string()); + out.push(','); + out.push_str(&csv_escape(&row.active_hhmm)); + out.push(','); + out.push_str(&csv_escape(&row.first_activity)); + out.push(','); + out.push_str(&csv_escape(&row.last_activity)); + out.push(','); + out.push_str(&row.idle_seconds.to_string()); + out.push(','); + out.push_str(&row.sessions_count.to_string()); + out.push(','); + out.push_str(&row.samples_count.to_string()); + out.push(','); + out.push_str(&row.active_samples.to_string()); + out.push('\n'); + } + fs::write(path, out).with_context(|| format!("write {}", path.display()))?; + Ok(()) +} + +fn csv_escape(value: &str) -> String { + if value.contains([',', '"', '\n', '\r']) { + format!("\"{}\"", value.replace('"', "\"\"")) + } else { + value.to_string() + } +} + +fn write_json( + path: &Path, + host: &str, + bucket_id: &str, + from: NaiveDate, + to: NaiveDate, + rows: &[ReportRow], +) -> Result<()> { + let report = JsonReport { + host: host.to_string(), + bucket_id: bucket_id.to_string(), + from: from.to_string(), + to: to.to_string(), + generated_at_utc: format_ts(Utc::now()), + rows: rows.to_vec(), + }; + fs::write(path, serde_json::to_string_pretty(&report)?) + .with_context(|| format!("write {}", path.display()))?; + Ok(()) +} + +fn format_ts(value: DateTime) -> String { + value.format("%Y-%m-%dT%H:%M:%S%.6fZ").to_string() +} + +#[cfg(test)] +mod tests { + use chrono::NaiveDate; + use serde_json::json; + + use super::*; + + #[test] + fn computes_active_intervals_and_merges_overlap() { + let events = vec![ + event( + "2026-06-01T10:00:00Z", + None, + json!({"username":"user5","userId":"HOST\\user5","sessionId":2,"active":true,"sampleSeconds":60}), + ), + event( + "2026-06-01T10:00:30Z", + None, + json!({"username":"user5","userId":"HOST\\user5","sessionId":2,"state":"Активно","sampleSeconds":60}), + ), + event( + "2026-06-01T11:00:00Z", + None, + json!({"username":"user5","sessionId":3,"state":"Disc","sampleSeconds":60}), + ), + ]; + let rows = build_rows( + events, + "SHARKON2025", + NaiveDate::from_ymd_opt(2026, 6, 1).unwrap(), + NaiveDate::from_ymd_opt(2026, 6, 1).unwrap(), + 30.0, + 300.0, + ) + .unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].active_seconds, 90); + assert_eq!(rows[0].active_hhmm, "00:01"); + assert_eq!(rows[0].sessions_count, 2); + assert_eq!(rows[0].samples_count, 3); + assert_eq!(rows[0].active_samples, 2); + assert_eq!(rows[0].user_id, "SHARKON2025\\user5"); + } + + #[test] + fn uses_next_timestamp_when_duration_missing() { + let events = vec![ + event( + "2026-06-01T10:00:00Z", + None, + json!({"username":"admin","sessionId":1,"state":"active"}), + ), + event( + "2026-06-01T10:02:00Z", + None, + json!({"username":"admin","sessionId":1,"state":"active"}), + ), + ]; + let rows = build_rows( + events, + "HOST", + NaiveDate::from_ymd_opt(2026, 6, 1).unwrap(), + NaiveDate::from_ymd_opt(2026, 6, 1).unwrap(), + 30.0, + 300.0, + ) + .unwrap(); + assert_eq!(rows[0].active_seconds, 150); + assert_eq!(rows[0].active_samples, 2); + } + + #[test] + fn normalizes_base_url() { + assert_eq!( + normalize_aw_base("http://127.0.0.1:5600"), + "http://127.0.0.1:5600/api/0" + ); + assert_eq!( + normalize_aw_base("http://127.0.0.1:5600/api/0/"), + "http://127.0.0.1:5600/api/0" + ); + } + + fn event(timestamp: &str, duration: Option, data: Value) -> AwEvent { + AwEvent { + timestamp: Some(timestamp.to_string()), + duration, + data, + } + } +} diff --git a/adk-rust/crates/rebuild-install-kit/Cargo.toml b/adk-rust/crates/rebuild-install-kit/Cargo.toml new file mode 100644 index 0000000..412ba63 --- /dev/null +++ b/adk-rust/crates/rebuild-install-kit/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "rebuild-install-kit" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +publish.workspace = true + +[dependencies] +anyhow.workspace = true +clap.workspace = true +flate2 = "1" +sha2.workspace = true +tar = "0.4" +zip = { version = "2", default-features = false, features = ["deflate"] } + +[dev-dependencies] +tempfile.workspace = true diff --git a/adk-rust/crates/rebuild-install-kit/src/main.rs b/adk-rust/crates/rebuild-install-kit/src/main.rs new file mode 100644 index 0000000..31c34db --- /dev/null +++ b/adk-rust/crates/rebuild-install-kit/src/main.rs @@ -0,0 +1,379 @@ +use std::fs::{self, File}; +use std::io::Read; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use clap::Parser; +use flate2::Compression; +use flate2::write::GzEncoder; +use sha2::{Digest, Sha256}; +use zip::write::SimpleFileOptions; + +const KIT_DIR: &str = "install-kit-awindows-20260427-211240"; +const SERVER_CONFIG_DIR: &str = "server-configs-192.168.100.18"; +const OLD_SERVER_CONFIG_DIR: &str = "server-configs-192.168.100.21"; +const README: &str = r#"ActivityWatch DetMir Windows Install Kit + +Includes: +- windows/* (deploy scripts, collectors, common module, configs/examples) +- ansible/* (Windows and AW server playbooks, examples, inventory, tasks) +- aw-server/* (server installer, health orchestrator, RU patch loader, host groups, default settings) +- scripts/* (install-kit rebuild/validation, quality gates, browser/web smoke checks) +- server-configs-192.168.100.18/* (working Windows/RDP config snapshots) + +Source: +- Local project snapshot at build time. +"#; + +const ANSIBLE_FILES: &[&str] = &[ + "ansible/README.md", + "ansible/deploy_aw_pfsense_poller.yml", + "ansible/deploy_aw_server.yml", + "ansible/deploy_aw_windows.yml", + "ansible/group_vars/all.example.yml", + "ansible/group_vars/pfsense-poller.example.yml", + "ansible/group_vars/proxmox-matrix.example.yml", + "ansible/group_vars/proxmox.example.yml", + "ansible/group_vars/windows.example.yml", + "ansible/install_full_stack.yml", + "ansible/inventory.example.ini", + "ansible/provision_proxmox_ct_and_deploy_aw.yml", + "ansible/provision_proxmox_ct_matrix_and_deploy_aw.yml", + "ansible/tasks/provision_ct_and_deploy_aw.yml", +]; + +const AW_SERVER_FILES: &[&str] = &[ + "aw-server/activitywatch-server.service", + "aw-server/apply_webui_ru_patch.sh", + "aw-server/aw-host-groups.json", + "aw-server/aw-ru-patch.js", + "aw-server/aw-rus-healthd.py", + "aw-server/aw-rus-healthd.service", + "aw-server/aw-rus-healthd.timer", + "aw-server/aw-browser-smoke.service", + "aw-server/aw-browser-smoke.timer", + "aw-server/aw-slo-monitor.py", + "aw-server/aw-slo-monitor.service", + "aw-server/aw-slo-monitor.timer", + "aw-server/aw-server.env.example", + "aw-server/aw-sw-cleanup.js", + "aw-server/aw-worktime-api.py", + "aw-server/aw-worktime-api.service", + "aw-server/aw-worktime-prewarm.sh", + "aw-server/aw-worktime-prewarm.service", + "aw-server/aw-worktime-prewarm.timer", + "aw-server/aw-worktime-panel.js", + "aw-server/health-check.sh", + "aw-server/install_aw_server.sh", + "aw-server/settings/classes-worktime.json", + "aw-server/settings/views-default.json", +]; + +const WINDOWS_FILES: &[&str] = &[ + "windows/ActivityWatch.Windows.Common.psd1", + "windows/ActivityWatch.Windows.Common.psm1", + "windows/browser-domains-native-collector.ps1", + "windows/deploy-domain-users.ps1", + "windows/deploy-ensemble.ps1", + "windows/deploy-single-user.ps1", + "windows/AWatchRusCollectorGuardService.cs", + "windows/aw-collector-guard.ps1", + "windows/install-collector-guard-service.ps1", + "windows/dlp-endpoint-signals-collector.ps1", + "windows/dlp-policy.example.json", + "windows/dlp-policy.native-cross-os.example.json", + "windows/email-outbound-collector.ps1", + "windows/hardening-recovery.ps1", + "windows/migrate-awatch-rus-paths.ps1", + "windows/validate-deployment.ps1", + "windows/web-category-rules.example.json", + "windows/worktime-session-collector.ps1", +]; + +const SCRIPTS_FILES: &[&str] = &[ + "scripts/aw-webui-browser-smoke.mjs", + "scripts/aw-webui-browser-smoke.sh", + "scripts/check_install_kit_vs_repo.sh", + "scripts/quality-gate.sh", + "scripts/rebuild_install_kit.sh", + "scripts/validate_install_kit.sh", + "scripts/verify_innosetup_installer.sh", +]; + +#[derive(Debug, Parser)] +#[command(about = "Rebuild ActivityWatch-Russian Windows install-kit directory and archives")] +struct Cli { + #[arg(long, default_value = ".")] + root: PathBuf, +} + +fn main() { + let code = match run() { + Ok(()) => 0, + Err(err) => { + eprintln!("{err:#}"); + 1 + } + }; + std::process::exit(code); +} + +fn run() -> Result<()> { + let cli = Cli::parse(); + let root = cli + .root + .canonicalize() + .with_context(|| format!("canonicalize root {}", cli.root.display()))?; + rebuild(&root) +} + +fn rebuild(root: &Path) -> Result<()> { + let kit = root.join(KIT_DIR); + let preserved_configs = preserve_server_configs(&kit)?; + for name in ["ansible", "aw-server", "windows", "scripts"] { + remove_if_exists(&kit.join(name))?; + } + remove_server_config_dirs(&kit)?; + + for rel in ANSIBLE_FILES + .iter() + .chain(AW_SERVER_FILES) + .chain(WINDOWS_FILES) + .chain(SCRIPTS_FILES) + { + copy_file(root, &kit, rel)?; + } + + let server_config_dir = kit.join(SERVER_CONFIG_DIR); + fs::create_dir_all(&server_config_dir) + .with_context(|| format!("create {}", server_config_dir.display()))?; + for (name, bytes) in preserved_configs { + let path = server_config_dir.join(name); + write_file_replace(&path, &bytes).with_context(|| { + format!("restore server config into {}", server_config_dir.display()) + })?; + } + + write_file_replace(&kit.join("README-INSTALL-KIT.txt"), README.as_bytes()) + .with_context(|| format!("write {}", kit.join("README-INSTALL-KIT.txt").display()))?; + write_manifest(root, &kit)?; + write_zip(root, &kit)?; + write_tar(root, &kit)?; + Ok(()) +} + +fn preserve_server_configs(kit: &Path) -> Result)>> { + let mut out = std::collections::BTreeMap::new(); + for dir in [OLD_SERVER_CONFIG_DIR, SERVER_CONFIG_DIR] { + let path = kit.join(dir); + if !path.is_dir() { + continue; + } + 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(); + if entry_path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.ends_with(".deployment-config.json")) + && entry_path.is_file() + { + let name = entry.file_name().to_string_lossy().to_string(); + let bytes = fs::read(&entry_path) + .with_context(|| format!("read {}", entry_path.display()))?; + out.insert(name, bytes); + } + } + } + Ok(out.into_iter().collect()) +} + +fn remove_server_config_dirs(kit: &Path) -> Result<()> { + if !kit.is_dir() { + return Ok(()); + } + for entry in fs::read_dir(kit).with_context(|| format!("read dir {}", kit.display()))? { + let entry = entry.with_context(|| format!("read dir entry {}", kit.display()))?; + let path = entry.path(); + if path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with("server-configs-")) + { + remove_if_exists(&path)?; + } + } + Ok(()) +} + +fn copy_file(root: &Path, kit: &Path, rel: &str) -> Result<()> { + let src = root.join(rel); + let dest = kit.join(rel); + fs::create_dir_all(dest.parent().context("destination parent")?) + .with_context(|| format!("create parent for {}", dest.display()))?; + let bytes = fs::read(&src).with_context(|| format!("read {}", src.display()))?; + write_file_replace(&dest, &bytes) + .with_context(|| format!("copy {} to {}", src.display(), dest.display()))?; + Ok(()) +} + +fn write_file_replace(path: &Path, bytes: &[u8]) -> Result<()> { + remove_if_exists(path)?; + fs::write(path, bytes).with_context(|| format!("write {}", path.display()))?; + Ok(()) +} + +fn remove_if_exists(path: &Path) -> Result<()> { + if !path.exists() { + return Ok(()); + } + let meta = fs::symlink_metadata(path).with_context(|| format!("stat {}", path.display()))?; + if meta.is_dir() { + fs::remove_dir_all(path).with_context(|| format!("remove dir {}", path.display()))?; + } else { + fs::remove_file(path).with_context(|| format!("remove file {}", path.display()))?; + } + Ok(()) +} + +fn write_manifest(root: &Path, kit: &Path) -> Result<()> { + let mut files = Vec::new(); + collect_files(kit, &mut files)?; + files.retain(|path| path.file_name().and_then(|name| name.to_str()) != Some("MANIFEST.txt")); + files.sort(); + let manifest = kit.join("MANIFEST.txt"); + let mut out = String::new(); + for path in files { + let digest = sha256_file(&path)?; + let rel = path + .strip_prefix(root) + .with_context(|| format!("strip root prefix from {}", path.display()))?; + out.push_str(&format!("{digest} {}\n", slash_path(rel))); + } + write_file_replace(&manifest, out.as_bytes())?; + Ok(()) +} + +fn write_zip(root: &Path, kit: &Path) -> Result<()> { + let archive_path = root.join(format!("{KIT_DIR}.zip")); + remove_if_exists(&archive_path)?; + let file = File::create(&archive_path) + .with_context(|| format!("create {}", archive_path.display()))?; + let mut zip = zip::ZipWriter::new(file); + let options = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated); + let mut files = Vec::new(); + collect_files(kit, &mut files)?; + files.sort(); + for path in files { + let rel = path + .strip_prefix(root) + .with_context(|| format!("strip root prefix from {}", path.display()))?; + let name = slash_path(rel); + zip.start_file(name, options).context("start zip file")?; + let mut input = File::open(&path).with_context(|| format!("open {}", path.display()))?; + std::io::copy(&mut input, &mut zip) + .with_context(|| format!("write zip {}", path.display()))?; + } + zip.finish().context("finish zip")?; + Ok(()) +} + +fn write_tar(root: &Path, kit: &Path) -> Result<()> { + let archive_path = root.join(format!("{KIT_DIR}.tar.gz")); + remove_if_exists(&archive_path)?; + let file = File::create(&archive_path) + .with_context(|| format!("create {}", archive_path.display()))?; + let encoder = GzEncoder::new(file, Compression::default()); + let mut builder = tar::Builder::new(encoder); + let mut files = Vec::new(); + collect_files(kit, &mut files)?; + files.sort(); + for path in files { + builder + .append_path_with_name( + &path, + slash_path( + path.strip_prefix(root) + .with_context(|| format!("strip root prefix from {}", path.display()))?, + ), + ) + .with_context(|| format!("append tar {}", path.display()))?; + } + builder.finish().context("finish tar")?; + Ok(()) +} + +fn collect_files(path: &Path, out: &mut Vec) -> 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(&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 { + let mut file = File::open(path).with_context(|| format!("open {}", path.display()))?; + let mut hasher = Sha256::new(); + let mut buf = [0_u8; 64 * 1024]; + loop { + let read = file + .read(&mut buf) + .with_context(|| format!("read {}", path.display()))?; + if read == 0 { + break; + } + hasher.update(&buf[..read]); + } + Ok(format!("{:x}", hasher.finalize())) +} + +fn slash_path(path: &Path) -> String { + path.components() + .map(|component| component.as_os_str().to_string_lossy()) + .collect::>() + .join("/") +} + +#[cfg(test)] +mod tests { + use std::fs; + + use tempfile::tempdir; + + use super::{preserve_server_configs, remove_server_config_dirs}; + + #[test] + fn preserves_new_server_configs_over_old_duplicates() { + let tmp = tempdir().unwrap(); + let kit = tmp.path(); + let old = kit.join("server-configs-192.168.100.21"); + let new = kit.join("server-configs-192.168.100.18"); + fs::create_dir_all(&old).unwrap(); + fs::create_dir_all(&new).unwrap(); + fs::write(old.join("a.deployment-config.json"), "old").unwrap(); + fs::write(new.join("a.deployment-config.json"), "new").unwrap(); + let configs = preserve_server_configs(kit).unwrap(); + assert_eq!(configs.len(), 1); + assert_eq!(configs[0].0, "a.deployment-config.json"); + assert_eq!(configs[0].1, b"new"); + } + + #[test] + fn removes_server_config_dirs_only() { + let tmp = tempdir().unwrap(); + let kit = tmp.path(); + fs::create_dir_all(kit.join("server-configs-x")).unwrap(); + fs::create_dir_all(kit.join("windows")).unwrap(); + remove_server_config_dirs(kit).unwrap(); + assert!(!kit.join("server-configs-x").exists()); + assert!(kit.join("windows").exists()); + } +} diff --git a/adk-rust/crates/tsj-guardian-status/Cargo.toml b/adk-rust/crates/tsj-guardian-status/Cargo.toml new file mode 100644 index 0000000..3d494ff --- /dev/null +++ b/adk-rust/crates/tsj-guardian-status/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "tsj-guardian-status" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +publish.workspace = true + +[[bin]] +name = "tsj-guardian-status" +path = "src/main.rs" + +[dependencies] +anyhow.workspace = true +chrono.workspace = true +clap.workspace = true +detmir-state.workspace = true +serde.workspace = true +serde_json.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/adk-rust/crates/tsj-guardian-status/src/main.rs b/adk-rust/crates/tsj-guardian-status/src/main.rs new file mode 100644 index 0000000..31f3fd0 --- /dev/null +++ b/adk-rust/crates/tsj-guardian-status/src/main.rs @@ -0,0 +1,2110 @@ +use std::{ + fs, + io::{self, Read}, + path::{Path, PathBuf}, + process::Command, + time::{SystemTime, UNIX_EPOCH}, +}; + +use anyhow::{Context, Result}; +use chrono::{DateTime, SecondsFormat, Utc}; +use clap::Parser; +use detmir_state::{DEFAULT_STATE_FILE, NormalizedStatus, read_state}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +#[derive(Debug, Parser)] +#[command(about = "Read-only backend helpers for TSJ Guardian Telegram status.")] +struct Cli { + #[arg(long, default_value = DEFAULT_STATE_FILE)] + state: PathBuf, + + #[arg(long)] + json: bool, + + #[arg(long)] + aw_slo_status_line: bool, + + #[arg(long)] + status_text: bool, + + #[arg(long)] + incident_suggestions: bool, + + #[arg(long)] + incident_defer_decision: bool, + + #[arg(long)] + escalation_decision: bool, + + #[arg(long)] + operator_action_decision: bool, + + #[arg(long)] + dlp_policy_decision: bool, + + #[arg(long)] + confirmation_decision: bool, + + #[arg(long)] + autoheal_plan_decision: bool, + + #[arg(long, default_value_t = 1)] + incident_failure_quorum_checks: i64, + + #[arg(long, default_value_t = 900)] + operator_timeout_seconds: i64, + + #[arg(long, default_value_t = 900)] + confirmation_ttl_seconds: i64, + + #[arg(long)] + aw_slo_summary_command: Option, + + #[arg(long, default_value = "24h")] + aw_slo_alert_window: String, + + #[arg(long)] + bot_state: Option, + + #[arg(long)] + rollback_file: Option, + + #[arg(long)] + pfsense_status_command: Option, + + #[arg(long)] + now_epoch: Option, +} + +fn detmir_auto_line(status: &NormalizedStatus) -> String { + let summary = &status.detmir_summary; + let dlp = &status.dlp_counts; + format!( + "- detmir_auto: {severity} check_ok={check_ok} dlp_ok={dlp_ok} \ + bucket_stale={bucket_stale} bucket_dead={bucket_dead} \ + service_fail={service_failures} service_warn={service_warnings} \ + dlp_warn={dlp_warn} dlp_fail={dlp_fail}", + severity = status.severity, + check_ok = status.check_ok, + dlp_ok = status.dlp_ok, + bucket_stale = summary.bucket_stale.unwrap_or(0), + bucket_dead = summary.bucket_dead.unwrap_or(0), + service_failures = summary.service_failures.unwrap_or(0), + service_warnings = summary.service_warnings.unwrap_or(0), + dlp_warn = dlp.warn.unwrap_or(0), + dlp_fail = dlp.fail.unwrap_or(0), + ) +} + +fn run_shell_json(command: &str) -> Result { + let stdout = run_shell_text(command)?; + serde_json::from_str(&stdout).context("parse AW SLO summary JSON") +} + +fn run_shell_text(command: &str) -> Result { + let output = Command::new("sh") + .arg("-lc") + .arg(command) + .output() + .with_context(|| format!("run command: {command}"))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("command failed rc={:?}: {}", output.status.code(), stderr); + } + String::from_utf8(output.stdout).context("decode command stdout as UTF-8") +} + +fn value_i64(value: Option<&Value>) -> Option { + match value { + Some(Value::Number(n)) => n.as_i64().or_else(|| n.as_f64().map(|v| v as i64)), + Some(Value::String(s)) => s.parse::().ok(), + _ => None, + } +} + +fn value_f64(value: Option<&Value>) -> Option { + match value { + Some(Value::Number(n)) => n.as_f64(), + Some(Value::String(s)) => s.parse::().ok(), + _ => None, + } +} + +fn value_string(value: Option<&Value>, fallback: &str) -> String { + match value { + Some(Value::String(s)) => s.clone(), + Some(Value::Number(n)) => n.to_string(), + Some(Value::Bool(v)) => v.to_string(), + Some(Value::Null) | None => fallback.to_string(), + Some(v) => v.to_string(), + } +} + +fn aw_slo_status_line(summary: Option<&Value>, alert_window: &str) -> String { + let Some(summary) = summary else { + return "- aw_rus_slo: unavailable".to_string(); + }; + let windows = summary.get("windows").and_then(Value::as_object); + let window_name = if windows + .and_then(|w| w.get(alert_window)) + .and_then(Value::as_object) + .is_some() + { + alert_window + } else { + "24h" + }; + let window = windows + .and_then(|w| w.get(window_name)) + .and_then(Value::as_object); + let availability = window.and_then(|w| value_f64(w.get("availability_percent"))); + let remaining_raw = window.and_then(|w| w.get("budget_remaining_seconds")); + let remaining_value = value_i64(remaining_raw).unwrap_or(0); + let remaining_text = value_string(remaining_raw, "None"); + let samples_text = value_string(window.and_then(|w| w.get("samples")), "None"); + let status = value_string(window.and_then(|w| w.get("status")), "unknown"); + let current = summary.get("current_sample").and_then(Value::as_object); + let current_ok = current + .and_then(|c| c.get("ok")) + .and_then(Value::as_bool) + .unwrap_or(false); + let display_status = if remaining_value < 0 && current_ok { + "recovered".to_string() + } else if remaining_value < 0 && !current_ok { + "fail".to_string() + } else { + status + }; + let availability_text = availability + .map(|v| format!("{v:.5}%")) + .unwrap_or_else(|| "n/a".to_string()); + format!( + "- aw_rus_slo: {display_status} {window_name} current_sample={} availability={availability_text} samples={samples_text} budget_remaining_seconds={remaining_text}", + if current_ok { "OK" } else { "FAIL" } + ) +} + +fn parse_generated_age_seconds(summary: &Value) -> Option { + let generated = summary.get("generated_at_utc")?.as_str()?; + let ts = DateTime::parse_from_rfc3339(&generated.replace('Z', "+00:00")) + .ok()? + .with_timezone(&Utc); + Some((Utc::now() - ts).num_seconds()) +} + +#[derive(Debug, Serialize)] +struct StatusTextPayload { + status_text: String, + pfsense_status: String, + aw_rus_slo_line: String, + detmir_auto_line: String, + rollback_pending_items: usize, +} + +#[derive(Debug, Deserialize)] +struct IncidentInput { + #[serde(default)] + failures: Vec, + #[serde(default)] + state: Value, +} + +#[derive(Debug, Serialize)] +struct SuggestionsPayload { + suggestions: Vec, +} + +#[derive(Debug, Serialize)] +struct DeferDecisionPayload { + defer: bool, + signature: String, + failure_streak_signature: String, + failure_streak_count: i64, + failure_streak_first_ts: i64, + reset_failure_streak: bool, + log_line: Option, +} + +#[derive(Debug, Serialize)] +struct EscalationDecisionPayload { + should_escalate: bool, + should_fallback: bool, + timed_out: bool, + operator_acked: bool, + age_seconds: i64, + reason: String, +} + +#[derive(Debug, Deserialize)] +struct OperatorActionInput { + #[serde(default)] + action: String, + #[serde(default)] + state: Value, +} + +#[derive(Debug, Serialize)] +struct OperatorActionDecisionPayload { + requested_action: String, + canonical_action: String, + handler: String, + allowed: bool, + requires_confirmation: bool, + risk_level: String, + reason: String, + message: Option, + state_update_hints: Vec, +} + +#[derive(Debug, Deserialize)] +struct DlpPolicyInput { + #[serde(default)] + policy: Value, + #[serde(default)] + target_mode: String, +} + +#[derive(Debug, Serialize)] +struct DlpRuleGroupSummary { + name: String, + total: usize, + blocked: usize, +} + +#[derive(Debug, Serialize)] +struct DlpPolicyDecisionPayload { + current_mode: String, + target_mode: Option, + changed_count: usize, + changed_rules: Vec, + groups: Vec, + updated_policy: Option, + reason: String, +} + +#[derive(Debug, Deserialize)] +struct ConfirmationInput { + #[serde(default)] + kind: String, + #[serde(default)] + action: String, + #[serde(default)] + code: String, + #[serde(default)] + state: Value, +} + +#[derive(Debug, Serialize)] +struct ConfirmationDecisionPayload { + kind: String, + action: String, + present: bool, + expired: bool, + allowed: bool, + clear_pending: bool, + next_stage: Option, + first_confirmed_ts: Option, + reason: String, + message: Option, +} + +#[derive(Debug, Deserialize)] +struct AutohealPlanInput { + #[serde(default)] + failures: Vec, + #[serde(default)] + slo_stale: bool, +} + +#[derive(Debug, Serialize)] +struct AutohealPlanPayload { + failures: Vec, + slo_only: bool, + slo_stale: bool, + include_watchers: bool, + include_worktime: bool, + include_windows_dlp: bool, + server_dlp_failures: Vec, + run_windows_heal: bool, + run_server_dlp_heal: bool, + run_worktime_heal: bool, + sleep_after_seconds: i64, + report_triggers: Vec, + direct_autoheal_target: bool, +} + +fn read_json_file(path: &Path) -> Option { + let content = fs::read_to_string(path).ok()?; + serde_json::from_str(&content).ok() +} + +fn read_stdin_value() -> Result { + let mut input = String::new(); + io::stdin() + .read_to_string(&mut input) + .context("read stdin JSON")?; + serde_json::from_str(&input).context("parse stdin JSON") +} + +fn read_stdin_json() -> Result { + serde_json::from_value(read_stdin_value()?).context("parse incident stdin JSON") +} + +fn read_operator_action_input() -> Result { + serde_json::from_value(read_stdin_value()?).context("parse operator action stdin JSON") +} + +fn read_dlp_policy_input() -> Result { + serde_json::from_value(read_stdin_value()?).context("parse DLP policy stdin JSON") +} + +fn read_confirmation_input() -> Result { + serde_json::from_value(read_stdin_value()?).context("parse confirmation stdin JSON") +} + +fn read_autoheal_plan_input() -> Result { + serde_json::from_value(read_stdin_value()?).context("parse autoheal plan stdin JSON") +} + +fn failure_signature(failures: &[String]) -> String { + let mut unique = failures.to_vec(); + unique.sort(); + unique.dedup(); + unique.join("\n") +} + +fn has_filesystem_critical(failures: &[String]) -> bool { + failures + .iter() + .any(|line| line.to_ascii_lowercase().contains("filesystem_usage")) +} + +fn suggestions_from_failures(failures: &[String]) -> Vec { + let text = failures.join("\n").to_ascii_lowercase(); + let mut suggestions = Vec::new(); + if text.contains("proxmox_api") { + suggestions.push("Перезапустить pveproxy/pvedaemon/pve-cluster и проверить порт 8006."); + } + if text.contains("pfsense_web") { + suggestions + .push("Проверить доступность pfSense 10.10.10.1:8443, перезапустить WebGUI/nginx."); + } + if text.contains("pfsense_mcp") { + suggestions.push("Проверить локальный pfsense-mcp-server.service, bearer token и endpoint 127.0.0.1:3010/mcp."); + } + if text.contains("influxdb") { + suggestions.push("Проверить контейнер InfluxDB и restart сервиса influxdb."); + } + if text.contains("grafana") { + suggestions.push("Проверить grafana-server и NO_PROXY для 10.10.10.0/24."); + } + if text.contains("loki") || text.contains("alloy") { + suggestions.push("Проверить LXC логов и restart сервисов loki/alloy."); + } + if text.contains("aw-rus:watcher-") || text.contains("aw-rus:worktime:") { + suggestions.push("Проверить Windows collector recovery: worktime-session-collector, ActivityWatch Recovery и Launch tasks на 192.168.100.18."); + suggestions.push("После Windows recovery проверить server-side aw-worktime-autoheal/ui-bridge для пересборки afk/window bucket'ов."); + } + if text.contains("aw-rus:dlp-") { + suggestions.push( + "Проверить DLP endpoint/fileops collectors и server-side DLP transport на 10.10.10.13.", + ); + } + if text.contains("filesystem_usage") { + suggestions.push("Проверить самые большие каталоги: du -x /var /srv /home, журналы в /var/log и apt cache."); + suggestions.push("Проверить давление по снапшотам/хранилищу Proxmox и решить: очистка, ротация или расширение диска."); + } + if suggestions.is_empty() { + suggestions.push("Запустить расширенную диагностику: /run check"); + } + suggestions.into_iter().map(ToOwned::to_owned).collect() +} + +fn incident_defer_decision( + failures: &[String], + state: &Value, + threshold: i64, + now_epoch: i64, +) -> DeferDecisionPayload { + let signature = failure_signature(failures); + if threshold <= 1 || has_filesystem_critical(failures) { + return DeferDecisionPayload { + defer: false, + signature, + failure_streak_signature: value_string(state.get("failure_streak_signature"), ""), + failure_streak_count: value_i64(state.get("failure_streak_count")).unwrap_or(0), + failure_streak_first_ts: value_i64(state.get("failure_streak_first_ts")).unwrap_or(0), + reset_failure_streak: false, + log_line: None, + }; + } + + let current_signature = value_string(state.get("failure_streak_signature"), ""); + let mut count = value_i64(state.get("failure_streak_count")).unwrap_or(0); + let first_ts; + if signature == current_signature { + count += 1; + first_ts = value_i64(state.get("failure_streak_first_ts")).unwrap_or(now_epoch); + } else { + count = 1; + first_ts = now_epoch; + } + let defer = count < threshold; + DeferDecisionPayload { + defer, + signature: signature.clone(), + failure_streak_signature: signature, + failure_streak_count: count, + failure_streak_first_ts: first_ts, + reset_failure_streak: !defer, + log_line: defer.then(|| { + format!( + "Suppressing transient incident streak={count}/{threshold} failures={}", + failures.len() + ) + }), + } +} + +fn escalation_decision( + state: &Value, + operator_timeout_seconds: i64, + now_epoch: i64, +) -> EscalationDecisionPayload { + let Some(pi) = state + .get("pending_incident") + .filter(|value| value.is_object()) + else { + return EscalationDecisionPayload { + should_escalate: false, + should_fallback: false, + timed_out: false, + operator_acked: false, + age_seconds: 0, + reason: "no_pending_incident".to_string(), + }; + }; + let operator_acked = pi + .get("operator_acked") + .and_then(Value::as_bool) + .unwrap_or(false); + let created_ts = value_i64(pi.get("created_ts")).unwrap_or(now_epoch); + let age_seconds = (now_epoch - created_ts).max(0); + if operator_acked { + return EscalationDecisionPayload { + should_escalate: false, + should_fallback: false, + timed_out: false, + operator_acked, + age_seconds, + reason: "operator_acked".to_string(), + }; + } + if age_seconds < operator_timeout_seconds { + return EscalationDecisionPayload { + should_escalate: false, + should_fallback: false, + timed_out: false, + operator_acked, + age_seconds, + reason: "timeout_not_reached".to_string(), + }; + } + let escalated_to_ai = pi + .get("escalated_to_ai") + .and_then(Value::as_bool) + .unwrap_or(false); + let fallback_executed = pi + .get("fallback_executed") + .and_then(Value::as_bool) + .unwrap_or(false); + EscalationDecisionPayload { + should_escalate: !escalated_to_ai, + should_fallback: !fallback_executed, + timed_out: true, + operator_acked, + age_seconds, + reason: "operator_timeout_reached".to_string(), + } +} + +fn strip_run_prefix(action: &str) -> String { + let trimmed = action.trim(); + if let Some(rest) = trimmed.strip_prefix("/run ") { + rest.trim().to_string() + } else { + trimmed.to_string() + } +} + +fn canonical_operator_action(action: &str) -> Option<&'static str> { + match strip_run_prefix(action).to_ascii_lowercase().as_str() { + "check" => Some("check"), + "aw-dlp-check" | "awrus-dlp-check" => Some("aw-dlp-check"), + "dlp-mode" | "dlp-policy-mode" => Some("dlp-mode"), + "dlp-mode-toggle" | "dlp-policy-toggle" => Some("dlp-mode-toggle"), + "heal" => Some("heal"), + "ai" | "support" | "techsupport" | "техподдержка" | "тех.поддержка" => { + Some("support") + } + "fallback" => Some("fallback"), + "updates-check" => Some("updates-check"), + "updates-install-request" => Some("updates-install-request"), + "updates-install-confirm" => Some("updates-install-confirm"), + "updates-rollback-confirm" => Some("updates-rollback-confirm"), + _ => None, + } +} + +fn operator_action_risk(action: &str) -> &'static str { + match action { + "check" | "dlp-mode" => "low", + "aw-dlp-check" | "updates-check" | "support" => "medium", + "heal" + | "fallback" + | "dlp-mode-toggle" + | "updates-install-request" + | "updates-install-confirm" => "high", + "updates-rollback-confirm" => "critical", + _ => "unknown", + } +} + +fn operator_action_handler(action: &str) -> &'static str { + match action { + "check" => "check_script", + "aw-dlp-check" => "aw_rus_dlp_check_and_heal", + "dlp-mode" => "aw_dlp_policy_mode", + "dlp-mode-toggle" => "aw_dlp_policy_toggle", + "heal" => "autoheal", + "support" => "ai_escalation", + "fallback" => "server_fallback", + "updates-check" => "updates_check", + "updates-install-request" => "updates_install_request", + "updates-install-confirm" => "updates_install_apply", + "updates-rollback-confirm" => "updates_rollback", + _ => "unknown", + } +} + +fn operator_action_requires_confirmation(action: &str) -> bool { + matches!( + action, + "updates-install-confirm" | "updates-rollback-confirm" + ) +} + +fn state_bool(state: &Value, key: &str) -> bool { + state.get(key).and_then(Value::as_bool).unwrap_or(false) +} + +fn operator_action_decision(input: &OperatorActionInput) -> OperatorActionDecisionPayload { + let requested_action = input.action.trim().to_string(); + let Some(canonical) = canonical_operator_action(&requested_action) else { + return OperatorActionDecisionPayload { + requested_action, + canonical_action: String::new(), + handler: "unknown".to_string(), + allowed: false, + requires_confirmation: false, + risk_level: "unknown".to_string(), + reason: "unknown_action".to_string(), + message: Some("Неизвестное действие.".to_string()), + state_update_hints: Vec::new(), + }; + }; + + if canonical == "updates-install-confirm" + && !state_bool(&input.state, "pending_update_install_confirm") + { + return OperatorActionDecisionPayload { + requested_action, + canonical_action: canonical.to_string(), + handler: operator_action_handler(canonical).to_string(), + allowed: false, + requires_confirmation: true, + risk_level: operator_action_risk(canonical).to_string(), + reason: "missing_update_install_confirmation".to_string(), + message: Some( + "Нет ожидающего запроса на установку. Сначала нажмите \"Установить критичные и важные обновления\"." + .to_string(), + ), + state_update_hints: Vec::new(), + }; + } + + if canonical == "updates-rollback-confirm" + && !state_bool(&input.state, "pending_rollback_confirm") + { + return OperatorActionDecisionPayload { + requested_action, + canonical_action: canonical.to_string(), + handler: operator_action_handler(canonical).to_string(), + allowed: false, + requires_confirmation: true, + risk_level: operator_action_risk(canonical).to_string(), + reason: "missing_update_rollback_confirmation".to_string(), + message: Some( + "Нет ожидающего отката. Откат доступен только после неуспешного ручного обновления." + .to_string(), + ), + state_update_hints: Vec::new(), + }; + } + + let state_update_hints = match canonical { + "updates-install-request" => vec!["set_pending_update_install_confirm".to_string()], + "updates-install-confirm" => vec![ + "clear_pending_update_install_confirm".to_string(), + "set_pending_rollback_confirm_if_rollback_items_exist".to_string(), + ], + "updates-rollback-confirm" => vec!["clear_pending_rollback_confirm".to_string()], + _ => Vec::new(), + }; + + OperatorActionDecisionPayload { + requested_action, + canonical_action: canonical.to_string(), + handler: operator_action_handler(canonical).to_string(), + allowed: true, + requires_confirmation: operator_action_requires_confirmation(canonical), + risk_level: operator_action_risk(canonical).to_string(), + reason: "allowed".to_string(), + message: None, + state_update_hints, + } +} + +fn dlp_toggle_group_names() -> [&'static str; 4] { + ["clipboard", "usb", "print", "email"] +} + +fn dlp_group_summaries(policy: &Value) -> Vec { + let Some(endpoint) = policy.get("endpoint").and_then(Value::as_object) else { + return Vec::new(); + }; + dlp_toggle_group_names() + .into_iter() + .filter_map(|key| { + let rules = endpoint.get(key)?.as_array()?; + let mut total = 0; + let mut blocked = 0; + for rule in rules { + let Some(rule) = rule.as_object() else { + continue; + }; + if rule.get("enabled").and_then(Value::as_bool) == Some(false) { + continue; + } + let action = value_string(rule.get("action"), "") + .trim() + .to_ascii_lowercase(); + if action.is_empty() { + continue; + } + total += 1; + if action == "block" { + blocked += 1; + } + } + Some(DlpRuleGroupSummary { + name: format!("endpoint.{key}"), + total, + blocked, + }) + }) + .collect() +} + +fn dlp_mode_from_policy(policy: &Value) -> String { + if let Some(mode) = policy + .get("_tsj_meta") + .and_then(Value::as_object) + .and_then(|meta| meta.get("dlp_mode")) + .and_then(Value::as_str) + .map(|mode| mode.trim().to_ascii_lowercase()) + .filter(|mode| mode == "monitor" || mode == "enforce") + { + return mode; + } + + let groups = dlp_group_summaries(policy); + let total: usize = groups.iter().map(|group| group.total).sum(); + let blocked: usize = groups.iter().map(|group| group.blocked).sum(); + if blocked == 0 { + "monitor".to_string() + } else if blocked == total { + "enforce".to_string() + } else { + "mixed".to_string() + } +} + +fn dlp_updated_at_utc(now_epoch: i64) -> String { + DateTime::::from_timestamp(now_epoch, 0) + .unwrap_or_else(Utc::now) + .to_rfc3339_opts(SecondsFormat::Secs, true) +} + +fn dlp_policy_for_mode( + policy: &Value, + target_mode: &str, + now_epoch: i64, +) -> Result<(Value, usize, Vec)> { + if !matches!(target_mode, "monitor" | "enforce") { + anyhow::bail!("unsupported DLP mode: {target_mode}"); + } + + let mut updated = policy.clone(); + let mut changed = 0; + let mut changed_rules = Vec::new(); + if let Some(endpoint) = updated.get_mut("endpoint").and_then(Value::as_object_mut) { + for key in dlp_toggle_group_names() { + let Some(rules) = endpoint.get_mut(key).and_then(Value::as_array_mut) else { + continue; + }; + for rule in rules { + let Some(rule_obj) = rule.as_object_mut() else { + continue; + }; + if rule_obj.get("enabled").and_then(Value::as_bool) == Some(false) { + continue; + } + let old_action = value_string(rule_obj.get("action"), "alert") + .trim() + .to_ascii_lowercase(); + let new_action = if target_mode == "monitor" { + if old_action == "block" { + "alert" + } else { + old_action.as_str() + } + } else if matches!(old_action.as_str(), "log" | "alert") { + "block" + } else { + old_action.as_str() + }; + if new_action == old_action { + continue; + } + rule_obj.insert("action".to_string(), Value::String(new_action.to_string())); + changed += 1; + if changed_rules.len() < 10 { + let rule_id = value_string(rule_obj.get("id"), "?"); + changed_rules.push(format!( + "endpoint.{key}:{rule_id} {old_action}->{new_action}" + )); + } + } + } + } + + let root = updated + .as_object_mut() + .context("DLP policy root must be a JSON object")?; + let meta = root.entry("_tsj_meta").or_insert_with(|| json!({})); + if !meta.is_object() { + *meta = json!({}); + } + let meta_obj = meta.as_object_mut().expect("meta object created above"); + meta_obj.insert( + "dlp_mode".to_string(), + Value::String(target_mode.to_string()), + ); + meta_obj.insert( + "updated_by".to_string(), + Value::String("tsj-guardian-bot".to_string()), + ); + meta_obj.insert( + "updated_at_utc".to_string(), + Value::String(dlp_updated_at_utc(now_epoch)), + ); + + Ok((updated, changed, changed_rules)) +} + +fn dlp_policy_decision(input: &DlpPolicyInput, now_epoch: i64) -> Result { + let current_mode = dlp_mode_from_policy(&input.policy); + let requested = input.target_mode.trim().to_ascii_lowercase(); + let target_mode = match requested.as_str() { + "" => None, + "toggle" => Some(if current_mode == "enforce" || current_mode == "mixed" { + "monitor".to_string() + } else { + "enforce".to_string() + }), + "monitor" | "enforce" => Some(requested), + other => anyhow::bail!("unsupported DLP target mode: {other}"), + }; + + let groups = dlp_group_summaries(&input.policy); + let Some(target_mode) = target_mode else { + return Ok(DlpPolicyDecisionPayload { + current_mode, + target_mode: None, + changed_count: 0, + changed_rules: Vec::new(), + groups, + updated_policy: None, + reason: "mode_only".to_string(), + }); + }; + + let (updated_policy, changed_count, changed_rules) = + dlp_policy_for_mode(&input.policy, &target_mode, now_epoch)?; + let reason = if changed_count == 0 { + "no_toggleable_changes" + } else { + "policy_plan_ready" + }; + Ok(DlpPolicyDecisionPayload { + current_mode, + target_mode: Some(target_mode), + changed_count, + changed_rules, + groups, + updated_policy: Some(updated_policy), + reason: reason.to_string(), + }) +} + +fn confirmation_no_pending_message(kind: &str) -> &'static str { + match kind { + "pfsense" => "Нет ожидающего изменения pfSense.", + "openvpn" => "Нет ожидающего запроса на OpenVPN конфиг.", + "proxmox_restore" => "Нет ожидающего восстановления Proxmox.", + "proxmox_selection" => "Нет ожидающего выбора узла Proxmox.", + _ => "Нет ожидающего подтверждения.", + } +} + +fn confirmation_wrong_stage_message(kind: &str) -> &'static str { + match kind { + "pfsense" => { + "Второе подтверждение пока недоступно. Сначала выполните первый шаг подтверждения." + } + "openvpn" => { + "Второе подтверждение пока недоступно. Сначала выполните первый шаг подтверждения." + } + _ => "Подтверждение пока недоступно.", + } +} + +fn confirmation_wrong_code_message(kind: &str) -> &'static str { + match kind { + "pfsense" => "Неверный код второго подтверждения pfSense.", + "openvpn" => "Неверный код второго подтверждения OpenVPN-конфига.", + "proxmox_restore" => "Неверный код подтверждения восстановления Proxmox.", + _ => "Неверный код подтверждения.", + } +} + +fn confirmation_cancel_message(kind: &str) -> &'static str { + match kind { + "pfsense" => "Ожидающее изменение pfSense отменено.", + "openvpn" => "Ожидающий запрос на OpenVPN конфиг отменён.", + "proxmox_restore" => "Ожидающее восстановление Proxmox отменено.", + "proxmox_selection" => "Выбор узла Proxmox отменён.", + _ => "Ожидающее подтверждение отменено.", + } +} + +fn confirmation_first_already_message(kind: &str, code: &str) -> String { + match kind { + "pfsense" => format!( + "Первое подтверждение уже принято.\nДля второго подтверждения отправьте `/pfsense_apply {code}`." + ), + "openvpn" => format!( + "Первое подтверждение уже принято.\nДля второго подтверждения отправьте `/openvpn_config_apply {code}`." + ), + _ => "Первое подтверждение уже принято.".to_string(), + } +} + +fn confirmation_state_object(state: &Value) -> Option<&serde_json::Map> { + state.as_object().filter(|object| !object.is_empty()) +} + +fn confirmation_decision( + input: &ConfirmationInput, + ttl_seconds: i64, + now_epoch: i64, +) -> ConfirmationDecisionPayload { + let kind = input.kind.trim().to_ascii_lowercase(); + let action = input.action.trim().to_ascii_lowercase(); + let state = confirmation_state_object(&input.state); + let present = state.is_some(); + let created_ts = state + .and_then(|object| value_i64(object.get("created_ts"))) + .unwrap_or(now_epoch); + let expired = present && ttl_seconds >= 0 && now_epoch - created_ts > ttl_seconds; + let stage = state + .and_then(|object| object.get("stage")) + .map(|value| value_string(Some(value), "")) + .unwrap_or_default(); + let confirm_code = state + .and_then(|object| object.get("confirm_code")) + .map(|value| value_string(Some(value), "")) + .unwrap_or_default(); + + let base = |allowed: bool, + clear_pending: bool, + next_stage: Option, + first_confirmed_ts: Option, + reason: &str, + message: Option| ConfirmationDecisionPayload { + kind: kind.clone(), + action: action.clone(), + present, + expired, + allowed, + clear_pending, + next_stage, + first_confirmed_ts, + reason: reason.to_string(), + message, + }; + + if action == "cancel" { + return base( + true, + true, + None, + None, + "cancelled", + Some(confirmation_cancel_message(&kind).to_string()), + ); + } + + if !present { + return base( + false, + false, + None, + None, + "no_pending", + Some(confirmation_no_pending_message(&kind).to_string()), + ); + } + + if expired { + return base( + false, + true, + None, + None, + "expired", + Some(confirmation_no_pending_message(&kind).to_string()), + ); + } + + match action.as_str() { + "expire" => base(false, false, None, None, "active", None), + "first_confirm" => { + if stage != "awaiting_first_confirm" { + return base( + false, + false, + None, + None, + "already_first_confirmed", + Some(confirmation_first_already_message(&kind, &confirm_code)), + ); + } + base( + true, + false, + Some("awaiting_second_confirm".to_string()), + Some(now_epoch), + "first_confirmed", + None, + ) + } + "apply" => { + if kind != "proxmox_restore" && stage != "awaiting_second_confirm" { + return base( + false, + false, + None, + None, + "wrong_stage", + Some(confirmation_wrong_stage_message(&kind).to_string()), + ); + } + if input.code.trim() != confirm_code { + return base( + false, + false, + None, + None, + "wrong_code", + Some(confirmation_wrong_code_message(&kind).to_string()), + ); + } + base(true, false, None, None, "apply_allowed", None) + } + _ => base( + false, + false, + None, + None, + "unknown_action", + Some("Неизвестное действие подтверждения.".to_string()), + ), + } +} + +fn autoheal_plan_decision(input: &AutohealPlanInput) -> AutohealPlanPayload { + let mut failures: Vec = input + .failures + .iter() + .map(|item| item.trim().to_string()) + .filter(|item| !item.is_empty()) + .collect(); + failures.sort(); + failures.dedup(); + + let slo_only = failures.iter().any(|item| item == "slo"); + if slo_only { + let trigger = if input.slo_stale { + "- heal trigger: SLO summary stale, check aw-slo-monitor.timer/service" + } else { + "- heal trigger: SLO error budget exhausted, no direct autoheal target" + }; + return AutohealPlanPayload { + failures, + slo_only: true, + slo_stale: input.slo_stale, + include_watchers: false, + include_worktime: false, + include_windows_dlp: false, + server_dlp_failures: Vec::new(), + run_windows_heal: false, + run_server_dlp_heal: false, + run_worktime_heal: false, + sleep_after_seconds: 0, + report_triggers: vec![trigger.to_string()], + direct_autoheal_target: false, + }; + } + + let watcher_failures: Vec = failures + .iter() + .filter(|item| item.starts_with("watcher-")) + .cloned() + .collect(); + let dlp_failures: Vec = failures + .iter() + .filter(|item| item.starts_with("dlp-")) + .cloned() + .collect(); + let include_worktime = failures.iter().any(|item| item == "worktime"); + let windows_dlp_failures: Vec = dlp_failures + .iter() + .filter(|item| matches!(item.as_str(), "dlp-endpoint" | "dlp-fileops-host")) + .cloned() + .collect(); + let server_dlp_failures: Vec = dlp_failures + .iter() + .filter(|item| !matches!(item.as_str(), "dlp-endpoint" | "dlp-fileops-host")) + .cloned() + .collect(); + + let include_watchers = !watcher_failures.is_empty(); + let include_windows_dlp = !windows_dlp_failures.is_empty(); + let run_windows_heal = include_watchers || include_worktime || include_windows_dlp; + let run_server_dlp_heal = !server_dlp_failures.is_empty(); + let run_worktime_heal = include_watchers || include_worktime; + let sleep_after_seconds = if include_watchers || include_windows_dlp { + 30 + } else { + 5 + }; + let mut report_triggers = Vec::new(); + if run_windows_heal { + report_triggers.push( + "- heal trigger: Windows session collectors degraded, starting remediation".to_string(), + ); + } + if run_server_dlp_heal { + report_triggers + .push("- heal trigger: server-side DLP degraded, starting remediation".to_string()); + } + if run_worktime_heal { + report_triggers.push( + "- heal trigger: worktime/watchers degraded, rebuilding server-side worktime views" + .to_string(), + ); + } + + AutohealPlanPayload { + failures, + slo_only: false, + slo_stale: false, + include_watchers, + include_worktime, + include_windows_dlp, + server_dlp_failures, + run_windows_heal, + run_server_dlp_heal, + run_worktime_heal, + sleep_after_seconds, + report_triggers, + direct_autoheal_target: run_windows_heal || run_server_dlp_heal || run_worktime_heal, + } +} + +fn pfsense_status_lines(command: Option<&str>) -> String { + let Some(command) = command.map(str::trim).filter(|value| !value.is_empty()) else { + return "- pfsense_security: status unavailable (command not configured)".to_string(); + }; + match run_shell_text(command) { + Ok(output) => { + let lines: Vec = output + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(ToOwned::to_owned) + .collect(); + if lines.is_empty() { + "- pfsense_security: status unavailable (empty output)".to_string() + } else { + lines.join("\n") + } + } + Err(error) => { + let text = error.to_string(); + let tail = text + .chars() + .rev() + .take(500) + .collect::>() + .into_iter() + .rev() + .collect::(); + format!("- pfsense_security: status unavailable ({tail})") + } + } +} + +fn rollback_pending_count(path: Option<&PathBuf>) -> usize { + let Some(path) = path else { + return 0; + }; + read_json_file(path) + .and_then(|payload| { + payload + .get("pending_rollback") + .and_then(Value::as_array) + .map(Vec::len) + }) + .unwrap_or(0) +} + +fn pending_line(state: &Value, key: &str, none: &str) -> String { + let Some(value) = state.get(key).filter(|value| value.is_object()) else { + return none.to_string(); + }; + match key { + "pending_pfsense_change" => format!( + "- pending_pfsense_change: {} stage={}", + value_string(value.get("request_id"), "unknown"), + value_string(value.get("stage"), "unknown") + ), + "pending_proxmox_selection" => format!( + "- pending_proxmox_selection: mode={}", + value_string(value.get("mode"), "unknown") + ), + "pending_proxmox_restore" => format!( + "- pending_proxmox_restore: {}:{} snapshot={}", + value_string(value.get("kind"), "unknown"), + value_string(value.get("guest_id"), "unknown"), + value_string(value.get("snapshot"), "unknown") + ), + "pending_openvpn_config" => format!( + "- pending_openvpn_config: {} cn={} stage={}", + value_string(value.get("request_id"), "unknown"), + value_string(value.get("common_name"), "unknown"), + value_string(value.get("stage"), "unknown") + ), + _ => none.to_string(), + } +} + +fn status_bool_line(state: &Value, key: &str) -> String { + format!( + "- {key}: {}", + state.get(key).and_then(Value::as_bool).unwrap_or(false) + ) +} + +fn status_text_payload( + detmir_state: &Path, + bot_state_path: Option<&PathBuf>, + rollback_path: Option<&PathBuf>, + pfsense_command: Option<&str>, + slo_command: Option<&str>, + slo_alert_window: &str, + now_epoch: i64, +) -> StatusTextPayload { + let detmir_auto_line = match read_state(detmir_state) { + Ok(status) => detmir_auto_line(&status), + Err(error) => format!("- detmir_auto: unavailable ({error})"), + }; + let summary = match slo_command.map(str::trim) { + Some(command) if !command.is_empty() => run_shell_json(command).ok(), + _ => None, + }; + let aw_rus_slo_line = aw_slo_status_line(summary.as_ref(), slo_alert_window); + let pfsense_status = pfsense_status_lines(pfsense_command); + let bot_state = bot_state_path + .and_then(|path| read_json_file(path)) + .unwrap_or_else(|| json!({})); + let rollback_pending_items = rollback_pending_count(rollback_path); + let pending_incident = bot_state + .get("pending_incident") + .filter(|value| value.is_object()); + + let ppc_line = pending_line( + &bot_state, + "pending_pfsense_change", + "- pending_pfsense_change: none", + ); + let ovpn_warn_line = if bot_state + .get("last_openvpn_expiry_signature") + .and_then(Value::as_str) + .unwrap_or("") + .is_empty() + { + "- openvpn_expiry_warning_signature: none".to_string() + } else { + "- openvpn_expiry_warning_signature: set".to_string() + }; + let pps_line = pending_line( + &bot_state, + "pending_proxmox_selection", + "- pending_proxmox_selection: none", + ); + let ppr_line = pending_line( + &bot_state, + "pending_proxmox_restore", + "- pending_proxmox_restore: none", + ); + let povpn_line = pending_line( + &bot_state, + "pending_openvpn_config", + "- pending_openvpn_config: none", + ); + let update_line = status_bool_line(&bot_state, "pending_update_install_confirm"); + let rollback_line = status_bool_line(&bot_state, "pending_rollback_confirm"); + + let status_text = if let Some(pi) = pending_incident { + let created_ts = value_i64(pi.get("created_ts")).unwrap_or(now_epoch); + let age = (now_epoch - created_ts).max(0); + let failures = pi + .get("failures") + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .map(|item| value_string(Some(item), "")) + .filter(|item| !item.is_empty()) + .collect::>() + .join(" | ") + }) + .unwrap_or_default(); + [ + format!( + "Статус: активный инцидент {}", + value_string(pi.get("incident_id"), "unknown") + ), + pfsense_status.clone(), + aw_rus_slo_line.clone(), + detmir_auto_line.clone(), + format!("- возраст: {age}s"), + format!( + "- autoheal attempts: {}", + value_string(pi.get("autoheal_attempts"), "0") + ), + format!( + "- operator_acked: {}", + value_string(pi.get("operator_acked"), "false") + ), + format!( + "- escalated_to_ai: {}", + value_string(pi.get("escalated_to_ai"), "false") + ), + format!( + "- fallback_executed: {}", + value_string(pi.get("fallback_executed"), "false") + ), + format!("- failures: {failures}"), + ppc_line, + ovpn_warn_line, + pps_line, + ppr_line, + povpn_line, + update_line, + rollback_line, + format!("- rollback_pending_items: {rollback_pending_items}"), + ] + .join("\n") + } else { + [ + "Статус: инцидентов нет.".to_string(), + pfsense_status.clone(), + aw_rus_slo_line.clone(), + detmir_auto_line.clone(), + ppc_line, + ovpn_warn_line, + pps_line, + ppr_line, + povpn_line, + update_line, + rollback_line, + format!("- rollback_pending_items: {rollback_pending_items}"), + ] + .join("\n") + }; + + StatusTextPayload { + status_text, + pfsense_status, + aw_rus_slo_line, + detmir_auto_line, + rollback_pending_items, + } +} + +fn now_epoch() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs() as i64) + .unwrap_or(0) +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + if cli.incident_suggestions { + let input = read_stdin_json()?; + println!( + "{}", + serde_json::to_string_pretty(&SuggestionsPayload { + suggestions: suggestions_from_failures(&input.failures), + })? + ); + return Ok(()); + } + if cli.incident_defer_decision { + let input = read_stdin_json()?; + let payload = incident_defer_decision( + &input.failures, + &input.state, + cli.incident_failure_quorum_checks, + cli.now_epoch.unwrap_or_else(now_epoch), + ); + println!("{}", serde_json::to_string_pretty(&payload)?); + return Ok(()); + } + if cli.escalation_decision { + let input = read_stdin_json()?; + let payload = escalation_decision( + &input.state, + cli.operator_timeout_seconds, + cli.now_epoch.unwrap_or_else(now_epoch), + ); + println!("{}", serde_json::to_string_pretty(&payload)?); + return Ok(()); + } + if cli.operator_action_decision { + let input = read_operator_action_input()?; + let payload = operator_action_decision(&input); + println!("{}", serde_json::to_string_pretty(&payload)?); + return Ok(()); + } + if cli.dlp_policy_decision { + let input = read_dlp_policy_input()?; + let payload = dlp_policy_decision(&input, cli.now_epoch.unwrap_or_else(now_epoch))?; + println!("{}", serde_json::to_string_pretty(&payload)?); + return Ok(()); + } + if cli.confirmation_decision { + let input = read_confirmation_input()?; + let payload = confirmation_decision( + &input, + cli.confirmation_ttl_seconds, + cli.now_epoch.unwrap_or_else(now_epoch), + ); + println!("{}", serde_json::to_string_pretty(&payload)?); + return Ok(()); + } + if cli.autoheal_plan_decision { + let input = read_autoheal_plan_input()?; + let payload = autoheal_plan_decision(&input); + println!("{}", serde_json::to_string_pretty(&payload)?); + return Ok(()); + } + if cli.status_text { + let payload = status_text_payload( + &cli.state, + cli.bot_state.as_ref(), + cli.rollback_file.as_ref(), + cli.pfsense_status_command.as_deref(), + cli.aw_slo_summary_command.as_deref(), + &cli.aw_slo_alert_window, + cli.now_epoch.unwrap_or_else(now_epoch), + ); + if cli.json { + println!("{}", serde_json::to_string_pretty(&payload)?); + } else { + println!("{}", payload.status_text); + } + return Ok(()); + } + if cli.aw_slo_status_line { + let summary = match cli.aw_slo_summary_command.as_deref().map(str::trim) { + Some(command) if !command.is_empty() => run_shell_json(command).ok(), + _ => None, + }; + let line = aw_slo_status_line(summary.as_ref(), &cli.aw_slo_alert_window); + if cli.json { + println!( + "{}", + serde_json::to_string_pretty(&json!({ + "aw_rus_slo_line": line, + "generated_age_seconds": summary.as_ref().and_then(parse_generated_age_seconds), + "summary": summary, + }))? + ); + } else { + println!("{line}"); + } + return Ok(()); + } + let status = read_state(&cli.state)?; + let line = detmir_auto_line(&status); + if cli.json { + println!( + "{}", + serde_json::to_string_pretty(&json!({ + "detmir_auto_line": line, + "normalized": status, + }))? + ); + } else { + println!("{line}"); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn renders_python_compatible_detmir_line() { + let raw = r#"{ + "severity": "OK", + "check_ok": true, + "dlp_ok": true, + "needs_heal": false, + "detmir_summary": { + "bucket_ok": 8, + "bucket_stale": 0, + "bucket_dead": 0, + "service_failures": 0, + "service_warnings": 0 + }, + "dlp_counts": {"ok": 22, "warn": 0, "fail": 0} + }"#; + let state: detmir_state::DetmirState = serde_json::from_str(raw).unwrap(); + let line = detmir_auto_line(&state.normalize()); + assert_eq!( + line, + "- detmir_auto: OK check_ok=true dlp_ok=true bucket_stale=0 bucket_dead=0 service_fail=0 service_warn=0 dlp_warn=0 dlp_fail=0" + ); + } + + #[test] + fn json_mode_includes_line_and_normalized_status() { + let dir = tempfile::tempdir().unwrap(); + let state_path = dir.path().join("state.json"); + std::fs::write( + &state_path, + r#"{ + "severity": "FAIL", + "check_ok": false, + "dlp_ok": true, + "needs_heal": true, + "detmir_summary": {"bucket_stale": 1, "service_failures": 1}, + "dlp_counts": {"warn": 2, "fail": 0} + }"#, + ) + .unwrap(); + let status = read_state(&state_path).unwrap(); + let payload = json!({ + "detmir_auto_line": detmir_auto_line(&status), + "normalized": status, + }); + assert_eq!(payload["normalized"]["severity"], "FAIL"); + assert!( + payload["detmir_auto_line"] + .as_str() + .unwrap() + .contains("bucket_stale=1") + ); + } + + #[test] + fn renders_aw_slo_recovered_status_line() { + let summary = json!({ + "generated_at_utc": "2026-06-01T00:00:00Z", + "windows": { + "24h": { + "status": "fail", + "availability_percent": 98.123456, + "samples": 42, + "budget_remaining_seconds": -12 + } + }, + "current_sample": {"ok": true} + }); + assert_eq!( + aw_slo_status_line(Some(&summary), "24h"), + "- aw_rus_slo: recovered 24h current_sample=OK availability=98.12346% samples=42 budget_remaining_seconds=-12" + ); + } + + #[test] + fn renders_aw_slo_unavailable_without_summary() { + assert_eq!(aw_slo_status_line(None, "24h"), "- aw_rus_slo: unavailable"); + } + + #[test] + fn renders_full_status_without_incident() { + let dir = tempfile::tempdir().unwrap(); + let detmir_path = dir.path().join("detmir.json"); + let bot_state_path = dir.path().join("bot.json"); + let rollback_path = dir.path().join("rollback.json"); + std::fs::write( + &detmir_path, + r#"{ + "severity": "OK", + "check_ok": true, + "dlp_ok": true, + "needs_heal": false, + "detmir_summary": {"bucket_stale": 0, "bucket_dead": 0, "service_failures": 0, "service_warnings": 0}, + "dlp_counts": {"ok": 22, "warn": 0, "fail": 0} + }"#, + ) + .unwrap(); + std::fs::write( + &bot_state_path, + r#"{ + "pending_pfsense_change": null, + "pending_openvpn_config": {"request_id": "ovpn-1", "common_name": "user1", "stage": "created"}, + "pending_update_install_confirm": true, + "pending_rollback_confirm": false, + "last_openvpn_expiry_signature": "sig" + }"#, + ) + .unwrap(); + std::fs::write(&rollback_path, r#"{"pending_rollback":[{"id":"101"}]}"#).unwrap(); + + let payload = status_text_payload( + &detmir_path, + Some(&bot_state_path), + Some(&rollback_path), + None, + None, + "24h", + 1000, + ); + + assert!(payload.status_text.starts_with("Статус: инцидентов нет.")); + assert!(payload.status_text.contains("- aw_rus_slo: unavailable")); + assert!(payload.status_text.contains("- detmir_auto: OK")); + assert!( + payload + .status_text + .contains("- pending_openvpn_config: ovpn-1 cn=user1 stage=created") + ); + assert!( + payload + .status_text + .contains("- pending_update_install_confirm: true") + ); + assert!(payload.status_text.contains("- rollback_pending_items: 1")); + } + + #[test] + fn renders_full_status_with_incident() { + let dir = tempfile::tempdir().unwrap(); + let detmir_path = dir.path().join("detmir.json"); + let bot_state_path = dir.path().join("bot.json"); + std::fs::write( + &detmir_path, + r#"{ + "severity": "FAIL", + "check_ok": false, + "dlp_ok": true, + "needs_heal": true, + "detmir_summary": {"bucket_stale": 2}, + "dlp_counts": {"warn": 1, "fail": 0} + }"#, + ) + .unwrap(); + std::fs::write( + &bot_state_path, + r#"{ + "pending_incident": { + "incident_id": "inc-1", + "created_ts": 900, + "failures": ["f1", "f2"], + "suggestions": [], + "last_autoheal_ts": 0, + "autoheal_attempts": 2, + "operator_acked": true, + "escalated_to_ai": false, + "fallback_executed": true + } + }"#, + ) + .unwrap(); + + let payload = status_text_payload( + &detmir_path, + Some(&bot_state_path), + None, + None, + None, + "24h", + 1000, + ); + + assert!( + payload + .status_text + .contains("Статус: активный инцидент inc-1") + ); + assert!(payload.status_text.contains("- возраст: 100s")); + assert!(payload.status_text.contains("- autoheal attempts: 2")); + assert!(payload.status_text.contains("- operator_acked: true")); + assert!(payload.status_text.contains("- fallback_executed: true")); + assert!(payload.status_text.contains("- failures: f1 | f2")); + } + + #[test] + fn incident_suggestions_match_filesystem_and_aw_paths() { + let suggestions = suggestions_from_failures(&[ + "[FAIL] filesystem_usage: /var 96%".to_string(), + "[FAIL] aw-rus:watcher-window: stale".to_string(), + ]); + + assert!( + suggestions + .iter() + .any(|line| line.contains("самые большие каталоги")) + ); + assert!( + suggestions + .iter() + .any(|line| line.contains("Windows collector recovery")) + ); + } + + #[test] + fn incident_defer_decision_suppresses_until_quorum() { + let state = json!({ + "failure_streak_signature": "", + "failure_streak_count": 0, + "failure_streak_first_ts": 0 + }); + let failures = vec!["[FAIL] node_13: unavailable".to_string()]; + + let first = incident_defer_decision(&failures, &state, 2, 1000); + assert!(first.defer); + assert_eq!(first.failure_streak_count, 1); + assert_eq!(first.failure_streak_first_ts, 1000); + + let state = json!({ + "failure_streak_signature": first.failure_streak_signature, + "failure_streak_count": first.failure_streak_count, + "failure_streak_first_ts": first.failure_streak_first_ts + }); + let second = incident_defer_decision(&failures, &state, 2, 1060); + assert!(!second.defer); + assert_eq!(second.failure_streak_count, 2); + assert!(second.reset_failure_streak); + } + + #[test] + fn incident_defer_decision_never_suppresses_filesystem_critical() { + let decision = incident_defer_decision( + &["[FAIL] filesystem_usage: /var 96%".to_string()], + &json!({}), + 3, + 1000, + ); + + assert!(!decision.defer); + assert!(!decision.reset_failure_streak); + } + + #[test] + fn escalation_decision_waits_for_timeout_and_ack() { + let state = json!({ + "pending_incident": { + "created_ts": 1000, + "operator_acked": false, + "escalated_to_ai": false, + "fallback_executed": false + } + }); + let early = escalation_decision(&state, 900, 1200); + assert!(!early.should_escalate); + assert_eq!(early.reason, "timeout_not_reached"); + + let timed_out = escalation_decision(&state, 900, 2000); + assert!(timed_out.timed_out); + assert!(timed_out.should_escalate); + assert!(timed_out.should_fallback); + + let acked = escalation_decision( + &json!({"pending_incident": {"created_ts": 1000, "operator_acked": true}}), + 900, + 2000, + ); + assert!(!acked.should_escalate); + assert_eq!(acked.reason, "operator_acked"); + } + + #[test] + fn operator_action_decision_normalizes_aliases() { + let decision = operator_action_decision(&OperatorActionInput { + action: "techsupport".to_string(), + state: json!({}), + }); + + assert!(decision.allowed); + assert_eq!(decision.canonical_action, "support"); + assert_eq!(decision.handler, "ai_escalation"); + assert_eq!(decision.risk_level, "medium"); + } + + #[test] + fn operator_action_decision_blocks_unknown_action() { + let decision = operator_action_decision(&OperatorActionInput { + action: "format-disk".to_string(), + state: json!({}), + }); + + assert!(!decision.allowed); + assert_eq!(decision.reason, "unknown_action"); + assert_eq!(decision.message.as_deref(), Some("Неизвестное действие.")); + } + + #[test] + fn operator_action_decision_guards_update_install_confirmation() { + let blocked = operator_action_decision(&OperatorActionInput { + action: "updates-install-confirm".to_string(), + state: json!({"pending_update_install_confirm": false}), + }); + assert!(!blocked.allowed); + assert!(blocked.requires_confirmation); + assert_eq!(blocked.reason, "missing_update_install_confirmation"); + + let allowed = operator_action_decision(&OperatorActionInput { + action: "updates-install-confirm".to_string(), + state: json!({"pending_update_install_confirm": true}), + }); + assert!(allowed.allowed); + assert_eq!(allowed.canonical_action, "updates-install-confirm"); + assert!( + allowed + .state_update_hints + .contains(&"clear_pending_update_install_confirm".to_string()) + ); + } + + #[test] + fn operator_action_decision_guards_update_rollback_confirmation() { + let blocked = operator_action_decision(&OperatorActionInput { + action: "/run updates-rollback-confirm".to_string(), + state: json!({"pending_rollback_confirm": false}), + }); + assert!(!blocked.allowed); + assert_eq!(blocked.reason, "missing_update_rollback_confirmation"); + + let allowed = operator_action_decision(&OperatorActionInput { + action: "/run updates-rollback-confirm".to_string(), + state: json!({"pending_rollback_confirm": true}), + }); + assert!(allowed.allowed); + assert_eq!(allowed.canonical_action, "updates-rollback-confirm"); + assert_eq!(allowed.risk_level, "critical"); + } + + #[test] + fn dlp_policy_decision_detects_monitor_and_group_counts() { + let policy = json!({ + "endpoint": { + "clipboard": [{"id": "c1", "enabled": true, "action": "alert"}], + "usb": [{"id": "u1", "enabled": true, "action": "log"}], + "print": [{"id": "p1", "enabled": false, "action": "block"}] + } + }); + + let decision = dlp_policy_decision( + &DlpPolicyInput { + policy, + target_mode: String::new(), + }, + 1000, + ) + .unwrap(); + + assert_eq!(decision.current_mode, "monitor"); + assert_eq!(decision.reason, "mode_only"); + assert_eq!(decision.groups.len(), 3); + assert_eq!(decision.groups[0].name, "endpoint.clipboard"); + assert_eq!(decision.groups[0].blocked, 0); + assert!(decision.updated_policy.is_none()); + } + + #[test] + fn dlp_policy_decision_toggle_promotes_endpoint_rules_only() { + let policy = json!({ + "rules": [{"id": "web1", "enabled": true, "action": "alert"}], + "endpoint": { + "clipboard": [{"id": "c1", "enabled": true, "action": "alert"}], + "usb": [{"id": "u1", "enabled": true, "action": "log"}], + "print": [{"id": "p1", "enabled": true, "action": "block"}], + "email": [{"id": "e1", "enabled": false, "action": "alert"}] + } + }); + + let decision = dlp_policy_decision( + &DlpPolicyInput { + policy, + target_mode: "toggle".to_string(), + }, + 1000, + ) + .unwrap(); + + let updated = decision.updated_policy.as_ref().unwrap(); + assert_eq!(decision.current_mode, "mixed"); + assert_eq!(decision.target_mode.as_deref(), Some("monitor")); + assert_eq!(decision.changed_count, 1); + assert_eq!( + updated["endpoint"]["print"][0]["action"].as_str(), + Some("alert") + ); + assert_eq!(updated["rules"][0]["action"].as_str(), Some("alert")); + assert_eq!( + updated["_tsj_meta"]["updated_at_utc"].as_str(), + Some("1970-01-01T00:16:40Z") + ); + } + + #[test] + fn dlp_policy_decision_enforce_sets_alert_and_log_to_block() { + let policy = json!({ + "endpoint": { + "clipboard": [{"id": "c1", "enabled": true, "action": "alert"}], + "usb": [{"id": "u1", "enabled": true, "action": "log"}], + "print": [{"id": "p1", "enabled": true, "action": "block"}] + } + }); + + let decision = dlp_policy_decision( + &DlpPolicyInput { + policy, + target_mode: "enforce".to_string(), + }, + 1000, + ) + .unwrap(); + + let updated = decision.updated_policy.as_ref().unwrap(); + assert_eq!(decision.target_mode.as_deref(), Some("enforce")); + assert_eq!(decision.changed_count, 2); + assert_eq!( + updated["endpoint"]["clipboard"][0]["action"].as_str(), + Some("block") + ); + assert_eq!( + updated["endpoint"]["usb"][0]["action"].as_str(), + Some("block") + ); + assert!( + decision + .changed_rules + .contains(&"endpoint.clipboard:c1 alert->block".to_string()) + ); + } + + #[test] + fn confirmation_decision_expires_pending_request() { + let decision = confirmation_decision( + &ConfirmationInput { + kind: "pfsense".to_string(), + action: "expire".to_string(), + code: String::new(), + state: json!({"created_ts": 1000, "stage": "awaiting_first_confirm", "confirm_code": "123456"}), + }, + 900, + 2000, + ); + + assert!(decision.present); + assert!(decision.expired); + assert!(decision.clear_pending); + assert!(!decision.allowed); + assert_eq!(decision.reason, "expired"); + } + + #[test] + fn confirmation_decision_first_confirm_advances_stage() { + let decision = confirmation_decision( + &ConfirmationInput { + kind: "openvpn".to_string(), + action: "first_confirm".to_string(), + code: String::new(), + state: json!({"created_ts": 1000, "stage": "awaiting_first_confirm", "confirm_code": "654321"}), + }, + 900, + 1200, + ); + + assert!(decision.allowed); + assert_eq!( + decision.next_stage.as_deref(), + Some("awaiting_second_confirm") + ); + assert_eq!(decision.first_confirmed_ts, Some(1200)); + assert_eq!(decision.reason, "first_confirmed"); + } + + #[test] + fn confirmation_decision_apply_guards_stage_and_code() { + let wrong_stage = confirmation_decision( + &ConfirmationInput { + kind: "pfsense".to_string(), + action: "apply".to_string(), + code: "123456".to_string(), + state: json!({"created_ts": 1000, "stage": "awaiting_first_confirm", "confirm_code": "123456"}), + }, + 900, + 1200, + ); + assert!(!wrong_stage.allowed); + assert_eq!(wrong_stage.reason, "wrong_stage"); + + let wrong_code = confirmation_decision( + &ConfirmationInput { + kind: "pfsense".to_string(), + action: "apply".to_string(), + code: "000000".to_string(), + state: json!({"created_ts": 1000, "stage": "awaiting_second_confirm", "confirm_code": "123456"}), + }, + 900, + 1200, + ); + assert!(!wrong_code.allowed); + assert_eq!(wrong_code.reason, "wrong_code"); + + let allowed = confirmation_decision( + &ConfirmationInput { + kind: "pfsense".to_string(), + action: "apply".to_string(), + code: "123456".to_string(), + state: json!({"created_ts": 1000, "stage": "awaiting_second_confirm", "confirm_code": "123456"}), + }, + 900, + 1200, + ); + assert!(allowed.allowed); + assert_eq!(allowed.reason, "apply_allowed"); + } + + #[test] + fn confirmation_decision_proxmox_restore_apply_uses_code_only() { + let allowed = confirmation_decision( + &ConfirmationInput { + kind: "proxmox_restore".to_string(), + action: "apply".to_string(), + code: "222333".to_string(), + state: json!({"created_ts": 1000, "confirm_code": "222333"}), + }, + 900, + 1200, + ); + + assert!(allowed.allowed); + assert_eq!(allowed.reason, "apply_allowed"); + } + + #[test] + fn autoheal_plan_routes_endpoint_failure_to_windows_heal_only() { + let plan = autoheal_plan_decision(&AutohealPlanInput { + failures: vec!["dlp-endpoint".to_string()], + slo_stale: false, + }); + + assert!(plan.run_windows_heal); + assert!(plan.include_windows_dlp); + assert!(!plan.include_watchers); + assert!(!plan.include_worktime); + assert!(!plan.run_server_dlp_heal); + assert!(!plan.run_worktime_heal); + assert_eq!(plan.sleep_after_seconds, 30); + assert!(plan.direct_autoheal_target); + } + + #[test] + fn autoheal_plan_routes_server_dlp_failure_to_server_dlp_heal() { + let plan = autoheal_plan_decision(&AutohealPlanInput { + failures: vec!["dlp-fileops-server".to_string()], + slo_stale: false, + }); + + assert!(!plan.run_windows_heal); + assert!(plan.run_server_dlp_heal); + assert_eq!(plan.server_dlp_failures, vec!["dlp-fileops-server"]); + assert_eq!(plan.sleep_after_seconds, 5); + } + + #[test] + fn autoheal_plan_routes_watcher_and_worktime_to_windows_and_worktime() { + let plan = autoheal_plan_decision(&AutohealPlanInput { + failures: vec!["watcher-window".to_string(), "worktime".to_string()], + slo_stale: false, + }); + + assert!(plan.run_windows_heal); + assert!(plan.include_watchers); + assert!(plan.include_worktime); + assert!(plan.run_worktime_heal); + assert_eq!(plan.sleep_after_seconds, 30); + assert_eq!(plan.report_triggers.len(), 2); + } + + #[test] + fn autoheal_plan_slo_has_no_direct_target() { + let plan = autoheal_plan_decision(&AutohealPlanInput { + failures: vec!["slo".to_string()], + slo_stale: true, + }); + + assert!(plan.slo_only); + assert!(plan.slo_stale); + assert!(!plan.direct_autoheal_target); + assert_eq!( + plan.report_triggers[0], + "- heal trigger: SLO summary stale, check aw-slo-monitor.timer/service" + ); + } +} diff --git a/adk-rust/crates/tsj-guardian-watchdog/Cargo.toml b/adk-rust/crates/tsj-guardian-watchdog/Cargo.toml new file mode 100644 index 0000000..9443465 --- /dev/null +++ b/adk-rust/crates/tsj-guardian-watchdog/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "tsj-guardian-watchdog" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +publish.workspace = true + +[dependencies] +anyhow.workspace = true +clap.workspace = true +detmir-core.workspace = true +serde.workspace = true +serde_json.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/adk-rust/crates/tsj-guardian-watchdog/src/main.rs b/adk-rust/crates/tsj-guardian-watchdog/src/main.rs new file mode 100644 index 0000000..3d4bd20 --- /dev/null +++ b/adk-rust/crates/tsj-guardian-watchdog/src/main.rs @@ -0,0 +1,345 @@ +use std::fs; +use std::process::Command; +use std::thread; +use std::time::Duration; + +use anyhow::{Context, Result}; +use clap::Parser; +use detmir_core::{exit_codes, now_utc_rfc3339}; +use serde::Serialize; + +const DEFAULT_HEARTBEAT_FILE: &str = "/opt/infra-admin/.state/tsj_guardian_heartbeat"; +const DEFAULT_SERVICE_NAME: &str = "tsj-guardian-bot.service"; +const DEFAULT_GOST_SERVICE_NAME: &str = "gost-tg.service"; +const DEFAULT_GOST_PATTERN: &str = "/usr/local/bin/gost -L http+socks5://127.0.0.1:11090 -F socks5+wss://gw.iri1968.dpdns.org:4443"; + +#[derive(Debug, Parser)] +#[command(about = "TSJ Guardian bot heartbeat watchdog and gost duplicate guard.")] +struct Cli { + #[arg(long, default_value = DEFAULT_HEARTBEAT_FILE)] + heartbeat_file: String, + + #[arg(long, default_value_t = 180)] + max_age_seconds: i64, + + #[arg(long, default_value = DEFAULT_SERVICE_NAME)] + service_name: String, + + #[arg(long, default_value = DEFAULT_GOST_SERVICE_NAME)] + gost_service_name: String, + + #[arg(long, default_value = DEFAULT_GOST_PATTERN)] + gost_dup_pattern: String, + + #[arg(long)] + apply: bool, + + #[arg(long)] + json: bool, + + #[arg(long)] + loop_forever: bool, + + #[arg(long, default_value_t = 60)] + interval_seconds: u64, +} + +impl Cli { + fn apply_env(mut self) -> Self { + self.heartbeat_file = env_string("HEARTBEAT_FILE").unwrap_or(self.heartbeat_file); + self.max_age_seconds = env_string("MAX_AGE_SEC") + .and_then(|value| value.parse().ok()) + .unwrap_or(self.max_age_seconds); + self.service_name = env_string("SERVICE_NAME").unwrap_or(self.service_name); + self.gost_service_name = env_string("GOST_SERVICE_NAME").unwrap_or(self.gost_service_name); + self.gost_dup_pattern = env_string("GOST_DUP_PATTERN").unwrap_or(self.gost_dup_pattern); + self + } +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +struct WatchdogAction { + action: String, + target: String, + status: String, +} + +#[derive(Debug, Serialize)] +struct WatchdogReport { + ok: bool, + dry_run: bool, + generated_at_utc: String, + heartbeat_file: String, + heartbeat_age_seconds: Option, + heartbeat_status: String, + gost_main_pid: Option, + gost_pids: Vec, + actions: Vec, +} + +fn env_string(name: &str) -> Option { + std::env::var(name).ok().filter(|value| !value.is_empty()) +} + +fn main_pid(service_name: &str) -> Option { + let output = Command::new("systemctl") + .args(["show", "-p", "MainPID", "--value", service_name]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + String::from_utf8_lossy(&output.stdout) + .trim() + .parse::() + .ok() + .filter(|pid| *pid > 1) +} + +fn matching_pids(pattern: &str) -> Vec { + let output = match Command::new("ps").args(["-eo", "pid=,args="]).output() { + Ok(output) => output, + Err(_) => return Vec::new(), + }; + if !output.status.success() { + return Vec::new(); + } + let self_pid = std::process::id(); + let mut pids = String::from_utf8_lossy(&output.stdout) + .lines() + .filter_map(|line| { + let line = line.trim_start(); + let (pid, args) = line.split_once(char::is_whitespace)?; + let pid = pid.parse::().ok()?; + if pid != self_pid && args.contains(pattern) { + Some(pid) + } else { + None + } + }) + .collect::>(); + pids.sort_unstable(); + pids.dedup(); + pids +} + +fn choose_keep_pid(main_pid: Option, pids: &[u32]) -> Option { + if pids.is_empty() { + return None; + } + match main_pid { + Some(pid) if pids.contains(&pid) => Some(pid), + _ => pids.first().copied(), + } +} + +fn signal_pid(pid: u32, signal: &str) -> Result<()> { + let status = Command::new("kill") + .args([format!("-{signal}"), pid.to_string()]) + .status() + .with_context(|| format!("failed to signal pid {pid}"))?; + if status.success() { + Ok(()) + } else { + Err(anyhow::anyhow!("kill -{signal} {pid} exited with {status}")) + } +} + +fn pid_alive(pid: u32) -> bool { + Command::new("kill") + .args(["-0", &pid.to_string()]) + .status() + .map(|status| status.success()) + .unwrap_or(false) +} + +fn restart_service(service_name: &str) -> Result<()> { + let status = Command::new("systemctl") + .args(["restart", service_name]) + .status() + .with_context(|| format!("failed to restart {service_name}"))?; + if status.success() { + Ok(()) + } else { + Err(anyhow::anyhow!( + "systemctl restart {service_name} exited with {status}" + )) + } +} + +fn heartbeat_status(path: &str, max_age_seconds: i64, now: i64) -> (String, Option) { + let raw = match fs::read_to_string(path) { + Ok(raw) => raw, + Err(_) => return ("missing".to_string(), None), + }; + let heartbeat = match raw.trim().parse::() { + Ok(value) => value, + Err(_) => return ("invalid".to_string(), None), + }; + let age = now.saturating_sub(heartbeat); + if age > max_age_seconds { + ("stale".to_string(), Some(age)) + } else { + ("fresh".to_string(), Some(age)) + } +} + +fn run_once(cli: &Cli) -> Result { + let mut actions = Vec::new(); + let gost_main_pid = main_pid(&cli.gost_service_name); + let gost_pids = matching_pids(&cli.gost_dup_pattern); + if let Some(keep_pid) = choose_keep_pid(gost_main_pid, &gost_pids) { + for pid in gost_pids.iter().copied().filter(|pid| *pid != keep_pid) { + if cli.apply { + let status = signal_pid(pid, "TERM") + .map(|_| "terminated") + .unwrap_or("term-failed"); + actions.push(WatchdogAction { + action: "kill-term".to_string(), + target: pid.to_string(), + status: status.to_string(), + }); + } else { + actions.push(WatchdogAction { + action: "kill-term".to_string(), + target: pid.to_string(), + status: "planned".to_string(), + }); + } + } + if cli.apply && gost_pids.len() > 1 { + thread::sleep(Duration::from_secs(2)); + for pid in gost_pids.iter().copied().filter(|pid| *pid != keep_pid) { + if pid_alive(pid) { + let status = signal_pid(pid, "KILL") + .map(|_| "killed") + .unwrap_or("kill-failed"); + actions.push(WatchdogAction { + action: "kill-kill".to_string(), + target: pid.to_string(), + status: status.to_string(), + }); + } + } + } + } + + let now = chrono_like_now_epoch(); + let (hb_status, hb_age) = heartbeat_status(&cli.heartbeat_file, cli.max_age_seconds, now); + if hb_status != "fresh" { + if cli.apply { + let status = restart_service(&cli.service_name) + .map(|_| "restarted") + .unwrap_or("restart-failed"); + actions.push(WatchdogAction { + action: "restart-service".to_string(), + target: cli.service_name.clone(), + status: status.to_string(), + }); + } else { + actions.push(WatchdogAction { + action: "restart-service".to_string(), + target: cli.service_name.clone(), + status: "planned".to_string(), + }); + } + } + + Ok(WatchdogReport { + ok: true, + dry_run: !cli.apply, + generated_at_utc: now_utc_rfc3339(), + heartbeat_file: cli.heartbeat_file.clone(), + heartbeat_age_seconds: hb_age, + heartbeat_status: hb_status, + gost_main_pid, + gost_pids, + actions, + }) +} + +fn chrono_like_now_epoch() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_secs() as i64) + .unwrap_or(0) +} + +fn print_report(report: &WatchdogReport, json: bool) -> Result<()> { + if json { + println!("{}", serde_json::to_string_pretty(report)?); + } else { + println!( + "tsj-guardian-watchdog: heartbeat={} age={:?} dry_run={}", + report.heartbeat_status, report.heartbeat_age_seconds, report.dry_run + ); + for action in &report.actions { + println!("{} {} {}", action.action, action.target, action.status); + } + } + Ok(()) +} + +fn run(cli: Cli) -> Result<()> { + if cli.loop_forever { + loop { + let report = run_once(&cli)?; + print_report(&report, cli.json)?; + thread::sleep(Duration::from_secs(cli.interval_seconds)); + } + } + let report = run_once(&cli)?; + print_report(&report, cli.json)?; + Ok(()) +} + +fn main() -> Result<()> { + let cli = Cli::parse().apply_env(); + match run(cli) { + Ok(()) => Ok(()), + Err(err) => { + eprintln!("{err:#}"); + std::process::exit(exit_codes::ERROR); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn keeps_main_pid_when_present() { + assert_eq!(choose_keep_pid(Some(20), &[10, 20, 30]), Some(20)); + } + + #[test] + fn keeps_first_pid_when_main_missing() { + assert_eq!(choose_keep_pid(Some(99), &[10, 20, 30]), Some(10)); + } + + #[test] + fn detects_heartbeat_states() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("heartbeat"); + assert_eq!( + heartbeat_status(path.to_str().unwrap(), 180, 1_000), + ("missing".to_string(), None) + ); + fs::write(&path, "bad\n").unwrap(); + assert_eq!( + heartbeat_status(path.to_str().unwrap(), 180, 1_000), + ("invalid".to_string(), None) + ); + fs::write(&path, "900\n").unwrap(); + assert_eq!( + heartbeat_status(path.to_str().unwrap(), 180, 1_000), + ("fresh".to_string(), Some(100)) + ); + fs::write(&path, "700\n").unwrap(); + assert_eq!( + heartbeat_status(path.to_str().unwrap(), 180, 1_000), + ("stale".to_string(), Some(300)) + ); + } +} diff --git a/adk-rust/crates/validate-install-kit/Cargo.toml b/adk-rust/crates/validate-install-kit/Cargo.toml new file mode 100644 index 0000000..a9c0eb8 --- /dev/null +++ b/adk-rust/crates/validate-install-kit/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "validate-install-kit" +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 +flate2 = "1" +tar = "0.4" +zip = { version = "2", default-features = false, features = ["deflate"] } + +[dev-dependencies] +tempfile.workspace = true diff --git a/adk-rust/crates/validate-install-kit/src/main.rs b/adk-rust/crates/validate-install-kit/src/main.rs new file mode 100644 index 0000000..8398da7 --- /dev/null +++ b/adk-rust/crates/validate-install-kit/src/main.rs @@ -0,0 +1,611 @@ +use std::collections::BTreeSet; +use std::fs::{self, File}; +use std::io::{BufRead, BufReader, Read}; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, bail}; +use clap::Parser; +use flate2::read::GzDecoder; +use serde::Serialize; +use sha2::{Digest, Sha256}; + +const DEFAULT_KIT_DIR: &str = "install-kit-awindows-20260427-211240"; +const DEFAULT_ZIP_ARCHIVE: &str = "install-kit-awindows-20260427-211240.zip"; +const DEFAULT_TAR_ARCHIVE: &str = "install-kit-awindows-20260427-211240.tar.gz"; +const MANIFEST_NAME: &str = "MANIFEST.txt"; +const REQUIRED_RELATIVE_FILES: &[&str] = &[ + "MANIFEST.txt", + "README-INSTALL-KIT.txt", + "windows/deploy-ensemble.ps1", + "windows/validate-deployment.ps1", + "ansible/deploy_aw_windows.yml", + "aw-server/install_aw_server.sh", + "scripts/rebuild_install_kit.sh", + "scripts/validate_install_kit.sh", + "scripts/check_install_kit_vs_repo.sh", + "scripts/quality-gate.sh", +]; + +#[derive(Debug, Parser)] +#[command(about = "Validate ActivityWatch-Russian Windows install-kit artifacts")] +struct Cli { + #[arg(long, default_value = ".")] + root: PathBuf, + + #[arg(long, default_value = DEFAULT_KIT_DIR)] + kit_dir: PathBuf, + + #[arg(long, default_value = DEFAULT_ZIP_ARCHIVE)] + zip_archive: PathBuf, + + #[arg(long, default_value = DEFAULT_TAR_ARCHIVE)] + tar_archive: PathBuf, + + #[arg(long, default_value_t = false)] + json: bool, +} + +#[derive(Debug, Serialize)] +struct ValidationReport { + ok: bool, + required_files: StageReport, + manifest_checksums: StageReport, + manifest_completeness: CompletenessReport, + archive_composition: ArchiveReport, +} + +#[derive(Debug, Default, Serialize)] +struct StageReport { + ok: bool, + checked: usize, + errors: Vec, +} + +#[derive(Debug, Default, Serialize)] +struct CompletenessReport { + ok: bool, + tracked_files: usize, + missing_listed: Vec, + unlisted_files: Vec, + errors: Vec, +} + +#[derive(Debug, Default, Serialize)] +struct ArchiveReport { + ok: bool, + files: usize, + errors: Vec, +} + +#[derive(Debug, Clone)] +struct ManifestEntry { + digest: String, + path: String, +} + +fn main() { + let code = match run() { + Ok(code) => code, + Err(err) => { + eprintln!("{err:#}"); + 1 + } + }; + std::process::exit(code); +} + +fn run() -> Result { + let cli = Cli::parse(); + let root = cli + .root + .canonicalize() + .with_context(|| format!("canonicalize root {}", cli.root.display()))?; + let cfg = Config::new(root, cli.kit_dir, cli.zip_archive, cli.tar_archive); + let report = validate(&cfg); + print_report(&report, cli.json)?; + Ok(if report.ok { 0 } else { 1 }) +} + +#[derive(Debug)] +struct Config { + root: PathBuf, + kit_dir: PathBuf, + zip_archive: PathBuf, + tar_archive: PathBuf, +} + +impl Config { + fn new(root: PathBuf, kit_dir: PathBuf, zip_archive: PathBuf, tar_archive: PathBuf) -> Self { + Self { + kit_dir: resolve_path(&root, kit_dir), + zip_archive: resolve_path(&root, zip_archive), + tar_archive: resolve_path(&root, tar_archive), + root, + } + } + + fn manifest(&self) -> PathBuf { + self.kit_dir.join(MANIFEST_NAME) + } +} + +fn validate(cfg: &Config) -> ValidationReport { + let required_files = check_required_files(cfg); + let manifest_entries = read_manifest(&cfg.manifest()); + let manifest_checksums = match &manifest_entries { + Ok(entries) => check_manifest_checksums(cfg, entries), + Err(err) => StageReport { + ok: false, + checked: 0, + errors: vec![err.to_string()], + }, + }; + let manifest_completeness = match &manifest_entries { + Ok(entries) => check_manifest_completeness(cfg, entries), + Err(err) => CompletenessReport { + ok: false, + errors: vec![err.to_string()], + ..CompletenessReport::default() + }, + }; + let archive_composition = check_archive_composition(cfg); + let ok = required_files.ok + && manifest_checksums.ok + && manifest_completeness.ok + && archive_composition.ok; + ValidationReport { + ok, + required_files, + manifest_checksums, + manifest_completeness, + archive_composition, + } +} + +fn check_required_files(cfg: &Config) -> StageReport { + let mut errors = Vec::new(); + for rel in REQUIRED_RELATIVE_FILES { + let path = cfg.kit_dir.join(rel); + if !path.is_file() { + errors.push(format!("Missing required file: {}", path.display())); + } + } + StageReport { + ok: errors.is_empty(), + checked: REQUIRED_RELATIVE_FILES.len(), + errors, + } +} + +fn read_manifest(path: &Path) -> Result> { + let file = File::open(path).with_context(|| format!("open manifest {}", path.display()))?; + let reader = BufReader::new(file); + let mut entries = Vec::new(); + for (idx, line) in reader.lines().enumerate() { + let line = line.with_context(|| format!("read manifest line {}", idx + 1))?; + let line = line.trim(); + if line.is_empty() { + continue; + } + let Some((digest, path)) = line.split_once(" ") else { + bail!("Invalid MANIFEST line {}: {}", idx + 1, line); + }; + if digest.len() != 64 || !digest.chars().all(|ch| ch.is_ascii_hexdigit()) { + bail!("Invalid MANIFEST digest on line {}: {}", idx + 1, digest); + } + entries.push(ManifestEntry { + digest: digest.to_ascii_lowercase(), + path: path.to_string(), + }); + } + Ok(entries) +} + +fn check_manifest_checksums(cfg: &Config, entries: &[ManifestEntry]) -> StageReport { + let mut errors = Vec::new(); + for entry in entries { + let path = cfg.root.join(&entry.path); + if !path.is_file() { + errors.push(format!("Manifest file missing: {}", entry.path)); + continue; + } + match sha256_file(&path) { + Ok(actual) if actual == entry.digest => {} + Ok(actual) => errors.push(format!( + "Checksum mismatch: {} expected {} got {}", + entry.path, entry.digest, actual + )), + Err(err) => errors.push(err.to_string()), + } + } + StageReport { + ok: errors.is_empty(), + checked: entries.len(), + errors, + } +} + +fn check_manifest_completeness(cfg: &Config, entries: &[ManifestEntry]) -> CompletenessReport { + let listed: BTreeSet = entries.iter().map(|entry| entry.path.clone()).collect(); + let actual = match collect_kit_manifest_paths(&cfg.kit_dir) { + Ok(paths) => paths, + Err(err) => { + return CompletenessReport { + ok: false, + errors: vec![err.to_string()], + ..CompletenessReport::default() + }; + } + }; + let missing_listed: Vec = listed.difference(&actual).cloned().collect(); + let unlisted_files: Vec = actual.difference(&listed).cloned().collect(); + CompletenessReport { + ok: missing_listed.is_empty() && unlisted_files.is_empty(), + tracked_files: actual.len(), + missing_listed, + unlisted_files, + errors: Vec::new(), + } +} + +fn collect_kit_manifest_paths(kit_dir: &Path) -> Result> { + let mut files = Vec::new(); + collect_files(kit_dir, &mut files)?; + let mut out = BTreeSet::new(); + for file in files { + let rel = file + .strip_prefix(kit_dir) + .with_context(|| format!("strip kit prefix from {}", file.display()))?; + if rel.file_name().and_then(|name| name.to_str()) == Some(MANIFEST_NAME) { + continue; + } + out.insert(format!("{}/{}", DEFAULT_KIT_DIR, slash_path(rel))); + } + Ok(out) +} + +fn check_archive_composition(cfg: &Config) -> ArchiveReport { + let zip_files = match read_zip_files(&cfg.zip_archive) { + Ok(files) => files, + Err(err) => { + return ArchiveReport { + ok: false, + errors: vec![err.to_string()], + ..ArchiveReport::default() + }; + } + }; + let tar_files = match read_tar_files(&cfg.tar_archive) { + Ok(files) => files, + Err(err) => { + return ArchiveReport { + ok: false, + errors: vec![err.to_string()], + ..ArchiveReport::default() + }; + } + }; + + let mut errors = Vec::new(); + if zip_files != tar_files { + errors.push("ZIP and TAR contents differ".to_string()); + } + let expected_prefix = format!("{DEFAULT_KIT_DIR}/"); + if !zip_files + .iter() + .all(|path| path.starts_with(&expected_prefix)) + { + errors.push("Unexpected archive prefix layout".to_string()); + } + ArchiveReport { + ok: errors.is_empty(), + files: zip_files.len(), + errors, + } +} + +fn read_zip_files(path: &Path) -> Result> { + let file = File::open(path).with_context(|| format!("open zip {}", path.display()))?; + let mut archive = + zip::ZipArchive::new(file).with_context(|| format!("read zip {}", path.display()))?; + let mut files = Vec::new(); + for idx in 0..archive.len() { + let file = archive + .by_index(idx) + .with_context(|| format!("read zip entry {idx}"))?; + let name = normalize_archive_path(file.name()); + if !name.ends_with('/') { + files.push(name); + } + } + files.sort(); + Ok(files) +} + +fn read_tar_files(path: &Path) -> Result> { + let file = File::open(path).with_context(|| format!("open tar {}", path.display()))?; + let decoder = GzDecoder::new(file); + let mut archive = tar::Archive::new(decoder); + let mut files = Vec::new(); + for entry in archive + .entries() + .with_context(|| format!("read tar {}", path.display()))? + { + let entry = entry.with_context(|| format!("read tar entry {}", path.display()))?; + if entry.header().entry_type().is_file() { + let path = entry.path().context("read tar entry path")?; + files.push(normalize_archive_path(&path.to_string_lossy())); + } + } + files.sort(); + Ok(files) +} + +fn collect_files(path: &Path, out: &mut Vec) -> 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(&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 { + 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 print_report(report: &ValidationReport, json: bool) -> Result<()> { + if json { + println!("{}", serde_json::to_string_pretty(report)?); + return Ok(()); + } + + println!("[1/4] Required files presence"); + for err in &report.required_files.errors { + println!("{err}"); + } + println!("[2/4] Manifest checksum verification"); + for err in &report.manifest_checksums.errors { + println!("{err}"); + } + println!("[3/4] Manifest completeness"); + if report.manifest_completeness.ok { + println!( + "MANIFEST complete: {} files tracked", + report.manifest_completeness.tracked_files + ); + } else { + println!( + "Missing files listed in MANIFEST: {:?}", + report.manifest_completeness.missing_listed + ); + println!( + "Files not listed in MANIFEST: {:?}", + report.manifest_completeness.unlisted_files + ); + for err in &report.manifest_completeness.errors { + println!("{err}"); + } + } + println!("[4/4] Archive composition check"); + if report.archive_composition.ok { + println!("Archives match: {} files", report.archive_composition.files); + } else { + for err in &report.archive_composition.errors { + println!("{err}"); + } + } + if report.ok { + println!("validate_install_kit: OK"); + } + Ok(()) +} + +fn resolve_path(root: &Path, path: PathBuf) -> PathBuf { + if path.is_absolute() { + path + } else { + root.join(path) + } +} + +fn normalize_archive_path(path: &str) -> String { + path.replace('\\', "/").trim_start_matches("./").to_string() +} + +fn slash_path(path: &Path) -> String { + path.components() + .map(|component| component.as_os_str().to_string_lossy()) + .collect::>() + .join("/") +} + +#[cfg(test)] +mod tests { + use std::fs; + use std::io::Write; + + use flate2::Compression; + use flate2::write::GzEncoder; + use tempfile::tempdir; + use zip::write::SimpleFileOptions; + + use super::{Config, DEFAULT_KIT_DIR, check_archive_composition, read_manifest, validate}; + + #[test] + fn manifest_parser_rejects_bad_line() { + let tmp = tempdir().unwrap(); + let manifest = tmp.path().join("MANIFEST.txt"); + fs::write(&manifest, "not-a-manifest-line\n").unwrap(); + let err = read_manifest(&manifest).unwrap_err(); + assert!(err.to_string().contains("Invalid MANIFEST line")); + } + + #[test] + fn validates_clean_fixture() { + let tmp = tempdir().unwrap(); + create_fixture(tmp.path(), true); + let cfg = Config::new( + tmp.path().to_path_buf(), + DEFAULT_KIT_DIR.into(), + format!("{DEFAULT_KIT_DIR}.zip").into(), + format!("{DEFAULT_KIT_DIR}.tar.gz").into(), + ); + let report = validate(&cfg); + assert!(report.ok, "{report:#?}"); + assert_eq!(report.manifest_completeness.tracked_files, 9); + assert_eq!(report.archive_composition.files, 10); + } + + #[test] + fn detects_manifest_extra_file() { + let tmp = tempdir().unwrap(); + create_fixture(tmp.path(), true); + fs::write( + tmp.path().join(DEFAULT_KIT_DIR).join("unlisted.txt"), + "extra", + ) + .unwrap(); + let cfg = Config::new( + tmp.path().to_path_buf(), + DEFAULT_KIT_DIR.into(), + format!("{DEFAULT_KIT_DIR}.zip").into(), + format!("{DEFAULT_KIT_DIR}.tar.gz").into(), + ); + let report = validate(&cfg); + assert!(!report.ok); + assert!( + report + .manifest_completeness + .unlisted_files + .contains(&format!("{DEFAULT_KIT_DIR}/unlisted.txt")) + ); + } + + #[test] + fn detects_archive_mismatch() { + let tmp = tempdir().unwrap(); + create_fixture(tmp.path(), false); + let cfg = Config::new( + tmp.path().to_path_buf(), + DEFAULT_KIT_DIR.into(), + format!("{DEFAULT_KIT_DIR}.zip").into(), + format!("{DEFAULT_KIT_DIR}.tar.gz").into(), + ); + let report = check_archive_composition(&cfg); + assert!(!report.ok); + assert!(report.errors.iter().any(|err| err.contains("differ"))); + } + + fn create_fixture(root: &std::path::Path, matching_archives: bool) { + let kit = root.join(DEFAULT_KIT_DIR); + for rel in [ + "windows/deploy-ensemble.ps1", + "windows/validate-deployment.ps1", + "ansible/deploy_aw_windows.yml", + "aw-server/install_aw_server.sh", + "scripts/rebuild_install_kit.sh", + "scripts/validate_install_kit.sh", + "scripts/check_install_kit_vs_repo.sh", + "scripts/quality-gate.sh", + ] { + let path = kit.join(rel); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, rel).unwrap(); + } + fs::write(kit.join("README-INSTALL-KIT.txt"), "readme").unwrap(); + let files = [ + "README-INSTALL-KIT.txt", + "windows/deploy-ensemble.ps1", + "windows/validate-deployment.ps1", + "ansible/deploy_aw_windows.yml", + "aw-server/install_aw_server.sh", + "scripts/rebuild_install_kit.sh", + "scripts/validate_install_kit.sh", + "scripts/check_install_kit_vs_repo.sh", + "scripts/quality-gate.sh", + ]; + let mut manifest = String::new(); + for rel in files { + let path = kit.join(rel); + let digest = super::sha256_file(&path).unwrap(); + manifest.push_str(&format!("{digest} {DEFAULT_KIT_DIR}/{rel}\n")); + } + fs::write(kit.join("MANIFEST.txt"), manifest).unwrap(); + write_zip(root, matching_archives); + write_tar(root, matching_archives); + } + + fn archive_files(matching: bool) -> Vec<(&'static str, &'static [u8])> { + let mut files = vec![ + ("README-INSTALL-KIT.txt", b"readme".as_slice()), + ("MANIFEST.txt", b"manifest".as_slice()), + ("windows/deploy-ensemble.ps1", b"deploy".as_slice()), + ("windows/validate-deployment.ps1", b"validate".as_slice()), + ("ansible/deploy_aw_windows.yml", b"ansible".as_slice()), + ("aw-server/install_aw_server.sh", b"server".as_slice()), + ("scripts/rebuild_install_kit.sh", b"rebuild".as_slice()), + ( + "scripts/validate_install_kit.sh", + b"validate-kit".as_slice(), + ), + ("scripts/check_install_kit_vs_repo.sh", b"check".as_slice()), + ("scripts/quality-gate.sh", b"quality".as_slice()), + ]; + if !matching { + files.pop(); + files.push(("scripts/other.sh", b"other".as_slice())); + } + files + } + + fn write_zip(root: &std::path::Path, _matching: bool) { + let path = root.join(format!("{DEFAULT_KIT_DIR}.zip")); + let file = fs::File::create(path).unwrap(); + let mut zip = zip::ZipWriter::new(file); + let options = SimpleFileOptions::default(); + for (rel, data) in archive_files(true) { + zip.start_file(format!("{DEFAULT_KIT_DIR}/{rel}"), options) + .unwrap(); + zip.write_all(data).unwrap(); + } + zip.finish().unwrap(); + } + + fn write_tar(root: &std::path::Path, matching: bool) { + let path = root.join(format!("{DEFAULT_KIT_DIR}.tar.gz")); + let file = fs::File::create(path).unwrap(); + let encoder = GzEncoder::new(file, Compression::default()); + let mut builder = tar::Builder::new(encoder); + for (rel, data) in archive_files(matching) { + let mut header = tar::Header::new_gnu(); + header.set_size(data.len() as u64); + header.set_mode(0o644); + header.set_cksum(); + builder + .append_data(&mut header, format!("{DEFAULT_KIT_DIR}/{rel}"), data) + .unwrap(); + } + builder.finish().unwrap(); + } +} diff --git a/adk-rust/crates/verify-innosetup-installer/Cargo.toml b/adk-rust/crates/verify-innosetup-installer/Cargo.toml new file mode 100644 index 0000000..4503724 --- /dev/null +++ b/adk-rust/crates/verify-innosetup-installer/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "verify-innosetup-installer" +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 diff --git a/adk-rust/crates/verify-innosetup-installer/src/main.rs b/adk-rust/crates/verify-innosetup-installer/src/main.rs new file mode 100644 index 0000000..8e43c8b --- /dev/null +++ b/adk-rust/crates/verify-innosetup-installer/src/main.rs @@ -0,0 +1,263 @@ +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +use anyhow::{Context, Result, bail}; +use clap::Parser; +use serde::Serialize; + +const DEFAULT_INSTALLER: &str = "windows/installkit/innosetup/AWatch-rus-InstallKit.exe"; +const DEFAULT_WINEPREFIX: &str = "/tmp/aw-inno-verify-wineprefix"; +const INSTALL_DIR_WIN: &str = r"C:\AWatchRusExtract"; +const REQUIRED_FILES: &[&str] = &[ + "windows/AWatchRusCollectorGuardService.cs", + "windows/aw-collector-guard.ps1", + "windows/install-collector-guard-service.ps1", + "windows/dlp-policy.native-cross-os.example.json", +]; +const GUARD_MARKER: &str = "collector guard self-test OK"; + +#[derive(Debug, Parser)] +#[command(about = "Verify AWatch-rus InnoSetup installer payload through Wine")] +struct Cli { + #[arg(default_value = DEFAULT_INSTALLER)] + installer: PathBuf, + + #[arg(long, default_value = ".")] + root: PathBuf, + + #[arg(long, env = "WINEPREFIX_VERIFY", default_value = DEFAULT_WINEPREFIX)] + wineprefix: PathBuf, + + #[arg(long, default_value_t = false)] + json: bool, +} + +#[derive(Debug, Serialize)] +struct Report { + ok: bool, + installer: PathBuf, + wineprefix: PathBuf, + checked_files: Vec, + errors: Vec, +} + +fn main() { + let code = match run() { + Ok(code) => code, + Err(err) => { + eprintln!("{err:#}"); + 1 + } + }; + std::process::exit(code); +} + +fn run() -> Result { + let cli = Cli::parse(); + let root = cli + .root + .canonicalize() + .with_context(|| format!("canonicalize root {}", cli.root.display()))?; + let installer = resolve_path(&root, cli.installer); + let report = verify(&root, &installer, &cli.wineprefix); + print_report(&report, cli.json)?; + Ok(if report.ok { 0 } else { 1 }) +} + +fn verify(root: &Path, installer: &Path, wineprefix: &Path) -> Report { + let mut errors = Vec::new(); + if !installer.is_file() { + errors.push(format!("Installer not found: {}", installer.display())); + return report(false, installer, wineprefix, Vec::new(), errors); + } + if command_path("wine").is_none() { + errors.push("wine not found".to_string()); + return report(false, installer, wineprefix, Vec::new(), errors); + } + if command_path("wineboot").is_none() { + errors.push("wineboot not found".to_string()); + return report(false, installer, wineprefix, Vec::new(), errors); + } + if command_path("wineserver").is_none() { + errors.push("wineserver not found".to_string()); + return report(false, installer, wineprefix, Vec::new(), errors); + } + + if wineprefix.exists() + && let Err(err) = fs::remove_dir_all(wineprefix) + { + errors.push(format!( + "failed to remove WINEPREFIX {}: {err}", + wineprefix.display() + )); + return report(false, installer, wineprefix, Vec::new(), errors); + } + if let Err(err) = fs::create_dir_all(wineprefix) { + errors.push(format!( + "failed to create WINEPREFIX {}: {err}", + wineprefix.display() + )); + return report(false, installer, wineprefix, Vec::new(), errors); + } + + if let Err(err) = run_quiet( + Command::new("wineboot") + .arg("-u") + .env("WINEPREFIX", wineprefix) + .env( + "WINEDEBUG", + env::var("WINEDEBUG").unwrap_or_else(|_| "-all".to_string()), + ), + ) { + errors.push(err.to_string()); + return report(false, installer, wineprefix, Vec::new(), errors); + } + if let Err(err) = run_quiet( + Command::new("wine") + .arg(installer) + .arg("/VERYSILENT") + .arg("/SUPPRESSMSGBOXES") + .arg("/NORESTART") + .arg("/SP-") + .arg("/TASKS=") + .arg(format!(r#"/DIR={INSTALL_DIR_WIN}"#)) + .env("WINEPREFIX", wineprefix) + .env( + "WINEDEBUG", + env::var("WINEDEBUG").unwrap_or_else(|_| "-all".to_string()), + ), + ) { + errors.push(err.to_string()); + return report(false, installer, wineprefix, Vec::new(), errors); + } + if let Err(err) = run_quiet( + Command::new("wineserver") + .arg("-w") + .env("WINEPREFIX", wineprefix), + ) { + errors.push(err.to_string()); + return report(false, installer, wineprefix, Vec::new(), errors); + } + + let install_dir = wineprefix.join("drive_c/AWatchRusExtract"); + let mut checked = Vec::new(); + for rel in REQUIRED_FILES { + let extracted = install_dir.join(rel); + let repo = root.join(rel); + if !extracted.is_file() { + errors.push(format!("Missing extracted file: {rel}")); + continue; + } + match files_equal(&repo, &extracted) { + Ok(true) => checked.push((*rel).to_string()), + Ok(false) => errors.push(format!("Extracted file differs from repo: {rel}")), + Err(err) => errors.push(err.to_string()), + } + } + let guard = install_dir.join("windows/aw-collector-guard.ps1"); + match fs::read_to_string(&guard) { + Ok(text) if text.contains(GUARD_MARKER) => {} + Ok(_) => { + errors.push("Guard self-test marker missing in extracted installer payload".to_string()) + } + Err(err) => errors.push(format!( + "read extracted guard script {}: {err}", + guard.display() + )), + } + + report(errors.is_empty(), installer, wineprefix, checked, errors) +} + +fn run_quiet(command: &mut Command) -> Result<()> { + let program = command.get_program().to_string_lossy().to_string(); + let status = command + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .with_context(|| format!("run {program}"))?; + if !status.success() { + bail!("{program} failed with status {status}"); + } + Ok(()) +} + +fn files_equal(left: &Path, right: &Path) -> Result { + let left = fs::read(left).with_context(|| format!("read {}", left.display()))?; + let right = fs::read(right).with_context(|| format!("read {}", right.display()))?; + Ok(left == right) +} + +fn print_report(report: &Report, json: bool) -> Result<()> { + if json { + println!("{}", serde_json::to_string_pretty(report)?); + return Ok(()); + } + for err in &report.errors { + eprintln!("{err}"); + } + if report.ok { + println!("verify_innosetup_installer: OK"); + } + Ok(()) +} + +fn report( + ok: bool, + installer: &Path, + wineprefix: &Path, + checked_files: Vec, + errors: Vec, +) -> Report { + Report { + ok, + installer: installer.to_path_buf(), + wineprefix: wineprefix.to_path_buf(), + checked_files, + errors, + } +} + +fn command_path(name: &str) -> Option { + let path = env::var_os("PATH")?; + env::split_paths(&path) + .map(|dir| dir.join(name)) + .find(|candidate| candidate.is_file()) +} + +fn resolve_path(root: &Path, path: PathBuf) -> PathBuf { + if path.is_absolute() { + path + } else { + root.join(path) + } +} + +#[cfg(test)] +mod tests { + use std::fs; + + use tempfile::tempdir; + + use super::{files_equal, resolve_path}; + + #[test] + fn compares_files_by_bytes() { + let tmp = tempdir().unwrap(); + let a = tmp.path().join("a"); + let b = tmp.path().join("b"); + fs::write(&a, b"same").unwrap(); + fs::write(&b, b"same").unwrap(); + assert!(files_equal(&a, &b).unwrap()); + fs::write(&b, b"different").unwrap(); + assert!(!files_equal(&a, &b).unwrap()); + } + + #[test] + fn resolves_relative_path_against_root() { + let root = std::path::Path::new("/tmp/root"); + assert_eq!(resolve_path(root, "a/b".into()), root.join("a/b")); + } +} diff --git a/adk-rust/crates/worktime-api/Cargo.toml b/adk-rust/crates/worktime-api/Cargo.toml new file mode 100644 index 0000000..c206f08 --- /dev/null +++ b/adk-rust/crates/worktime-api/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "worktime-api" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +publish = false + +[dependencies] +anyhow.workspace = true +chrono.workspace = true +clap.workspace = true +reqwest.workspace = true +serde.workspace = true +serde_json.workspace = true +tiny_http.workspace = true +url.workspace = true +urlencoding.workspace = true diff --git a/adk-rust/crates/worktime-api/src/main.rs b/adk-rust/crates/worktime-api/src/main.rs new file mode 100644 index 0000000..1530572 --- /dev/null +++ b/adk-rust/crates/worktime-api/src/main.rs @@ -0,0 +1,2585 @@ +use std::{ + collections::{BTreeMap, BTreeSet, HashMap}, + fs, + io::Cursor, + path::PathBuf, + sync::{Arc, Mutex}, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, +}; + +use anyhow::{Context, Result, anyhow}; +use chrono::{ + DateTime, Datelike, FixedOffset, Local, NaiveDate, SecondsFormat, TimeDelta, TimeZone, Utc, +}; +use clap::Parser; +use reqwest::{ + blocking::Client, + header::{CONNECTION, HeaderMap, HeaderValue}, +}; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value, json}; +use tiny_http::{Header, Method, Request, Response, Server, StatusCode}; +use url::form_urlencoded; + +const DEFAULT_AW_URL: &str = "http://127.0.0.1:5600"; +const DEFAULT_HOST: &str = "SHARKON2025"; + +#[derive(Debug, Parser)] +#[command(about = "AW Worktime Report API")] +struct Cli { + #[arg(long)] + once: bool, +} + +#[derive(Debug, Clone)] +struct Config { + aw_api_base: String, + ioc_dir: PathBuf, + default_host: String, + default_sample_seconds: f64, + max_sample_seconds: f64, + listen_host: String, + listen_port: u16, + workday_start_hour: u32, + workday_end_hour: u32, + manager_target_coverage_pct: i64, + manager_low_coverage_pct: i64, + manager_late_start_grace_minutes: i64, + manager_early_finish_grace_minutes: i64, + manager_critical_source_max_age_seconds: i64, + manager_web_source_max_age_seconds: i64, + manager_session_source_max_age_seconds: i64, + manager_infra_source_max_age_seconds: i64, + manager_aliases_json: PathBuf, + manager_exclude_users: BTreeSet, + events_cache_ttl_seconds: i64, + worktime_events_limit: usize, + aw_http_timeout_seconds: f64, + source_http_timeout_seconds: f64, + report_cache_ttl_seconds: i64, + report_stale_ttl_seconds: i64, + report_disk_cache_dir: PathBuf, + report_disk_stale_ttl_seconds: i64, + true_active_evidence_window_seconds: i64, + true_active_max_event_seconds: i64, + offset: FixedOffset, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +struct AwEvent { + timestamp: Option, + #[serde(default)] + duration: Option, + #[serde(default)] + data: Value, +} + +#[derive(Debug, Clone)] +struct UserBucket { + user: String, + user_id: String, + samples_count: i64, + active_samples: i64, + session_ids: BTreeSet, + intervals: Vec<(DateTime, DateTime)>, +} + +#[derive(Debug, Clone)] +struct AliasProfile { + display_name: String, + manager_owner: String, + department: String, + role: String, + notes: String, + canonical_user_id: String, + exclude: bool, +} + +#[derive(Debug, Clone)] +struct CachedResponse { + stored: Instant, + data: Vec, + content_type: String, +} + +type EventsCache = Arc)>>>; +type ReportCache = Arc>>; +type IdentitySamples = BTreeMap<(String, String), Vec<(DateTime, AwEvent)>>; +type DateBounds = (DateTime, DateTime); +type EventsForDate = (DateBounds, Vec); + +#[derive(Clone)] +struct App { + config: Arc, + aw: Client, + events_cache: EventsCache, + report_cache: ReportCache, +} + +fn env(name: &str, fallback: &str) -> String { + std::env::var(name) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| fallback.to_string()) +} + +fn env_f64(name: &str, fallback: f64) -> f64 { + std::env::var(name) + .ok() + .and_then(|value| value.trim().parse().ok()) + .filter(|value| *value > 0.0) + .unwrap_or(fallback) +} + +fn env_i64(name: &str, fallback: i64) -> i64 { + std::env::var(name) + .ok() + .and_then(|value| value.trim().parse().ok()) + .unwrap_or(fallback) +} + +fn env_usize(name: &str, fallback: usize) -> usize { + std::env::var(name) + .ok() + .and_then(|value| value.trim().parse().ok()) + .filter(|value| *value > 0) + .unwrap_or(fallback) +} + +fn build_aw_api_base(raw_url: &str) -> String { + let url = raw_url.trim().trim_end_matches('/'); + if url.ends_with("/api/0") { + url.to_string() + } else { + format!("{url}/api/0") + } +} + +fn load_config() -> Config { + let default_sample_seconds = env_f64("AW_WORKTIME_DEFAULT_SAMPLE_SECONDS", 30.0).max(1.0); + let max_sample_seconds = + env_f64("AW_WORKTIME_MAX_SAMPLE_SECONDS", 300.0).max(default_sample_seconds); + let manager_target_coverage_pct = + env_i64("AW_WORKTIME_MANAGER_TARGET_COVERAGE_PCT", 75).clamp(1, 100); + let manager_low_coverage_pct = + env_i64("AW_WORKTIME_MANAGER_LOW_COVERAGE_PCT", 35).clamp(1, 100); + let report_cache_ttl_seconds = env_i64("AW_WORKTIME_REPORT_CACHE_TTL_SECONDS", 60).max(0); + let report_stale_ttl_seconds = + env_i64("AW_WORKTIME_REPORT_STALE_TTL_SECONDS", 900).max(report_cache_ttl_seconds); + Config { + aw_api_base: build_aw_api_base(&env("AW_SERVER_URL", DEFAULT_AW_URL)), + ioc_dir: PathBuf::from(env("AW_DLP_IOC_DIR", "/opt/activitywatch/dlp-ioc/output")), + default_host: env("AW_WORKTIME_HOST", DEFAULT_HOST), + default_sample_seconds, + max_sample_seconds, + listen_host: env("AW_WORKTIME_LISTEN_HOST", "0.0.0.0"), + listen_port: env("AW_WORKTIME_PORT", "5610").parse().unwrap_or(5610), + workday_start_hour: env_i64("AW_WORKTIME_MANAGER_START_HOUR", 9).clamp(0, 23) as u32, + workday_end_hour: env_i64("AW_WORKTIME_MANAGER_END_HOUR", 18).clamp(0, 23) as u32, + manager_target_coverage_pct, + manager_low_coverage_pct, + manager_late_start_grace_minutes: env_i64( + "AW_WORKTIME_MANAGER_LATE_START_GRACE_MINUTES", + 60, + ) + .max(0), + manager_early_finish_grace_minutes: env_i64( + "AW_WORKTIME_MANAGER_EARLY_FINISH_GRACE_MINUTES", + 90, + ) + .max(0), + manager_critical_source_max_age_seconds: env_i64( + "AW_WORKTIME_MANAGER_CRITICAL_SOURCE_MAX_AGE_SECONDS", + 900, + ) + .max(60), + manager_web_source_max_age_seconds: env_i64( + "AW_WORKTIME_MANAGER_WEB_SOURCE_MAX_AGE_SECONDS", + 259200, + ) + .max(3600), + manager_session_source_max_age_seconds: env_i64( + "AW_WORKTIME_MANAGER_SESSION_SOURCE_MAX_AGE_SECONDS", + 604800, + ) + .max(3600), + manager_infra_source_max_age_seconds: env_i64( + "AW_WORKTIME_MANAGER_INFRA_SOURCE_MAX_AGE_SECONDS", + 172800, + ) + .max(3600), + manager_aliases_json: PathBuf::from(env( + "AW_WORKTIME_MANAGER_ALIASES_JSON", + "/etc/activitywatch/worktime-manager-aliases.json", + )), + manager_exclude_users: env("AW_WORKTIME_MANAGER_EXCLUDE_USERS", "") + .split(',') + .map(|item| item.trim().to_lowercase()) + .filter(|item| !item.is_empty()) + .collect(), + events_cache_ttl_seconds: env_i64("AW_WORKTIME_EVENTS_CACHE_TTL_SECONDS", 30).max(0), + worktime_events_limit: env_usize("AW_WORKTIME_EVENTS_LIMIT", 50_000).max(1000), + aw_http_timeout_seconds: env_f64("AW_WORKTIME_AW_HTTP_TIMEOUT_SECONDS", 8.0).max(0.5), + source_http_timeout_seconds: env_f64("AW_WORKTIME_SOURCE_HTTP_TIMEOUT_SECONDS", 1.5) + .max(0.25), + report_cache_ttl_seconds, + report_stale_ttl_seconds, + report_disk_cache_dir: PathBuf::from(env( + "AW_WORKTIME_REPORT_DISK_CACHE_DIR", + "/var/lib/activitywatch/worktime-report-cache", + )), + report_disk_stale_ttl_seconds: env_i64("AW_WORKTIME_REPORT_DISK_STALE_TTL_SECONDS", 86400) + .max(report_stale_ttl_seconds), + true_active_evidence_window_seconds: env_i64( + "AW_WORKTIME_TRUE_ACTIVE_EVIDENCE_WINDOW_SECONDS", + 180, + ) + .max(30), + true_active_max_event_seconds: env_i64("AW_WORKTIME_TRUE_ACTIVE_MAX_EVENT_SECONDS", 600) + .max(30), + offset: FixedOffset::east_opt(3 * 3600).expect("valid Moscow offset"), + } +} + +impl App { + fn new(config: Config) -> Result { + let mut headers = HeaderMap::new(); + headers.insert(CONNECTION, HeaderValue::from_static("close")); + let aw = Client::builder() + .timeout(Duration::from_secs_f64(config.aw_http_timeout_seconds)) + .no_proxy() + .pool_max_idle_per_host(0) + .default_headers(headers) + .build() + .context("build AW HTTP client")?; + Ok(Self { + config: Arc::new(config), + aw, + events_cache: Arc::new(Mutex::new(HashMap::new())), + report_cache: Arc::new(Mutex::new(HashMap::new())), + }) + } + + fn aw_get_json_once(&self, path: &str) -> Result { + self.aw_get_json_with(path, 1, None) + } + + fn aw_get_json_with( + &self, + path: &str, + attempts: usize, + timeout: Option, + ) -> Result { + let url = format!("{}{}", self.config.aw_api_base, path); + let mut last_error = None; + for attempt in 1..=attempts.max(1) { + let mut request = self.aw.get(&url); + if let Some(timeout) = timeout { + request = request.timeout(timeout); + } + match request.send() { + Ok(response) => { + if !response.status().is_success() { + return Err(anyhow!("AW {path} returned HTTP {}", response.status())); + } + return response + .json() + .with_context(|| format!("decode AW JSON {path}")); + } + Err(error) => { + let is_timeout = error.is_timeout(); + last_error = Some(error); + if is_timeout { + break; + } + if attempt < attempts { + std::thread::sleep(Duration::from_millis(250)); + } + } + } + } + Err(last_error.expect("request failed")).with_context(|| format!("request AW {path}")) + } + + fn fetch_bucket_events( + &self, + bucket_id: &str, + start: Option>, + end: Option>, + ) -> Vec { + match self.fetch_bucket_events_result(bucket_id, start, end) { + Ok(events) => events, + Err(error) => { + eprintln!( + "[aw-worktime-api-rust] events fetch failed bucket={bucket_id}: {error:#}" + ); + Vec::new() + } + } + } + + fn fetch_bucket_events_result( + &self, + bucket_id: &str, + start: Option>, + end: Option>, + ) -> Result> { + let mut key = bucket_id.to_string(); + let query_limit = self.config.worktime_events_limit.min(5_000); + let params = [format!("limit={query_limit}")]; + if let (Some(start), Some(end)) = (start, end) { + let s = to_iso_utc(start); + let e = to_iso_utc(end); + key = format!("{bucket_id}|{s}|{e}"); + } + if self.config.events_cache_ttl_seconds > 0 { + if let Some((stored, events)) = self + .events_cache + .lock() + .ok() + .and_then(|cache| cache.get(&key).cloned()) + { + if stored.elapsed().as_secs() <= self.config.events_cache_ttl_seconds as u64 { + return Ok(events); + } + } + } + let path = format!("/buckets/{bucket_id}/events?{}", params.join("&")); + let mut events: Vec = serde_json::from_value(self.aw_get_json_once(&path)?) + .with_context(|| format!("decode AW events for {bucket_id}"))?; + if let (Some(start), Some(end)) = (start, end) { + events.retain(|event| { + event + .timestamp + .as_deref() + .and_then(parse_iso_utc) + .is_some_and(|ts| ts >= start && ts <= end) + }); + } + if self.config.events_cache_ttl_seconds > 0 { + if let Ok(mut cache) = self.events_cache.lock() { + cache.insert(key, (Instant::now(), events.clone())); + } + } + Ok(events) + } + + fn latest_bucket_event(&self, bucket_id: &str) -> Option { + let bucket = self + .aw_get_json_with( + &format!("/buckets/{bucket_id}"), + 1, + Some(Duration::from_secs_f64( + self.config.source_http_timeout_seconds, + )), + ) + .ok()?; + let end = bucket.pointer("/metadata/end").and_then(Value::as_str)?; + Some(AwEvent { + timestamp: Some(end.to_string()), + duration: Some(0.0), + data: json!({"source": "bucket_metadata"}), + }) + } + + fn report_response( + &self, + path: &str, + params: &Params, + accept: &str, + ) -> (Vec, String, Vec<(String, String)>) { + let fmt = resolve_report_format(params, accept); + let host = resolve_host(&self.config, params.first("host").as_deref()); + let report_date = resolve_report_date( + &self.config, + params.first("day").as_deref(), + params.first("date").as_deref(), + ); + let owner = normalize_filter(params.first("owner").as_deref().unwrap_or("")); + let department = normalize_filter(params.first("department").as_deref().unwrap_or("")); + let cache_key = make_report_cache_key( + path, + &fmt, + &host, + report_date, + params.first("day").as_deref().unwrap_or(""), + &owner, + &department, + ); + if let Some(cached) = self.get_report_cache(&cache_key, false) { + return ( + cached.data, + cached.content_type, + vec![ + ("X-AW-Worktime-Cache".into(), "fresh".into()), + ("X-AW-Worktime-Cache-Reason".into(), "ttl".into()), + ], + ); + } + let built = + self.build_report_response(path, params, &fmt, &host, report_date, &owner, &department); + match built { + Ok((data, content_type)) => { + self.save_report_cache(cache_key, data.clone(), content_type.clone()); + (data, content_type, Vec::new()) + } + Err(error) => { + eprintln!("[aw-worktime-api-rust] report build failed path={path}: {error:#}"); + if let Some(cached) = self.get_report_cache(&cache_key, true) { + return ( + cached.data, + cached.content_type, + vec![ + ("X-AW-Worktime-Cache".into(), "stale".into()), + ("X-AW-Worktime-Cache-Reason".into(), "build-error".into()), + ], + ); + } + let data = serde_json::to_vec_pretty(&json!({ + "ok": false, + "error": "report_unavailable", + "message": "report build failed and no cached response is available", + "generated_at_utc": to_iso_utc(Utc::now()), + })) + .unwrap_or_default(); + ( + data, + "application/json; charset=utf-8".to_string(), + Vec::new(), + ) + } + } + } + + fn get_report_cache(&self, key: &str, allow_stale: bool) -> Option { + let cached = self.report_cache.lock().ok()?.get(key).cloned(); + if let Some(cached) = cached { + let max_age = if allow_stale { + self.config.report_stale_ttl_seconds + } else { + self.config.report_cache_ttl_seconds + }; + if max_age > 0 && cached.stored.elapsed().as_secs() <= max_age as u64 { + return Some(cached); + } + } + if allow_stale { + return self.load_disk_cache(key); + } + None + } + + fn save_report_cache(&self, key: String, data: Vec, content_type: String) { + if self.config.report_stale_ttl_seconds <= 0 { + return; + } + if let Ok(mut cache) = self.report_cache.lock() { + cache.insert( + key.clone(), + CachedResponse { + stored: Instant::now(), + data: data.clone(), + content_type: content_type.clone(), + }, + ); + } + self.save_disk_cache(&key, &data, &content_type); + } + + fn disk_cache_path(&self, key: &str) -> PathBuf { + let mut h: u64 = 1469598103934665603; + for b in key.as_bytes() { + h ^= *b as u64; + h = h.wrapping_mul(1099511628211); + } + self.config + .report_disk_cache_dir + .join(format!("{h:016x}.json")) + } + + fn save_disk_cache(&self, key: &str, data: &[u8], content_type: &str) { + if self.config.report_disk_stale_ttl_seconds <= 0 { + return; + } + let path = self.disk_cache_path(key); + let _ = fs::create_dir_all(&self.config.report_disk_cache_dir); + let payload = json!({ + "stored_epoch": SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs_f64()).unwrap_or(0.0), + "content_type": content_type, + "data": String::from_utf8_lossy(data), + }); + let _ = fs::write(path, serde_json::to_vec(&payload).unwrap_or_default()); + } + + fn load_disk_cache(&self, key: &str) -> Option { + let path = self.disk_cache_path(key); + let payload: Value = serde_json::from_slice(&fs::read(path).ok()?).ok()?; + let stored_epoch = payload.get("stored_epoch")?.as_f64()?; + let now_epoch = SystemTime::now() + .duration_since(UNIX_EPOCH) + .ok()? + .as_secs_f64(); + if now_epoch - stored_epoch > self.config.report_disk_stale_ttl_seconds as f64 { + return None; + } + Some(CachedResponse { + stored: Instant::now() - Duration::from_secs_f64((now_epoch - stored_epoch).max(0.0)), + data: payload.get("data")?.as_str()?.as_bytes().to_vec(), + content_type: payload.get("content_type")?.as_str()?.to_string(), + }) + } + + #[allow(clippy::too_many_arguments)] + fn build_report_response( + &self, + path: &str, + params: &Params, + fmt: &str, + host: &str, + report_date: NaiveDate, + owner: &str, + department: &str, + ) -> Result<(Vec, String)> { + let is_management = path == "/reports/worktime/management"; + if is_management { + let payload = self.management_report_for_date(host, report_date, owner, department)?; + return Ok(match fmt { + "csv" => ( + management_csv(&payload).into_bytes(), + "text/csv; charset=utf-8".to_string(), + ), + "html" => ( + render_management_html(&payload).into_bytes(), + "text/html; charset=utf-8".to_string(), + ), + _ => ( + serde_json::to_vec_pretty(&payload)?, + "application/json; charset=utf-8".to_string(), + ), + }); + } + + let (bounds, events) = self.fetch_events_for_date(host, report_date)?; + let rows = aggregate_rows(&self.config, &events, bounds.0, bounds.1, host, false); + let total_active_seconds = rows + .iter() + .map(|row| { + row.get("active_seconds") + .and_then(Value::as_i64) + .unwrap_or(0) + }) + .sum::(); + let true_active_apps = if total_active_seconds > 0 { + self.build_true_active_apps(host, report_date) + } else { + Vec::new() + }; + let day = params.first("day"); + Ok(match fmt { + "csv" => ( + today_csv(&rows).into_bytes(), + "text/csv; charset=utf-8".to_string(), + ), + "html" => ( + render_today_html( + &self.config, + &rows, + host, + report_date, + day.as_deref(), + &true_active_apps, + ) + .into_bytes(), + "text/html; charset=utf-8".to_string(), + ), + _ => { + let payload = json!({ + "generated_at_utc": to_iso_utc(Utc::now()), + "report_timezone": "Europe/Moscow", + "host": host, + "report_date": report_date.to_string(), + "bucket_id": sessions_bucket(host), + "rows": rows, + "true_active_apps": true_active_apps, + }); + ( + serde_json::to_vec_pretty(&payload)?, + "application/json; charset=utf-8".to_string(), + ) + } + }) + } + + fn fetch_events_for_date(&self, host: &str, report_date: NaiveDate) -> Result { + let bounds = report_bounds(&self.config, report_date); + let events = self.fetch_bucket_events_result( + &sessions_bucket(host), + Some(bounds.0), + Some(bounds.1), + )?; + Ok((bounds, events)) + } + + fn management_report_for_date( + &self, + host: &str, + report_date: NaiveDate, + owner: &str, + department: &str, + ) -> Result { + let (bounds, events) = self.fetch_events_for_date(host, report_date)?; + let rows = aggregate_rows(&self.config, &events, bounds.0, bounds.1, host, true); + Ok(self.build_management_payload(rows, host, report_date, owner, department)) + } + + fn build_true_active_apps(&self, host: &str, report_date: NaiveDate) -> Vec { + let bounds = report_bounds(&self.config, report_date); + let window_events = { + let primary = self.fetch_bucket_events( + &format!("aw-watcher-window_{host}"), + Some(bounds.0), + Some(bounds.1), + ); + if primary.is_empty() { + self.fetch_bucket_events( + &format!("aw-rdp-window_{host}"), + Some(bounds.0), + Some(bounds.1), + ) + } else { + primary + } + }; + let afk_events = { + let primary = self.fetch_bucket_events( + &format!("aw-watcher-afk_{host}"), + Some(bounds.0), + Some(bounds.1), + ); + if primary.is_empty() { + self.fetch_bucket_events( + &format!("aw-rdp-afk_{host}"), + Some(bounds.0), + Some(bounds.1), + ) + } else { + primary + } + }; + let mut evidence = HashMap::new(); + for bucket in [ + format!("aw-file-operations_{host}"), + format!("aw-dlp-endpoint-signals_{host}"), + format!("aw-watcher-web-chrome_{host}"), + format!("aw-watcher-web-edge_{host}"), + format!("aw-detmir-web-category_{host}"), + ] { + evidence.insert( + bucket.clone(), + self.fetch_bucket_events(&bucket, Some(bounds.0), Some(bounds.1)), + ); + } + build_true_active_apps_from_events( + &self.config, + &window_events, + &afk_events, + &evidence, + bounds.0, + bounds.1, + ) + } +} + +#[derive(Debug, Clone, Default)] +struct Params(HashMap>); + +impl Params { + fn parse(query: &str) -> Self { + let mut map: HashMap> = HashMap::new(); + for (k, v) in form_urlencoded::parse(query.as_bytes()) { + if !v.is_empty() { + map.entry(k.into_owned()).or_default().push(v.into_owned()); + } + } + Self(map) + } + fn first(&self, name: &str) -> Option { + self.0.get(name).and_then(|v| v.first()).cloned() + } +} + +fn parse_iso_utc(value: &str) -> Option> { + DateTime::parse_from_rfc3339(value) + .map(|dt| dt.with_timezone(&Utc)) + .ok() +} + +fn to_iso_utc(dt: DateTime) -> String { + dt.to_rfc3339_opts(SecondsFormat::Secs, true) +} + +fn hhmm(seconds: i64) -> String { + let seconds = seconds.max(0); + format!("{:02}:{:02}", seconds / 3600, (seconds % 3600) / 60) +} + +fn human_duration_ru(seconds: i64) -> String { + let seconds = seconds.max(0); + let h = seconds / 3600; + let m = (seconds % 3600) / 60; + if h > 0 && m > 0 { + format!("{h} ч {m} мин") + } else if h > 0 { + format!("{h} ч") + } else if m > 0 { + format!("{m} мин") + } else { + format!("{seconds} сек") + } +} + +fn resolve_host(config: &Config, host: Option<&str>) -> String { + let h = host.unwrap_or(&config.default_host).trim(); + if h.is_empty() { + config.default_host.clone() + } else { + h.to_string() + } +} + +fn sessions_bucket(host: &str) -> String { + format!("aw-worktime-sessions_{host}") +} + +fn resolve_report_date(config: &Config, day: Option<&str>, date: Option<&str>) -> NaiveDate { + if let Some(date) = date.and_then(|d| NaiveDate::parse_from_str(d, "%Y-%m-%d").ok()) { + return date; + } + let today = Utc::now().with_timezone(&config.offset).date_naive(); + if day == Some("yesterday") { + today - TimeDelta::days(1) + } else { + today + } +} + +fn report_bounds(config: &Config, report_date: NaiveDate) -> (DateTime, DateTime) { + let start_local = config + .offset + .with_ymd_and_hms( + report_date.year(), + report_date.month(), + report_date.day(), + 0, + 0, + 0, + ) + .single() + .unwrap(); + let start = start_local.with_timezone(&Utc); + let end = (start_local + TimeDelta::days(1) - TimeDelta::seconds(1)).with_timezone(&Utc); + (start, end) +} + +fn workday_bounds( + config: &Config, + report_date: NaiveDate, +) -> (DateTime, DateTime, i64) { + let start = config + .offset + .with_ymd_and_hms( + report_date.year(), + report_date.month(), + report_date.day(), + config.workday_start_hour, + 0, + 0, + ) + .single() + .unwrap(); + let mut end = config + .offset + .with_ymd_and_hms( + report_date.year(), + report_date.month(), + report_date.day(), + config.workday_end_hour, + 0, + 0, + ) + .single() + .unwrap(); + if end <= start { + end = start + TimeDelta::hours(8); + } + let duration = (end - start).num_seconds(); + (start, end, duration) +} + +fn value_string(data: &Value, key: &str) -> String { + match data.get(key) { + Some(Value::String(value)) => value.trim().to_string(), + Some(Value::Number(value)) => value.to_string(), + Some(Value::Bool(value)) => value.to_string(), + Some(Value::Null) | None => String::new(), + Some(value) => value.to_string().trim_matches('"').trim().to_string(), + } +} + +fn is_machine_user(user: &str) -> bool { + let u = user.trim().to_lowercase(); + u.ends_with('$') || matches!(u.as_str(), "system" | "localservice" | "networkservice") +} + +fn is_active_sample(data: &Value) -> bool { + if data.get("active").and_then(Value::as_bool).unwrap_or(false) { + return true; + } + let state = value_string(data, "state").to_lowercase(); + if state.contains("актив") || state == "active" { + return true; + } + if state == "unknown" { + let sid = value_string(data, "sessionId").parse::().unwrap_or(-1); + let user = value_string(data, "username"); + let session_name = value_string(data, "sessionName").to_lowercase(); + return sid > 0 + && !user.is_empty() + && !is_machine_user(&user) + && (session_name.starts_with("rdp-") || session_name == "console"); + } + false +} + +fn normalize_user_id(data: &Value, host: &str, username: &str) -> String { + let user_id = value_string(data, "userId"); + if !user_id.is_empty() { + if let Some((_, right)) = user_id.split_once('\\') { + return format!("{host}\\{right}"); + } + return user_id; + } + format!("{host}\\{username}") +} + +fn clamp_seconds(config: &Config, value: f64, fallback: f64) -> f64 { + let seconds = if value > 0.0 { value } else { fallback }; + seconds.min(config.max_sample_seconds).max(1.0) +} + +fn event_sample_seconds( + config: &Config, + event: &AwEvent, + ts: DateTime, + next_ts: Option>, +) -> f64 { + for key in ["sampleSeconds", "pollSeconds"] { + let value = event.data.get(key).and_then(|v| { + if let Some(n) = v.as_f64() { + Some(n) + } else { + v.as_str().and_then(|s| s.parse().ok()) + } + }); + if let Some(value) = value.filter(|v| *v > 0.0) { + return clamp_seconds(config, value, config.default_sample_seconds); + } + } + if let Some(duration) = event.duration.filter(|v| *v > 0.0) { + return clamp_seconds(config, duration, config.default_sample_seconds); + } + if let Some(next_ts) = next_ts { + let delta = (next_ts - ts).num_milliseconds() as f64 / 1000.0; + if delta > 0.0 { + return clamp_seconds(config, delta, config.default_sample_seconds); + } + } + clamp_seconds( + config, + config.default_sample_seconds, + config.default_sample_seconds, + ) +} + +fn merge_intervals( + mut intervals: Vec<(DateTime, DateTime)>, +) -> Vec<(DateTime, DateTime)> { + intervals.sort_by_key(|i| i.0); + let mut merged: Vec<(DateTime, DateTime)> = Vec::new(); + for (start, end) in intervals { + if let Some(last) = merged.last_mut() { + if start <= last.1 { + if end > last.1 { + last.1 = end; + } + continue; + } + } + merged.push((start, end)); + } + merged +} + +fn aggregate_rows( + config: &Config, + events: &[AwEvent], + start: DateTime, + end: DateTime, + host: &str, + intervals: bool, +) -> Vec { + let mut by_identity: IdentitySamples = BTreeMap::new(); + for event in events { + let Some(ts) = event.timestamp.as_deref().and_then(parse_iso_utc) else { + continue; + }; + if ts < start || ts > end { + continue; + } + let username = value_string(&event.data, "username"); + if username.is_empty() { + continue; + } + let session_id = value_string(&event.data, "sessionId"); + by_identity + .entry(( + username, + if session_id.is_empty() { + "unknown".into() + } else { + session_id + }, + )) + .or_default() + .push((ts, event.clone())); + } + let end_exclusive = end + TimeDelta::seconds(1); + let mut by_user: BTreeMap = BTreeMap::new(); + for ((username, session_id), mut samples) in by_identity { + samples.sort_by_key(|item| item.0); + for idx in 0..samples.len() { + let (ts, event) = &samples[idx]; + let next_ts = samples.get(idx + 1).map(|item| item.0); + let active = is_active_sample(&event.data); + let row = by_user + .entry(username.clone()) + .or_insert_with(|| UserBucket { + user: username.clone(), + user_id: normalize_user_id(&event.data, host, &username), + samples_count: 0, + active_samples: 0, + session_ids: BTreeSet::new(), + intervals: Vec::new(), + }); + row.samples_count += 1; + row.session_ids.insert(session_id.clone()); + if active { + row.active_samples += 1; + let sample_seconds = event_sample_seconds(config, event, *ts, next_ts); + let interval_end = (*ts + + TimeDelta::milliseconds((sample_seconds * 1000.0) as i64)) + .min(end_exclusive); + if interval_end > *ts { + row.intervals.push((*ts, interval_end)); + } + } + } + } + let full_range = (end - start).num_seconds() + 1; + let mut rows = Vec::new(); + for (_, row) in by_user { + let merged = merge_intervals(row.intervals); + let active_seconds = merged + .iter() + .map(|(s, e)| (*e - *s).num_seconds()) + .sum::() + .min(full_range); + let first = merged.first().map(|i| to_iso_utc(i.0)).unwrap_or_default(); + let last = merged.last().map(|i| to_iso_utc(i.1)).unwrap_or_default(); + let mut obj = Map::new(); + obj.insert("user".into(), json!(row.user)); + obj.insert("user_id".into(), json!(row.user_id)); + obj.insert("active_seconds".into(), json!(active_seconds)); + obj.insert("active_hhmm".into(), json!(hhmm(active_seconds))); + obj.insert("first_activity".into(), json!(first)); + obj.insert("last_activity".into(), json!(last)); + obj.insert( + "idle_seconds".into(), + json!((full_range - active_seconds).max(0)), + ); + obj.insert("sessions_count".into(), json!(row.session_ids.len())); + obj.insert("samples_count".into(), json!(row.samples_count)); + obj.insert("active_samples".into(), json!(row.active_samples)); + if intervals { + obj.insert( + "_intervals".into(), + json!( + merged + .iter() + .map(|(s, e)| json!([to_iso_utc(*s), to_iso_utc(*e)])) + .collect::>() + ), + ); + } + rows.push(Value::Object(obj)); + } + rows +} + +fn build_report_summary(rows: &[Value]) -> Value { + let total_active = rows + .iter() + .map(|r| r.get("active_seconds").and_then(Value::as_i64).unwrap_or(0)) + .sum::(); + let first = rows + .iter() + .filter_map(|r| r.get("first_activity").and_then(Value::as_str)) + .filter(|s| !s.is_empty()) + .min() + .unwrap_or(""); + let last = rows + .iter() + .filter_map(|r| r.get("last_activity").and_then(Value::as_str)) + .filter(|s| !s.is_empty()) + .max() + .unwrap_or(""); + let top = rows + .iter() + .max_by_key(|r| r.get("active_seconds").and_then(Value::as_i64).unwrap_or(0)); + json!({ + "users_count": rows.len(), + "total_active_seconds": total_active, + "total_active_hhmm": hhmm(total_active), + "first_activity": first, + "last_activity": last, + "top_user": top.and_then(|r| r.get("user")).and_then(Value::as_str).unwrap_or(""), + "top_user_active_hhmm": top.and_then(|r| r.get("active_hhmm")).and_then(Value::as_str).unwrap_or("00:00"), + }) +} + +fn normalize_key(value: &str) -> String { + value.trim().to_lowercase() +} + +fn default_display_name(user: &str, user_id: &str) -> String { + let mut base = if !user_id.trim().is_empty() { + user_id.trim().to_string() + } else { + user.trim().to_string() + }; + if let Some((_, right)) = base.split_once('\\') { + base = right.to_string(); + } + if base.is_ascii() && base.to_lowercase() == base && base.chars().any(|c| c.is_alphabetic()) { + base.to_uppercase() + } else { + base + } +} + +fn load_aliases(config: &Config) -> Value { + fs::read_to_string(&config.manager_aliases_json) + .ok() + .and_then(|raw| serde_json::from_str(&raw).ok()) + .unwrap_or_else(|| json!({})) +} + +fn resolve_user_alias(config: &Config, user: &str, user_id: &str, host: &str) -> AliasProfile { + let raw = load_aliases(config); + let users = raw.get("users").unwrap_or(&raw); + let candidates = [ + normalize_key(user_id), + normalize_key(&format!("{host}\\{user}")), + normalize_key(user), + ]; + let mut alias = json!({}); + if let Some(map) = users.as_object() { + for candidate in candidates { + if let Some(value) = map.get(&candidate) { + alias = if value.is_string() { + json!({"display_name": value.as_str().unwrap_or("")}) + } else { + value.clone() + }; + break; + } + } + } + let display_name = value_string(&alias, "display_name") + .if_empty(value_string(&alias, "name")) + .if_empty(default_display_name(user, user_id)); + let manager_owner = value_string(&alias, "manager") + .if_empty(value_string(&alias, "owner")) + .if_empty(display_name.clone()); + let exclude = alias + .get("exclude") + .and_then(Value::as_bool) + .unwrap_or(false) + || config.manager_exclude_users.contains(&normalize_key(user)) + || config + .manager_exclude_users + .contains(&normalize_key(&display_name)); + AliasProfile { + display_name, + manager_owner, + department: value_string(&alias, "department"), + role: value_string(&alias, "role"), + notes: value_string(&alias, "notes"), + canonical_user_id: value_string(&alias, "canonical_user_id").if_empty(user_id.to_string()), + exclude, + } +} + +trait IfEmpty { + fn if_empty(self, fallback: String) -> String; +} +impl IfEmpty for String { + fn if_empty(self, fallback: String) -> String { + if self.trim().is_empty() { + fallback + } else { + self + } + } +} + +fn normalize_filter(value: &str) -> String { + value.split_whitespace().collect::>().join(" ") +} + +fn filter_key(value: &str) -> String { + normalize_filter(value).to_lowercase() +} + +fn interval_overlap_seconds( + intervals: &[Value], + start: DateTime, + end: DateTime, +) -> (i64, Option>, Option>) { + let mut total = 0; + let mut first = None; + let mut last = None; + for item in intervals { + let Some(arr) = item.as_array() else { + continue; + }; + if arr.len() != 2 { + continue; + } + let Some(s) = arr[0].as_str().and_then(parse_iso_utc) else { + continue; + }; + let Some(e) = arr[1].as_str().and_then(parse_iso_utc) else { + continue; + }; + let os = s.max(start); + let oe = e.min(end); + if oe <= os { + continue; + } + total += (oe - os).num_seconds(); + if first.is_none_or(|f| os < f) { + first = Some(os); + } + if last.is_none_or(|l| oe > l) { + last = Some(oe); + } + } + (total, first, last) +} + +impl App { + fn build_management_payload( + &self, + rows: Vec, + host: &str, + report_date: NaiveDate, + owner_filter: &str, + department_filter: &str, + ) -> Value { + let (day_start, day_end) = report_bounds(&self.config, report_date); + let (work_start_local, work_end_local, work_duration) = + workday_bounds(&self.config, report_date); + let now_local = Utc::now().with_timezone(&self.config.offset); + let is_today = report_date == now_local.date_naive(); + let effective_end_local = if is_today && now_local < work_end_local { + now_local + } else { + work_end_local + }; + let expected_seconds = if is_today { + (effective_end_local - work_start_local) + .num_seconds() + .max(0) + .min(work_duration) + } else { + work_duration + }; + let target_seconds = expected_seconds * self.config.manager_target_coverage_pct / 100; + let low_seconds = expected_seconds * self.config.manager_low_coverage_pct / 100; + let late_start = + work_start_local + TimeDelta::minutes(self.config.manager_late_start_grace_minutes); + let early_finish = + work_end_local - TimeDelta::minutes(self.config.manager_early_finish_grace_minutes); + let owner_filter = normalize_filter(owner_filter); + let department_filter = normalize_filter(department_filter); + + let mut roster = Vec::new(); + let mut actions = Vec::new(); + for row in rows { + let user = row.get("user").and_then(Value::as_str).unwrap_or(""); + let user_id = row.get("user_id").and_then(Value::as_str).unwrap_or(""); + let alias = resolve_user_alias(&self.config, user, user_id, host); + if alias.exclude { + continue; + } + if !owner_filter.is_empty() + && filter_key(&alias.manager_owner) != filter_key(&owner_filter) + { + continue; + } + if !department_filter.is_empty() + && filter_key(&alias.department) != filter_key(&department_filter) + { + continue; + } + let intervals = row + .get("_intervals") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + let (work_secs, work_first, work_last) = interval_overlap_seconds( + &intervals, + work_start_local.with_timezone(&Utc), + effective_end_local.with_timezone(&Utc), + ); + let calendar_secs = row + .get("active_seconds") + .and_then(Value::as_i64) + .unwrap_or(0); + let coverage = if expected_seconds > 0 { + clamp_pct(work_secs as f64 / expected_seconds as f64 * 100.0) + } else { + 0.0 + }; + let status = if work_secs <= 0 { + "inactive" + } else if work_secs < target_seconds { + "below_target" + } else { + "ok" + }; + let first_local = row + .get("first_activity") + .and_then(Value::as_str) + .and_then(parse_iso_utc) + .map(|dt| dt.with_timezone(&self.config.offset).to_rfc3339()) + .unwrap_or_default(); + let last_local = row + .get("last_activity") + .and_then(Value::as_str) + .and_then(parse_iso_utc) + .map(|dt| dt.with_timezone(&self.config.offset).to_rfc3339()) + .unwrap_or_default(); + let work_first_local = work_first + .map(|dt| dt.with_timezone(&self.config.offset)) + .map(|dt| dt.to_rfc3339()) + .unwrap_or_default(); + let work_last_local = work_last + .map(|dt| dt.with_timezone(&self.config.offset)) + .map(|dt| dt.to_rfc3339()) + .unwrap_or_default(); + let mut public = row.as_object().cloned().unwrap_or_default(); + public.remove("_intervals"); + public.insert("user".into(), json!(alias.display_name)); + public.insert("user_original".into(), json!(user)); + public.insert("manager_owner".into(), json!(alias.manager_owner)); + public.insert("department".into(), json!(alias.department)); + public.insert("role".into(), json!(alias.role)); + public.insert("notes".into(), json!(alias.notes)); + public.insert("canonical_user_id".into(), json!(alias.canonical_user_id)); + public.insert("calendar_active_seconds".into(), json!(calendar_secs)); + public.insert("calendar_active_hhmm".into(), json!(hhmm(calendar_secs))); + public.insert("workday_active_seconds".into(), json!(work_secs)); + public.insert("workday_active_hhmm".into(), json!(hhmm(work_secs))); + public.insert("coverage_pct".into(), json!(coverage)); + public.insert("status".into(), json!(status)); + public.insert("first_activity_local".into(), json!(first_local)); + public.insert("last_activity_local".into(), json!(last_local)); + public.insert( + "workday_first_activity_local".into(), + json!(work_first_local), + ); + public.insert("workday_last_activity_local".into(), json!(work_last_local)); + let public_value = Value::Object(public.clone()); + let evidence = json!({ + "calendar_active_hhmm": hhmm(calendar_secs), + "workday_active_hhmm": hhmm(work_secs), + "coverage_pct": coverage, + "first_activity": row.get("first_activity").cloned().unwrap_or(json!("")), + "last_activity": row.get("last_activity").cloned().unwrap_or(json!("")), + "sessions_count": row.get("sessions_count").cloned().unwrap_or(json!(0)), + "manager_owner": public.get("manager_owner").cloned().unwrap_or(json!("")), + "department": public.get("department").cloned().unwrap_or(json!("")), + "role": public.get("role").cloned().unwrap_or(json!("")), + }); + let owner = public + .get("manager_owner") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + let display = public + .get("user") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + let canonical = public + .get("canonical_user_id") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + if work_secs <= 0 { + actions.push(action("missing_activity", "critical", &owner, "today", &format!("За {} у сотрудника {} нет подтверждённой активности в рабочем окне RDP.", report_date, display), &format!("Проверить сотрудника {display}: работал ли он в рабочее время, была ли потеря сбора данных или отсутствие входа в систему."), &canonical, evidence)); + } else { + if expected_seconds > 0 && work_secs < low_seconds { + actions.push(action("low_activity_review", "high", &owner, "24h", &format!("У сотрудника {display} активное время в рабочем окне {} ниже {}% от ожидаемого окна.", hhmm(work_secs), self.config.manager_low_coverage_pct), &format!("Проверить загрузку сотрудника {display}, задачи и фактическое присутствие в рабочем процессе."), &canonical, evidence.clone())); + } else if expected_seconds > 0 && work_secs < target_seconds { + actions.push(action("target_gap_review", "medium", &owner, "24h", &format!("У сотрудника {display} активное время в рабочем окне {} ниже управленческого целевого порога {}%.", hhmm(work_secs), self.config.manager_target_coverage_pct), &format!("Уточнить причину отклонения по сотруднику {display} и подтвердить план работ."), &canonical, evidence.clone())); + } + if work_first.is_some_and(|dt| dt.with_timezone(&self.config.offset) > late_start) { + actions.push(action("late_start_review", "medium", &owner, "24h", &format!("У сотрудника {display} первая активность в рабочем окне зафиксирована поздно."), &format!("Проверить причину позднего старта сотрудника {display} и подтвердить, что это не проблема доступа или дисциплины."), &canonical, evidence.clone())); + } + if !is_today + && work_last + .is_some_and(|dt| dt.with_timezone(&self.config.offset) < early_finish) + { + actions.push(action("early_finish_review", "medium", &owner, "24h", &format!("У сотрудника {display} последняя активность в рабочем окне завершилась рано."), &format!("Проверить, было ли досрочное завершение рабочего дня сотрудника {display} согласовано и чем оно объясняется."), &canonical, evidence)); + } + } + roster.push(public_value); + } + actions.sort_by_key(|a| { + ( + priority_rank(a.get("priority").and_then(Value::as_str).unwrap_or("")), + a.get("owner") + .and_then(Value::as_str) + .unwrap_or("") + .to_lowercase(), + a.get("action_id") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(), + ) + }); + let sources_actions = self.build_source_freshness( + host, + roster + .iter() + .any(|r| r.get("status").and_then(Value::as_str) != Some("inactive")), + ); + let sources = sources_actions.0; + actions.extend(sources_actions.1); + actions.sort_by_key(|a| { + ( + priority_rank(a.get("priority").and_then(Value::as_str).unwrap_or("")), + a.get("owner") + .and_then(Value::as_str) + .unwrap_or("") + .to_lowercase(), + a.get("action_id") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(), + ) + }); + let summary = summarize_management_rows(&roster, &actions, expected_seconds); + let owner_rollups = build_rollups(&roster, &actions, "manager_owner"); + let department_rollups = build_rollups(&roster, &actions, "department"); + let owner_roster = owner_rollups.clone(); + let executive = build_executive_summary(&summary, &actions, &sources); + let trend = vec![json!({ + "report_date": report_date.to_string(), + "users_count": summary["users_count"], + "active_users": summary["active_users"], + "inactive_users": summary["inactive_users"], + "workday_total_active_seconds": summary["workday_total_active_seconds"], + "workday_total_active_hhmm": summary["workday_total_active_hhmm"], + "portfolio_coverage_pct": summary["portfolio_coverage_pct"], + "actions_count": summary["actions_count"], + "critical_actions_count": summary["critical_actions_count"], + })]; + json!({ + "generated_at_utc": to_iso_utc(Utc::now()), + "host": host, + "report_date": report_date.to_string(), + "report_timezone": "Europe/Moscow", + "filters": {"owner": owner_filter, "department": department_filter}, + "workday": { + "start_local": work_start_local.to_rfc3339(), + "end_local": work_end_local.to_rfc3339(), + "expected_seconds_per_user": expected_seconds, + "expected_hhmm_per_user": hhmm(expected_seconds), + "target_coverage_pct": self.config.manager_target_coverage_pct, + "low_coverage_pct": self.config.manager_low_coverage_pct, + }, + "summary": summary, + "actions": actions, + "rows": roster, + "sources": sources, + "trend": trend, + "trend_scope": "portfolio", + "executive": executive, + "owner_rollups": owner_rollups, + "department_rollups": department_rollups, + "owner_roster": owner_roster, + "bucket_id": sessions_bucket(host), + "report_bounds": {"start_utc": to_iso_utc(day_start), "end_utc": to_iso_utc(day_end)}, + }) + } + + fn build_source_freshness( + &self, + host: &str, + interactive_required: bool, + ) -> (Vec, Vec) { + let specs = vec![ + ( + "worktime_sessions", + "RDP worktime sessions", + sessions_bucket(host), + self.config.manager_critical_source_max_age_seconds, + true, + false, + ), + ( + "rdp_window", + "RDP current window", + format!("aw-rdp-window_{host}"), + self.config.manager_critical_source_max_age_seconds, + true, + false, + ), + ( + "rdp_afk", + "RDP AFK", + format!("aw-rdp-afk_{host}"), + self.config.manager_critical_source_max_age_seconds, + true, + false, + ), + ( + "watcher_window", + "Local watcher window", + format!("aw-watcher-window_{host}"), + self.config.manager_critical_source_max_age_seconds, + true, + true, + ), + ( + "watcher_afk", + "Local watcher AFK", + format!("aw-watcher-afk_{host}"), + self.config.manager_critical_source_max_age_seconds, + true, + false, + ), + ( + "file_operations", + "File operations collector", + format!("aw-file-operations_{host}"), + self.config.manager_critical_source_max_age_seconds, + true, + true, + ), + ( + "web_categories", + "Browser/web categories", + format!("aw-detmir-web-category_{host}"), + self.config.manager_web_source_max_age_seconds, + false, + false, + ), + ( + "session_events", + "Windows session events", + format!("aw-session-events_{host}"), + self.config.manager_session_source_max_age_seconds, + false, + false, + ), + ( + "pve_tasks", + "PVE task feed", + "aw-pve-task-events_pve-detmir".to_string(), + self.config.manager_infra_source_max_age_seconds, + false, + false, + ), + ]; + let now = Utc::now(); + let mut sources = Vec::new(); + let mut actions = Vec::new(); + for (source_id, label, bucket, max_age, required, interactive_only) in specs { + if interactive_only && !interactive_required { + sources.push(json!({ + "source_id": source_id, + "label": label, + "status": "inactive", + "status_label": "inactive", + "bucket_id": bucket, + "timestamp": "", + "age_seconds": null, + "required": required, + "interactive_only": interactive_only, + "interactive_required": interactive_required, + "max_age_seconds": max_age, + "summary": "inactive: no active interactive users", + "event_summary": "", + })); + continue; + } + let event = self.latest_bucket_event(&bucket); + let ts = event + .as_ref() + .and_then(|e| e.timestamp.as_deref()) + .and_then(parse_iso_utc); + let age = ts.map(|dt| (now - dt).num_seconds().max(0)); + let mut status = if event.is_none() { + if required { "fail" } else { "warn" } + } else if age.is_none() { + "warn" + } else if age.unwrap() > max_age { + if required { "fail" } else { "warn" } + } else { + "ok" + }; + let mut summary = if event.is_none() { + "bucket missing or empty".to_string() + } else if age.is_none() { + "timestamp parse failed".to_string() + } else if status == "ok" { + format!("fresh ({}s)", age.unwrap()) + } else { + format!("stale ({}s)", age.unwrap()) + }; + if interactive_only && !interactive_required && status != "ok" { + status = "inactive"; + summary = "inactive: no active interactive users".to_string(); + } + let source = json!({ + "source_id": source_id, + "label": label, + "status": status, + "status_label": source_status_label(status), + "bucket_id": bucket, + "timestamp": event.as_ref().and_then(|e| e.timestamp.clone()).unwrap_or_default(), + "age_seconds": age, + "required": required, + "interactive_only": interactive_only, + "interactive_required": interactive_required, + "max_age_seconds": max_age, + "summary": summary, + "event_summary": event.as_ref().map(source_summary).unwrap_or_default(), + }); + if !matches!(status, "ok" | "inactive") { + actions.push(action( + "source_freshness_review", + if required { "critical" } else { "medium" }, + "ops", + if required { "today" } else { "3d" }, + &format!("Источник '{label}' в состоянии {}: {}.", source_status_label(status), summary), + "Проверить collector/service, причину отставания и подтвердить, что управленческие выводы по данным ещё надёжны.", + "", + json!({"source_id": source_id, "bucket_id": bucket, "age_seconds": age, "required": required}), + )); + } + sources.push(source); + } + (sources, actions) + } +} + +#[allow(clippy::too_many_arguments)] +fn action( + id: &str, + priority: &str, + owner: &str, + deadline: &str, + reason: &str, + recommended: &str, + user_id: &str, + evidence: Value, +) -> Value { + json!({ + "action_id": id, + "priority": priority, + "owner": owner, + "user_id": user_id, + "deadline_hint": deadline, + "reason": reason, + "recommended_action": recommended, + "evidence": evidence, + }) +} + +fn priority_rank(priority: &str) -> i32 { + match priority { + "critical" => 0, + "high" => 1, + "medium" => 2, + "low" => 3, + _ => 9, + } +} + +fn clamp_pct(value: f64) -> f64 { + (value.clamp(0.0, 100.0) * 100.0).round() / 100.0 +} + +fn source_status_label(status: &str) -> &str { + match status { + "ok" => "fresh", + "warn" => "stale", + "fail" => "missing", + "inactive" => "inactive", + _ => status, + } +} + +fn source_summary(event: &AwEvent) -> String { + let d = &event.data; + let signal = value_string(d, "signalType"); + if signal == "collector_health" { + return format!( + "queue={} failures={} flushed={}", + d.get("queueDepth").unwrap_or(&json!(0)), + d.get("sendFailures").unwrap_or(&json!(0)), + d.get("eventsFlushed").unwrap_or(&json!(0)) + ); + } + for key in ["domain", "eventType", "action", "title", "status", "app"] { + let value = value_string(d, key); + if !value.is_empty() { + return value.chars().take(120).collect(); + } + } + String::new() +} + +fn summarize_management_rows(rows: &[Value], actions: &[Value], expected_seconds: i64) -> Value { + let users_count = rows.len() as i64; + let active_users = rows + .iter() + .filter(|r| r.get("status").and_then(Value::as_str) != Some("inactive")) + .count() as i64; + let inactive_users = rows + .iter() + .filter(|r| r.get("status").and_then(Value::as_str) == Some("inactive")) + .count() as i64; + let below_target_users = rows + .iter() + .filter(|r| r.get("status").and_then(Value::as_str) == Some("below_target")) + .count() as i64; + let on_target_users = rows + .iter() + .filter(|r| r.get("status").and_then(Value::as_str) == Some("ok")) + .count() as i64; + let work_total = rows + .iter() + .map(|r| { + r.get("workday_active_seconds") + .and_then(Value::as_i64) + .unwrap_or(0) + }) + .sum::(); + let cal_total = rows + .iter() + .map(|r| { + r.get("calendar_active_seconds") + .and_then(Value::as_i64) + .unwrap_or(0) + }) + .sum::(); + let coverage = if users_count > 0 && expected_seconds > 0 { + clamp_pct(work_total as f64 / (expected_seconds * users_count) as f64 * 100.0) + } else { + 0.0 + }; + let top = rows.iter().max_by_key(|r| { + r.get("workday_active_seconds") + .and_then(Value::as_i64) + .unwrap_or(0) + }); + json!({ + "users_count": users_count, + "active_users": active_users, + "inactive_users": inactive_users, + "on_target_users": on_target_users, + "below_target_users": below_target_users, + "portfolio_coverage_pct": coverage, + "actions_count": actions.len(), + "critical_actions_count": actions.iter().filter(|a| a.get("priority").and_then(Value::as_str) == Some("critical")).count(), + "high_actions_count": actions.iter().filter(|a| a.get("priority").and_then(Value::as_str) == Some("high")).count(), + "calendar_total_active_seconds": cal_total, + "calendar_total_active_hhmm": hhmm(cal_total), + "workday_total_active_seconds": work_total, + "workday_total_active_hhmm": hhmm(work_total), + "total_active_seconds": work_total, + "total_active_hhmm": hhmm(work_total), + "first_activity": rows.iter().filter_map(|r| r.get("workday_first_activity_local").and_then(Value::as_str)).filter(|s| !s.is_empty()).min().unwrap_or(""), + "last_activity": rows.iter().filter_map(|r| r.get("workday_last_activity_local").and_then(Value::as_str)).filter(|s| !s.is_empty()).max().unwrap_or(""), + "top_user": top.and_then(|r| r.get("user")).and_then(Value::as_str).unwrap_or(""), + "top_user_active_hhmm": top.and_then(|r| r.get("workday_active_hhmm")).and_then(Value::as_str).unwrap_or("00:00"), + }) +} + +fn build_rollups(rows: &[Value], actions: &[Value], field: &str) -> Vec { + let mut groups: BTreeMap> = BTreeMap::new(); + for row in rows { + let name = row + .get(field) + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + .unwrap_or("Без подразделения") + .to_string(); + let group = groups.entry(name.clone()).or_insert_with(|| { + let mut g = Map::new(); + g.insert("name".into(), json!(name)); + for key in [ + "users_count", + "active_users", + "inactive_users", + "below_target_users", + "workday_total_active_seconds", + "actions_count", + "critical_actions_count", + "high_actions_count", + "medium_actions_count", + "low_actions_count", + ] { + g.insert(key.into(), json!(0)); + } + g.insert("users".into(), json!([])); + g + }); + inc(group, "users_count", 1); + if row.get("status").and_then(Value::as_str) == Some("inactive") { + inc(group, "inactive_users", 1); + } else { + inc(group, "active_users", 1); + } + if row.get("status").and_then(Value::as_str) == Some("below_target") { + inc(group, "below_target_users", 1); + } + inc( + group, + "workday_total_active_seconds", + row.get("workday_active_seconds") + .and_then(Value::as_i64) + .unwrap_or(0), + ); + if let Some(arr) = group.get_mut("users").and_then(Value::as_array_mut) { + arr.push(row.get("user").cloned().unwrap_or(json!(""))); + } + } + for action in actions { + let name = action + .get("owner") + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + .unwrap_or("unassigned") + .to_string(); + let group = groups.entry(name.clone()).or_insert_with(|| { + let mut g = Map::new(); + g.insert("name".into(), json!(name)); + for key in [ + "users_count", + "active_users", + "inactive_users", + "below_target_users", + "workday_total_active_seconds", + "actions_count", + "critical_actions_count", + "high_actions_count", + "medium_actions_count", + "low_actions_count", + ] { + g.insert(key.into(), json!(0)); + } + g.insert("users".into(), json!([])); + g + }); + inc(group, "actions_count", 1); + let key = match action.get("priority").and_then(Value::as_str).unwrap_or("") { + "critical" => "critical_actions_count", + "high" => "high_actions_count", + "medium" => "medium_actions_count", + "low" => "low_actions_count", + _ => "low_actions_count", + }; + inc(group, key, 1); + } + groups + .into_values() + .map(|mut g| { + let secs = g + .get("workday_total_active_seconds") + .and_then(Value::as_i64) + .unwrap_or(0); + let users = g.get("users_count").and_then(Value::as_i64).unwrap_or(0); + g.insert("workday_total_active_hhmm".into(), json!(hhmm(secs))); + g.insert( + "portfolio_coverage_pct".into(), + json!(if users > 0 { + clamp_pct(secs as f64 / (users * 9 * 3600) as f64 * 100.0) + } else { + 0.0 + }), + ); + Value::Object(g) + }) + .collect() +} + +fn inc(map: &mut Map, key: &str, amount: i64) { + let old = map.get(key).and_then(Value::as_i64).unwrap_or(0); + map.insert(key.into(), json!(old + amount)); +} + +fn build_executive_summary(summary: &Value, actions: &[Value], sources: &[Value]) -> Value { + let critical = actions + .iter() + .filter(|a| a.get("priority").and_then(Value::as_str) == Some("critical")) + .count(); + let high = actions + .iter() + .filter(|a| a.get("priority").and_then(Value::as_str) == Some("high")) + .count(); + let portfolio_state = if critical > 0 { + "critical" + } else if high > 0 { + "attention" + } else { + "stable" + }; + let headline = if critical > 0 { + format!("Есть {critical} критичных вопроса, требующих решения сегодня.") + } else if high > 0 { + format!("Критичных провалов нет, но есть {high} вопроса повышенного внимания.") + } else { + "Критичных отклонений не найдено, рабочий день идёт в пределах нормы.".to_string() + }; + let stale = sources + .iter() + .filter(|s| s.get("status").and_then(Value::as_str) != Some("ok")) + .count(); + json!({ + "portfolio_state": portfolio_state, + "headline": headline, + "message": format!("Активны {} из {} сотрудников. Покрытие рабочего окна {}%.", summary["active_users"], summary["users_count"], summary["portfolio_coverage_pct"]), + "focus_items": actions.iter().take(5).map(|a| json!({"priority": a["priority"], "owner": a["owner"], "title": a["action_id"], "reason": a["reason"], "recommended_action": a["recommended_action"]})).collect::>(), + "stale_sources": sources.iter().filter(|s| s.get("status").and_then(Value::as_str) != Some("ok")).take(3).cloned().collect::>(), + "stale_sources_count": stale, + }) +} + +fn normalize_app_name(app: &str, title: &str) -> String { + let app_l = app.trim().to_lowercase(); + let title_l = title.to_lowercase(); + if app_l.starts_with("1cv8") || app_l.starts_with("1cestart") { + "1С".into() + } else if app_l == "chrome.exe" || title_l.contains("google chrome") { + "Chrome".into() + } else if app_l == "msedge.exe" || title_l.contains("microsoft edge") { + "Edge".into() + } else if app_l == "browser.exe" || title_l.contains("яндекс") { + "Яндекс Браузер".into() + } else if app_l == "excel.exe" { + "Excel".into() + } else if app_l == "winword.exe" { + "Word".into() + } else if app_l == "explorer.exe" { + "Проводник".into() + } else if app_l == "totalcmd.exe" || app_l == "totalcmd64.exe" { + "Total Commander".into() + } else if !app.trim().is_empty() { + app.trim().trim_end_matches(".exe").to_string() + } else if !title.trim().is_empty() { + title.trim().to_string() + } else { + "Неизвестное приложение".into() + } +} + +fn event_duration(config: &Config, event: &AwEvent, fallback: f64) -> f64 { + event + .duration + .unwrap_or(0.0) + .max(fallback) + .min(config.true_active_max_event_seconds as f64) + .max(1.0) +} + +fn event_context(event: &AwEvent) -> String { + for key in [ + "title", + "url", + "path", + "filePath", + "targetPath", + "windowTitle", + "foregroundTitle", + "signalType", + ] { + let value = value_string(&event.data, key); + if !value.is_empty() { + return value; + } + } + "активность".into() +} + +fn is_real_evidence(event: &AwEvent) -> bool { + let signal = value_string(&event.data, "signalType").to_lowercase(); + if matches!( + signal.as_str(), + "collector_health" | "self_test" | "heartbeat" | "health" + ) { + return false; + } + ["url", "title", "path", "filePath", "targetPath"] + .iter() + .any(|key| !value_string(&event.data, key).is_empty()) + || !signal.is_empty() +} + +fn build_true_active_apps_from_events( + config: &Config, + window_events: &[AwEvent], + afk_events: &[AwEvent], + evidence_by_bucket: &HashMap>, + start: DateTime, + end: DateTime, +) -> Vec { + let mut windows = Vec::new(); + let mut prev_key = None; + for event in sorted_events(window_events, start, end) { + let app = value_string(&event.data, "app") + .if_empty(value_string(&event.data, "process")) + .if_empty(value_string(&event.data, "processName")); + let title = + value_string(&event.data, "title").if_empty(value_string(&event.data, "windowTitle")); + if app.is_empty() && title.is_empty() { + continue; + } + let ts = event.timestamp.as_deref().and_then(parse_iso_utc).unwrap(); + let dur = event_duration(config, event, config.default_sample_seconds); + let interval = ( + ts.max(start), + (ts + TimeDelta::milliseconds((dur * 1000.0) as i64)).min(end + TimeDelta::seconds(1)), + ); + if interval.1 <= interval.0 { + continue; + } + let app_name = normalize_app_name(&app, &title); + let key = (app_name.clone(), title.clone()); + let changed = prev_key.as_ref().is_some_and(|p| p != &key); + prev_key = Some(key); + windows.push((app_name, app, title, interval.0, interval.1, changed, ts)); + } + let mut afk_intervals = Vec::new(); + for event in sorted_events(afk_events, start, end) { + let status = value_string(&event.data, "status").to_lowercase(); + if !matches!( + status.as_str(), + "not-afk" | "not_afk" | "active" | "активно" + ) { + continue; + } + let ts = event.timestamp.as_deref().and_then(parse_iso_utc).unwrap(); + let dur = event_duration(config, event, 5.0); + let interval = ( + ts.max(start), + (ts + TimeDelta::milliseconds((dur * 1000.0) as i64)).min(end + TimeDelta::seconds(1)), + ); + if interval.1 > interval.0 { + afk_intervals.push(interval); + } + } + afk_intervals = merge_intervals(afk_intervals); + let mut evidence: HashMap, String)>> = HashMap::new(); + for (app_name, _raw, title, _s, _e, changed, ts) in &windows { + if *changed { + evidence + .entry(app_name.clone()) + .or_default() + .push((*ts, title.clone())); + } + } + for events in evidence_by_bucket.values() { + for event in sorted_events(events, start, end) { + if !is_real_evidence(event) { + continue; + } + let ts = event.timestamp.as_deref().and_then(parse_iso_utc).unwrap(); + if let Some((app_name, _, _, _, _, _, _)) = windows + .iter() + .find(|(_, _, _, s, e, _, _)| *s <= ts && ts < *e) + { + evidence + .entry(app_name.clone()) + .or_default() + .push((ts, event_context(event))); + } + } + } + let mut rows = Vec::new(); + let delta = TimeDelta::seconds(config.true_active_evidence_window_seconds); + let apps: BTreeSet = windows + .iter() + .map(|w| w.0.clone()) + .chain(evidence.keys().cloned()) + .collect(); + for app in apps { + let app_evidence = evidence.get(&app).cloned().unwrap_or_default(); + if app_evidence.is_empty() { + continue; + } + let evidence_windows = merge_intervals( + app_evidence + .iter() + .map(|(ts, _)| (*ts - delta, *ts + delta)) + .collect(), + ); + let mut proved = Vec::new(); + for window in windows.iter().filter(|w| w.0 == app) { + for afk in &afk_intervals { + if let Some(active) = overlap((window.3, window.4), *afk) { + for ev in &evidence_windows { + if let Some(p) = overlap(active, *ev) { + proved.push(p); + } + } + } + } + } + let proved = merge_intervals(proved); + let seconds = proved + .iter() + .map(|(s, e)| (*e - *s).num_seconds()) + .sum::(); + if seconds <= 0 { + continue; + } + let (last_ts, last_context) = app_evidence.last().cloned().unwrap(); + rows.push(json!({ + "application": app, + "proved_work_seconds": seconds, + "proved_work_hhmm": hhmm(seconds), + "proved_work_human": human_duration_ru(seconds), + "last_action_utc": to_iso_utc(last_ts), + "last_action_local": last_ts.with_timezone(&config.offset).format("%H:%M").to_string(), + "last_action": last_context, + "evidence_events": app_evidence.len(), + })); + } + rows.sort_by_key(|r| { + ( + -r.get("proved_work_seconds") + .and_then(Value::as_i64) + .unwrap_or(0), + r.get("application") + .and_then(Value::as_str) + .unwrap_or("") + .to_lowercase(), + ) + }); + rows +} + +fn sorted_events(events: &[AwEvent], start: DateTime, end: DateTime) -> Vec<&AwEvent> { + let mut out: Vec<&AwEvent> = events + .iter() + .filter(|e| { + e.timestamp + .as_deref() + .and_then(parse_iso_utc) + .is_some_and(|ts| ts >= start && ts <= end) + }) + .collect(); + out.sort_by_key(|e| e.timestamp.clone()); + out +} + +fn overlap( + a: (DateTime, DateTime), + b: (DateTime, DateTime), +) -> Option<(DateTime, DateTime)> { + let s = a.0.max(b.0); + let e = a.1.min(b.1); + if e > s { Some((s, e)) } else { None } +} + +fn resolve_report_format(params: &Params, accept: &str) -> String { + let requested = params.first("format").unwrap_or_default().to_lowercase(); + if matches!(requested.as_str(), "csv" | "html" | "json") { + return requested; + } + let accept = accept.to_lowercase(); + if accept.contains("text/html") && !accept.contains("application/json") { + "html".into() + } else { + "json".into() + } +} + +fn make_report_cache_key( + path: &str, + fmt: &str, + host: &str, + report_date: NaiveDate, + day: &str, + owner: &str, + department: &str, +) -> String { + format!("{path}|{fmt}|{host}|{report_date}|{day}|{owner}|{department}") +} + +fn today_csv(rows: &[Value]) -> String { + let mut out = "user,user_id,active_seconds,active_hhmm,first_activity,last_activity,idle_seconds,sessions_count,samples_count,active_samples\n".to_string(); + for row in rows { + out.push_str(&format!( + "{},{},{},{},{},{},{},{},{},{}\n", + csv_cell(row["user"].as_str().unwrap_or("")), + csv_cell(row["user_id"].as_str().unwrap_or("")), + row["active_seconds"].as_i64().unwrap_or(0), + row["active_hhmm"].as_str().unwrap_or(""), + row["first_activity"].as_str().unwrap_or(""), + row["last_activity"].as_str().unwrap_or(""), + row["idle_seconds"].as_i64().unwrap_or(0), + row["sessions_count"].as_i64().unwrap_or(0), + row["samples_count"].as_i64().unwrap_or(0), + row["active_samples"].as_i64().unwrap_or(0) + )); + } + out +} + +fn management_csv(payload: &Value) -> String { + let mut out = + "priority,owner,user_id,action_id,deadline_hint,reason,recommended_action\n".to_string(); + for action in payload + .get("actions") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + out.push_str(&format!( + "{},{},{},{},{},{},{}\n", + csv_cell(action["priority"].as_str().unwrap_or("")), + csv_cell(action["owner"].as_str().unwrap_or("")), + csv_cell(action["user_id"].as_str().unwrap_or("")), + csv_cell(action["action_id"].as_str().unwrap_or("")), + csv_cell(action["deadline_hint"].as_str().unwrap_or("")), + csv_cell(action["reason"].as_str().unwrap_or("")), + csv_cell(action["recommended_action"].as_str().unwrap_or("")) + )); + } + out +} + +fn csv_cell(value: &str) -> String { + if value.contains([',', '"', '\n']) { + format!("\"{}\"", value.replace('"', "\"\"")) + } else { + value.to_string() + } +} + +fn esc(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) +} + +fn render_today_html( + _config: &Config, + rows: &[Value], + host: &str, + report_date: NaiveDate, + selected_day: Option<&str>, + true_apps: &[Value], +) -> String { + let summary = build_report_summary(rows); + let cards = format!( + "
Пользователи{}
Активное время{}
Лидер дня{}
", + summary["users_count"], + summary["total_active_hhmm"].as_str().unwrap_or("00:00"), + esc(summary["top_user"].as_str().unwrap_or("")) + ); + let app_rows = if true_apps.is_empty() { + "Пока нет доказанной активной работы по приложениям за выбранную дату.".to_string() + } else { + true_apps + .iter() + .map(|r| { + format!( + "{}{}{} · {}", + esc(r["application"].as_str().unwrap_or("-")), + esc(r["proved_work_human"].as_str().unwrap_or("0 сек")), + esc(r["last_action_local"].as_str().unwrap_or("-")), + esc(r["last_action"].as_str().unwrap_or("-")) + ) + }) + .collect::() + }; + let user_rows = if rows.is_empty() { + "За выбранную дату данных пока нет.".to_string() + } else { + rows.iter().map(|r| format!("{}{}{}{}{}{}{}{}{}", esc(r["user"].as_str().unwrap_or("")), esc(r["user_id"].as_str().unwrap_or("")), esc(r["active_hhmm"].as_str().unwrap_or("")), r["active_seconds"], esc(r["first_activity"].as_str().unwrap_or("")), esc(r["last_activity"].as_str().unwrap_or("")), r["idle_seconds"], r["sessions_count"], r["samples_count"])).collect::() + }; + let day_query = selected_day + .filter(|d| matches!(*d, "today" | "yesterday")) + .map(|d| format!("day={d}")) + .unwrap_or_else(|| format!("date={report_date}")); + format!( + r#"AW-rus Отчёт по работе в RDP

Отчёт по работе в RDP

Хост: {} · Дата: {} · Часовой пояс: Europe/Moscow · Сформировано: {}

{}

Доказанная работа по приложениям

{}
ПриложениеДоказанная работаПоследнее действие

Таблица по пользователям

{}
ПользовательУчётная записьАктивноАктивно, секНачалоКонецПростойСессииСэмплы
"#, + base_css(), + esc(host), + report_date, + Local::now().format("%F %T"), + esc(host), + esc(host), + esc(host), + day_query, + esc(host), + day_query, + esc(host), + day_query, + cards, + app_rows, + user_rows + ) +} + +fn render_management_html(payload: &Value) -> String { + let rows = payload["rows"].as_array().cloned().unwrap_or_default(); + let actions = payload["actions"].as_array().cloned().unwrap_or_default(); + let sources = payload["sources"].as_array().cloned().unwrap_or_default(); + let action_rows = if actions.is_empty() { + "Нет действий.".to_string() + } else { + actions + .iter() + .map(|a| { + format!( + "{}{}{}{}{}{}", + esc(a["priority"].as_str().unwrap_or("")), + esc(a["owner"].as_str().unwrap_or("")), + esc(a["action_id"].as_str().unwrap_or("")), + esc(a["deadline_hint"].as_str().unwrap_or("")), + esc(a["reason"].as_str().unwrap_or("")), + esc(a["recommended_action"].as_str().unwrap_or("")) + ) + }) + .collect() + }; + let user_rows = if rows.is_empty() { + "Нет сотрудников в выборке.".to_string() + } else { + rows.iter().map(|r| format!("{}{}{}{}{}{}%{}{}", esc(r["user"].as_str().unwrap_or("")), esc(r["manager_owner"].as_str().unwrap_or("")), esc(r["department"].as_str().unwrap_or("")), esc(r["status"].as_str().unwrap_or("")), esc(r["workday_active_hhmm"].as_str().unwrap_or("")), r["coverage_pct"].as_f64().unwrap_or(0.0), esc(r["workday_first_activity_local"].as_str().unwrap_or("")), esc(r["workday_last_activity_local"].as_str().unwrap_or("")))).collect() + }; + let source_rows = sources + .iter() + .map(|s| { + format!( + "{}{}{}{}{}{}", + esc(s["label"].as_str().unwrap_or("")), + esc(s["status_label"].as_str().unwrap_or("")), + esc(s["bucket_id"].as_str().unwrap_or("")), + esc(s["timestamp"].as_str().unwrap_or("")), + s["age_seconds"], + esc(s["summary"].as_str().unwrap_or("")) + ) + }) + .collect::(); + format!( + r#"AW-rus Управленческий отчёт по работе в RDP

AW-rus Управленческий отчёт по работе в RDP

{} · {} · {}

Что делать сегодня

{}

Очередь действий руководителя

{}

Сотрудники

{}

Тренд за период

Тренд за {} дней

По ответственным

{}

Ответственные и эскалация

{}

По подразделениям

{}

Свежесть источников данных

{}

Фильтр: {}

"#, + base_css(), + esc(payload["host"].as_str().unwrap_or("")), + esc(payload["report_date"].as_str().unwrap_or("")), + esc(payload["report_timezone"].as_str().unwrap_or("")), + esc(payload + .pointer("/executive/headline") + .and_then(Value::as_str) + .unwrap_or("")), + esc(payload["host"].as_str().unwrap_or("")), + esc(payload["host"].as_str().unwrap_or("")), + esc(payload["host"].as_str().unwrap_or("")), + esc(payload["host"].as_str().unwrap_or("")), + action_rows, + user_rows, + payload["trend"].as_array().map(|a| a.len()).unwrap_or(0), + esc(&payload["owner_rollups"].to_string()), + esc(&payload["owner_roster"].to_string()), + esc(&payload["department_rollups"].to_string()), + source_rows, + esc(&payload["filters"].to_string()) + ) +} + +fn base_css() -> &'static str { + "body{margin:0;background:#f5f7fb;color:#172033;font:14px/1.45 'Segoe UI',Arial,sans-serif}main{max-width:1360px;margin:0 auto;padding:20px}.hero{background:#12324a;color:white;border-radius:8px;padding:18px 20px;margin-bottom:16px}.hero a{color:white;margin-right:12px}.grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px}.card{background:rgba(255,255,255,.12);border:1px solid rgba(255,255,255,.18);border-radius:8px;padding:12px}.card b{display:block;color:#dbeafe}.card strong{font-size:22px}section{background:white;border:1px solid #dbe3ee;border-radius:8px;margin:14px 0;padding:14px;overflow:auto}table{border-collapse:collapse;width:100%}th,td{border-bottom:1px solid #e5eaf2;padding:9px 10px;text-align:left;vertical-align:top}.good{color:#0f766e;font-weight:700}@media(max-width:800px){main{padding:10px}.grid{grid-template-columns:1fr}table{min-width:900px}}" +} + +fn header(name: &str, value: &str) -> Header { + Header::from_bytes(name.as_bytes(), value.as_bytes()).expect("valid header") +} + +fn respond( + request: Request, + status: u16, + data: Vec, + content_type: &str, + extra_headers: Vec<(String, String)>, +) { + let mut response = Response::new( + StatusCode(status), + vec![ + header("Content-Type", content_type), + header("Content-Length", &data.len().to_string()), + ], + Cursor::new(data), + None, + None, + ); + for (key, value) in extra_headers { + response.add_header(header(&key, &value)); + } + let _ = request.respond(response); +} + +fn handle(app: &App, request: Request) { + if request.method() != &Method::Get { + respond( + request, + 405, + Vec::new(), + "text/plain; charset=utf-8", + Vec::new(), + ); + return; + } + let url = request.url().to_string(); + let (path, query) = url.split_once('?').unwrap_or((&url, "")); + if path == "/health" || path == "/api/health" { + let payload = json!({ + "ok": true, + "generated_at_utc": to_iso_utc(Utc::now()), + "report_timezone": "Europe/Moscow", + "default_host": app.config.default_host, + "aw_api_base": app.config.aw_api_base, + }); + respond( + request, + 200, + serde_json::to_vec_pretty(&payload).unwrap(), + "application/json; charset=utf-8", + Vec::new(), + ); + return; + } + if path.starts_with("/dlp-ioc/") { + let name = path.rsplit('/').next().unwrap_or(""); + if !matches!( + name, + "ioc_blacklist.json" | "ioc_blacklist.csv" | "ioc_blacklist.sql" + ) { + respond( + request, + 404, + Vec::new(), + "text/plain; charset=utf-8", + Vec::new(), + ); + return; + } + let file = app.config.ioc_dir.join(name); + match fs::read(file) { + Ok(data) => { + let ctype = if name.ends_with(".json") { + "application/json; charset=utf-8" + } else if name.ends_with(".csv") { + "text/csv; charset=utf-8" + } else { + "text/plain; charset=utf-8" + }; + respond(request, 200, data, ctype, Vec::new()); + } + Err(_) => respond( + request, + 404, + Vec::new(), + "text/plain; charset=utf-8", + Vec::new(), + ), + } + return; + } + if path != "/reports/worktime/today" && path != "/reports/worktime/management" { + respond( + request, + 404, + Vec::new(), + "text/plain; charset=utf-8", + Vec::new(), + ); + return; + } + let params = Params::parse(query); + let accept = request + .headers() + .iter() + .find(|h| h.field.equiv("Accept")) + .map(|h| h.value.as_str()) + .unwrap_or(""); + let (data, content_type, headers) = app.report_response(path, ¶ms, accept); + let status = if content_type.starts_with("application/json") + && serde_json::from_slice::(&data) + .ok() + .and_then(|v| v.get("error").cloned()) + .is_some() + { + 503 + } else { + 200 + }; + respond(request, status, data, &content_type, headers); +} + +fn main() -> Result<()> { + let _cli = Cli::parse(); + let config = load_config(); + let addr = format!("{}:{}", config.listen_host, config.listen_port); + let app = App::new(config)?; + let server = Server::http(&addr).map_err(|error| anyhow!("bind {addr}: {error}"))?; + println!("{} listening on {}", Local::now().format("%F %T"), addr); + for request in server.incoming_requests() { + let app = app.clone(); + std::thread::spawn(move || handle(&app, request)); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_config() -> Config { + load_config() + } + + fn event(ts: &str, username: &str, sid: i64, active: bool, extra: Value) -> AwEvent { + let mut data = Map::new(); + data.insert("username".into(), json!(username)); + data.insert("userId".into(), json!(format!("WORKGROUP\\{username}"))); + data.insert("sessionId".into(), json!(sid)); + data.insert( + "state".into(), + json!(if active { "Активно" } else { "Диск" }), + ); + data.insert("active".into(), json!(active)); + if let Some(extra) = extra.as_object() { + for (k, v) in extra { + data.insert(k.clone(), v.clone()); + } + } + AwEvent { + timestamp: Some(ts.into()), + duration: Some(0.0), + data: Value::Object(data), + } + } + + #[test] + fn aggregate_rows_merges_overlap() { + let cfg = test_config(); + let start = parse_iso_utc("2026-05-14T06:00:00Z").unwrap(); + let end = parse_iso_utc("2026-05-14T06:59:59Z").unwrap(); + let rows = aggregate_rows( + &cfg, + &[ + event( + "2026-05-14T06:00:00Z", + "user5", + 4, + true, + json!({"sampleSeconds":30}), + ), + event( + "2026-05-14T06:00:30Z", + "user5", + 4, + true, + json!({"sampleSeconds":30}), + ), + event( + "2026-05-14T06:00:15Z", + "user5", + 5, + true, + json!({"sampleSeconds":30}), + ), + ], + start, + end, + "SHARKON2025", + false, + ); + assert_eq!(rows[0]["active_seconds"], 60); + assert_eq!(rows[0]["sessions_count"], 2); + } + + #[test] + fn report_format_prefers_html_for_browser() { + assert_eq!( + resolve_report_format(&Params::default(), "text/html,application/xhtml+xml"), + "html" + ); + assert_eq!( + resolve_report_format(&Params::parse("format=json"), "text/html"), + "json" + ); + } + + #[test] + fn true_active_apps_require_evidence() { + let cfg = test_config(); + let start = parse_iso_utc("2026-05-14T06:00:00Z").unwrap(); + let end = parse_iso_utc("2026-05-14T07:59:59Z").unwrap(); + let windows = vec![ + AwEvent { + timestamp: Some("2026-05-14T06:00:00Z".into()), + duration: Some(120.0), + data: json!({"app":"1cv8.exe","title":"ИНФОВЕСТ"}), + }, + AwEvent { + timestamp: Some("2026-05-14T06:02:00Z".into()), + duration: Some(180.0), + data: json!({"app":"1cv8.exe","title":"Счета учета: Материалы"}), + }, + ]; + let afk = vec![AwEvent { + timestamp: Some("2026-05-14T06:00:00Z".into()), + duration: Some(600.0), + data: json!({"status":"not-afk"}), + }]; + let rows = + build_true_active_apps_from_events(&cfg, &windows, &afk, &HashMap::new(), start, end); + assert_eq!(rows[0]["application"], "1С"); + assert_eq!(rows[0]["proved_work_seconds"], 300); + } +} diff --git a/adk-rust/crates/worktime-autoheal/Cargo.toml b/adk-rust/crates/worktime-autoheal/Cargo.toml new file mode 100644 index 0000000..47e7438 --- /dev/null +++ b/adk-rust/crates/worktime-autoheal/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "worktime-autoheal" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +publish = false + +[dependencies] +anyhow.workspace = true +chrono.workspace = true +clap.workspace = true +reqwest.workspace = true +serde.workspace = true +serde_json.workspace = true diff --git a/adk-rust/crates/worktime-autoheal/src/main.rs b/adk-rust/crates/worktime-autoheal/src/main.rs new file mode 100644 index 0000000..e47f7a9 --- /dev/null +++ b/adk-rust/crates/worktime-autoheal/src/main.rs @@ -0,0 +1,815 @@ +use std::{ + collections::{BTreeMap, BTreeSet}, + process::Command, + thread, + time::Duration, +}; + +use anyhow::{Context, Result, anyhow}; +use chrono::{DateTime, Datelike, FixedOffset, SecondsFormat, TimeZone, Utc}; +use clap::Parser; +use reqwest::header::{CONNECTION, HeaderMap, HeaderValue}; +use reqwest::{ + Method, StatusCode, + blocking::{Client, Response}, +}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +const DEFAULT_AW_URL: &str = "http://127.0.0.1:5600"; +const DEFAULT_HOST: &str = "SHARKON2025"; +const AUTOHEAL_SOURCE: &str = "aw-worktime-autoheal"; + +#[derive(Debug, Parser)] +#[command(about = "AW Worktime autoheal")] +struct Cli { + #[arg(long)] + dry_run: bool, + + #[arg(long)] + json: bool, +} + +#[derive(Debug, Clone)] +struct Config { + aw_url: String, + host: String, + worktime_health_url: String, + worktime_report_timeout_seconds: f64, + management_warm_enabled: bool, + management_warm_url: String, + management_warm_timeout_seconds: f64, + today_probe_enabled: bool, + today_probe_url: String, + today_probe_timeout_seconds: f64, + session_freshness_seconds: f64, + aw_timeout_seconds: f64, + aw_post_chunk_size: usize, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +struct AwEvent { + timestamp: Option, + #[serde(default)] + duration: Option, + #[serde(default)] + data: Value, +} + +#[derive(Debug, Serialize)] +struct RunSummary { + ok: bool, + dry_run: bool, + host: String, + worktime_ok: bool, + api_restarted: bool, + management_warm_ok: Option, + need_heal: bool, + ui_bridge_started: bool, + reset_buckets: bool, + backfill_afk: usize, + backfill_window: usize, + reason: String, +} + +struct AwClient { + base_url: String, + client: Client, +} + +fn env(name: &str, fallback: &str) -> String { + std::env::var(name) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| fallback.to_string()) +} + +fn env_bool_legacy(name: &str, fallback: bool) -> bool { + match std::env::var(name) + .ok() + .map(|value| value.trim().to_ascii_lowercase()) + { + Some(value) if !value.is_empty() => matches!(value.as_str(), "1" | "true" | "yes" | "on"), + _ => fallback, + } +} + +fn env_f64(name: &str, fallback: f64) -> f64 { + std::env::var(name) + .ok() + .and_then(|value| value.trim().parse().ok()) + .filter(|value| *value > 0.0) + .unwrap_or(fallback) +} + +fn env_usize(name: &str, fallback: usize) -> usize { + std::env::var(name) + .ok() + .and_then(|value| value.trim().parse().ok()) + .filter(|value| *value > 0) + .unwrap_or(fallback) +} + +fn load_config() -> Config { + Config { + aw_url: env("AW_URL", DEFAULT_AW_URL) + .trim_end_matches('/') + .to_string(), + host: env("AW_WORKTIME_HOST", DEFAULT_HOST), + worktime_health_url: env("WORKTIME_HEALTH_URL", "http://127.0.0.1:5610/health"), + worktime_report_timeout_seconds: env_f64("WORKTIME_REPORT_TIMEOUT_SECONDS", 20.0), + management_warm_enabled: env_bool_legacy("WORKTIME_MANAGEMENT_WARM_ENABLED", true), + management_warm_url: env( + "WORKTIME_MANAGEMENT_WARM_URL", + "http://127.0.0.1:5610/reports/worktime/management?day=today&format=json", + ), + management_warm_timeout_seconds: env_f64("WORKTIME_MANAGEMENT_WARM_TIMEOUT_SECONDS", 60.0), + today_probe_enabled: env_bool_legacy("WORKTIME_TODAY_PROBE_ENABLED", true), + today_probe_url: env( + "WORKTIME_TODAY_PROBE_URL", + "http://127.0.0.1:5610/reports/worktime/today?day=today&format=json", + ), + today_probe_timeout_seconds: env_f64("WORKTIME_TODAY_PROBE_TIMEOUT_SECONDS", 20.0), + session_freshness_seconds: env_f64("WORKTIME_SESSION_FRESHNESS_SECONDS", 600.0), + aw_timeout_seconds: env_f64("WORKTIME_AUTOHEAL_AW_TIMEOUT_SECONDS", 30.0), + aw_post_chunk_size: env_usize("WORKTIME_AUTOHEAL_AW_POST_CHUNK_SIZE", 500), + } +} + +impl Config { + fn window_bucket(&self) -> String { + format!("aw-rdp-window_{}", self.host) + } + + fn afk_bucket(&self) -> String { + format!("aw-rdp-afk_{}", self.host) + } + + fn session_bucket(&self) -> String { + format!("aw-worktime-sessions_{}", self.host) + } +} + +impl AwClient { + fn new(config: &Config) -> Result { + let mut headers = HeaderMap::new(); + headers.insert(CONNECTION, HeaderValue::from_static("close")); + let client = Client::builder() + .timeout(Duration::from_secs_f64( + config.aw_timeout_seconds.max(0.001), + )) + .no_proxy() + .pool_max_idle_per_host(0) + .default_headers(headers) + .build() + .context("build ActivityWatch HTTP client")?; + Ok(Self { + base_url: config.aw_url.clone(), + client, + }) + } + + fn url(&self, path: &str) -> String { + format!("{}{}", self.base_url, path) + } + + fn send_retry(&self, method: Method, path: &str, payload: Option) -> Result { + let mut last_error = None; + for attempt in 1..=3 { + let mut request = self.client.request(method.clone(), self.url(path)); + if let Some(payload) = payload.clone() { + request = request.json(&payload); + } + match request.send() { + Ok(response) => return Ok(response), + Err(error) => { + last_error = Some(error); + if attempt < 3 { + log(&format!("warn aw request={path} retry_after_send_error")); + thread::sleep(Duration::from_millis(750)); + } + } + } + } + Err(last_error.expect("error exists after failed request")) + .with_context(|| format!("request ActivityWatch {path}")) + } + + fn request_json( + &self, + method: Method, + path: &str, + payload: Option, + ignore_not_found: bool, + ) -> Result> { + let response = self.send_retry(method, path, payload)?; + let status = response.status(); + if ignore_not_found && status == StatusCode::NOT_FOUND { + return Ok(None); + } + if !status.is_success() { + return Err(anyhow!("ActivityWatch {path} returned HTTP {status}")); + } + let bytes = response.bytes().context("read ActivityWatch response")?; + if bytes.is_empty() { + Ok(None) + } else { + serde_json::from_slice(&bytes) + .map(Some) + .with_context(|| format!("decode ActivityWatch JSON from {path}")) + } + } + + fn get_events(&self, bucket_id: &str, limit: usize) -> Result> { + let path = format!("/api/0/buckets/{bucket_id}/events?limit={limit}"); + match self.request_json(Method::GET, &path, None, true)? { + Some(value) => serde_json::from_value(value).context("decode AW events"), + None => Ok(Vec::new()), + } + } + + fn delete_bucket(&self, bucket_id: &str) -> Result<()> { + let path = format!("/api/0/buckets/{bucket_id}"); + let response = self.send_retry(Method::DELETE, &path, None)?; + if response.status().is_success() || response.status() == StatusCode::NOT_FOUND { + Ok(()) + } else { + Err(anyhow!( + "delete bucket {bucket_id} returned HTTP {}", + response.status() + )) + } + } + + fn ensure_bucket( + &self, + bucket_id: &str, + event_type: &str, + client_name: &str, + host: &str, + ) -> Result<()> { + let path = format!("/api/0/buckets/{bucket_id}"); + let payload = json!({ + "client": client_name, + "type": event_type, + "hostname": host, + }); + let response = self.send_retry(Method::POST, &path, Some(payload))?; + let status = response.status(); + if status.is_success() + || status == StatusCode::NOT_MODIFIED + || status == StatusCode::CONFLICT + { + Ok(()) + } else { + Err(anyhow!("ensure bucket {bucket_id} returned HTTP {status}")) + } + } + + fn post_events_chunked( + &self, + bucket_id: &str, + events: &[AwEvent], + chunk_size: usize, + ) -> Result<()> { + for chunk in events.chunks(chunk_size.max(1)) { + let path = format!("/api/0/buckets/{bucket_id}/events"); + self.request_json(Method::POST, &path, Some(json!(chunk)), false)?; + } + Ok(()) + } +} + +fn log(message: &str) { + println!("{} {}", chrono::Local::now().format("%F %T"), message); +} + +fn build_probe_client(timeout_seconds: f64) -> Result { + Client::builder() + .timeout(Duration::from_secs_f64(timeout_seconds.max(0.001))) + .no_proxy() + .build() + .context("build probe HTTP client") +} + +fn probe_url(url: &str, timeout_seconds: f64) -> bool { + let Ok(client) = build_probe_client(timeout_seconds) else { + return false; + }; + client + .get(url) + .send() + .map(|response| response.status().is_success()) + .unwrap_or(false) +} + +fn probe_reports(config: &Config) -> bool { + if !probe_url( + &config.worktime_health_url, + config.worktime_report_timeout_seconds, + ) { + return false; + } + if config.today_probe_enabled + && !probe_url(&config.today_probe_url, config.today_probe_timeout_seconds) + { + return false; + } + true +} + +fn run_systemctl(args: &[&str], dry_run: bool) -> bool { + if dry_run { + log(&format!("dry-run systemctl {}", args.join(" "))); + return true; + } + Command::new("systemctl") + .args(args) + .status() + .map(|status| status.success()) + .unwrap_or(false) +} + +fn parse_iso_utc(ts: &str) -> Result> { + DateTime::parse_from_rfc3339(ts) + .map(|parsed| parsed.with_timezone(&Utc)) + .with_context(|| format!("parse timestamp {ts}")) +} + +fn to_iso_utc(dt: DateTime) -> String { + dt.to_rfc3339_opts(SecondsFormat::Secs, true) +} + +fn moscow_day_start_utc() -> DateTime { + let msk = FixedOffset::east_opt(3 * 3600).expect("valid Moscow offset"); + let today = Utc::now().with_timezone(&msk); + msk.with_ymd_and_hms(today.year(), today.month(), today.day(), 0, 0, 0) + .single() + .expect("valid day start") + .with_timezone(&Utc) +} + +fn value_string(data: &Value, key: &str) -> String { + match data.get(key) { + Some(Value::String(value)) => value.trim().to_string(), + Some(Value::Number(value)) => value.to_string(), + Some(Value::Bool(value)) => value.to_string(), + Some(Value::Null) | None => String::new(), + Some(value) => value.to_string().trim_matches('"').trim().to_string(), + } +} + +fn value_i64(data: &Value, key: &str) -> Option { + match data.get(key) { + Some(Value::Number(value)) => value.as_i64(), + Some(Value::String(value)) => value.trim().parse().ok(), + _ => None, + } +} + +fn is_session_active(data: &Value) -> bool { + if data.get("active").and_then(Value::as_bool).unwrap_or(false) { + return true; + } + let state = value_string(data, "state").to_lowercase(); + if state == "active" || state == "активно" { + return true; + } + if state == "unknown" { + let sid = value_i64(data, "sessionId").unwrap_or(-1); + let user = value_string(data, "username").to_lowercase(); + let session_name = value_string(data, "sessionName").to_lowercase(); + return sid > 0 + && !user.is_empty() + && !user.ends_with('$') + && (session_name.starts_with("rdp-") || session_name == "console"); + } + false +} + +fn active_users_at_latest_session(events: &[AwEvent]) -> (Option>, BTreeSet) { + let mut latest_ts = None; + let mut active_users = BTreeSet::new(); + for event in events { + let Some(ts) = event.timestamp.as_deref() else { + continue; + }; + let Ok(cur) = parse_iso_utc(ts) else { + continue; + }; + if latest_ts.is_none_or(|latest| cur > latest) { + latest_ts = Some(cur); + active_users.clear(); + } + if latest_ts == Some(cur) { + let user = value_string(&event.data, "username"); + if !user.is_empty() && is_session_active(&event.data) { + active_users.insert(user); + } + } + } + (latest_ts, active_users) +} + +fn active_window_duration_today(window_events: &[AwEvent], start: DateTime) -> f64 { + window_events + .iter() + .filter(|event| { + event + .timestamp + .as_deref() + .and_then(|ts| parse_iso_utc(ts).ok()) + .is_some_and(|ts| ts >= start) + }) + .filter(|event| { + value_string(&event.data, "title") + .to_lowercase() + .contains("rdp active") + }) + .map(|event| event.duration.unwrap_or(0.0).max(0.0)) + .sum() +} + +fn should_heal( + aw: &AwClient, + config: &Config, + now: DateTime, + start: DateTime, +) -> Result<(bool, String)> { + let window_events = match aw.get_events(&config.window_bucket(), 12_000) { + Ok(events) => events, + Err(error) => { + return Ok((true, format!("window_read_failed: {error:#}"))); + } + }; + let session_events = match aw.get_events(&config.session_bucket(), 12_000) { + Ok(events) => events, + Err(error) => { + return Ok((true, format!("session_read_failed: {error:#}"))); + } + }; + let (latest_ts, active_users) = active_users_at_latest_session(&session_events); + let Some(latest_ts) = latest_ts else { + return Ok((false, "no_sessions".to_string())); + }; + let age = (now - latest_ts).num_milliseconds() as f64 / 1000.0; + if age > config.session_freshness_seconds { + return Ok((false, format!("sessions_stale age_seconds={age:.0}"))); + } + if active_users.is_empty() { + return Ok((false, "no_active_users".to_string())); + } + let active = active_window_duration_today(&window_events, start); + if active <= 0.0 { + Ok(( + true, + format!("zero_activity active_users={}", active_users.len()), + )) + } else { + Ok(( + false, + format!("activity_present active_seconds={active:.0}"), + )) + } +} + +fn build_window_title(users: &[String]) -> String { + if users.is_empty() { + "RDP idle".to_string() + } else { + format!("RDP active ({}): {}", users.len(), users.join(", ")) + } +} + +fn build_backfill( + session_events: Vec, + start: DateTime, +) -> (Vec, Vec) { + let mut grouped: BTreeMap, Vec> = BTreeMap::new(); + for event in session_events { + let Some(ts) = event.timestamp.as_deref() else { + continue; + }; + let Ok(parsed) = parse_iso_utc(ts) else { + continue; + }; + if parsed >= start { + grouped.entry(parsed).or_default().push(event); + } + } + let keys: Vec> = grouped.keys().copied().collect(); + let mut out_afk = Vec::new(); + let mut out_win = Vec::new(); + for (idx, ts) in keys.iter().enumerate() { + let rows = &grouped[ts]; + let mut duration = keys + .get(idx + 1) + .map(|next| ((*next - *ts).num_milliseconds() as f64 / 1000.0).max(0.0)) + .unwrap_or(10.0); + if duration <= 0.0 { + duration = 10.0; + } + duration = duration.min(30.0); + let users: Vec = rows + .iter() + .filter_map(|event| { + let user = value_string(&event.data, "username"); + if !user.is_empty() && is_session_active(&event.data) { + Some(user) + } else { + None + } + }) + .collect::>() + .into_iter() + .collect(); + let active = !users.is_empty(); + let ts = to_iso_utc(*ts); + out_afk.push(AwEvent { + timestamp: Some(ts.clone()), + duration: Some(duration), + data: json!({ + "status": if active { "not-afk" } else { "afk" }, + "source": AUTOHEAL_SOURCE, + }), + }); + out_win.push(AwEvent { + timestamp: Some(ts), + duration: Some(duration), + data: json!({ + "app": "RDP", + "title": build_window_title(&users), + "source": AUTOHEAL_SOURCE, + }), + }); + } + (out_afk, out_win) +} + +fn reset_bucket( + aw: &AwClient, + bucket_id: &str, + event_type: &str, + client_name: &str, + host: &str, +) -> Result<()> { + if let Err(error) = aw.delete_bucket(bucket_id) { + log(&format!( + "warn delete bucket {bucket_id} ignored: {error:#}" + )); + } + aw.ensure_bucket(bucket_id, event_type, client_name, host) +} + +fn run(cli: &Cli) -> Result { + let config = load_config(); + let aw = AwClient::new(&config)?; + let mut api_restarted = false; + let mut worktime_ok = true; + + if !probe_reports(&config) { + log("worktime API probe failed, restarting aw-worktime-api.service"); + api_restarted = true; + run_systemctl(&["restart", "aw-worktime-api.service"], cli.dry_run); + thread::sleep(Duration::from_secs(2)); + if !probe_reports(&config) { + log("worktime API still degraded after restart"); + worktime_ok = false; + } else { + log("worktime API recovered after restart"); + } + } + + if !worktime_ok { + log("skip warm/heal because worktime API is still unavailable"); + return Ok(RunSummary { + ok: true, + dry_run: cli.dry_run, + host: config.host, + worktime_ok, + api_restarted, + management_warm_ok: None, + need_heal: false, + ui_bridge_started: false, + reset_buckets: false, + backfill_afk: 0, + backfill_window: 0, + reason: "worktime_unavailable".to_string(), + }); + } + + let management_warm_ok = if config.management_warm_enabled { + let ok = probe_url( + &config.management_warm_url, + config.management_warm_timeout_seconds, + ); + if ok { + log("management cache warm ok"); + } else { + log("management cache warm failed"); + } + Some(ok) + } else { + None + }; + + let start = moscow_day_start_utc(); + let now = Utc::now(); + let (need_heal, reason) = should_heal(&aw, &config, now, start)?; + if !need_heal { + log(&format!( + "health ok: {reason} for {}, no action", + config.host + )); + return Ok(RunSummary { + ok: true, + dry_run: cli.dry_run, + host: config.host, + worktime_ok, + api_restarted, + management_warm_ok, + need_heal: false, + ui_bridge_started: false, + reset_buckets: false, + backfill_afk: 0, + backfill_window: 0, + reason, + }); + } + + log(&format!( + "detected zero activity for {}, running heal", + config.host + )); + let mut ui_bridge_started = + run_systemctl(&["restart", "aw-worktime-ui-bridge.timer"], cli.dry_run); + ui_bridge_started &= run_systemctl(&["start", "aw-worktime-ui-bridge.service"], cli.dry_run); + + let session_events = aw.get_events(&config.session_bucket(), 12_000)?; + let (afk_events, window_events) = build_backfill(session_events, start); + if afk_events.is_empty() || window_events.is_empty() { + log(&format!( + "heal skipped for {}, no source sessions", + config.host + )); + return Ok(RunSummary { + ok: true, + dry_run: cli.dry_run, + host: config.host, + worktime_ok, + api_restarted, + management_warm_ok, + need_heal: true, + ui_bridge_started, + reset_buckets: false, + backfill_afk: 0, + backfill_window: 0, + reason: "no_source_sessions".to_string(), + }); + } + + if !cli.dry_run { + reset_bucket( + &aw, + &config.afk_bucket(), + "afkstatus", + "aw-worktime-ui-bridge", + &config.host, + )?; + reset_bucket( + &aw, + &config.window_bucket(), + "currentwindow", + "aw-worktime-ui-bridge", + &config.host, + )?; + aw.post_events_chunked(&config.afk_bucket(), &afk_events, config.aw_post_chunk_size)?; + aw.post_events_chunked( + &config.window_bucket(), + &window_events, + config.aw_post_chunk_size, + )?; + } + println!( + "autoheal backfill posted afk={} win={}", + afk_events.len(), + window_events.len() + ); + log(&format!("heal completed for {}", config.host)); + Ok(RunSummary { + ok: true, + dry_run: cli.dry_run, + host: config.host, + worktime_ok, + api_restarted, + management_warm_ok, + need_heal: true, + ui_bridge_started, + reset_buckets: !cli.dry_run, + backfill_afk: afk_events.len(), + backfill_window: window_events.len(), + reason, + }) +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + let summary = run(&cli)?; + if cli.json { + println!("{}", serde_json::to_string_pretty(&summary)?); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn event(ts: &str, duration: f64, data: Value) -> AwEvent { + AwEvent { + timestamp: Some(ts.to_string()), + duration: Some(duration), + data, + } + } + + #[test] + fn unknown_rdp_session_counts_as_active() { + assert!(is_session_active(&json!({ + "sessionId": 5, + "state": "Unknown", + "username": "user5", + "sessionName": "rdp-tcp#0" + }))); + } + + #[test] + fn active_users_use_latest_sample_only() { + let events = vec![ + event( + "2026-05-27T07:59:25Z", + 0.0, + json!({"sessionId": 2, "state": "Активно", "username": "admin", "sessionName": "console"}), + ), + event( + "2026-05-27T07:59:30Z", + 0.0, + json!({"sessionId": 3, "state": "Диск", "username": "old", "sessionName": ""}), + ), + event( + "2026-05-27T07:59:30Z", + 0.0, + json!({"sessionId": 4, "state": "Активно", "username": "user5", "sessionName": "rdp-tcp#0"}), + ), + ]; + let (latest, users) = active_users_at_latest_session(&events); + assert_eq!( + latest.map(to_iso_utc).as_deref(), + Some("2026-05-27T07:59:30Z") + ); + assert_eq!(users, BTreeSet::from(["user5".to_string()])); + } + + #[test] + fn active_window_duration_counts_today_rdp_active_titles() { + let start = parse_iso_utc("2026-06-01T00:00:00Z").unwrap(); + let events = vec![ + event( + "2026-05-31T23:59:59Z", + 30.0, + json!({"title": "RDP active (1): user"}), + ), + event( + "2026-06-01T00:00:00Z", + 10.0, + json!({"title": "RDP active (1): user"}), + ), + event("2026-06-01T00:01:00Z", 20.0, json!({"title": "RDP idle"})), + ]; + assert_eq!(active_window_duration_today(&events, start), 10.0); + } + + #[test] + fn build_backfill_caps_duration_and_marks_active() { + let start = parse_iso_utc("2026-06-01T00:00:00Z").unwrap(); + let events = vec![ + event( + "2026-06-01T00:00:00Z", + 0.0, + json!({"sessionId": 3, "state": "Активно", "username": "user5", "sessionName": "rdp-tcp#0"}), + ), + event( + "2026-06-01T00:00:45Z", + 0.0, + json!({"sessionId": 3, "state": "Диск", "username": "user5", "sessionName": "rdp-tcp#0"}), + ), + ]; + let (afk, win) = build_backfill(events, start); + assert_eq!(afk.len(), 2); + assert_eq!(win[0].duration, Some(30.0)); + assert_eq!(value_string(&afk[0].data, "status"), "not-afk"); + assert_eq!(value_string(&win[0].data, "title"), "RDP active (1): user5"); + assert_eq!(value_string(&afk[1].data, "status"), "afk"); + } +} diff --git a/adk-rust/crates/worktime-influx-exporter/Cargo.toml b/adk-rust/crates/worktime-influx-exporter/Cargo.toml new file mode 100644 index 0000000..94a7849 --- /dev/null +++ b/adk-rust/crates/worktime-influx-exporter/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "worktime-influx-exporter" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +publish = false + +[dependencies] +anyhow.workspace = true +chrono.workspace = true +clap.workspace = true +reqwest.workspace = true +serde.workspace = true +serde_json.workspace = true +urlencoding.workspace = true diff --git a/adk-rust/crates/worktime-influx-exporter/src/main.rs b/adk-rust/crates/worktime-influx-exporter/src/main.rs new file mode 100644 index 0000000..bb2ac7a --- /dev/null +++ b/adk-rust/crates/worktime-influx-exporter/src/main.rs @@ -0,0 +1,1025 @@ +use std::{collections::HashMap, thread, time::Duration}; + +use anyhow::{Context, Result, anyhow, bail}; +use chrono::{DateTime, Datelike, FixedOffset, NaiveDate, TimeDelta, TimeZone, Timelike, Utc}; +use clap::Parser; +use reqwest::blocking::Client; +use serde::Serialize; +use serde_json::Value; + +const DEFAULT_AW_BASE: &str = "http://127.0.0.1:5600"; +const DEFAULT_WORKTIME_REPORT_BASE: &str = "http://127.0.0.1:5610"; +const DEFAULT_INFLUX_ORG: &str = "proxmox"; +const DEFAULT_INFLUX_BUCKET: &str = "aw_metrics"; +const DEFAULT_HOST: &str = "SHARKON2025"; +const DEFAULT_DAYS: &str = "today,yesterday"; + +#[derive(Debug, Parser)] +#[command(about = "AW Worktime InfluxDB exporter")] +struct Cli { + #[arg(long)] + dry_run: bool, + + #[arg(long)] + json: bool, + + #[arg(long, default_value_t = 30)] + timeout_seconds: u64, +} + +#[derive(Debug, Clone)] +struct Config { + aw_api_base: String, + report_base: String, + influx_url: String, + influx_org: String, + influx_bucket: String, + influx_token: String, + influx_enabled: bool, + hosts: Vec, + days: Vec, + report_offset: FixedOffset, + default_sample_seconds: f64, + max_sample_seconds: f64, + events_limit: usize, +} + +#[derive(Debug, Serialize)] +struct RunSummary { + ok: bool, + enabled: bool, + dry_run: bool, + hosts: Vec, + days: Vec, + lines: usize, + written: usize, + bucket: String, + error: Option, +} + +#[derive(Debug, Clone)] +enum FieldValue { + Int(i64), + String(String), +} + +#[derive(Debug, Clone)] +struct Interval { + start: DateTime, + end: DateTime, +} + +#[derive(Debug, Clone)] +struct UserAccumulator { + user: String, + user_id: String, + samples_count: i64, + active_samples: i64, + session_ids: Vec, + intervals: Vec, +} + +#[derive(Debug, Clone)] +struct DailyRow { + user: String, + user_id: String, + active_seconds: i64, + idle_seconds: i64, + sessions_count: i64, + samples_count: i64, + active_samples: i64, +} + +#[derive(Debug, Clone)] +struct HourlyRow { + user: String, + user_id: String, + bucket_start_utc: DateTime, + report_date: String, + hour_local: String, + active_seconds: i64, +} + +type IdentitySamples = HashMap<(String, String), Vec<(DateTime, Value)>>; + +fn env(name: &str, fallback: &str) -> String { + std::env::var(name) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| fallback.to_string()) +} + +fn env_bool(name: &str, fallback: bool) -> bool { + match std::env::var(name) + .ok() + .map(|value| value.trim().to_ascii_lowercase()) + { + Some(value) if !value.is_empty() => matches!(value.as_str(), "1" | "true" | "yes" | "on"), + _ => fallback, + } +} + +fn env_f64(name: &str, fallback: f64) -> f64 { + std::env::var(name) + .ok() + .and_then(|value| value.trim().parse().ok()) + .unwrap_or(fallback) +} + +fn env_usize(name: &str, fallback: usize) -> usize { + std::env::var(name) + .ok() + .and_then(|value| value.trim().parse().ok()) + .unwrap_or(fallback) +} + +fn split_csv(value: &str) -> Vec { + value + .split(',') + .map(str::trim) + .filter(|item| !item.is_empty()) + .map(ToOwned::to_owned) + .collect() +} + +fn build_aw_api_base(raw: &str) -> String { + let base = raw.trim().trim_end_matches('/'); + if base.ends_with("/api/0") { + base.to_string() + } else { + format!("{base}/api/0") + } +} + +fn report_offset_from_env() -> FixedOffset { + let tz = env("AW_WORKTIME_TZ", "Europe/Moscow"); + match tz.as_str() { + "UTC" | "Etc/UTC" => FixedOffset::east_opt(0).expect("valid UTC offset"), + "Europe/Moscow" => FixedOffset::east_opt(3 * 3600).expect("valid Moscow offset"), + value if value.starts_with('+') || value.starts_with('-') => parse_offset(value) + .unwrap_or_else(|| FixedOffset::east_opt(3 * 3600).expect("valid Moscow offset")), + _ => FixedOffset::east_opt(3 * 3600).expect("valid Moscow offset"), + } +} + +fn parse_offset(value: &str) -> Option { + let sign = if value.starts_with('-') { -1 } else { 1 }; + let raw = value.trim_start_matches(['+', '-']); + let mut parts = raw.split(':'); + let hours: i32 = parts.next()?.parse().ok()?; + let minutes: i32 = parts.next().unwrap_or("0").parse().ok()?; + FixedOffset::east_opt(sign * (hours * 3600 + minutes * 60)) +} + +fn load_config() -> Config { + let aw_base = env( + "AW_WORKTIME_AW_API_BASE", + &env("AW_SERVER_URL", DEFAULT_AW_BASE), + ); + let default_sample_seconds = env_f64("AW_WORKTIME_DEFAULT_SAMPLE_SECONDS", 30.0).max(1.0); + let max_sample_seconds = + env_f64("AW_WORKTIME_MAX_SAMPLE_SECONDS", 300.0).max(default_sample_seconds); + Config { + aw_api_base: build_aw_api_base(&aw_base), + report_base: env("AW_WORKTIME_REPORT_BASE", DEFAULT_WORKTIME_REPORT_BASE) + .trim_end_matches('/') + .to_string(), + influx_url: env("AW_WORKTIME_INFLUX_URL", "") + .trim_end_matches('/') + .to_string(), + influx_org: env("AW_WORKTIME_INFLUX_ORG", DEFAULT_INFLUX_ORG), + influx_bucket: env("AW_WORKTIME_INFLUX_BUCKET", DEFAULT_INFLUX_BUCKET), + influx_token: env("AW_WORKTIME_INFLUX_TOKEN", ""), + influx_enabled: env_bool("AW_WORKTIME_INFLUX_ENABLED", false), + hosts: split_csv(&env( + "AW_WORKTIME_INFLUX_HOSTS", + &env("AW_WORKTIME_HOST", DEFAULT_HOST), + )), + days: split_csv(&env("AW_WORKTIME_INFLUX_DAYS", DEFAULT_DAYS)), + report_offset: report_offset_from_env(), + default_sample_seconds, + max_sample_seconds, + events_limit: env_usize("AW_WORKTIME_EVENTS_LIMIT", 50_000).max(1000), + } +} + +fn utc_now() -> DateTime { + Utc::now() +} + +fn pts(value: Option<&str>) -> Option> { + let value = value?.trim(); + if value.is_empty() { + return None; + } + DateTime::parse_from_rfc3339(&value.replace('Z', "+00:00")) + .map(|parsed| parsed.with_timezone(&Utc)) + .ok() +} + +fn resolve_report_date(day: &str, offset: FixedOffset) -> Result { + if let Ok(date) = NaiveDate::parse_from_str(day, "%Y-%m-%d") { + return Ok(date); + } + let today = utc_now().with_timezone(&offset).date_naive(); + if day == "yesterday" { + Ok(today - TimeDelta::days(1)) + } else { + Ok(today) + } +} + +fn report_bounds( + report_date: NaiveDate, + offset: FixedOffset, +) -> Result<(DateTime, DateTime, DateTime)> { + let start_local = offset + .with_ymd_and_hms( + report_date.year(), + report_date.month(), + report_date.day(), + 0, + 0, + 0, + ) + .single() + .ok_or_else(|| anyhow!("invalid local report date"))?; + let start = start_local.with_timezone(&Utc); + let end_exclusive = (start_local + TimeDelta::days(1)).with_timezone(&Utc); + let end_inclusive = end_exclusive - TimeDelta::seconds(1); + Ok((start, end_inclusive, end_exclusive)) +} + +fn escape_tag(value: &str) -> String { + value + .replace('\\', "\\\\") + .replace(' ', "\\ ") + .replace(',', "\\,") + .replace('=', "\\=") +} + +fn timestamp_ns(dt: DateTime) -> i64 { + dt.timestamp_nanos_opt() + .unwrap_or_else(|| dt.timestamp() * 1_000_000_000) +} + +fn line( + measurement: &str, + tags: Vec<(&str, String)>, + fields: Vec<(&str, FieldValue)>, + timestamp_ns: i64, +) -> Option { + let mut tag_items: Vec<(&str, String)> = tags + .into_iter() + .filter(|(_key, value)| !value.is_empty()) + .collect(); + tag_items.sort_by(|left, right| left.0.cmp(right.0)); + let tag_part = tag_items + .iter() + .map(|(key, value)| format!("{key}={}", escape_tag(value))) + .collect::>() + .join(","); + + let field_parts = fields + .into_iter() + .map(|(key, value)| match value { + FieldValue::Int(value) => format!("{key}={value}i"), + FieldValue::String(value) => { + let text = value.replace('\\', "\\\\").replace('"', "\\\""); + format!("{key}=\"{text}\"") + } + }) + .collect::>(); + if field_parts.is_empty() { + return None; + } + if tag_part.is_empty() { + Some(format!( + "{measurement} {} {timestamp_ns}", + field_parts.join(",") + )) + } else { + Some(format!( + "{measurement},{tag_part} {} {timestamp_ns}", + field_parts.join(",") + )) + } +} + +fn get_json(client: &Client, url: &str) -> Result { + let mut last_error = None; + for attempt in 1..=6 { + match client + .get(url) + .header("Accept", "application/json") + .send() + .and_then(|resp| resp.error_for_status()) + .and_then(|resp| resp.json()) + { + Ok(payload) => return Ok(payload), + Err(err) => { + last_error = Some(err); + if attempt < 6 { + thread::sleep(Duration::from_millis(250 * attempt)); + } + } + } + } + Err(anyhow!( + "GET {url}: {}", + last_error + .map(|err| err.to_string()) + .unwrap_or_else(|| "unknown error".to_string()) + )) +} + +fn format_aw_time(value: DateTime) -> String { + value.to_rfc3339_opts(chrono::SecondsFormat::Secs, true) +} + +fn fetch_events_for_date( + client: &Client, + config: &Config, + host: &str, + start: DateTime, + end: DateTime, +) -> Vec { + let bucket_id = format!("aw-worktime-sessions_{host}"); + let bucket_url = format!( + "{}/buckets/{}", + config.aw_api_base, + urlencoding::encode(&bucket_id) + ); + if let Err(err) = get_json(client, &bucket_url) { + eprintln!( + "[aw-worktime-influx-exporter] bucket lookup failed for host={host} bucket={bucket_id}: {err}" + ); + return Vec::new(); + } + let query = format!( + "start={}&end={}&limit={}", + urlencoding::encode(&format_aw_time(start)), + urlencoding::encode(&format_aw_time(end)), + config.events_limit + ); + let events_url = format!( + "{}/buckets/{}/events?{}", + config.aw_api_base, + urlencoding::encode(&bucket_id), + query + ); + match get_json(client, &events_url) { + Ok(payload) => payload.as_array().cloned().unwrap_or_default(), + Err(err) => { + eprintln!( + "[aw-worktime-influx-exporter] events fetch failed for host={host} bucket={bucket_id}: {err}" + ); + Vec::new() + } + } +} + +fn s(value: Option<&Value>) -> String { + match value { + Some(Value::Null) | None => String::new(), + Some(Value::String(value)) => value.trim().to_string(), + Some(Value::Bool(value)) => value.to_string(), + Some(Value::Number(value)) => value.to_string(), + Some(other) => other.to_string(), + } +} + +fn int_value(value: Option<&Value>) -> i64 { + match value { + Some(Value::Number(value)) => value + .as_i64() + .or_else(|| value.as_u64().map(|v| v as i64)) + .unwrap_or(0), + Some(Value::String(value)) => value.parse().unwrap_or(0), + Some(Value::Bool(value)) => i64::from(*value), + _ => 0, + } +} + +fn bool_value(value: Option<&Value>) -> bool { + match value { + Some(Value::Bool(value)) => *value, + Some(Value::Number(value)) => value.as_i64().unwrap_or(0) != 0, + Some(Value::String(value)) => matches!( + value.to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "on" + ), + _ => false, + } +} + +fn is_machine_user(user: &str) -> bool { + let user = user.trim().to_ascii_lowercase(); + user.ends_with('$') || matches!(user.as_str(), "system" | "localservice" | "networkservice") +} + +fn is_active_sample(data: &serde_json::Map) -> bool { + let state = s(data.get("state")).to_ascii_lowercase(); + if bool_value(data.get("active")) { + return true; + } + if state.contains("актив") || state == "active" { + return true; + } + if state == "unknown" { + let session_id = int_value(data.get("sessionId")); + let user = s(data.get("username")); + let session_name = s(data.get("sessionName")).to_ascii_lowercase(); + return session_id > 0 + && !user.is_empty() + && !is_machine_user(&user) + && (session_name.starts_with("rdp-") || session_name == "console"); + } + false +} + +fn normalize_user_id(data: &serde_json::Map, host: &str, username: &str) -> String { + let user_id = s(data.get("userId")); + if !user_id.is_empty() { + if let Some((_left, right)) = user_id.split_once('\\') { + if !right.is_empty() { + return format!("{host}\\{right}"); + } + } + return user_id; + } + format!("{host}\\{username}") +} + +fn clamp_seconds(value: f64, config: &Config) -> f64 { + let mut seconds = value; + if seconds <= 0.0 || !seconds.is_finite() { + seconds = config.default_sample_seconds; + } + seconds.min(config.max_sample_seconds) +} + +fn event_sample_seconds( + event: &Value, + next_same_session_ts: Option>, + event_ts: DateTime, + data: &serde_json::Map, + config: &Config, +) -> f64 { + for key in ["sampleSeconds", "pollSeconds"] { + let value = data.get(key); + let parsed = match value { + Some(Value::Number(number)) => number.as_f64(), + Some(Value::String(text)) => text.parse().ok(), + _ => None, + }; + if let Some(seconds) = parsed.filter(|seconds| *seconds > 0.0) { + return clamp_seconds(seconds, config); + } + } + let duration = match event.get("duration") { + Some(Value::Number(number)) => number.as_f64().unwrap_or(0.0), + Some(Value::String(text)) => text.parse().unwrap_or(0.0), + _ => 0.0, + }; + if duration > 0.0 { + return clamp_seconds(duration, config); + } + if let Some(next_ts) = next_same_session_ts { + let delta = (next_ts - event_ts).num_milliseconds() as f64 / 1000.0; + if delta > 0.0 { + return clamp_seconds(delta, config); + } + } + clamp_seconds(config.default_sample_seconds, config) +} + +fn merge_intervals(intervals: &[Interval]) -> Vec { + if intervals.is_empty() { + return Vec::new(); + } + let mut ordered = intervals.to_vec(); + ordered.sort_by_key(|item| item.start); + let mut merged = vec![ordered[0].clone()]; + for interval in ordered.into_iter().skip(1) { + let last = merged.last_mut().expect("merged interval exists"); + if interval.start <= last.end { + if interval.end > last.end { + last.end = interval.end; + } + } else { + merged.push(interval); + } + } + merged +} + +fn collect_user_rows( + events: &[Value], + end_exclusive: DateTime, + host: &str, + config: &Config, +) -> HashMap { + let mut by_identity: IdentitySamples = HashMap::new(); + for event in events { + let Some(ts) = pts(event.get("timestamp").and_then(Value::as_str)) else { + continue; + }; + let Some(data) = event.get("data").and_then(Value::as_object) else { + continue; + }; + let username = s(data.get("username")); + if username.is_empty() { + continue; + } + let session_id = s(data.get("sessionId")); + by_identity + .entry(( + username, + if session_id.is_empty() { + "unknown".to_string() + } else { + session_id + }, + )) + .or_default() + .push((ts, event.clone())); + } + + let mut by_user: HashMap = HashMap::new(); + for ((username, session_id), mut samples) in by_identity { + samples.sort_by_key(|item| item.0); + for idx in 0..samples.len() { + let (event_ts, event) = &samples[idx]; + let Some(data) = event.get("data").and_then(Value::as_object) else { + continue; + }; + let next_ts = samples.get(idx + 1).map(|item| item.0); + let active = is_active_sample(data); + let sample_seconds = event_sample_seconds(event, next_ts, *event_ts, data, config); + let row = by_user + .entry(username.clone()) + .or_insert_with(|| UserAccumulator { + user: username.clone(), + user_id: normalize_user_id(data, host, &username), + samples_count: 0, + active_samples: 0, + session_ids: Vec::new(), + intervals: Vec::new(), + }); + row.samples_count += 1; + if !row.session_ids.contains(&session_id) { + row.session_ids.push(session_id.clone()); + } + if active { + row.active_samples += 1; + let interval_start = *event_ts; + let interval_end = (*event_ts + + TimeDelta::milliseconds((sample_seconds * 1000.0) as i64)) + .min(end_exclusive); + if interval_end > interval_start { + row.intervals.push(Interval { + start: interval_start, + end: interval_end, + }); + } + } + } + } + by_user +} + +fn aggregate_daily_rows( + events: &[Value], + start: DateTime, + end_exclusive: DateTime, + host: &str, + config: &Config, +) -> Vec { + let by_user = collect_user_rows(events, end_exclusive, host, config); + let mut users: Vec<_> = by_user.keys().cloned().collect(); + users.sort(); + let full_range = (end_exclusive - start).num_seconds(); + let mut rows = Vec::new(); + for username in users { + let row = &by_user[&username]; + let active_seconds: i64 = merge_intervals(&row.intervals) + .iter() + .map(|interval| (interval.end - interval.start).num_seconds()) + .sum::() + .min(full_range); + rows.push(DailyRow { + user: row.user.clone(), + user_id: row.user_id.clone(), + active_seconds, + idle_seconds: (full_range - active_seconds).max(0), + sessions_count: row.session_ids.len() as i64, + samples_count: row.samples_count, + active_samples: row.active_samples, + }); + } + rows +} + +fn aggregate_hourly_rows( + events: &[Value], + end_exclusive: DateTime, + host: &str, + config: &Config, +) -> Vec { + let by_user = collect_user_rows(events, end_exclusive, host, config); + let mut users: Vec<_> = by_user.keys().cloned().collect(); + users.sort(); + let mut rows = Vec::new(); + for username in users { + let row = &by_user[&username]; + let mut per_bucket: HashMap, i64> = HashMap::new(); + for interval in merge_intervals(&row.intervals) { + let mut cursor = interval.start; + while cursor < interval.end { + let local = cursor.with_timezone(&config.report_offset); + let bucket_local = config + .report_offset + .with_ymd_and_hms(local.year(), local.month(), local.day(), local.hour(), 0, 0) + .single() + .expect("valid hourly bucket"); + let bucket_start = bucket_local.with_timezone(&Utc); + let bucket_end = (bucket_local + TimeDelta::hours(1)).with_timezone(&Utc); + let overlap_start = interval.start.max(bucket_start); + let overlap_end = interval.end.min(bucket_end); + if overlap_end > overlap_start { + *per_bucket.entry(bucket_start).or_default() += + (overlap_end - overlap_start).num_seconds(); + } + cursor = bucket_end; + } + } + let mut buckets: Vec<_> = per_bucket.into_iter().collect(); + buckets.sort_by_key(|item| item.0); + for (bucket_start_utc, active_seconds) in buckets { + if active_seconds <= 0 { + continue; + } + let bucket_local = bucket_start_utc.with_timezone(&config.report_offset); + rows.push(HourlyRow { + user: row.user.clone(), + user_id: row.user_id.clone(), + bucket_start_utc, + report_date: bucket_local.date_naive().to_string(), + hour_local: format!("{:02}:00", bucket_local.hour()), + active_seconds, + }); + } + } + rows +} + +fn fetch_true_active_apps( + client: &Client, + config: &Config, + host: &str, + day: &str, + report_date: NaiveDate, +) -> Vec { + let date_param = if matches!(day, "today" | "yesterday") { + format!("day={}", urlencoding::encode(day)) + } else { + format!("date={report_date}") + }; + let url = format!( + "{}/reports/worktime/today?host={}&{}&allow_stale=1", + config.report_base, + urlencoding::encode(host), + date_param + ); + match get_json(client, &url) { + Ok(payload) => payload + .get("true_active_apps") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(), + Err(err) => { + eprintln!( + "[aw-worktime-influx-exporter] true-active fetch failed for host={host} day={day}: {err}" + ); + Vec::new() + } + } +} + +fn build_report_summary(rows: &[DailyRow]) -> (i64, i64, String) { + if rows.is_empty() { + return (0, 0, String::new()); + } + let total_active_seconds = rows.iter().map(|row| row.active_seconds).sum(); + let top_user = rows + .iter() + .max_by_key(|row| row.active_seconds) + .map(|row| row.user.clone()) + .unwrap_or_default(); + (rows.len() as i64, total_active_seconds, top_user) +} + +fn build_lines_for_day( + client: &Client, + config: &Config, + host: &str, + day: &str, +) -> Result> { + let report_date = resolve_report_date(day, config.report_offset)?; + let (start, end_inclusive, end_exclusive) = report_bounds(report_date, config.report_offset)?; + let daily_ts = timestamp_ns(start); + let events = fetch_events_for_date(client, config, host, start, end_inclusive); + let daily_rows = aggregate_daily_rows(&events, start, end_exclusive, host, config); + let hourly_rows = aggregate_hourly_rows(&events, end_exclusive, host, config); + let true_active_apps = fetch_true_active_apps(client, config, host, day, report_date); + let mut lines = Vec::new(); + + for row in &daily_rows { + if let Some(line) = line( + "aw_rdp_worktime_daily", + vec![ + ("host", host.to_string()), + ("user", row.user.clone()), + ("user_id", row.user_id.clone()), + ("report_date", report_date.to_string()), + ], + vec![ + ("active_seconds", FieldValue::Int(row.active_seconds)), + ("idle_seconds", FieldValue::Int(row.idle_seconds)), + ("sessions_count", FieldValue::Int(row.sessions_count)), + ("samples_count", FieldValue::Int(row.samples_count)), + ("active_samples", FieldValue::Int(row.active_samples)), + ], + daily_ts, + ) { + lines.push(line); + } + } + + for row in &hourly_rows { + if let Some(line) = line( + "aw_rdp_worktime_hourly", + vec![ + ("host", host.to_string()), + ("user", row.user.clone()), + ("user_id", row.user_id.clone()), + ("report_date", row.report_date.clone()), + ("hour_local", row.hour_local.clone()), + ], + vec![("active_seconds", FieldValue::Int(row.active_seconds))], + timestamp_ns(row.bucket_start_utc), + ) { + lines.push(line); + } + } + + let (users_count, total_active_seconds, top_user) = build_report_summary(&daily_rows); + if let Some(line) = line( + "aw_rdp_worktime_summary_daily", + vec![ + ("host", host.to_string()), + ("report_date", report_date.to_string()), + ], + vec![ + ("users_count", FieldValue::Int(users_count)), + ( + "total_active_seconds", + FieldValue::Int(total_active_seconds), + ), + ("top_user", FieldValue::String(top_user)), + ], + daily_ts, + ) { + lines.push(line); + } + + for app in &true_active_apps { + if let Some(line) = line( + "aw_true_active_app_daily", + vec![ + ("host", host.to_string()), + ("application", s(app.get("application"))), + ("report_date", report_date.to_string()), + ], + vec![ + ( + "proved_work_seconds", + FieldValue::Int(int_value(app.get("proved_work_seconds"))), + ), + ( + "evidence_events", + FieldValue::Int(int_value(app.get("evidence_events"))), + ), + ( + "proved_work_human", + FieldValue::String(s(app.get("proved_work_human"))), + ), + ( + "proved_work_hhmm", + FieldValue::String(s(app.get("proved_work_hhmm"))), + ), + ("last_action", FieldValue::String(s(app.get("last_action")))), + ( + "last_action_local", + FieldValue::String(s(app.get("last_action_local"))), + ), + ( + "last_action_utc", + FieldValue::String(s(app.get("last_action_utc"))), + ), + ], + daily_ts, + ) { + lines.push(line); + } + } + + Ok(lines) +} + +fn write_lines(client: &Client, config: &Config, lines: &[String]) -> Result { + if lines.is_empty() { + return Ok(0); + } + if config.influx_url.is_empty() || config.influx_token.is_empty() { + bail!("InfluxDB destination is not configured"); + } + let url = format!( + "{}/api/v2/write?org={}&bucket={}&precision=ns", + config.influx_url, + urlencoding::encode(&config.influx_org), + urlencoding::encode(&config.influx_bucket) + ); + let payload = format!("{}\n", lines.join("\n")); + client + .post(url) + .header("Authorization", format!("Token {}", config.influx_token)) + .header("Content-Type", "text/plain; charset=utf-8") + .body(payload) + .send() + .and_then(|resp| resp.error_for_status()) + .context("InfluxDB write failed")?; + Ok(lines.len()) +} + +fn run(cli: &Cli) -> Result { + let config = load_config(); + if !config.influx_enabled && !cli.dry_run { + return Ok(RunSummary { + ok: true, + enabled: false, + dry_run: false, + hosts: config.hosts, + days: config.days, + lines: 0, + written: 0, + bucket: config.influx_bucket, + error: None, + }); + } + let client = Client::builder() + .timeout(Duration::from_secs(cli.timeout_seconds)) + .no_proxy() + .build() + .context("build HTTP client")?; + let mut lines = Vec::new(); + for host in &config.hosts { + for day in &config.days { + lines.extend(build_lines_for_day(&client, &config, host, day)?); + } + } + let written = if cli.dry_run { + 0 + } else { + write_lines(&client, &config, &lines)? + }; + Ok(RunSummary { + ok: true, + enabled: config.influx_enabled, + dry_run: cli.dry_run, + hosts: config.hosts, + days: config.days, + lines: lines.len(), + written, + bucket: config.influx_bucket, + error: None, + }) +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + match run(&cli) { + Ok(summary) => { + if cli.json || cli.dry_run { + println!("{}", serde_json::to_string_pretty(&summary)?); + } else if !summary.enabled { + eprintln!("[aw-worktime-influx-exporter] disabled by AW_WORKTIME_INFLUX_ENABLED"); + } else { + eprintln!( + "[aw-worktime-influx-exporter] wrote {} points to {}", + summary.written, summary.bucket + ); + } + Ok(()) + } + Err(err) => { + if cli.json { + println!( + "{}", + serde_json::to_string_pretty(&RunSummary { + ok: false, + enabled: false, + dry_run: cli.dry_run, + hosts: Vec::new(), + days: Vec::new(), + lines: 0, + written: 0, + bucket: String::new(), + error: Some(err.to_string()), + })? + ); + } + Err(err) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn test_config() -> Config { + Config { + aw_api_base: "http://127.0.0.1:5600/api/0".to_string(), + report_base: "http://127.0.0.1:5610".to_string(), + influx_url: String::new(), + influx_org: DEFAULT_INFLUX_ORG.to_string(), + influx_bucket: DEFAULT_INFLUX_BUCKET.to_string(), + influx_token: String::new(), + influx_enabled: false, + hosts: vec![DEFAULT_HOST.to_string()], + days: vec!["today".to_string()], + report_offset: FixedOffset::east_opt(3 * 3600).unwrap(), + default_sample_seconds: 30.0, + max_sample_seconds: 300.0, + events_limit: 50_000, + } + } + + #[test] + fn line_escapes_tags_and_string_fields() { + let out = line( + "m", + vec![("host", "A B,C=D".to_string())], + vec![("text", FieldValue::String("a\"b".to_string()))], + 10, + ) + .unwrap(); + assert_eq!(out, "m,host=A\\ B\\,C\\=D text=\"a\\\"b\" 10"); + } + + #[test] + fn aggregates_daily_and_hourly_active_samples() { + let config = test_config(); + let report_date = NaiveDate::from_ymd_opt(2026, 5, 14).unwrap(); + let (start, _end_inclusive, end_exclusive) = + report_bounds(report_date, config.report_offset).unwrap(); + let events = vec![json!({ + "timestamp": "2026-05-14T06:00:00Z", + "duration": 0.0, + "data": { + "username": "user5", + "userId": "WORKGROUP\\user5", + "sessionId": 4, + "state": "Активно", + "active": true, + "sampleSeconds": 1800 + } + })]; + + let daily = aggregate_daily_rows(&events, start, end_exclusive, "SHARKON2025", &config); + let hourly = aggregate_hourly_rows(&events, end_exclusive, "SHARKON2025", &config); + + assert_eq!(daily.len(), 1); + assert_eq!(daily[0].user_id, "SHARKON2025\\user5"); + assert_eq!(daily[0].active_seconds, 300); + assert_eq!(daily[0].active_samples, 1); + assert_eq!(hourly.len(), 1); + assert_eq!(hourly[0].report_date, "2026-05-14"); + assert_eq!(hourly[0].hour_local, "09:00"); + assert_eq!(hourly[0].active_seconds, 300); + } + + #[test] + fn resolves_moscow_report_bounds() { + let offset = FixedOffset::east_opt(3 * 3600).unwrap(); + let report_date = NaiveDate::from_ymd_opt(2026, 6, 1).unwrap(); + let (start, end_inclusive, end_exclusive) = report_bounds(report_date, offset).unwrap(); + assert_eq!(format_aw_time(start), "2026-05-31T21:00:00Z"); + assert_eq!(format_aw_time(end_inclusive), "2026-06-01T20:59:59Z"); + assert_eq!(format_aw_time(end_exclusive), "2026-06-01T21:00:00Z"); + } +} diff --git a/adk-rust/crates/worktime-prewarm/Cargo.toml b/adk-rust/crates/worktime-prewarm/Cargo.toml new file mode 100644 index 0000000..7012bba --- /dev/null +++ b/adk-rust/crates/worktime-prewarm/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "worktime-prewarm" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +publish = false + +[dependencies] +anyhow.workspace = true +chrono.workspace = true +clap.workspace = true +reqwest.workspace = true +serde.workspace = true +serde_json.workspace = true +urlencoding.workspace = true diff --git a/adk-rust/crates/worktime-prewarm/src/main.rs b/adk-rust/crates/worktime-prewarm/src/main.rs new file mode 100644 index 0000000..f57f7a8 --- /dev/null +++ b/adk-rust/crates/worktime-prewarm/src/main.rs @@ -0,0 +1,348 @@ +use std::{thread, time::Duration}; + +use anyhow::{Context, Result}; +use chrono::Local; +use clap::Parser; +use reqwest::blocking::Client; +use serde::Serialize; + +const DEFAULT_BASE_URL: &str = "http://127.0.0.1:5610"; +const DEFAULT_HOST: &str = "SHARKON2025"; + +#[derive(Debug, Parser)] +#[command(about = "AW Worktime report cache prewarm")] +struct Cli { + #[arg(long)] + dry_run: bool, + + #[arg(long)] + json: bool, +} + +#[derive(Debug, Clone)] +struct Config { + base_url: String, + timeout_seconds: f64, + health_timeout_seconds: f64, + ready_timeout_seconds: f64, + ready_interval_seconds: f64, + host: String, + profile: String, +} + +#[derive(Debug, Serialize)] +struct ProbeResult { + url: String, + ok: bool, + code: Option, + cache: Option, + reason: Option, +} + +#[derive(Debug, Serialize)] +struct RunSummary { + ok: bool, + dry_run: bool, + profile: String, + host: String, + urls: Vec, + failures: usize, + readiness_ok: bool, + probes: Vec, +} + +fn env(name: &str, fallback: &str) -> String { + std::env::var(name) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| fallback.to_string()) +} + +fn env_f64(name: &str, fallback: f64) -> f64 { + std::env::var(name) + .ok() + .and_then(|value| value.trim().parse().ok()) + .filter(|value| *value > 0.0) + .unwrap_or(fallback) +} + +fn load_config() -> Config { + Config { + base_url: env("WORKTIME_BASE_URL", DEFAULT_BASE_URL) + .trim_end_matches('/') + .to_string(), + timeout_seconds: env_f64("WORKTIME_PREWARM_TIMEOUT_SECONDS", 45.0), + health_timeout_seconds: env_f64("WORKTIME_PREWARM_HEALTH_TIMEOUT_SECONDS", 10.0), + ready_timeout_seconds: env_f64("WORKTIME_PREWARM_READY_TIMEOUT_SECONDS", 60.0), + ready_interval_seconds: env_f64("WORKTIME_PREWARM_READY_INTERVAL_SECONDS", 2.0), + host: env( + "WORKTIME_PREWARM_HOST", + &env("AW_WORKTIME_HOST", DEFAULT_HOST), + ), + profile: env("WORKTIME_PREWARM_PROFILE", "full"), + } +} + +fn log(message: &str) { + println!("{} {}", Local::now().format("%F %T"), message); +} + +fn duration(seconds: f64) -> Duration { + Duration::from_secs_f64(seconds.max(0.001)) +} + +fn profile_urls(config: &Config) -> Option> { + let host = urlencoding::encode(&config.host); + let base = &config.base_url; + let full = vec![ + format!("{base}/reports/worktime/today?day=today&format=csv&host={host}"), + format!("{base}/reports/worktime/today?day=today&format=json&host={host}"), + format!("{base}/reports/worktime/today?day=today&format=html&host={host}"), + format!("{base}/reports/worktime/management?day=today&format=csv&host={host}"), + format!("{base}/reports/worktime/management?day=today&format=json&host={host}"), + format!("{base}/reports/worktime/management?day=today&format=html&host={host}"), + ]; + let startup = vec![ + format!("{base}/reports/worktime/today?day=today&format=csv&host={host}"), + format!("{base}/reports/worktime/today?day=today&format=json&host={host}"), + format!("{base}/reports/worktime/management?day=today&format=json&host={host}"), + format!("{base}/reports/worktime/management?day=today&format=csv&host={host}"), + ]; + match config.profile.as_str() { + "full" => Some(full), + "startup" => Some(startup), + _ => None, + } +} + +fn build_client(timeout_seconds: f64) -> Result { + Client::builder() + .timeout(duration(timeout_seconds)) + .no_proxy() + .build() + .context("build HTTP client") +} + +fn probe(url: &str, timeout_seconds: f64) -> ProbeResult { + let client = match build_client(timeout_seconds) { + Ok(client) => client, + Err(_) => { + return ProbeResult { + url: url.to_string(), + ok: false, + code: None, + cache: None, + reason: None, + }; + } + }; + match client.get(url).send() { + Ok(resp) => { + let code = resp.status().as_u16(); + let cache = resp + .headers() + .get("x-aw-worktime-cache") + .and_then(|value| value.to_str().ok()) + .map(ToOwned::to_owned); + let reason = resp + .headers() + .get("x-aw-worktime-cache-reason") + .and_then(|value| value.to_str().ok()) + .map(ToOwned::to_owned); + ProbeResult { + url: url.to_string(), + ok: (200..300).contains(&code), + code: Some(code), + cache, + reason, + } + } + Err(_) => ProbeResult { + url: url.to_string(), + ok: false, + code: None, + cache: None, + reason: None, + }, + } +} + +fn log_probe(result: &ProbeResult) { + if result.ok { + log(&format!( + "ok code={} cache={} reason={} url={}", + result.code.unwrap_or(0), + result.cache.as_deref().unwrap_or("none"), + result.reason.as_deref().unwrap_or("none"), + result.url + )); + } else { + log(&format!( + "warn code={} url={}", + result + .code + .map(|code| code.to_string()) + .unwrap_or_else(|| "000".to_string()), + result.url + )); + } +} + +fn wait_until_ready(config: &Config) -> bool { + let deadline = std::time::Instant::now() + duration(config.ready_timeout_seconds); + loop { + let result = probe( + &format!("{}/health", config.base_url), + config.health_timeout_seconds, + ); + log_probe(&result); + if result.ok { + return true; + } + if std::time::Instant::now() >= deadline { + return false; + } + thread::sleep(duration(config.ready_interval_seconds)); + } +} + +fn run(cli: &Cli) -> RunSummary { + let config = load_config(); + let Some(urls) = profile_urls(&config) else { + log(&format!("unknown profile={}", config.profile)); + return RunSummary { + ok: true, + dry_run: cli.dry_run, + profile: config.profile, + host: config.host, + urls: Vec::new(), + failures: 0, + readiness_ok: false, + probes: Vec::new(), + }; + }; + + if cli.dry_run { + return RunSummary { + ok: true, + dry_run: true, + profile: config.profile, + host: config.host, + urls, + failures: 0, + readiness_ok: false, + probes: Vec::new(), + }; + } + + if !wait_until_ready(&config) { + log("health readiness timed out; skip prewarm"); + return RunSummary { + ok: true, + dry_run: false, + profile: config.profile, + host: config.host, + urls, + failures: 0, + readiness_ok: false, + probes: Vec::new(), + }; + } + + let mut probes = Vec::new(); + let mut failures = 0; + for url in &urls { + let result = probe(url, config.timeout_seconds); + if !result.ok { + failures += 1; + } + log_probe(&result); + probes.push(result); + } + + if failures > 0 { + log(&format!( + "completed profile={} with failures={failures}", + config.profile + )); + } else { + log(&format!( + "completed profile={} successfully", + config.profile + )); + } + + RunSummary { + ok: true, + dry_run: false, + profile: config.profile, + host: config.host, + urls, + failures, + readiness_ok: true, + probes, + } +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + let summary = run(&cli); + if cli.json || cli.dry_run { + println!("{}", serde_json::to_string_pretty(&summary)?); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn full_profile_has_legacy_urls() { + let config = Config { + base_url: DEFAULT_BASE_URL.to_string(), + timeout_seconds: 45.0, + health_timeout_seconds: 10.0, + ready_timeout_seconds: 60.0, + ready_interval_seconds: 2.0, + host: DEFAULT_HOST.to_string(), + profile: "full".to_string(), + }; + let urls = profile_urls(&config).unwrap(); + assert_eq!(urls.len(), 6); + assert!(urls[0].contains("format=csv")); + assert!(urls[5].contains("/reports/worktime/management")); + } + + #[test] + fn startup_profile_has_legacy_urls() { + let config = Config { + base_url: DEFAULT_BASE_URL.to_string(), + timeout_seconds: 45.0, + health_timeout_seconds: 10.0, + ready_timeout_seconds: 60.0, + ready_interval_seconds: 2.0, + host: DEFAULT_HOST.to_string(), + profile: "startup".to_string(), + }; + let urls = profile_urls(&config).unwrap(); + assert_eq!(urls.len(), 4); + assert!(urls[2].contains("format=json")); + assert!(urls[3].contains("format=csv")); + } + + #[test] + fn unknown_profile_is_ignored_like_legacy() { + let config = Config { + base_url: DEFAULT_BASE_URL.to_string(), + timeout_seconds: 45.0, + health_timeout_seconds: 10.0, + ready_timeout_seconds: 60.0, + ready_interval_seconds: 2.0, + host: DEFAULT_HOST.to_string(), + profile: "bad".to_string(), + }; + assert!(profile_urls(&config).is_none()); + } +} diff --git a/adk-rust/crates/worktime-ui-bridge/Cargo.toml b/adk-rust/crates/worktime-ui-bridge/Cargo.toml new file mode 100644 index 0000000..bca0586 --- /dev/null +++ b/adk-rust/crates/worktime-ui-bridge/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "worktime-ui-bridge" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +publish = false + +[dependencies] +anyhow.workspace = true +chrono.workspace = true +clap.workspace = true +reqwest.workspace = true +serde.workspace = true +serde_json.workspace = true diff --git a/adk-rust/crates/worktime-ui-bridge/src/main.rs b/adk-rust/crates/worktime-ui-bridge/src/main.rs new file mode 100644 index 0000000..f4cd608 --- /dev/null +++ b/adk-rust/crates/worktime-ui-bridge/src/main.rs @@ -0,0 +1,997 @@ +use std::{ + collections::{BTreeMap, BTreeSet}, + fs, + path::{Path, PathBuf}, + thread, + time::Duration, +}; + +use anyhow::{Context, Result, anyhow}; +use chrono::{DateTime, SecondsFormat, TimeDelta, Utc}; +use clap::Parser; +use reqwest::header::{CONNECTION, HeaderMap, HeaderValue}; +use reqwest::{Method, StatusCode, blocking::Client}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +const DEFAULT_AW_URL: &str = "http://127.0.0.1:5600"; +const DEFAULT_HOST: &str = "SHARKON2025"; +const DEFAULT_STATE_PATH: &str = "/var/lib/activitywatch/aw-worktime-ui-bridge-state.json"; +const BRIDGE_SOURCE: &str = "aw-worktime-ui-bridge"; + +#[derive(Debug, Parser)] +#[command(about = "AW Worktime UI bridge (sessions -> afk/window)")] +struct Cli { + #[arg(long)] + dry_run: bool, + + #[arg(long)] + json: bool, +} + +#[derive(Debug, Clone)] +struct Config { + aw_url: String, + host: String, + state_path: PathBuf, + timeout_seconds: f64, + watcher_fallback_enabled: bool, + watcher_fallback_stale_seconds: f64, + collector_health_max_age_seconds: f64, + collector_health_query_limit: usize, + foreground_context_cache_seconds: f64, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +struct AwEvent { + timestamp: Option, + #[serde(default)] + duration: Option, + #[serde(default)] + data: Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct State { + last_ts: String, + #[serde(skip_serializing_if = "Option::is_none")] + last_foreground_context: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +struct ForegroundContext { + app: String, + title: String, + timestamp: Option, +} + +#[derive(Debug, Serialize)] +struct RunSummary { + ok: bool, + dry_run: bool, + host: String, + input_events: usize, + posted_afk: usize, + posted_win: usize, + watcher_afk_posted: usize, + watcher_win_posted: usize, + last_ts: Option, + state_saved: bool, +} + +struct AwClient { + base_url: String, + client: Client, +} + +fn env(name: &str, fallback: &str) -> String { + std::env::var(name) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| fallback.to_string()) +} + +fn env_bool(name: &str, fallback: bool) -> bool { + match std::env::var(name) + .ok() + .map(|value| value.trim().to_ascii_lowercase()) + { + Some(value) if !value.is_empty() => matches!(value.as_str(), "1" | "true" | "yes" | "on"), + _ => fallback, + } +} + +fn env_f64(name: &str, fallback: f64) -> f64 { + std::env::var(name) + .ok() + .and_then(|value| value.trim().parse().ok()) + .unwrap_or(fallback) +} + +fn env_usize(name: &str, fallback: usize) -> usize { + std::env::var(name) + .ok() + .and_then(|value| value.trim().parse().ok()) + .unwrap_or(fallback) +} + +fn load_config() -> Config { + Config { + aw_url: env("AW_SERVER_URL", DEFAULT_AW_URL) + .trim_end_matches('/') + .to_string(), + host: env("AW_WORKTIME_HOST", DEFAULT_HOST), + state_path: PathBuf::from(env("AW_WORKTIME_UI_BRIDGE_STATE", DEFAULT_STATE_PATH)), + timeout_seconds: env_f64("AW_WORKTIME_UI_BRIDGE_TIMEOUT", 60.0), + watcher_fallback_enabled: env_bool("AW_WORKTIME_UI_BRIDGE_WATCHER_FALLBACK", true), + watcher_fallback_stale_seconds: env_f64( + "AW_WORKTIME_UI_BRIDGE_WATCHER_STALE_SECONDS", + 600.0, + ), + collector_health_max_age_seconds: env_f64( + "AW_WORKTIME_UI_BRIDGE_COLLECTOR_HEALTH_MAX_AGE_SECONDS", + 300.0, + ), + collector_health_query_limit: env_usize( + "AW_WORKTIME_UI_BRIDGE_COLLECTOR_HEALTH_QUERY_LIMIT", + 200, + ), + foreground_context_cache_seconds: env_f64( + "AW_WORKTIME_UI_BRIDGE_FOREGROUND_CACHE_SECONDS", + 900.0, + ), + } +} + +impl Config { + fn sessions_bucket(&self) -> String { + format!("aw-worktime-sessions_{}", self.host) + } + fn afk_bucket(&self) -> String { + format!("aw-rdp-afk_{}", self.host) + } + fn window_bucket(&self) -> String { + format!("aw-rdp-window_{}", self.host) + } + fn watcher_afk_bucket(&self) -> String { + format!("aw-watcher-afk_{}", self.host) + } + fn watcher_window_bucket(&self) -> String { + format!("aw-watcher-window_{}", self.host) + } + fn web_category_bucket(&self) -> String { + format!("aw-detmir-web-category_{}", self.host) + } +} + +impl AwClient { + fn new(config: &Config) -> Result { + let mut headers = HeaderMap::new(); + headers.insert(CONNECTION, HeaderValue::from_static("close")); + let client = Client::builder() + .timeout(Duration::from_secs_f64(config.timeout_seconds.max(0.001))) + .no_proxy() + .pool_max_idle_per_host(0) + .default_headers(headers) + .build() + .context("build HTTP client")?; + Ok(Self { + base_url: config.aw_url.clone(), + client, + }) + } + + fn url(&self, path: &str) -> String { + format!("{}{}", self.base_url, path) + } + + fn request_json( + &self, + method: Method, + path: &str, + payload: Option, + ) -> Result> { + let mut last_error = None; + let mut response = None; + for attempt in 1..=3 { + let mut request = self.client.request(method.clone(), self.url(path)); + if let Some(payload) = payload.clone() { + request = request.json(&payload); + } + match request.send() { + Ok(ok_response) => { + response = Some(ok_response); + break; + } + Err(error) => { + last_error = Some(error); + if attempt < 3 { + eprintln!("warn request={path} retry_after_send_error"); + thread::sleep(Duration::from_millis(750)); + } + } + } + } + let response = match response { + Some(response) => response, + None => { + let error = last_error.expect("error exists after failed request"); + return Err(error).with_context(|| format!("request ActivityWatch {path}")); + } + }; + let status = response.status(); + if status == StatusCode::NOT_FOUND { + return Err(anyhow!("not found: {path}")); + } + if !status.is_success() { + return Err(anyhow!("ActivityWatch {path} returned HTTP {status}")); + } + let bytes = response.bytes().context("read ActivityWatch response")?; + if bytes.is_empty() { + Ok(None) + } else { + serde_json::from_slice(&bytes) + .map(Some) + .with_context(|| format!("decode ActivityWatch JSON from {path}")) + } + } + + fn get_events(&self, bucket_id: &str, limit: usize) -> Result> { + let path = format!("/api/0/buckets/{bucket_id}/events?limit={limit}"); + match self.request_json(Method::GET, &path, None) { + Ok(Some(value)) => serde_json::from_value(value).context("decode AW events"), + Ok(None) => Ok(Vec::new()), + Err(error) if error.to_string().starts_with("not found:") => Ok(Vec::new()), + Err(error) => Err(error), + } + } + + fn get_latest_bucket_event(&self, bucket_id: &str) -> Result> { + Ok(self.get_events(bucket_id, 1)?.into_iter().next()) + } + + fn ensure_bucket( + &self, + bucket_id: &str, + event_type: &str, + client_name: &str, + host: &str, + ) -> Result<()> { + let payload = json!({ + "client": client_name, + "type": event_type, + "hostname": host, + }); + let path = format!("/api/0/buckets/{bucket_id}"); + let response = match self.client.post(self.url(&path)).json(&payload).send() { + Ok(response) => response, + Err(error) => { + eprintln!("warn ensure_bucket={bucket_id} skipped: {error}"); + return Ok(()); + } + }; + let status = response.status(); + if status.is_success() + || status == StatusCode::NOT_MODIFIED + || status == StatusCode::CONFLICT + { + Ok(()) + } else { + eprintln!("warn ensure_bucket={bucket_id} returned HTTP {status}; continuing"); + Ok(()) + } + } + + fn post_events(&self, bucket_id: &str, events: &[AwEvent]) -> Result<()> { + let path = format!("/api/0/buckets/{bucket_id}/events"); + self.request_json(Method::POST, &path, Some(json!(events))) + .map(|_| ()) + } +} + +fn load_state(path: &Path) -> State { + let fallback = State { + last_ts: "1970-01-01T00:00:00Z".to_string(), + last_foreground_context: None, + }; + let Ok(raw) = fs::read_to_string(path) else { + return fallback; + }; + let Ok(state) = serde_json::from_str::(&raw) else { + return fallback; + }; + if state.last_ts.trim().is_empty() { + fallback + } else { + state + } +} + +fn save_state(path: &Path, state: &State) -> Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; + } + let tmp = PathBuf::from(format!("{}.tmp", path.display())); + let raw = serde_json::to_vec(state).context("encode state")?; + fs::write(&tmp, raw).with_context(|| format!("write {}", tmp.display()))?; + fs::rename(&tmp, path).with_context(|| format!("replace {}", path.display()))?; + Ok(()) +} + +fn parse_iso_utc(ts: &str) -> Result> { + DateTime::parse_from_rfc3339(ts) + .map(|parsed| parsed.with_timezone(&Utc)) + .with_context(|| format!("parse timestamp {ts}")) +} + +fn to_iso_utc(dt: DateTime) -> String { + dt.to_rfc3339_opts(SecondsFormat::Secs, true) +} + +fn normalize_event_ts(ts: &str) -> String { + parse_iso_utc(ts) + .map(|dt| to_iso_utc(dt - TimeDelta::nanoseconds(dt.timestamp_subsec_nanos().into()))) + .unwrap_or_else(|_| ts.to_string()) +} + +fn value_string(data: &Value, key: &str) -> String { + match data.get(key) { + Some(Value::String(value)) => value.trim().to_string(), + Some(Value::Number(value)) => value.to_string(), + Some(Value::Bool(value)) => value.to_string(), + Some(Value::Null) | None => String::new(), + Some(value) => value.to_string().trim_matches('"').trim().to_string(), + } +} + +fn value_i64(data: &Value, key: &str) -> Option { + match data.get(key) { + Some(Value::Number(value)) => value.as_i64(), + Some(Value::String(value)) => value.trim().parse().ok(), + _ => None, + } +} + +fn is_session_active(data: &Value) -> bool { + if data.get("active").and_then(Value::as_bool).unwrap_or(false) { + return true; + } + let state = value_string(data, "state").to_lowercase(); + if state == "active" || state == "активно" { + return true; + } + if state == "unknown" { + let sid = value_i64(data, "sessionId").unwrap_or(-1); + let user = value_string(data, "username").to_lowercase(); + let session_name = value_string(data, "sessionName").to_lowercase(); + return sid > 0 + && !user.is_empty() + && !user.ends_with('$') + && (session_name.starts_with("rdp-") || session_name == "console"); + } + false +} + +fn build_window_title(users: &[String], active_count: usize) -> String { + if users.is_empty() { + "RDP idle".to_string() + } else { + format!("RDP active ({active_count}): {}", users.join(", ")) + } +} + +fn get_latest_active_session_ids(events: &[AwEvent]) -> BTreeSet { + let mut grouped: BTreeMap, Vec<&AwEvent>> = BTreeMap::new(); + for event in events { + let Some(ts) = event.timestamp.as_deref() else { + continue; + }; + let Ok(parsed) = parse_iso_utc(ts) else { + continue; + }; + grouped.entry(parsed).or_default().push(event); + } + let Some((_, latest_events)) = grouped.iter().next_back() else { + return BTreeSet::new(); + }; + latest_events + .iter() + .filter(|event| is_session_active(&event.data)) + .filter_map(|event| value_i64(&event.data, "sessionId")) + .collect() +} + +fn normalize_foreground_context(data: &Value) -> Option { + let foreground_process = value_string(data, "foregroundProcess"); + let foreground_title = value_string(data, "foregroundTitle"); + if foreground_process.is_empty() && foreground_title.is_empty() { + return None; + } + let app = if foreground_process.ends_with(".exe") { + foreground_process.clone() + } else { + format!("{foreground_process}.exe") + }; + Some(ForegroundContext { + app, + title: if foreground_title.is_empty() { + foreground_process + } else { + foreground_title + }, + timestamp: None, + }) +} + +fn get_latest_foreground_context( + aw: &AwClient, + config: &Config, + now_utc: DateTime, + active_session_ids: &BTreeSet, + state: &State, +) -> Result> { + let mut recent_candidates: Vec<(Option, DateTime, ForegroundContext)> = Vec::new(); + for event in aw + .get_events( + &config.web_category_bucket(), + config.collector_health_query_limit, + )? + .into_iter() + .rev() + { + let data = &event.data; + if value_string(data, "signalType").to_lowercase() != "collector_health" { + continue; + } + let Some(ts) = event.timestamp.as_deref() else { + continue; + }; + let Ok(event_dt) = parse_iso_utc(ts) else { + continue; + }; + if (now_utc - event_dt).num_milliseconds() as f64 / 1000.0 + > config.collector_health_max_age_seconds + { + continue; + } + let Some(normalized) = normalize_foreground_context(data) else { + continue; + }; + recent_candidates.push((value_i64(data, "sessionId"), event_dt, normalized)); + } + + for (session_id, event_dt, mut normalized) in recent_candidates.iter().cloned() { + if !active_session_ids.is_empty() + && session_id.is_some_and(|sid| active_session_ids.contains(&sid)) + { + normalized.timestamp = Some(to_iso_utc(event_dt)); + return Ok(Some(normalized)); + } + } + + if let Some((_, event_dt, mut normalized)) = recent_candidates.into_iter().next() { + normalized.timestamp = Some(to_iso_utc(event_dt)); + return Ok(Some(normalized)); + } + + if let Some(cached) = &state.last_foreground_context { + if let Some(cached_ts) = cached + .timestamp + .as_deref() + .filter(|value| !value.trim().is_empty()) + { + if let Ok(cached_dt) = parse_iso_utc(cached_ts) { + let age = (now_utc - cached_dt).num_milliseconds() as f64 / 1000.0; + if age <= config.foreground_context_cache_seconds + && (!cached.app.trim().is_empty() || !cached.title.trim().is_empty()) + { + return Ok(Some(ForegroundContext { + app: if cached.app.trim().is_empty() { + "RDP".to_string() + } else { + cached.app.trim().to_string() + }, + title: if cached.title.trim().is_empty() { + cached.app.trim().to_string() + } else { + cached.title.trim().to_string() + }, + timestamp: Some(cached_ts.to_string()), + })); + } + } + } + } + Ok(None) +} + +fn transform( + events: &[AwEvent], + foreground_context: Option<&ForegroundContext>, +) -> (Vec, Vec, Option) { + let mut out_afk = Vec::new(); + let mut out_win = Vec::new(); + let mut last_ts = None; + let mut grouped: BTreeMap> = BTreeMap::new(); + for event in events { + let Some(ts) = event.timestamp.as_deref() else { + continue; + }; + grouped + .entry(normalize_event_ts(ts)) + .or_default() + .push(event); + } + let ordered_ts: Vec = grouped.keys().cloned().collect(); + let parsed_ts: BTreeMap>> = ordered_ts + .iter() + .map(|ts| (ts.clone(), parse_iso_utc(ts).ok())) + .collect(); + + for (idx, ts) in ordered_ts.iter().enumerate() { + let rows = &grouped[ts]; + let src_duration = rows + .iter() + .map(|event| event.duration.unwrap_or(0.0)) + .fold(0.0, f64::max); + let mut duration = src_duration; + let cur_dt = parsed_ts.get(ts).and_then(|dt| *dt); + let next_dt = ordered_ts + .get(idx + 1) + .and_then(|next_ts| parsed_ts.get(next_ts)) + .and_then(|dt| *dt); + let next_gap = match (cur_dt, next_dt) { + (Some(cur_dt), Some(next_dt)) => { + Some(((next_dt - cur_dt).num_milliseconds() as f64 / 1000.0).max(0.0)) + } + _ => None, + }; + if duration <= 0.0 { + if let Some(next_gap) = next_gap { + duration = next_gap; + } + if duration <= 0.0 { + duration = 10.0; + } + } else if let Some(next_gap) = next_gap.filter(|value| *value > 0.0) { + duration = duration.min(next_gap); + } + duration = duration.min(30.0); + + let mut active_users: BTreeSet = BTreeSet::new(); + for row in rows { + let user = value_string(&row.data, "username"); + if !user.is_empty() && is_session_active(&row.data) { + active_users.insert(user); + } + } + let active_users: Vec = active_users.into_iter().collect(); + let active_count = active_users.len(); + let is_active = active_count > 0; + + out_afk.push(AwEvent { + timestamp: Some(ts.clone()), + duration: Some(duration), + data: json!({ + "status": if is_active { "not-afk" } else { "afk" }, + "source": BRIDGE_SOURCE, + }), + }); + + let win_data = if is_active { + if let Some(context) = foreground_context { + let mut title = context.title.trim().to_string(); + if active_count > 1 { + let rdp_title = build_window_title(&active_users, active_count); + title = if title.is_empty() { + rdp_title + } else { + format!("{title} | {rdp_title}") + }; + } + json!({ + "app": if context.app.trim().is_empty() { "RDP" } else { context.app.trim() }, + "title": if title.is_empty() { build_window_title(&active_users, active_count) } else { title }, + "source": BRIDGE_SOURCE, + }) + } else { + json!({ + "app": "RDP", + "title": build_window_title(&active_users, active_count), + "source": BRIDGE_SOURCE, + }) + } + } else { + json!({ + "app": "RDP", + "title": build_window_title(&active_users, active_count), + "source": BRIDGE_SOURCE, + }) + }; + out_win.push(AwEvent { + timestamp: Some(ts.clone()), + duration: Some(duration), + data: win_data, + }); + last_ts = Some(ts.clone()); + } + (out_afk, out_win, last_ts) +} + +fn normalize_watcher_window_events(win_events: &[AwEvent]) -> Vec { + let mut normalized = Vec::new(); + for event in win_events { + let app = value_string(&event.data, "app"); + if app.is_empty() || app.to_uppercase() == "RDP" { + continue; + } + let mut data = event.data.clone(); + let title = value_string(&data, "title"); + if let Some((prefix, _)) = title.split_once(" | RDP active (") { + if let Some(map) = data.as_object_mut() { + map.insert( + "title".to_string(), + Value::String(prefix.trim().to_string()), + ); + } + } + let mut cloned = event.clone(); + cloned.data = data; + normalized.push(cloned); + } + normalized +} + +fn get_latest_bucket_event_ts(aw: &AwClient, bucket_id: &str) -> Result>> { + let Some(event) = aw.get_latest_bucket_event(bucket_id)? else { + return Ok(None); + }; + let Some(ts) = event.timestamp.as_deref() else { + return Ok(None); + }; + Ok(parse_iso_utc(ts).ok()) +} + +fn bucket_needs_fallback( + aw: &AwClient, + bucket_id: &str, + now_utc: DateTime, + stale_after_seconds: f64, +) -> Result { + let Some(latest_dt) = get_latest_bucket_event_ts(aw, bucket_id)? else { + return Ok(true); + }; + Ok((now_utc - latest_dt).num_milliseconds() as f64 / 1000.0 >= stale_after_seconds) +} + +fn watcher_window_needs_bridge_sync( + aw: &AwClient, + config: &Config, + now_utc: DateTime, +) -> Result { + let bucket_id = config.watcher_window_bucket(); + let Some(latest_event) = aw.get_latest_bucket_event(&bucket_id)? else { + return Ok(true); + }; + let Some(latest_dt) = get_latest_bucket_event_ts(aw, &bucket_id)? else { + return Ok(true); + }; + if (now_utc - latest_dt).num_milliseconds() as f64 / 1000.0 + >= config.watcher_fallback_stale_seconds + { + return Ok(true); + } + + let source = value_string(&latest_event.data, "source").to_lowercase(); + Ok(source == BRIDGE_SOURCE) +} + +fn run(cli: &Cli) -> Result { + let config = load_config(); + let aw = AwClient::new(&config)?; + let state = load_state(&config.state_path); + let last_dt = parse_iso_utc(&state.last_ts) + .unwrap_or_else(|_| DateTime::from_timestamp(0, 0).expect("valid unix epoch")); + + if !cli.dry_run { + aw.ensure_bucket( + &config.afk_bucket(), + "afkstatus", + BRIDGE_SOURCE, + &config.host, + )?; + aw.ensure_bucket( + &config.window_bucket(), + "currentwindow", + BRIDGE_SOURCE, + &config.host, + )?; + } + + let now_utc = Utc::now(); + let recent = aw.get_events(&config.sessions_bucket(), 5000)?; + if recent.is_empty() { + return Ok(RunSummary { + ok: true, + dry_run: cli.dry_run, + host: config.host, + input_events: 0, + posted_afk: 0, + posted_win: 0, + watcher_afk_posted: 0, + watcher_win_posted: 0, + last_ts: None, + state_saved: false, + }); + } + + let events: Vec = recent + .into_iter() + .filter(|event| { + event + .timestamp + .as_deref() + .and_then(|ts| parse_iso_utc(ts).ok()) + .is_some_and(|ts| ts > last_dt) + }) + .collect(); + if events.is_empty() { + return Ok(RunSummary { + ok: true, + dry_run: cli.dry_run, + host: config.host, + input_events: 0, + posted_afk: 0, + posted_win: 0, + watcher_afk_posted: 0, + watcher_win_posted: 0, + last_ts: None, + state_saved: false, + }); + } + + let active_session_ids = get_latest_active_session_ids(&events); + let foreground_context = + get_latest_foreground_context(&aw, &config, now_utc, &active_session_ids, &state)?; + let (afk_events, win_events, new_last_ts) = transform(&events, foreground_context.as_ref()); + let Some(new_last_ts) = new_last_ts else { + return Ok(RunSummary { + ok: true, + dry_run: cli.dry_run, + host: config.host, + input_events: events.len(), + posted_afk: 0, + posted_win: 0, + watcher_afk_posted: 0, + watcher_win_posted: 0, + last_ts: None, + state_saved: false, + }); + }; + if afk_events.is_empty() || win_events.is_empty() { + return Ok(RunSummary { + ok: true, + dry_run: cli.dry_run, + host: config.host, + input_events: events.len(), + posted_afk: 0, + posted_win: 0, + watcher_afk_posted: 0, + watcher_win_posted: 0, + last_ts: Some(new_last_ts), + state_saved: false, + }); + } + + let watcher_win_events = normalize_watcher_window_events(&win_events); + let mut watcher_afk_posted = 0; + let mut watcher_win_posted = 0; + + if !cli.dry_run { + aw.post_events(&config.afk_bucket(), &afk_events)?; + aw.post_events(&config.window_bucket(), &win_events)?; + } + + if config.watcher_fallback_enabled { + if bucket_needs_fallback( + &aw, + &config.watcher_afk_bucket(), + now_utc, + config.watcher_fallback_stale_seconds, + )? { + watcher_afk_posted = afk_events.len(); + if !cli.dry_run { + aw.ensure_bucket( + &config.watcher_afk_bucket(), + "afkstatus", + "aw-watcher-afk", + &config.host, + )?; + aw.post_events(&config.watcher_afk_bucket(), &afk_events)?; + } + } + if !watcher_win_events.is_empty() + && watcher_window_needs_bridge_sync(&aw, &config, now_utc)? + { + watcher_win_posted = watcher_win_events.len(); + if !cli.dry_run { + aw.ensure_bucket( + &config.watcher_window_bucket(), + "currentwindow", + "aw-watcher-window", + &config.host, + )?; + aw.post_events(&config.watcher_window_bucket(), &watcher_win_events)?; + } + } + } + + let next_context = foreground_context.or(state.last_foreground_context); + let mut next_state = State { + last_ts: new_last_ts.clone(), + last_foreground_context: None, + }; + if let Some(context) = next_context { + next_state.last_foreground_context = Some(ForegroundContext { + app: context.app, + title: context.title, + timestamp: context.timestamp.or_else(|| Some(to_iso_utc(now_utc))), + }); + } + if !cli.dry_run { + save_state(&config.state_path, &next_state)?; + } + + Ok(RunSummary { + ok: true, + dry_run: cli.dry_run, + host: config.host, + input_events: events.len(), + posted_afk: afk_events.len(), + posted_win: win_events.len(), + watcher_afk_posted, + watcher_win_posted, + last_ts: Some(new_last_ts), + state_saved: !cli.dry_run, + }) +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + let summary = run(&cli)?; + if cli.json { + println!("{}", serde_json::to_string_pretty(&summary)?); + } else if summary.posted_afk > 0 || summary.posted_win > 0 { + println!( + "posted_afk={} posted_win={} last_ts={}", + summary.posted_afk, + summary.posted_win, + summary.last_ts.as_deref().unwrap_or("") + ); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn event(ts: &str, duration: f64, data: Value) -> AwEvent { + AwEvent { + timestamp: Some(ts.to_string()), + duration: Some(duration), + data, + } + } + + #[test] + fn latest_active_session_ids_uses_latest_timestamp_group() { + let events = vec![ + event( + "2026-05-27T07:59:25Z", + 0.0, + json!({"sessionId": 2, "state": "Активно", "username": "администратор", "sessionName": "console"}), + ), + event( + "2026-05-27T07:59:30Z", + 0.0, + json!({"sessionId": 2, "state": "Активно", "username": "администратор", "sessionName": "console"}), + ), + event( + "2026-05-27T07:59:30Z", + 0.0, + json!({"sessionId": 3, "state": "Активно", "username": "user5", "sessionName": "rdp-tcp#0"}), + ), + event( + "2026-05-27T07:59:30Z", + 0.0, + json!({"sessionId": 4, "state": "Диск", "username": "user1", "sessionName": ""}), + ), + ]; + assert_eq!( + get_latest_active_session_ids(&events), + BTreeSet::from([2, 3]) + ); + } + + #[test] + fn transform_uses_foreground_context_for_active_sessions() { + let events = vec![event( + "2026-05-27T07:59:30Z", + 0.0, + json!({"username": "user5", "state": "Активно", "sessionId": 3, "sessionName": "rdp-tcp#0"}), + )]; + let ctx = ForegroundContext { + app: "totalcmd.exe".to_string(), + title: "Total Commander 6.01 - HARVEST".to_string(), + timestamp: None, + }; + let (afk_events, win_events, last_ts) = transform(&events, Some(&ctx)); + assert_eq!(value_string(&afk_events[0].data, "status"), "not-afk"); + assert_eq!(value_string(&win_events[0].data, "app"), "totalcmd.exe"); + assert_eq!( + value_string(&win_events[0].data, "title"), + "Total Commander 6.01 - HARVEST" + ); + assert_eq!(last_ts.as_deref(), Some("2026-05-27T07:59:30Z")); + } + + #[test] + fn transform_caps_duration_at_next_timestamp_gap() { + let events = vec![ + event( + "2026-05-27T07:59:30Z", + 5.0, + json!({"username": "user5", "state": "Активно", "sessionId": 3, "sessionName": "rdp-tcp#0"}), + ), + event( + "2026-05-27T07:59:31Z", + 5.0, + json!({"username": "user5", "state": "Активно", "sessionId": 3, "sessionName": "rdp-tcp#0"}), + ), + ]; + let ctx = ForegroundContext { + app: "totalcmd.exe".to_string(), + title: "Total Commander 6.01 - HARVEST".to_string(), + timestamp: None, + }; + let (_, win_events, _) = transform(&events, Some(&ctx)); + assert_eq!(win_events[0].duration, Some(1.0)); + assert_eq!(win_events[1].duration, Some(5.0)); + } + + #[test] + fn normalize_watcher_window_events_strips_rdp_suffix_for_real_apps() { + let events = vec![ + event( + "2026-05-27T08:10:00Z", + 5.0, + json!({"app": "totalcmd.exe", "title": "Total Commander 6.01 - HARVEST | RDP active (2): user5, администратор", "source": BRIDGE_SOURCE}), + ), + event( + "2026-05-27T08:10:05Z", + 5.0, + json!({"app": "RDP", "title": "RDP active (2): user5, администратор", "source": BRIDGE_SOURCE}), + ), + ]; + let normalized = normalize_watcher_window_events(&events); + assert_eq!( + value_string(&normalized[0].data, "title"), + "Total Commander 6.01 - HARVEST" + ); + assert_eq!(normalized.len(), 1); + } + + #[test] + fn unknown_rdp_session_counts_as_active() { + let data = json!({"sessionId": 5, "state": "Unknown", "username": "user5", "sessionName": "rdp-tcp#0"}); + assert!(is_session_active(&data)); + } + + #[test] + fn machine_unknown_session_is_not_active() { + let data = json!({"sessionId": 5, "state": "Unknown", "username": "HOST$", "sessionName": "rdp-tcp#0"}); + assert!(!is_session_active(&data)); + } +} diff --git a/ansible/README.md b/ansible/README.md index 7a1acbb..d251432 100644 --- a/ansible/README.md +++ b/ansible/README.md @@ -122,8 +122,9 @@ Playbook: - выгружает полный `windows/*` toolkit на целевой хост в InnoSetup-compatible каталог `C:\Program Files\AWatch-rus\windows`, включая DLP и `worktime-session-collector.ps1`; - если найден legacy config `C:\ProgramData\ActivityWatch-Phase2\deployment-config.json`, выполняет безопасную миграцию через `migrate-awatch-rus-paths.ps1`: backup, остановка задач, перенос данных, переписывание путей, пересоздание scheduled tasks и validation; - выполняет `deploy-ensemble.ps1` (deploy + hardening/recovery) с policy/rules из AWatch-rus toolkit; -- после deploy принудительно запускает `ActivityWatch Recovery` и все `ActivityWatch Launch *` задачи; -- включает (`Enable-ScheduledTask`) `ActivityWatch Recovery` и все `ActivityWatch Launch *` задачи перед запуском (иначе WebUI может показывать `Active time: 0s`); +- после deploy принудительно запускает `ActivityWatch Recovery` и managed `ActivityWatch Launch *` задачи; +- включает (`Enable-ScheduledTask`) `ActivityWatch Recovery` и managed `ActivityWatch Launch *` задачи перед запуском (иначе WebUI может показывать `Active time: 0s`); +- оставляет `ActivityWatch Recovery` включённым даже при активном `AWatchRusCollectorGuard`: guard является основным контроллером, recovery остаётся fallback/bootstrap path; - выполняет API smoke-check bucket `aw-watcher-afk_` и ожидает свежие события; - выполняет API smoke-check bucket `aw-watcher-window_` и ожидает свежие события (по умолчанию включено); - запускает `validate-deployment.ps1`; diff --git a/ansible/deploy_aw_server.yml b/ansible/deploy_aw_server.yml index 371dac5..4cab6f9 100644 --- a/ansible/deploy_aw_server.yml +++ b/ansible/deploy_aw_server.yml @@ -14,6 +14,7 @@ aw_sw_cleanup_cache_bust: "{{ lookup('file', aw_repo_root + '/aw-server/aw-sw-cleanup.js') | hash('sha1') | truncate(12, true, '') }}" aw_worktime_classes: "{{ lookup('file', aw_repo_root + '/aw-server/settings/classes-worktime.json') | from_json }}" aw_default_views: "{{ lookup('file', aw_repo_root + '/aw-server/settings/views-default.json') | from_json }}" + aw_rust_release_dir: "{{ (lookup('env', 'CARGO_TARGET_DIR') | default(aw_repo_root + '/adk-rust/target', true)) + '/release' }}" tasks: - name: Установить базовые пакеты @@ -29,6 +30,15 @@ state: present update_cache: true + - name: Установить пакеты для browser smoke проверки + ansible.builtin.apt: + name: + - chromium + - nodejs + - node-playwright + state: present + when: aw_browser_smoke_enabled | default(true) | bool + - name: Создать системную группу сервиса ansible.builtin.group: name: "{{ aw_server_group }}" @@ -63,10 +73,13 @@ - "{{ aw_server_data_dir }}/.config/activitywatch" - "{{ aw_server_data_dir }}/.config/activitywatch/aw-server-rust" - "{{ aw_server_data_dir }}/backups" + - "{{ aw_server_data_dir }}/slo" + - "{{ aw_server_data_dir }}/browser-smoke" - "{{ aw_rus_health_state_dir }}" - "{{ aw_rus_health_validation_dir }}" - "{{ aw_server_log_dir }}" - /etc/activitywatch + - /usr/local/lib/aw-rus-browser-smoke - "{{ aw_bootstrap_dir }}" - name: Настроить каталоги ActivityWatch с владельцем сервиса @@ -90,6 +103,8 @@ - "{{ aw_server_data_dir }}/.config/activitywatch" - "{{ aw_server_data_dir }}/.config/activitywatch/aw-server-rust" - "{{ aw_server_data_dir }}/backups" + - "{{ aw_server_data_dir }}/slo" + - "{{ aw_server_data_dir }}/browser-smoke" - "{{ aw_rus_health_state_dir }}" - "{{ aw_rus_health_validation_dir }}" - "{{ aw_server_log_dir }}" @@ -102,6 +117,30 @@ group: root mode: "0755" + - name: Положить legacy prune script в server-side ops bundle + ansible.builtin.copy: + src: "{{ aw_repo_root }}/scripts/legacy/aw-prune-local-state.sh" + dest: /opt/activitywatch/aw-rus-ops/aw-prune-local-state.sh + owner: root + group: root + mode: "0755" + + - name: Проверить локальный Rust prune local state + ansible.builtin.stat: + path: "{{ aw_rust_release_dir }}/aw-prune-local-state" + delegate_to: localhost + register: aw_prune_local_state_rust_binary + become: false + + - name: Установить Rust prune local state + ansible.builtin.copy: + src: "{{ aw_rust_release_dir }}/aw-prune-local-state" + dest: /usr/local/bin/aw-prune-local-state-rust + owner: root + group: root + mode: "0755" + when: aw_prune_local_state_rust_binary.stat.exists | default(false) + - name: Ограничить рост journald на aw-server ansible.builtin.copy: dest: /etc/systemd/journald.conf.d/aw-rus-retention.conf @@ -376,20 +415,138 @@ dest: /opt/activitywatch/aw-server/apply_webui_ru_patch.sh mode: "0755" + - name: Создать server-side ops bundle для ранних health helpers + ansible.builtin.file: + path: /opt/activitywatch/aw-rus-ops + state: directory + owner: root + group: root + mode: "0755" + + - name: Установить wrapper aw-health-check до Influx проверок + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/aw-health-check-wrapper.sh" + dest: /usr/local/bin/aw-health-check + owner: root + group: root + mode: "0755" + + - name: Положить legacy aw-health-check до Influx проверок + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/health-check.sh" + dest: /opt/activitywatch/aw-rus-ops/health-check.sh + owner: root + group: root + mode: "0755" + + - name: Проверить локальный Rust aw-health-check до Influx проверок + ansible.builtin.stat: + path: "{{ aw_rust_release_dir }}/aw-health-check" + delegate_to: localhost + register: aw_health_check_rust_binary_early + become: false + + - name: Установить Rust aw-health-check до Influx проверок + ansible.builtin.copy: + src: "{{ aw_rust_release_dir }}/aw-health-check" + dest: /usr/local/bin/aw-health-check-rust + owner: root + group: root + mode: "0755" + when: aw_health_check_rust_binary_early.stat.exists | default(false) + + - name: Проверить локальный Rust aw-db-health до Influx проверок + ansible.builtin.stat: + path: "{{ aw_rust_release_dir }}/aw-db-health" + delegate_to: localhost + register: aw_db_health_rust_binary_early + become: false + + - name: Установить Rust aw-db-health до Influx проверок + ansible.builtin.copy: + src: "{{ aw_rust_release_dir }}/aw-db-health" + dest: /usr/local/bin/aw-db-health + owner: root + group: root + mode: "0755" + when: aw_db_health_rust_binary_early.stat.exists | default(false) + + - name: Проверить локальный Rust aw-db-maintenance до Influx проверок + ansible.builtin.stat: + path: "{{ aw_rust_release_dir }}/aw-db-maintenance" + delegate_to: localhost + register: aw_db_maintenance_rust_binary_early + become: false + + - name: Установить Rust aw-db-maintenance до Influx проверок + ansible.builtin.copy: + src: "{{ aw_rust_release_dir }}/aw-db-maintenance" + dest: /usr/local/bin/aw-db-maintenance + owner: root + group: root + mode: "0755" + when: aw_db_maintenance_rust_binary_early.stat.exists | default(false) + + - name: Установить aw-db-maintenance service до Influx проверок + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/aw-db-maintenance.service" + dest: /etc/systemd/system/aw-db-maintenance.service + owner: root + group: root + mode: "0644" + when: aw_db_maintenance_rust_binary_early.stat.exists | default(false) + + - name: Установить aw-db-maintenance timer до Influx проверок + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/aw-db-maintenance.timer" + dest: /etc/systemd/system/aw-db-maintenance.timer + owner: root + group: root + mode: "0644" + when: aw_db_maintenance_rust_binary_early.stat.exists | default(false) + + - name: Включить weekly aw-db-maintenance timer до Influx проверок + ansible.builtin.systemd: + name: aw-db-maintenance.timer + enabled: true + state: started + daemon_reload: true + when: aw_db_maintenance_rust_binary_early.stat.exists | default(false) + + - name: Прочитать текущий aw-server.env для сохранения Influx token + ansible.builtin.slurp: + path: /etc/activitywatch/aw-server.env + register: aw_existing_server_env_raw + failed_when: false + no_log: true + + - name: Подготовить effective Influx tokens без вывода секретов + ansible.builtin.set_fact: + aw_existing_server_env_text: "{{ aw_existing_server_env_raw.content | default('') | b64decode }}" + aw_existing_worktime_influx_token: "{{ (aw_existing_server_env_raw.content | default('') | b64decode | regex_search('(?m)^AW_WORKTIME_INFLUX_TOKEN=.*$') | default('', true) | regex_replace('^AW_WORKTIME_INFLUX_TOKEN=', '')) }}" + aw_existing_dlp_influx_token: "{{ (aw_existing_server_env_raw.content | default('') | b64decode | regex_search('(?m)^AW_DLP_INFLUX_TOKEN=.*$') | default('', true) | regex_replace('^AW_DLP_INFLUX_TOKEN=', '')) }}" + no_log: true + + - name: Выбрать effective Influx tokens + ansible.builtin.set_fact: + aw_effective_worktime_influx_token: "{{ (aw_worktime_influx_token | default('') | string) if ((aw_worktime_influx_token | default('') | string | length) > 0) else aw_existing_worktime_influx_token }}" + aw_effective_dlp_influx_token: "{{ (aw_dlp_influx_token | default('') | string) if ((aw_dlp_influx_token | default('') | string | length) > 0) else aw_existing_dlp_influx_token }}" + no_log: true + - name: Проверить Influx token для AW worktime exporter ansible.builtin.assert: that: - - aw_worktime_influx_token is defined - - aw_worktime_influx_token | length > 0 - fail_msg: "aw_worktime_influx_enabled=true, но aw_worktime_influx_token пуст. Exporter будет падать и Grafana не получит worktime-ряды." + - aw_effective_worktime_influx_token is defined + - aw_effective_worktime_influx_token | length > 0 + fail_msg: "aw_worktime_influx_enabled=true, но token пуст и в локальном env, и в текущем /etc/activitywatch/aw-server.env. Exporter будет падать и Grafana не получит worktime-ряды." when: aw_worktime_influx_enabled | default(false) | bool - name: Проверить Influx token для AW DLP exporter ansible.builtin.assert: that: - - aw_dlp_influx_token is defined - - aw_dlp_influx_token | length > 0 - fail_msg: "aw_dlp_influx_enabled=true, но aw_dlp_influx_token пуст. Exporter будет падать и Grafana не получит DLP-ряды." + - aw_effective_dlp_influx_token is defined + - aw_effective_dlp_influx_token | length > 0 + fail_msg: "aw_dlp_influx_enabled=true, но token пуст и в локальном env, и в текущем /etc/activitywatch/aw-server.env. Exporter будет падать и Grafana не получит DLP-ряды." when: aw_dlp_influx_enabled | default(false) | bool - name: Записать /etc/activitywatch/aw-server.env перед хотфиксами @@ -427,7 +584,7 @@ AW_WORKTIME_INFLUX_BUCKET={{ aw_worktime_influx_bucket | default('aw_metrics') }} AW_WORKTIME_INFLUX_HOSTS={{ aw_worktime_influx_hosts | default('SHARKON2025') }} AW_WORKTIME_INFLUX_DAYS={{ aw_worktime_influx_days | default('today,yesterday') }} - AW_WORKTIME_INFLUX_TOKEN={{ aw_worktime_influx_token | default('') }} + AW_WORKTIME_INFLUX_TOKEN={{ aw_effective_worktime_influx_token | default('') }} AW_DLP_INFLUX_ENABLED={{ 'true' if (aw_dlp_influx_enabled | default(false) | bool) else 'false' }} AW_DLP_INFLUX_URL={{ aw_dlp_influx_url | default('') }} AW_DLP_INFLUX_ORG={{ aw_dlp_influx_org | default('proxmox') }} @@ -435,7 +592,7 @@ AW_DLP_INFLUX_HOSTS={{ aw_dlp_influx_hosts | default('SHARKON2025') }} AW_DLP_INFLUX_LOOKBACK_DAYS={{ aw_dlp_influx_lookback_days | default(30) }} AW_DLP_INFLUX_EVENT_LIMIT={{ aw_dlp_influx_event_limit | default(2000) }} - AW_DLP_INFLUX_TOKEN={{ aw_dlp_influx_token | default('') }} + AW_DLP_INFLUX_TOKEN={{ aw_effective_dlp_influx_token | default('') }} AW_DLP_AW_API_BASE=http://127.0.0.1:5600/api/0 AW_DLP_CASE_API_BASE=http://127.0.0.1:5602/api/0/dlp/cases AW_EXPECT_START_OF_DAY={{ aw_worktime_start_of_day | default('') }} @@ -448,6 +605,19 @@ AW_RUS_HEALTH_STATE_DIR={{ aw_rus_health_state_dir }} AW_RUS_HEALTH_VALIDATION_DIR={{ aw_rus_health_validation_dir }} AW_RUS_HEALTH_SESSION_EVENTS_MAX_AGE_SECONDS={{ aw_rus_health_session_events_max_age_seconds | default(86400) }} + AW_RUS_HEALTH_GUARD_MAX_AGE_SECONDS={{ aw_rus_health_guard_max_age_seconds | default(300) }} + AW_RUS_HEALTH_GUARD_REQUIRED={{ 1 if (aw_rus_health_guard_required | default(true) | bool) else 0 }} + AW_RUS_SLO_STATE_DIR={{ aw_server_data_dir }}/slo + AW_RUS_SLO_AW_BASE=http://127.0.0.1:5600 + AW_RUS_SLO_WORKTIME_BASE={{ aw_rus_health_worktime_api_base | default('http://127.0.0.1:5610') }} + AW_RUS_SLO_TARGET_PERCENT={{ aw_rus_slo_target_percent | default('99.97') }} + AW_BROWSER_SMOKE_AW_BASE=http://127.0.0.1:5600 + AW_BROWSER_SMOKE_WORKTIME_BASE={{ aw_rus_health_worktime_api_base | default('http://127.0.0.1:5610') }} + AW_BROWSER_SMOKE_HOST={{ aw_monitored_windows_hostname }} + AW_BROWSER_SMOKE_OUTPUT_DIR={{ aw_server_data_dir }}/browser-smoke + AW_BROWSER_SMOKE_ENGINE={{ aw_browser_smoke_engine | default('chromium-cli') }} + AW_BROWSER_SMOKE_TIMEOUT_MS={{ aw_browser_smoke_timeout_ms | default(20000) }} + AW_BROWSER_SMOKE_RENDER_TIMEOUT_MS={{ aw_browser_smoke_render_timeout_ms | default(15000) }} AW_HAYABUSA_AUTO_CASE_ENABLED={{ 'true' if (aw_hayabusa_auto_case_enabled | default(true) | bool) else 'false' }} AW_HAYABUSA_AUTO_CASE_MIN_SEVERITY={{ aw_hayabusa_auto_case_min_severity | default('medium') }} AW_HAYABUSA_TELEGRAM_ENABLED={{ 'true' if (aw_hayabusa_telegram_enabled | default(false) | bool) else 'false' }} @@ -501,6 +671,50 @@ mode: "0644" when: aw_dlp_policy_engine_enabled | default(false) | bool + - name: Проверить локальный Rust DLP policy engine + ansible.builtin.stat: + path: "{{ aw_rust_release_dir }}/dlp-policy-engine" + delegate_to: localhost + register: aw_dlp_policy_engine_rust_binary + become: false + when: aw_dlp_policy_engine_enabled | default(false) | bool + + - name: Установить Rust DLP policy engine + ansible.builtin.copy: + src: "{{ aw_rust_release_dir }}/dlp-policy-engine" + dest: /usr/local/bin/aw-dlp-policy-engine-rust + owner: root + group: root + mode: "0755" + when: + - aw_dlp_policy_engine_enabled | default(false) | bool + - aw_dlp_policy_engine_rust_binary.stat.exists | default(false) + + - name: Создать drop-in каталог DLP policy engine + ansible.builtin.file: + path: /etc/systemd/system/aw-dlp-policy-engine.service.d + state: directory + owner: root + group: root + mode: "0755" + when: + - aw_dlp_policy_engine_enabled | default(false) | bool + - aw_dlp_policy_engine_rust_binary.stat.exists | default(false) + + - name: Переключить DLP policy engine на Rust + ansible.builtin.copy: + dest: /etc/systemd/system/aw-dlp-policy-engine.service.d/20-rust-switch.conf + owner: root + group: root + mode: "0644" + content: | + [Service] + ExecStart= + ExecStart=/usr/local/bin/aw-dlp-policy-engine-rust + when: + - aw_dlp_policy_engine_enabled | default(false) | bool + - aw_dlp_policy_engine_rust_binary.stat.exists | default(false) + - name: Создать каталог DLP content analysis ansible.builtin.file: path: /opt/activitywatch/dlp-content-analysis @@ -528,6 +742,25 @@ mode: "0755" when: aw_dlp_content_analysis_enabled | default(true) | bool + - name: Проверить локальный Rust DLP content analyzer + ansible.builtin.stat: + path: "{{ aw_rust_release_dir }}/dlp-content-analyzer" + delegate_to: localhost + register: dlp_content_analyzer_rust_binary + become: false + when: aw_dlp_content_analysis_enabled | default(true) | bool + + - name: Установить Rust DLP content analyzer + ansible.builtin.copy: + src: "{{ aw_rust_release_dir }}/dlp-content-analyzer" + dest: /usr/local/bin/aw-dlp-content-analyzer-rust + owner: root + group: root + mode: "0755" + when: + - aw_dlp_content_analysis_enabled | default(true) | bool + - dlp_content_analyzer_rust_binary.stat.exists | default(false) + - name: Создать virtualenv DLP content analysis ansible.builtin.command: cmd: python3 -m venv /opt/activitywatch/dlp-content-analysis/.venv @@ -619,6 +852,50 @@ mode: "0644" when: aw_dlp_integrations_enabled | default(true) | bool + - name: Проверить локальный Rust CEF exporter + ansible.builtin.stat: + path: "{{ aw_rust_release_dir }}/dlp-cef-exporter" + delegate_to: localhost + register: dlp_cef_exporter_rust_binary + become: false + when: aw_dlp_integrations_enabled | default(true) | bool + + - name: Установить Rust CEF exporter + ansible.builtin.copy: + src: "{{ aw_rust_release_dir }}/dlp-cef-exporter" + dest: /usr/local/bin/dlp-cef-exporter-rust + owner: root + group: root + mode: "0755" + when: + - aw_dlp_integrations_enabled | default(true) | bool + - dlp_cef_exporter_rust_binary.stat.exists | default(false) + + - name: Создать drop-in каталог CEF exporter + ansible.builtin.file: + path: /etc/systemd/system/aw-dlp-cef-exporter.service.d + state: directory + owner: root + group: root + mode: "0755" + when: + - aw_dlp_integrations_enabled | default(true) | bool + - dlp_cef_exporter_rust_binary.stat.exists | default(false) + + - name: Переключить CEF exporter на Rust + ansible.builtin.copy: + dest: /etc/systemd/system/aw-dlp-cef-exporter.service.d/20-rust-switch.conf + owner: root + group: root + mode: "0644" + content: | + [Service] + ExecStart= + ExecStart=/usr/local/bin/dlp-cef-exporter-rust + when: + - aw_dlp_integrations_enabled | default(true) | bool + - dlp_cef_exporter_rust_binary.stat.exists | default(false) + - name: Установить systemd unit syslog forwarder ansible.builtin.copy: src: "{{ aw_repo_root }}/aw-server/dlp-integrations/syslog-forwarder.service" @@ -637,6 +914,50 @@ mode: "0644" when: aw_dlp_integrations_enabled | default(true) | bool + - name: Проверить локальный Rust syslog forwarder + ansible.builtin.stat: + path: "{{ aw_rust_release_dir }}/dlp-syslog-forwarder" + delegate_to: localhost + register: dlp_syslog_forwarder_rust_binary + become: false + when: aw_dlp_integrations_enabled | default(true) | bool + + - name: Установить Rust syslog forwarder + ansible.builtin.copy: + src: "{{ aw_rust_release_dir }}/dlp-syslog-forwarder" + dest: /usr/local/bin/dlp-syslog-forwarder-rust + owner: root + group: root + mode: "0755" + when: + - aw_dlp_integrations_enabled | default(true) | bool + - dlp_syslog_forwarder_rust_binary.stat.exists | default(false) + + - name: Создать drop-in каталог syslog forwarder + ansible.builtin.file: + path: /etc/systemd/system/aw-dlp-syslog-forwarder.service.d + state: directory + owner: root + group: root + mode: "0755" + when: + - aw_dlp_integrations_enabled | default(true) | bool + - dlp_syslog_forwarder_rust_binary.stat.exists | default(false) + + - name: Переключить syslog forwarder на Rust + ansible.builtin.copy: + dest: /etc/systemd/system/aw-dlp-syslog-forwarder.service.d/20-rust-switch.conf + owner: root + group: root + mode: "0644" + content: | + [Service] + ExecStart= + ExecStart=/usr/local/bin/dlp-syslog-forwarder-rust + when: + - aw_dlp_integrations_enabled | default(true) | bool + - dlp_syslog_forwarder_rust_binary.stat.exists | default(false) + - name: Установить systemd unit webhook sender ansible.builtin.copy: src: "{{ aw_repo_root }}/aw-server/dlp-integrations/webhook-sender.service" @@ -655,6 +976,50 @@ mode: "0644" when: aw_dlp_integrations_enabled | default(true) | bool + - name: Проверить локальный Rust webhook sender + ansible.builtin.stat: + path: "{{ aw_rust_release_dir }}/dlp-webhook-sender" + delegate_to: localhost + register: dlp_webhook_sender_rust_binary + become: false + when: aw_dlp_integrations_enabled | default(true) | bool + + - name: Установить Rust webhook sender + ansible.builtin.copy: + src: "{{ aw_rust_release_dir }}/dlp-webhook-sender" + dest: /usr/local/bin/dlp-webhook-sender-rust + owner: root + group: root + mode: "0755" + when: + - aw_dlp_integrations_enabled | default(true) | bool + - dlp_webhook_sender_rust_binary.stat.exists | default(false) + + - name: Создать drop-in каталог webhook sender + ansible.builtin.file: + path: /etc/systemd/system/aw-dlp-webhook-sender.service.d + state: directory + owner: root + group: root + mode: "0755" + when: + - aw_dlp_integrations_enabled | default(true) | bool + - dlp_webhook_sender_rust_binary.stat.exists | default(false) + + - name: Переключить webhook sender на Rust + ansible.builtin.copy: + dest: /etc/systemd/system/aw-dlp-webhook-sender.service.d/20-rust-switch.conf + owner: root + group: root + mode: "0644" + content: | + [Service] + ExecStart= + ExecStart=/usr/local/bin/dlp-webhook-sender-rust + when: + - aw_dlp_integrations_enabled | default(true) | bool + - dlp_webhook_sender_rust_binary.stat.exists | default(false) + - name: Создать каталог DLP case management ansible.builtin.file: path: /opt/activitywatch/dlp-case-management @@ -710,6 +1075,50 @@ mode: "0644" when: aw_dlp_case_management_enabled | default(true) | bool + - name: Проверить локальный Rust DLP case management + ansible.builtin.stat: + path: "{{ aw_rust_release_dir }}/dlp-case-management" + delegate_to: localhost + register: aw_dlp_case_management_rust_binary + become: false + when: aw_dlp_case_management_enabled | default(true) | bool + + - name: Установить Rust DLP case management + ansible.builtin.copy: + src: "{{ aw_rust_release_dir }}/dlp-case-management" + dest: /usr/local/bin/aw-dlp-case-management-rust + owner: root + group: root + mode: "0755" + when: + - aw_dlp_case_management_enabled | default(true) | bool + - aw_dlp_case_management_rust_binary.stat.exists | default(false) + + - name: Создать drop-in каталог DLP case management + ansible.builtin.file: + path: /etc/systemd/system/aw-dlp-case-management.service.d + state: directory + owner: root + group: root + mode: "0755" + when: + - aw_dlp_case_management_enabled | default(true) | bool + - aw_dlp_case_management_rust_binary.stat.exists | default(false) + + - name: Переключить DLP case management на Rust + ansible.builtin.copy: + dest: /etc/systemd/system/aw-dlp-case-management.service.d/20-rust-switch.conf + owner: root + group: root + mode: "0644" + content: | + [Service] + ExecStart= + ExecStart=/usr/local/bin/aw-dlp-case-management-rust + when: + - aw_dlp_case_management_enabled | default(true) | bool + - aw_dlp_case_management_rust_binary.stat.exists | default(false) + - name: Создать каталоги DLP compliance ansible.builtin.file: path: "{{ item }}" @@ -753,6 +1162,50 @@ virtualenv: /opt/activitywatch/dlp-compliance/.venv when: aw_dlp_compliance_enabled | default(true) | bool + - name: Проверить локальный Rust DLP compliance + ansible.builtin.stat: + path: "{{ aw_rust_release_dir }}/dlp-compliance" + delegate_to: localhost + register: aw_dlp_compliance_rust_binary + become: false + when: aw_dlp_compliance_enabled | default(true) | bool + + - name: Установить Rust DLP compliance + ansible.builtin.copy: + src: "{{ aw_rust_release_dir }}/dlp-compliance" + dest: /usr/local/bin/aw-dlp-compliance-rust + owner: root + group: root + mode: "0755" + when: + - aw_dlp_compliance_enabled | default(true) | bool + - aw_dlp_compliance_rust_binary.stat.exists | default(false) + + - name: Создать drop-in каталог DLP compliance report scheduler + ansible.builtin.file: + path: /etc/systemd/system/aw-dlp-report-scheduler.service.d + state: directory + owner: root + group: root + mode: "0755" + when: + - aw_dlp_compliance_enabled | default(true) | bool + - aw_dlp_compliance_rust_binary.stat.exists | default(false) + + - name: Переключить DLP compliance report scheduler на Rust + ansible.builtin.copy: + dest: /etc/systemd/system/aw-dlp-report-scheduler.service.d/20-rust-switch.conf + owner: root + group: root + mode: "0644" + content: | + [Service] + ExecStart= + ExecStart=/usr/local/bin/aw-dlp-compliance-rust + when: + - aw_dlp_compliance_enabled | default(true) | bool + - aw_dlp_compliance_rust_binary.stat.exists | default(false) + - name: Установить dlp-admin-cli ansible.builtin.copy: src: "{{ aw_repo_root }}/scripts/dlp-admin-cli.py" @@ -761,6 +1214,21 @@ group: root mode: "0755" + - name: Проверить локальный Rust dlp-admin-cli + ansible.builtin.stat: + path: "{{ aw_rust_release_dir }}/dlp-admin-cli" + delegate_to: localhost + register: aw_dlp_admin_cli_rust_binary + + - name: Установить Rust dlp-admin-cli + ansible.builtin.copy: + src: "{{ aw_rust_release_dir }}/dlp-admin-cli" + dest: /usr/local/bin/dlp-admin-cli + owner: root + group: root + mode: "0755" + when: aw_dlp_admin_cli_rust_binary.stat.exists + - name: Установить скрипт AW worktime API ansible.builtin.copy: src: "{{ aw_repo_root }}/aw-server/aw-worktime-api.py" @@ -769,6 +1237,22 @@ group: root mode: "0755" + - name: Проверить локальный Rust AW worktime API + ansible.builtin.stat: + path: "{{ aw_rust_release_dir }}/worktime-api" + delegate_to: localhost + register: aw_worktime_api_rust_binary + become: false + + - name: Установить Rust AW worktime API + ansible.builtin.copy: + src: "{{ aw_rust_release_dir }}/worktime-api" + dest: /usr/local/bin/aw-worktime-api-rust + owner: root + group: root + mode: "0755" + when: aw_worktime_api_rust_binary.stat.exists | default(false) + - name: Установить systemd unit AW worktime API ansible.builtin.copy: src: "{{ aw_repo_root }}/aw-server/aw-worktime-api.service" @@ -777,6 +1261,27 @@ group: root mode: "0644" + - name: Создать drop-in каталог AW worktime API + ansible.builtin.file: + path: /etc/systemd/system/aw-worktime-api.service.d + state: directory + owner: root + group: root + mode: "0755" + when: aw_worktime_api_rust_binary.stat.exists | default(false) + + - name: Переключить AW worktime API на Rust + ansible.builtin.copy: + dest: /etc/systemd/system/aw-worktime-api.service.d/20-rust-switch.conf + owner: root + group: root + mode: "0644" + content: | + [Service] + ExecStart= + ExecStart=/usr/local/bin/aw-worktime-api-rust + when: aw_worktime_api_rust_binary.stat.exists | default(false) + - name: Установить скрипт AW worktime UI bridge ansible.builtin.copy: src: "{{ aw_repo_root }}/aw-server/aw-worktime-ui-bridge.py" @@ -785,6 +1290,22 @@ group: root mode: "0755" + - name: Проверить локальный Rust AW worktime UI bridge + ansible.builtin.stat: + path: "{{ aw_rust_release_dir }}/worktime-ui-bridge" + delegate_to: localhost + register: aw_worktime_ui_bridge_rust_binary + become: false + + - name: Установить Rust AW worktime UI bridge + ansible.builtin.copy: + src: "{{ aw_rust_release_dir }}/worktime-ui-bridge" + dest: /usr/local/bin/aw-worktime-ui-bridge-rust + owner: root + group: root + mode: "0755" + when: aw_worktime_ui_bridge_rust_binary.stat.exists | default(false) + - name: Установить скрипт AW worktime autoheal ansible.builtin.copy: src: "{{ aw_repo_root }}/aw-server/aw-worktime-autoheal.sh" @@ -793,6 +1314,46 @@ group: root mode: "0755" + - name: Проверить локальный Rust AW worktime autoheal + ansible.builtin.stat: + path: "{{ aw_rust_release_dir }}/worktime-autoheal" + delegate_to: localhost + register: aw_worktime_autoheal_rust_binary + become: false + + - name: Установить Rust AW worktime autoheal + ansible.builtin.copy: + src: "{{ aw_rust_release_dir }}/worktime-autoheal" + dest: /usr/local/bin/aw-worktime-autoheal-rust + owner: root + group: root + mode: "0755" + when: aw_worktime_autoheal_rust_binary.stat.exists | default(false) + + - name: Установить скрипт AW worktime prewarm + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/aw-worktime-prewarm.sh" + dest: /usr/local/bin/aw-worktime-prewarm.sh + owner: root + group: root + mode: "0755" + + - name: Проверить локальный Rust AW worktime prewarm + ansible.builtin.stat: + path: "{{ aw_rust_release_dir }}/worktime-prewarm" + delegate_to: localhost + register: aw_worktime_prewarm_rust_binary + become: false + + - name: Установить Rust AW worktime prewarm + ansible.builtin.copy: + src: "{{ aw_rust_release_dir }}/worktime-prewarm" + dest: /usr/local/bin/aw-worktime-prewarm-rust + owner: root + group: root + mode: "0755" + when: aw_worktime_prewarm_rust_binary.stat.exists | default(false) + - name: Установить скрипт AW worktime Influx exporter ansible.builtin.copy: src: "{{ aw_repo_root }}/aw-server/aw-worktime-influx-exporter.py" @@ -811,21 +1372,118 @@ mode: "0755" when: aw_dlp_influx_enabled | default(false) | bool + - name: Создать server-side ops bundle для legacy aw-health-check + ansible.builtin.file: + path: /opt/activitywatch/aw-rus-ops + state: directory + owner: root + group: root + mode: "0755" + - name: Установить скрипт aw-health-check ansible.builtin.copy: - src: "{{ aw_repo_root }}/aw-server/health-check.sh" + src: "{{ aw_repo_root }}/aw-server/aw-health-check-wrapper.sh" dest: /usr/local/bin/aw-health-check owner: root group: root mode: "0755" - - name: Установить скрипт dlp-health-check + - name: Положить legacy aw-health-check в server-side ops bundle + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/health-check.sh" + dest: /opt/activitywatch/aw-rus-ops/health-check.sh + owner: root + group: root + mode: "0755" + + - name: Проверить локальный Rust aw-health-check + ansible.builtin.stat: + path: "{{ aw_rust_release_dir }}/aw-health-check" + delegate_to: localhost + register: aw_health_check_rust_binary + become: false + + - name: Установить Rust aw-health-check + ansible.builtin.copy: + src: "{{ aw_rust_release_dir }}/aw-health-check" + dest: /usr/local/bin/aw-health-check-rust + owner: root + group: root + mode: "0755" + when: aw_health_check_rust_binary.stat.exists | default(false) + + - name: Проверить локальный Rust aw-db-health + ansible.builtin.stat: + path: "{{ aw_rust_release_dir }}/aw-db-health" + delegate_to: localhost + register: aw_db_health_rust_binary + become: false + + - name: Установить Rust aw-db-health + ansible.builtin.copy: + src: "{{ aw_rust_release_dir }}/aw-db-health" + dest: /usr/local/bin/aw-db-health + owner: root + group: root + mode: "0755" + when: aw_db_health_rust_binary.stat.exists | default(false) + + - name: Установить скрипт check-aw-data + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/check-aw-data-wrapper.sh" + dest: /usr/local/bin/check-aw-data + owner: root + group: root + mode: "0755" + + - name: Положить legacy check-aw-data в server-side ops bundle + ansible.builtin.copy: + src: "{{ aw_repo_root }}/scripts/legacy/check-aw-data.sh" + dest: /opt/activitywatch/aw-rus-ops/check-aw-data.sh + owner: root + group: root + mode: "0755" + + - name: Проверить локальный Rust check-aw-data + ansible.builtin.stat: + path: "{{ aw_rust_release_dir }}/check-aw-data" + delegate_to: localhost + register: check_aw_data_rust_binary + become: false + + - name: Установить Rust check-aw-data + ansible.builtin.copy: + src: "{{ aw_rust_release_dir }}/check-aw-data" + dest: /usr/local/bin/check-aw-data-rust + owner: root + group: root + mode: "0755" + when: check_aw_data_rust_binary.stat.exists | default(false) + + - name: Проверить локальный Rust dlp-health-check + ansible.builtin.stat: + path: "{{ aw_rust_release_dir }}/dlp-health-check" + delegate_to: localhost + register: dlp_health_check_rust_binary + become: false + + - name: Установить Rust dlp-health-check + ansible.builtin.copy: + src: "{{ aw_rust_release_dir }}/dlp-health-check" + dest: /usr/local/bin/dlp-health-check + owner: root + group: root + mode: "0755" + when: dlp_health_check_rust_binary.stat.exists | default(false) + + - name: Установить legacy Python dlp-health-check ansible.builtin.copy: src: "{{ aw_repo_root }}/scripts/dlp-health-check.py" dest: /usr/local/bin/dlp-health-check owner: root group: root mode: "0755" + when: not (dlp_health_check_rust_binary.stat.exists | default(false)) - name: Установить скрипт aw-rus-healthd ansible.builtin.copy: @@ -835,6 +1493,39 @@ group: root mode: "0755" + - name: Установить скрипт aw-slo-monitor + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/aw-slo-monitor.py" + dest: /usr/local/bin/aw-slo-monitor.py + owner: root + group: root + mode: "0755" + + - name: Проверить локальный Rust AW-RUS SLO monitor + ansible.builtin.stat: + path: "{{ aw_rust_release_dir }}/aw-slo-monitor" + delegate_to: localhost + register: aw_slo_monitor_rust_binary + become: false + + - name: Установить Rust AW-RUS SLO monitor + ansible.builtin.copy: + src: "{{ aw_rust_release_dir }}/aw-slo-monitor" + dest: /usr/local/bin/aw-slo-monitor-rust + owner: root + group: root + mode: "0755" + when: aw_slo_monitor_rust_binary.stat.exists | default(false) + + - name: Установить browser smoke скрипт + ansible.builtin.copy: + src: "{{ aw_repo_root }}/scripts/aw-webui-browser-smoke.mjs" + dest: /usr/local/lib/aw-rus-browser-smoke/aw-webui-browser-smoke.mjs + owner: root + group: root + mode: "0755" + when: aw_browser_smoke_enabled | default(true) | bool + - name: Установить systemd unit AW worktime UI bridge ansible.builtin.copy: src: "{{ aw_repo_root }}/aw-server/aw-worktime-ui-bridge.service" @@ -843,6 +1534,27 @@ group: root mode: "0644" + - name: Создать drop-in каталог AW worktime UI bridge + ansible.builtin.file: + path: /etc/systemd/system/aw-worktime-ui-bridge.service.d + state: directory + owner: root + group: root + mode: "0755" + when: aw_worktime_ui_bridge_rust_binary.stat.exists | default(false) + + - name: Переключить AW worktime UI bridge на Rust + ansible.builtin.copy: + dest: /etc/systemd/system/aw-worktime-ui-bridge.service.d/20-rust-switch.conf + owner: root + group: root + mode: "0644" + content: | + [Service] + ExecStart= + ExecStart=/usr/local/bin/aw-worktime-ui-bridge-rust + when: aw_worktime_ui_bridge_rust_binary.stat.exists | default(false) + - name: Установить systemd timer AW worktime UI bridge ansible.builtin.copy: src: "{{ aw_repo_root }}/aw-server/aw-worktime-ui-bridge.timer" @@ -859,6 +1571,27 @@ group: root mode: "0644" + - name: Создать drop-in каталог AW worktime autoheal + ansible.builtin.file: + path: /etc/systemd/system/aw-worktime-autoheal.service.d + state: directory + owner: root + group: root + mode: "0755" + when: aw_worktime_autoheal_rust_binary.stat.exists | default(false) + + - name: Переключить AW worktime autoheal на Rust + ansible.builtin.copy: + dest: /etc/systemd/system/aw-worktime-autoheal.service.d/20-rust-switch.conf + owner: root + group: root + mode: "0644" + content: | + [Service] + ExecStart= + ExecStart=/usr/local/bin/aw-worktime-autoheal-rust + when: aw_worktime_autoheal_rust_binary.stat.exists | default(false) + - name: Установить systemd timer AW worktime autoheal ansible.builtin.copy: src: "{{ aw_repo_root }}/aw-server/aw-worktime-autoheal.timer" @@ -867,6 +1600,43 @@ group: root mode: "0644" + - name: Установить systemd unit AW worktime prewarm + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/aw-worktime-prewarm.service" + dest: /etc/systemd/system/aw-worktime-prewarm.service + owner: root + group: root + mode: "0644" + + - name: Создать drop-in каталог AW worktime prewarm + ansible.builtin.file: + path: /etc/systemd/system/aw-worktime-prewarm.service.d + state: directory + owner: root + group: root + mode: "0755" + when: aw_worktime_prewarm_rust_binary.stat.exists | default(false) + + - name: Переключить AW worktime prewarm на Rust + ansible.builtin.copy: + dest: /etc/systemd/system/aw-worktime-prewarm.service.d/20-rust-switch.conf + owner: root + group: root + mode: "0644" + content: | + [Service] + ExecStart= + ExecStart=/usr/local/bin/aw-worktime-prewarm-rust + when: aw_worktime_prewarm_rust_binary.stat.exists | default(false) + + - name: Установить systemd timer AW worktime prewarm + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/aw-worktime-prewarm.timer" + dest: /etc/systemd/system/aw-worktime-prewarm.timer + owner: root + group: root + mode: "0644" + - name: Установить systemd unit AW-RUS healthd ansible.builtin.copy: src: "{{ aw_repo_root }}/aw-server/aw-rus-healthd.service" @@ -883,6 +1653,58 @@ group: root mode: "0644" + - name: Установить systemd unit AW-RUS SLO monitor + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/aw-slo-monitor.service" + dest: /etc/systemd/system/aw-slo-monitor.service + owner: root + group: root + mode: "0644" + + - name: Создать drop-in каталог AW-RUS SLO monitor + ansible.builtin.file: + path: /etc/systemd/system/aw-slo-monitor.service.d + state: directory + owner: root + group: root + mode: "0755" + when: aw_slo_monitor_rust_binary.stat.exists | default(false) + + - name: Переключить AW-RUS SLO monitor на Rust + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/aw-slo-monitor-rust-switch.conf" + dest: /etc/systemd/system/aw-slo-monitor.service.d/20-rust-switch.conf + owner: root + group: root + mode: "0644" + when: aw_slo_monitor_rust_binary.stat.exists | default(false) + + - name: Установить systemd timer AW-RUS SLO monitor + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/aw-slo-monitor.timer" + dest: /etc/systemd/system/aw-slo-monitor.timer + owner: root + group: root + mode: "0644" + + - name: Установить systemd unit AW-RUS browser smoke + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/aw-browser-smoke.service" + dest: /etc/systemd/system/aw-browser-smoke.service + owner: root + group: root + mode: "0644" + when: aw_browser_smoke_enabled | default(true) | bool + + - name: Установить systemd timer AW-RUS browser smoke + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/aw-browser-smoke.timer" + dest: /etc/systemd/system/aw-browser-smoke.timer + owner: root + group: root + mode: "0644" + when: aw_browser_smoke_enabled | default(true) | bool + - name: Установить systemd unit AW worktime Influx exporter ansible.builtin.copy: src: "{{ aw_repo_root }}/aw-server/aw-worktime-influx-exporter.service" @@ -892,6 +1714,50 @@ mode: "0644" when: aw_worktime_influx_enabled | default(false) | bool + - name: Проверить локальный Rust AW worktime Influx exporter + ansible.builtin.stat: + path: "{{ aw_rust_release_dir }}/worktime-influx-exporter" + delegate_to: localhost + register: aw_worktime_influx_exporter_rust_binary + become: false + when: aw_worktime_influx_enabled | default(false) | bool + + - name: Установить Rust AW worktime Influx exporter + ansible.builtin.copy: + src: "{{ aw_rust_release_dir }}/worktime-influx-exporter" + dest: /usr/local/bin/aw-worktime-influx-exporter-rust + owner: root + group: root + mode: "0755" + when: + - aw_worktime_influx_enabled | default(false) | bool + - aw_worktime_influx_exporter_rust_binary.stat.exists | default(false) + + - name: Создать drop-in каталог AW worktime Influx exporter + ansible.builtin.file: + path: /etc/systemd/system/aw-worktime-influx-exporter.service.d + state: directory + owner: root + group: root + mode: "0755" + when: + - aw_worktime_influx_enabled | default(false) | bool + - aw_worktime_influx_exporter_rust_binary.stat.exists | default(false) + + - name: Переключить AW worktime Influx exporter на Rust + ansible.builtin.copy: + dest: /etc/systemd/system/aw-worktime-influx-exporter.service.d/20-rust-switch.conf + owner: root + group: root + mode: "0644" + content: | + [Service] + ExecStart= + ExecStart=/usr/local/bin/aw-worktime-influx-exporter-rust + when: + - aw_worktime_influx_enabled | default(false) | bool + - aw_worktime_influx_exporter_rust_binary.stat.exists | default(false) + - name: Установить systemd timer AW worktime Influx exporter ansible.builtin.copy: src: "{{ aw_repo_root }}/aw-server/aw-worktime-influx-exporter.timer" @@ -910,6 +1776,50 @@ mode: "0644" when: aw_dlp_influx_enabled | default(false) | bool + - name: Проверить локальный Rust AW DLP Influx exporter + ansible.builtin.stat: + path: "{{ aw_rust_release_dir }}/dlp-influx-exporter" + delegate_to: localhost + register: aw_dlp_influx_exporter_rust_binary + become: false + when: aw_dlp_influx_enabled | default(false) | bool + + - name: Установить Rust AW DLP Influx exporter + ansible.builtin.copy: + src: "{{ aw_rust_release_dir }}/dlp-influx-exporter" + dest: /usr/local/bin/aw-dlp-influx-exporter-rust + owner: root + group: root + mode: "0755" + when: + - aw_dlp_influx_enabled | default(false) | bool + - aw_dlp_influx_exporter_rust_binary.stat.exists | default(false) + + - name: Создать drop-in каталог AW DLP Influx exporter + ansible.builtin.file: + path: /etc/systemd/system/aw-dlp-influx-exporter.service.d + state: directory + owner: root + group: root + mode: "0755" + when: + - aw_dlp_influx_enabled | default(false) | bool + - aw_dlp_influx_exporter_rust_binary.stat.exists | default(false) + + - name: Переключить AW DLP Influx exporter на Rust + ansible.builtin.copy: + dest: /etc/systemd/system/aw-dlp-influx-exporter.service.d/20-rust-switch.conf + owner: root + group: root + mode: "0644" + content: | + [Service] + ExecStart= + ExecStart=/usr/local/bin/aw-dlp-influx-exporter-rust + when: + - aw_dlp_influx_enabled | default(false) | bool + - aw_dlp_influx_exporter_rust_binary.stat.exists | default(false) + - name: Установить systemd timer AW DLP Influx exporter ansible.builtin.copy: src: "{{ aw_repo_root }}/aw-server/aw-dlp-influx-exporter.timer" @@ -929,6 +1839,19 @@ enabled: true state: started + - name: Включить таймер AW-RUS SLO monitor + ansible.builtin.systemd: + name: aw-slo-monitor.timer + enabled: true + state: started + + - name: Включить таймер AW-RUS browser smoke + ansible.builtin.systemd: + name: aw-browser-smoke.timer + enabled: true + state: started + when: aw_browser_smoke_enabled | default(true) | bool + - name: Включить и перезапустить DLP policy engine ansible.builtin.systemd: name: aw-dlp-policy-engine.service @@ -1015,6 +1938,18 @@ state: started failed_when: false + - name: Включить и перезапустить AW worktime prewarm timer + ansible.builtin.systemd: + name: aw-worktime-prewarm.timer + enabled: true + state: restarted + + - name: Выполнить разовый прогон AW worktime prewarm + ansible.builtin.systemd: + name: aw-worktime-prewarm.service + state: started + failed_when: false + - name: Включить и перезапустить AW worktime Influx exporter timer ansible.builtin.systemd: name: aw-worktime-influx-exporter.timer @@ -1101,6 +2036,22 @@ group: root mode: "0755" + - name: Check Rust merge AW DB binary artifact + ansible.builtin.stat: + path: "{{ aw_rust_release_dir }}/merge-aw-server-dbs" + register: aw_merge_db_rust_binary + delegate_to: localhost + become: false + + - name: Install Rust merge AW DB binary + ansible.builtin.copy: + src: "{{ aw_rust_release_dir }}/merge-aw-server-dbs" + dest: /usr/local/bin/merge-aw-server-dbs + owner: root + group: root + mode: "0755" + when: aw_merge_db_rust_binary.stat.exists | default(false) + - name: Проверить наличие legacy root DB ansible.builtin.stat: path: /root/.local/share/activitywatch/aw-server-rust/sqlite.db @@ -1115,7 +2066,9 @@ ansible.builtin.systemd: name: activitywatch-server.service state: stopped - when: aw_legacy_root_db.stat.exists | default(false) + when: + - aw_legacy_db_merge_enabled | default(false) | bool + - aw_legacy_root_db.stat.exists | default(false) - name: Создать backup каталоги server DB ansible.builtin.file: @@ -1124,7 +2077,9 @@ owner: "{{ aw_server_user }}" group: "{{ aw_server_group }}" mode: "0755" - when: aw_legacy_root_db.stat.exists | default(false) + when: + - aw_legacy_db_merge_enabled | default(false) | bool + - aw_legacy_root_db.stat.exists | default(false) - name: Backup target DB перед merge ansible.builtin.copy: @@ -1135,6 +2090,7 @@ group: "{{ aw_server_group }}" mode: "0644" when: + - aw_legacy_db_merge_enabled | default(false) | bool - aw_legacy_root_db.stat.exists | default(false) - aw_target_db.stat.exists | default(false) @@ -1146,7 +2102,9 @@ owner: "{{ aw_server_user }}" group: "{{ aw_server_group }}" mode: "0644" - when: aw_legacy_root_db.stat.exists | default(false) + when: + - aw_legacy_db_merge_enabled | default(false) | bool + - aw_legacy_root_db.stat.exists | default(false) - name: Merge legacy root DB в target DB ansible.builtin.command: @@ -1162,6 +2120,7 @@ register: aw_merge_result failed_when: false when: + - aw_legacy_db_merge_enabled | default(false) | bool - aw_legacy_root_db.stat.exists | default(false) - aw_target_db.stat.exists | default(false) @@ -1169,6 +2128,7 @@ ansible.builtin.debug: msg: "{{ aw_merge_result.stdout | default(aw_merge_result.stderr | default('merge not executed')) }}" when: + - aw_legacy_db_merge_enabled | default(false) | bool - aw_legacy_root_db.stat.exists | default(false) - aw_target_db.stat.exists | default(false) @@ -1181,6 +2141,7 @@ group: "{{ aw_server_group }}" mode: "0644" when: + - aw_legacy_db_merge_enabled | default(false) | bool - aw_legacy_root_db.stat.exists | default(false) - aw_target_db.stat.exists | default(false) - aw_merge_result is defined @@ -1195,6 +2156,7 @@ group: "{{ aw_server_group }}" mode: "0644" when: + - aw_legacy_db_merge_enabled | default(false) | bool - aw_legacy_root_db.stat.exists | default(false) - not (aw_target_db.stat.exists | default(false)) @@ -1306,6 +2268,46 @@ [Install] WantedBy=multi-user.target + - name: Проверить локальный Rust DLP aggregator + ansible.builtin.stat: + path: "{{ aw_rust_release_dir }}/dlp-aggregator" + delegate_to: localhost + register: dlp_aggregator_rust_binary + become: false + + - name: Установить Rust DLP aggregator + ansible.builtin.copy: + src: "{{ aw_rust_release_dir }}/dlp-aggregator" + dest: /usr/local/bin/dlp-aggregator-rust + owner: root + group: root + mode: "0755" + when: dlp_aggregator_rust_binary.stat.exists | default(false) + + - name: Создать drop-in каталог DLP aggregator + ansible.builtin.file: + path: /etc/systemd/system/activitywatch-dlp-aggregator.service.d + state: directory + owner: root + group: root + mode: "0755" + when: dlp_aggregator_rust_binary.stat.exists | default(false) + + - name: Переключить DLP aggregator на Rust + ansible.builtin.copy: + dest: /etc/systemd/system/activitywatch-dlp-aggregator.service.d/20-rust-switch.conf + owner: root + group: root + mode: "0644" + content: | + [Service] + ExecStart= + ExecStart=/usr/local/bin/dlp-aggregator-rust \ + --aw-url http://127.0.0.1:{{ aw_server_port }}/api/0 \ + --sqlite-path {{ aw_server_data_dir }}/dlp_warehouse.sqlite \ + --state-path {{ aw_server_data_dir }}/dlp-aggregator-state.json + when: dlp_aggregator_rust_binary.stat.exists | default(false) + - name: Установить systemd timer для агрегатора ansible.builtin.copy: dest: /etc/systemd/system/activitywatch-dlp-aggregator.timer @@ -1314,9 +2316,10 @@ Description=Run ActivityWatch DLP Aggregator every 5 minutes [Timer] - OnBootSec=1min - OnUnitActiveSec=5min - AccuracySec=1s + OnCalendar=*:3/10:10 + AccuracySec=30s + RandomizedDelaySec=30s + Persistent=false [Install] WantedBy=timers.target @@ -1681,9 +2684,31 @@ group: root mode: "0755" + - name: Проверить локальный Rust helper link-case для Hayabusa + ansible.builtin.stat: + path: "{{ aw_rust_release_dir }}/aw-hayabusa-link-case-rust" + delegate_to: localhost + register: aw_hayabusa_link_case_rust_binary + become: false + + - name: Установить Rust helper link-case для Hayabusa + ansible.builtin.copy: + src: "{{ aw_rust_release_dir }}/aw-hayabusa-link-case-rust" + dest: /usr/local/bin/aw-hayabusa-link-case-rust + owner: root + group: root + mode: "0755" + when: aw_hayabusa_link_case_rust_binary.stat.exists | default(false) + - name: Установить helper link-case для Hayabusa ansible.builtin.copy: - src: "{{ aw_repo_root }}/aw-server/hayabusa/aw-hayabusa-link-case.py" + content: | + #!/usr/bin/env bash + set -euo pipefail + if [[ -x /usr/local/bin/aw-hayabusa-link-case-rust ]]; then + exec /usr/local/bin/aw-hayabusa-link-case-rust "$@" + fi + exec /usr/bin/python3 /opt/activitywatch/aw-rus-ops/hayabusa/aw-hayabusa-link-case.py "$@" dest: /usr/local/bin/aw-hayabusa-link-case owner: root group: root @@ -1697,9 +2722,31 @@ group: root mode: "0755" + - name: Проверить локальный Rust helper from-windows для Hayabusa + ansible.builtin.stat: + path: "{{ aw_rust_release_dir }}/aw-hayabusa-from-windows-rust" + delegate_to: localhost + register: aw_hayabusa_from_windows_rust_binary + become: false + + - name: Установить Rust helper from-windows для Hayabusa + ansible.builtin.copy: + src: "{{ aw_rust_release_dir }}/aw-hayabusa-from-windows-rust" + dest: /usr/local/bin/aw-hayabusa-from-windows-rust + owner: root + group: root + mode: "0755" + when: aw_hayabusa_from_windows_rust_binary.stat.exists | default(false) + - name: Установить helper from-windows для Hayabusa ansible.builtin.copy: - src: "{{ aw_repo_root }}/aw-server/hayabusa/aw-hayabusa-from-windows.py" + content: | + #!/usr/bin/env bash + set -euo pipefail + if [[ -x /usr/local/bin/aw-hayabusa-from-windows-rust ]]; then + exec /usr/local/bin/aw-hayabusa-from-windows-rust "$@" + fi + exec /usr/bin/python3 /opt/activitywatch/aw-rus-ops/hayabusa/aw-hayabusa-from-windows.py "$@" dest: /usr/local/bin/aw-hayabusa-from-windows owner: root group: root @@ -1721,9 +2768,31 @@ group: root mode: "0644" + - name: Проверить локальный Rust helper autoprocess для Hayabusa + ansible.builtin.stat: + path: "{{ aw_rust_release_dir }}/aw-hayabusa-autoprocess-rust" + delegate_to: localhost + register: aw_hayabusa_autoprocess_rust_binary + become: false + + - name: Установить Rust helper autoprocess для Hayabusa + ansible.builtin.copy: + src: "{{ aw_rust_release_dir }}/aw-hayabusa-autoprocess-rust" + dest: /usr/local/bin/aw-hayabusa-autoprocess-rust + owner: root + group: root + mode: "0755" + when: aw_hayabusa_autoprocess_rust_binary.stat.exists | default(false) + - name: Установить helper autoprocess для Hayabusa ansible.builtin.copy: - src: "{{ aw_repo_root }}/aw-server/hayabusa/aw-hayabusa-autoprocess.py" + content: | + #!/usr/bin/env bash + set -euo pipefail + if [[ -x /usr/local/bin/aw-hayabusa-autoprocess-rust ]]; then + exec /usr/local/bin/aw-hayabusa-autoprocess-rust "$@" + fi + exec /usr/bin/python3 /opt/activitywatch/aw-rus-ops/hayabusa/aw-hayabusa-autoprocess.py "$@" dest: /usr/local/bin/aw-hayabusa-autoprocess owner: root group: root @@ -1737,9 +2806,31 @@ group: root mode: "0755" + - name: Проверить локальный Rust helper case-alert для Hayabusa + ansible.builtin.stat: + path: "{{ aw_rust_release_dir }}/aw-hayabusa-case-alert-rust" + delegate_to: localhost + register: aw_hayabusa_case_alert_rust_binary + become: false + + - name: Установить Rust helper case-alert для Hayabusa + ansible.builtin.copy: + src: "{{ aw_rust_release_dir }}/aw-hayabusa-case-alert-rust" + dest: /usr/local/bin/aw-hayabusa-case-alert-rust + owner: root + group: root + mode: "0755" + when: aw_hayabusa_case_alert_rust_binary.stat.exists | default(false) + - name: Установить helper case-alert для Hayabusa ansible.builtin.copy: - src: "{{ aw_repo_root }}/aw-server/hayabusa/aw-hayabusa-case-alert.py" + content: | + #!/usr/bin/env bash + set -euo pipefail + if [[ -x /usr/local/bin/aw-hayabusa-case-alert-rust ]]; then + exec /usr/local/bin/aw-hayabusa-case-alert-rust "$@" + fi + exec /usr/bin/python3 /opt/activitywatch/aw-rus-ops/hayabusa/aw-hayabusa-case-alert.py "$@" dest: /usr/local/bin/aw-hayabusa-case-alert owner: root group: root diff --git a/ansible/deploy_aw_windows.yml b/ansible/deploy_aw_windows.yml index eec5eeb..e71a3a0 100644 --- a/ansible/deploy_aw_windows.yml +++ b/ansible/deploy_aw_windows.yml @@ -50,7 +50,7 @@ aw_windows_incident_artifacts_root: "{{ aw_windows_state_root }}\\incident-artifacts" aw_windows_forensics_root: "{{ aw_windows_state_root }}\\forensics\\evtx-exports" aw_windows_logon_marker_enabled: true - aw_windows_process_events_enabled: true + aw_windows_process_events_enabled: false aw_windows_skip_hardening: false aw_windows_rules_path: "{{ aw_windows_deploy_root }}\\windows\\web-category-rules.example.json" aw_windows_policy_path: "{{ aw_windows_deploy_root }}\\windows\\dlp-policy.example.json" @@ -58,6 +58,10 @@ aw_windows_validation_local_dir: "/tmp/aw-rus-validation-{{ lookup('env','USER') | default('ansible', true) }}" aw_windows_launch_task_pattern: "ActivityWatch Launch *" aw_windows_recovery_task_name: "ActivityWatch Recovery" + aw_windows_collector_guard_enabled: true + aw_windows_collector_guard_mode: "enforce" + aw_windows_collector_guard_service_name: "AWatchRusCollectorGuard" + aw_windows_collector_guard_loop_seconds: 60 aw_windows_force_task_restart: true aw_windows_api_smoke_check_enabled: true aw_windows_api_smoke_check_bucket: "" @@ -181,6 +185,9 @@ - deploy-domain-users.ps1 - deploy-ensemble.ps1 - hardening-recovery.ps1 + - AWatchRusCollectorGuardService.cs + - aw-collector-guard.ps1 + - install-collector-guard-service.ps1 - rebuild-worktime-tasks.ps1 - audit-cryptopro.ps1 - validate-deployment.ps1 @@ -371,27 +378,50 @@ Start-Sleep -Seconds 2 + $modulePath = "{{ aw_windows_deploy_root }}\windows\ActivityWatch.Windows.Common.psm1" + Import-Module $modulePath -Force + # Ensure tasks are enabled (some environments keep them disabled, causing "0s" in WebUI). try { Enable-ScheduledTask -TaskName "{{ aw_windows_recovery_task_name }}" -ErrorAction SilentlyContinue | Out-Null } catch {} $config = Get-Content -Raw -LiteralPath "{{ aw_windows_state_root }}\deployment-config.json" | ConvertFrom-Json - foreach ($taskDef in @($config.userTasks)) { - try { Enable-ScheduledTask -TaskName ([string]$taskDef.launchTaskName) -ErrorAction SilentlyContinue | Out-Null } catch {} + $configPaths = Get-ActivityWatchRecoveryConfigPaths -PrimaryConfigPath "{{ aw_windows_state_root }}\deployment-config.json" + $taskDefs = @(Get-ActivityWatchRecoveryTaskDefinitions -ConfigPaths $configPaths) + $sessionRecords = @(Get-ActivityWatchSessionRecords) + + foreach ($taskDef in @($taskDefs)) { + try { Enable-ScheduledTask -TaskName ([string]$taskDef.taskName) -ErrorAction SilentlyContinue | Out-Null } catch {} } $recoveryTask = Get-ScheduledTask -TaskName "{{ aw_windows_recovery_task_name }}" -ErrorAction SilentlyContinue if ($recoveryTask -and $recoveryTask.State -notin @('Running', 'Queued')) { Start-ScheduledTask -TaskName "{{ aw_windows_recovery_task_name }}" } - foreach ($taskDef in @($config.userTasks)) { - $launchTask = Get-ScheduledTask -TaskName ([string]$taskDef.launchTaskName) -ErrorAction SilentlyContinue + foreach ($taskDef in @($taskDefs)) { + if (-not (Test-ActivityWatchUserHasManagedSession -UserId ([string]$taskDef.userId) -SessionRecords $sessionRecords -IncludeLive -IncludeDisconnected)) { + continue + } + $launchTask = Get-ScheduledTask -TaskName ([string]$taskDef.taskName) -ErrorAction SilentlyContinue if ($launchTask -and $launchTask.State -notin @('Running', 'Queued')) { - Start-ScheduledTask -TaskName ([string]$taskDef.launchTaskName) -ErrorAction SilentlyContinue + Start-ScheduledTask -TaskName ([string]$taskDef.taskName) -ErrorAction SilentlyContinue } } + - name: Установить session-aware AWatch-rus Collector Guard service + when: aw_windows_collector_guard_enabled | bool + ansible.windows.win_powershell: + script: | + $ErrorActionPreference = 'Stop' + $guardParams = @{ + ConfigPath = "{{ aw_windows_state_root }}\deployment-config.json" + Mode = "{{ aw_windows_collector_guard_mode }}" + ServiceName = "{{ aw_windows_collector_guard_service_name }}" + LoopSeconds = {{ aw_windows_collector_guard_loop_seconds | int }} + } + & "{{ aw_windows_deploy_root }}\windows\install-collector-guard-service.ps1" @guardParams + - name: Получить Windows hostname для AW smoke-check bucket when: - aw_windows_api_smoke_check_enabled | bool diff --git a/ansible/deploy_proxmox_web_gateway.yml b/ansible/deploy_proxmox_web_gateway.yml index a912393..744a752 100644 --- a/ansible/deploy_proxmox_web_gateway.yml +++ b/ansible/deploy_proxmox_web_gateway.yml @@ -14,67 +14,94 @@ proxmox_web_gateway_tls_cert_path: "{{ proxmox_web_gateway_tls_dir }}/fullchain.pem" proxmox_web_gateway_tls_key_path: "{{ proxmox_web_gateway_tls_dir }}/privkey.pem" proxmox_web_gateway_health_path: "/healthz" + proxmox_web_gateway_auth_realm: "DetMir operations gateway" + proxmox_web_gateway_auth_user: "detmir" + proxmox_web_gateway_auth_file: "/etc/nginx/proxmox-web-gateway.htpasswd" + proxmox_web_gateway_credentials_file: "/root/proxmox-web-gateway.credentials" proxmox_web_gateway_routes: - slug: "proxmox-gui" title: "Proxmox VE" category: "Host" description: "Основная панель управления Proxmox VE." target_url: "https://10.10.10.2:8006/" + external_enabled: false - slug: "file1c-brief" title: "1C Executive Brief" category: "Management" description: "Сводка по предприятиям и рискам 1С." target_url: "http://10.10.10.2:8710/manager/brief" + proxy_path: "/r/file1c/brief" + proxy_target_url: "http://10.10.10.2:8710/manager/brief" + external_enabled: true - slug: "file1c-actions" title: "1C Management Actions" category: "Management" description: "Очередь действий по предприятиям в 1С." target_url: "http://10.10.10.2:8710/manager/actions" + proxy_path: "/r/file1c/actions" + proxy_target_url: "http://10.10.10.2:8710/manager/actions" + external_enabled: true - slug: "file1c-finance" title: "1C Financial Reporting" category: "Management" description: "Первый financial board по файловой 1С с разделением ledger/proxy." target_url: "http://10.10.10.11:3000/d/1c-file-finance/1c-file-financial-reporting?orgId=1" + proxy_path: "/d/1c-file-finance/1c-file-financial-reporting?orgId=1" + external_enabled: true - slug: "file1c-telemetry" title: "1C Telemetry Board" category: "Dashboards" description: "Read-only telemetry экран по состоянию файловых баз, reglog и host." target_url: "http://10.10.10.11:3000/d/1c-file-telemetry/1c-file-telemetry-board?orgId=1" + proxy_path: "/d/1c-file-telemetry/1c-file-telemetry-board?orgId=1" + external_enabled: true - slug: "grafana-1c" title: "Grafana 1C" category: "Dashboards" description: "Рабочий file-1c dashboard contour в внешней Grafana." target_url: "http://10.10.10.11:3000/d/1c-file-mgmt/1c-file-management-board?orgId=1" + proxy_path: "/d/1c-file-mgmt/1c-file-management-board?orgId=1" + external_enabled: true - slug: "clickhouse-http" title: "ClickHouse HTTP" category: "Data" description: "HTTP endpoint ClickHouse для file-1C analytics." target_url: "http://10.10.10.2:8123/" + external_enabled: false - slug: "influxdb" title: "InfluxDB" category: "Data" description: "InfluxDB LXC на CT 200." target_url: "http://10.10.10.10:8086/" + external_enabled: false - slug: "grafana-core" title: "Grafana Core" category: "Dashboards" description: "Отдельный Grafana CT 201." target_url: "http://10.10.10.11:3000/" + proxy_path: "/dashboards" + external_enabled: true - slug: "loki-alloy" title: "Grafana Alloy" category: "Logs" description: "Web UI Alloy на CT 202." target_url: "http://10.10.10.12:12345/" + external_enabled: false - slug: "aw-ui" title: "AW-rus UI" category: "Operations" description: "Основной ActivityWatch-Russian Web UI на CT 203." target_url: "http://10.10.10.13:5600/" + proxy_path: "/r/aw/" + external_enabled: true - slug: "aw-worktime" title: "AW-rus Management Report" category: "Operations" description: "Управленческий worktime/report API на CT 203." target_url: "http://10.10.10.13:5610/reports/worktime/management?day=today" + proxy_path: "/r/aw-worktime" + proxy_target_url: "http://10.10.10.13:5610/reports/worktime/management?day=today" + external_enabled: true tasks: - name: Установить nginx @@ -123,6 +150,50 @@ args: creates: "{{ proxmox_web_gateway_tls_cert_path }}" + - name: Проверить наличие gateway Basic Auth файла + ansible.builtin.stat: + path: "{{ proxmox_web_gateway_auth_file }}" + register: proxmox_web_gateway_auth_stat + + - name: Создать gateway Basic Auth credential при первом запуске + ansible.builtin.shell: | + set -eu + umask 077 + password="$(openssl rand -base64 24 | tr -d '\n')" + hash="$(openssl passwd -apr1 "$password")" + printf '%s:%s\n' '{{ proxmox_web_gateway_auth_user }}' "$hash" > '{{ proxmox_web_gateway_auth_file }}' + printf 'url=https://{{ proxmox_web_gateway_public_hostname }}/\nuser={{ proxmox_web_gateway_auth_user }}\npassword=%s\n' "$password" > '{{ proxmox_web_gateway_credentials_file }}' + args: + executable: /bin/sh + creates: "{{ proxmox_web_gateway_auth_file }}" + no_log: true + when: not proxmox_web_gateway_auth_stat.stat.exists + + - name: Зафиксировать права gateway Basic Auth файла + ansible.builtin.file: + path: "{{ proxmox_web_gateway_auth_file }}" + owner: root + group: www-data + mode: "0640" + + - name: Зафиксировать права файла с gateway credential + ansible.builtin.file: + path: "{{ proxmox_web_gateway_credentials_file }}" + owner: root + group: root + mode: "0600" + + - name: Прочитать gateway credential для локальной проверки + ansible.builtin.slurp: + src: "{{ proxmox_web_gateway_credentials_file }}" + register: proxmox_web_gateway_credentials_slurp + no_log: true + + - name: Подготовить gateway auth password для проверок + ansible.builtin.set_fact: + proxmox_web_gateway_auth_password: "{{ (proxmox_web_gateway_credentials_slurp.content | b64decode).split('password=')[1].split('\n')[0] }}" + no_log: true + - name: Развернуть index.html gateway ansible.builtin.template: src: "templates/proxmox-web-gateway-index.html.j2" @@ -180,25 +251,36 @@ - proxmox_web_gateway_http_redirect.location != "https://{{ proxmox_web_gateway_public_hostname }}/" changed_when: false - - name: Проверить redirect на Proxmox GUI + - name: Проверить старый /go путь без auth закрыт ansible.builtin.uri: url: "https://127.0.0.1/go/proxmox-gui" headers: Host: "{{ proxmox_web_gateway_public_hostname }}" validate_certs: false follow_redirects: none - status_code: 302 + status_code: 401 register: proxmox_web_gateway_redirect failed_when: - - proxmox_web_gateway_redirect.status != 302 - - proxmox_web_gateway_redirect.location != "https://10.10.10.2:8006/" + - proxmox_web_gateway_redirect.status != 401 changed_when: false - - name: Проверить index по public hostname + - name: Проверить index без auth закрыт ansible.builtin.uri: url: "https://127.0.0.1/" headers: Host: "{{ proxmox_web_gateway_public_hostname }}" + validate_certs: false + status_code: 401 + changed_when: false + + - name: Проверить index по public hostname с auth + ansible.builtin.uri: + url: "https://127.0.0.1/" + headers: + Host: "{{ proxmox_web_gateway_public_hostname }}" + url_username: "{{ proxmox_web_gateway_auth_user }}" + url_password: "{{ proxmox_web_gateway_auth_password }}" + force_basic_auth: true return_content: true validate_certs: false register: proxmox_web_gateway_named_index @@ -207,6 +289,69 @@ - proxmox_web_gateway_public_hostname not in proxmox_web_gateway_named_index.content changed_when: false + - name: Проверить reverse proxy к 1C brief с auth + ansible.builtin.uri: + url: "https://127.0.0.1/r/file1c/brief" + headers: + Host: "{{ proxmox_web_gateway_public_hostname }}" + url_username: "{{ proxmox_web_gateway_auth_user }}" + url_password: "{{ proxmox_web_gateway_auth_password }}" + force_basic_auth: true + validate_certs: false + status_code: 200 + changed_when: false + + - name: Проверить reverse proxy к Grafana health с auth + ansible.builtin.uri: + url: "https://127.0.0.1/r/grafana/api/health" + headers: + Host: "{{ proxmox_web_gateway_public_hostname }}" + url_username: "{{ proxmox_web_gateway_auth_user }}" + url_password: "{{ proxmox_web_gateway_auth_password }}" + force_basic_auth: true + validate_certs: false + status_code: 200 + changed_when: false + + - name: Проверить reverse proxy к AW info с auth + ansible.builtin.uri: + url: "https://127.0.0.1/r/aw/api/0/info" + headers: + Host: "{{ proxmox_web_gateway_public_hostname }}" + url_username: "{{ proxmox_web_gateway_auth_user }}" + url_password: "{{ proxmox_web_gateway_auth_password }}" + force_basic_auth: true + validate_certs: false + status_code: 200 + changed_when: false + + - name: Проверить browser-origin AW API query без 403 + ansible.builtin.uri: + url: "https://127.0.0.1/api/0/query/" + method: POST + headers: + Host: "{{ proxmox_web_gateway_public_hostname }}" + Origin: "https://{{ proxmox_web_gateway_public_hostname }}" + Referer: "https://{{ proxmox_web_gateway_public_hostname }}/r/aw/" + Content-Type: "application/json" + body_format: json + body: + timeperiods: + - "1970-01-01T00:00:00+00:00/1970-01-01T00:01:00+00:00" + query: + - "RETURN = [];" + url_username: "{{ proxmox_web_gateway_auth_user }}" + url_password: "{{ proxmox_web_gateway_auth_password }}" + force_basic_auth: true + validate_certs: false + status_code: 200 + return_content: true + register: proxmox_web_gateway_aw_query + failed_when: + - proxmox_web_gateway_aw_query.status != 200 + - proxmox_web_gateway_aw_query.content != "[[]]" + changed_when: false + - name: Проверить локальный HTTPS health endpoint gateway ansible.builtin.uri: url: "https://127.0.0.1{{ proxmox_web_gateway_health_path }}" diff --git a/ansible/deploy_tsj_guardian_bot_proxmox.yml b/ansible/deploy_tsj_guardian_bot_proxmox.yml index aaaedca..ed3e24e 100644 --- a/ansible/deploy_tsj_guardian_bot_proxmox.yml +++ b/ansible/deploy_tsj_guardian_bot_proxmox.yml @@ -12,6 +12,10 @@ tsj_bot_script_name: "tsj_guardian_bot.py" tsj_bot_script_dest: "{{ tsj_bot_root }}/{{ tsj_bot_script_name }}" tsj_bot_source_local_path: "{{ aw_repo_root }}/proxmox/tsj_guardian_bot.py" + tsj_bot_watchdog_name: "tsj_guardian_watchdog.sh" + tsj_bot_watchdog_dest: "{{ tsj_bot_root }}/{{ tsj_bot_watchdog_name }}" + tsj_bot_watchdog_source_local_path: "{{ aw_repo_root }}/proxmox/{{ tsj_bot_watchdog_name }}" + tsj_bot_watchdog_service_name: "tsj-guardian-watchdog.service" tsj_bot_openvpn_helper_name: "pfsense_openvpn_client_export.php" tsj_bot_openvpn_helper_dest: "{{ tsj_bot_root }}/{{ tsj_bot_openvpn_helper_name }}" tsj_bot_openvpn_helper_source_local_path: "{{ aw_repo_root }}/proxmox/{{ tsj_bot_openvpn_helper_name }}" @@ -20,6 +24,16 @@ tsj_bot_state_dir: "{{ tsj_bot_runtime_root }}/.state" tsj_bot_logs_dir: "{{ tsj_bot_runtime_root }}/logs" tsj_bot_default_chat_id: "{{ telegram_default_chat_id | default(telegram_allowed_chat_ids.split(',')[0]) }}" + aw_rust_release_dir: "{{ (lookup('env', 'CARGO_TARGET_DIR') | default(aw_repo_root + '/adk-rust/target', true)) + '/release' }}" + tsj_guardian_status_required_flags: + - "--status-text" + - "--incident-suggestions" + - "--incident-defer-decision" + - "--escalation-decision" + - "--operator-action-decision" + - "--dlp-policy-decision" + - "--confirmation-decision" + - "--autoheal-plan-decision" pre_tasks: - name: Проверить наличие существующего .env бота на хосте @@ -62,6 +76,42 @@ delegate_to: localhost become: false + - name: Проверить наличие watchdog скрипта на контроллере + ansible.builtin.stat: + path: "{{ tsj_bot_watchdog_source_local_path }}" + register: tsj_bot_watchdog_stat + delegate_to: localhost + become: false + + - name: Проверить локальный Rust TSJ guardian status helper + ansible.builtin.stat: + path: "{{ aw_rust_release_dir }}/tsj-guardian-status" + register: tsj_guardian_status_rust_binary + delegate_to: localhost + become: false + + - name: Остановить выполнение если Rust TSJ guardian status helper не найден + ansible.builtin.assert: + that: + - tsj_guardian_status_rust_binary.stat.exists + - tsj_guardian_status_rust_binary.stat.isreg + fail_msg: "Rust helper не найден: {{ aw_rust_release_dir }}/tsj-guardian-status. Соберите binary и проверьте CARGO_TARGET_DIR." + + - name: Проверить контракт локального Rust TSJ guardian status helper + ansible.builtin.command: + cmd: "{{ aw_rust_release_dir }}/tsj-guardian-status --help" + register: tsj_guardian_status_help + changed_when: false + delegate_to: localhost + become: false + + - name: Остановить выполнение если Rust TSJ guardian status helper устарел + ansible.builtin.assert: + that: + - item in tsj_guardian_status_help.stdout + fail_msg: "Rust helper {{ aw_rust_release_dir }}/tsj-guardian-status не поддерживает {{ item }}. Проверьте CARGO_TARGET_DIR и пересоберите helper." + loop: "{{ tsj_guardian_status_required_flags }}" + - name: Остановить выполнение если helper для OpenVPN не найден ansible.builtin.assert: that: @@ -69,6 +119,13 @@ - tsj_bot_openvpn_helper_stat.stat.isreg fail_msg: "Файл helper для OpenVPN не найден: {{ tsj_bot_openvpn_helper_source_local_path }}" + - name: Остановить выполнение если watchdog не найден + ansible.builtin.assert: + that: + - tsj_bot_watchdog_stat.stat.exists + - tsj_bot_watchdog_stat.stat.isreg + fail_msg: "Файл watchdog не найден: {{ tsj_bot_watchdog_source_local_path }}" + tasks: - name: Установить зависимости Python для бота ansible.builtin.package: @@ -99,6 +156,16 @@ mode: "0750" notify: Restart tsj bot + - name: Развернуть Rust helper статуса TSJ guardian + ansible.builtin.copy: + src: "{{ aw_rust_release_dir }}/tsj-guardian-status" + dest: /usr/local/bin/tsj-guardian-status + owner: root + group: root + mode: "0755" + when: tsj_guardian_status_rust_binary.stat.exists | default(false) + notify: Restart tsj bot + - name: Развернуть helper для OpenVPN экспорта ansible.builtin.copy: src: "{{ tsj_bot_openvpn_helper_source_local_path }}" @@ -108,12 +175,21 @@ mode: "0640" notify: Restart tsj bot + - name: Развернуть watchdog скрипт бота + ansible.builtin.copy: + src: "{{ tsj_bot_watchdog_source_local_path }}" + dest: "{{ tsj_bot_watchdog_dest }}" + owner: root + group: root + mode: "0755" + - name: Сгенерировать полный .env бота when: - telegram_bot_token is defined - (telegram_bot_token | string | length) > 20 - telegram_allowed_chat_ids is defined - (telegram_allowed_chat_ids | string | length) > 0 + no_log: true ansible.builtin.copy: dest: "{{ tsj_bot_env_path }}" owner: "{{ tsj_bot_user }}" @@ -142,6 +218,7 @@ LOG_FILE={{ tsj_bot_log_file | default(tsj_bot_runtime_root + '/logs/tsj_guardian_bot.log') }} HEARTBEAT_FILE={{ tsj_bot_heartbeat_file | default(tsj_bot_runtime_root + '/.state/tsj_guardian_heartbeat') }} CHECK_INTERVAL_SEC={{ tsj_bot_check_interval_sec | default(60) }} + INCIDENT_FAILURE_QUORUM_CHECKS={{ tsj_bot_incident_failure_quorum_checks | default(2) }} OPERATOR_TIMEOUT_SEC={{ tsj_bot_operator_timeout_sec | default(900) }} RETRY_AUTORECOVERY_EVERY_SEC={{ tsj_bot_retry_autorecovery_every_sec | default(300) }} EXIT_ON_AUTORECOVERY_SUCCESS={{ tsj_bot_exit_on_autorecovery_success | default('true') }} @@ -175,18 +252,25 @@ SERVER_FALLBACK_COMMANDS={{ tsj_bot_server_fallback_commands | default(tsj_bot_runtime_root + '/scripts/system_self_support.sh --heal') }} UPDATES_SCRIPT={{ tsj_bot_updates_script | default('/usr/bin/python3 ' + tsj_bot_runtime_root + '/scripts/proxmox_lxc_critical_updates.py') }} UPDATE_TARGETS={{ tsj_bot_update_targets | default('auto') }} + DETMIR_AI_STATE_FILE={{ tsj_bot_detmir_ai_state_file | default('/var/lib/detmir-ai/latest-state.json') }} + TSJ_GUARDIAN_STATUS_BIN={{ tsj_bot_guardian_status_bin | default('/usr/local/bin/tsj-guardian-status') }} AW_RUS_API_BASE={{ tsj_bot_aw_rus_api_base | default('http://10.10.10.13:5600/api/0') }} AW_RUS_WORKTIME_BASE={{ tsj_bot_aw_rus_worktime_base | default('http://10.10.10.13:5610') }} AW_DLP_POLICY_API_BASE={{ tsj_bot_aw_dlp_policy_api_base | default('http://10.10.10.13:5601/api/0') }} AW_DLP_POLICY_ACTOR={{ tsj_bot_aw_dlp_policy_actor | default('tsj-guardian-bot') }} - AW_RUS_WORKTIME_HEAL_CMD={{ tsj_bot_aw_rus_worktime_heal_cmd | default("sshpass -p '04091968' ssh -o PubkeyAuthentication=no -o StrictHostKeyChecking=no igor@10.10.10.13 'sudo -S /usr/local/bin/aw-worktime-autoheal.sh && sudo -S systemctl reset-failed aw-worktime-ui-bridge.service && sudo -S systemctl start aw-worktime-ui-bridge.service'") }} - AW_RUS_DLP_HEAL_CMD={{ tsj_bot_aw_rus_dlp_heal_cmd | default("sshpass -p '04091968' ssh -o PubkeyAuthentication=no -o StrictHostKeyChecking=no igor@10.10.10.13 'sudo -S systemctl restart activitywatch-server.service && sudo -S systemctl start activitywatch-dlp-aggregator.service || true && sudo -S /usr/local/bin/aw-health-check && sudo -S /usr/local/bin/dlp-health-check'") }} + AW_RUS_WORKTIME_HEAL_CMD={{ tsj_bot_aw_rus_worktime_heal_cmd | default("ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new igor@10.10.10.13 'sudo -n /usr/local/bin/aw-worktime-autoheal.sh && sudo -n systemctl reset-failed aw-worktime-ui-bridge.service && sudo -n systemctl start aw-worktime-ui-bridge.service'") }} + AW_RUS_DLP_HEAL_CMD={{ tsj_bot_aw_rus_dlp_heal_cmd | default("ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new igor@10.10.10.13 'sudo -n systemctl restart activitywatch-server.service && (sudo -n systemctl start activitywatch-dlp-aggregator.service || true) && sudo -n /usr/local/bin/aw-health-check && sudo -n /usr/local/bin/dlp-health-check'") }} AW_RUS_CASE_API_BASE={{ tsj_bot_aw_rus_case_api_base | default('http://10.10.10.13:5602') }} AW_RUS_HAYABUSA_ENABLED={{ tsj_bot_aw_rus_hayabusa_enabled | default('true') }} - AW_RUS_HAYABUSA_SSH_CMD={{ tsj_bot_aw_rus_hayabusa_ssh_cmd | default("sshpass -p '04091968' ssh -o PubkeyAuthentication=no -o StrictHostKeyChecking=no igor@10.10.10.13") }} + AW_RUS_HAYABUSA_SSH_CMD={{ tsj_bot_aw_rus_hayabusa_ssh_cmd | default("ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new igor@10.10.10.13") }} AW_RUS_HOST={{ tsj_bot_aw_rus_host | default('SHARKON2025') }} AW_RUS_PRIMARY_USER={{ tsj_bot_aw_rus_primary_user | default('USER1') }} AW_RUS_STALE_SEC={{ tsj_bot_aw_rus_stale_sec | default(900) }} + AW_RUS_SLO_ENABLED={{ tsj_bot_aw_rus_slo_enabled | default('true') }} + AW_RUS_SLO_ALERT_WINDOW={{ tsj_bot_aw_rus_slo_alert_window | default('24h') }} + AW_RUS_SLO_MIN_SAMPLES={{ tsj_bot_aw_rus_slo_min_samples | default(4) }} + AW_RUS_SLO_MAX_AGE_SEC={{ tsj_bot_aw_rus_slo_max_age_sec | default(90) }} + AW_RUS_SLO_SUMMARY_CMD={{ tsj_bot_aw_rus_slo_summary_cmd | default("ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new igor@10.10.10.13 'cat /var/lib/activitywatch/slo/aw-slo-summary.json'") }} AW_RUS_WINDOWS_HOST={{ tsj_bot_aw_rus_windows_host | default(hostvars[(groups['aw_windows'] | first)].ansible_host | default('192.168.100.18')) }} AW_RUS_WINDOWS_SSH_USER={{ tsj_bot_aw_rus_windows_ssh_user | default(hostvars[(groups['aw_windows'] | first)].ansible_user | default('Администратор')) }} AW_RUS_WINDOWS_SSH_PASSWORD={{ tsj_bot_aw_rus_windows_ssh_password | default(hostvars[(groups['aw_windows'] | first)].ansible_password | default('')) }} @@ -207,6 +291,7 @@ (telegram_allowed_chat_ids | string | length) > 0 ) - tsj_bot_existing_env.stat.exists | default(false) + no_log: true ansible.builtin.lineinfile: path: "{{ tsj_bot_env_path }}" regexp: "^{{ item.key }}=" @@ -220,14 +305,19 @@ - { key: "AW_RUS_WORKTIME_BASE", value: "{{ tsj_bot_aw_rus_worktime_base | default('http://10.10.10.13:5610') }}" } - { key: "AW_DLP_POLICY_API_BASE", value: "{{ tsj_bot_aw_dlp_policy_api_base | default('http://10.10.10.13:5601/api/0') }}" } - { key: "AW_DLP_POLICY_ACTOR", value: "{{ tsj_bot_aw_dlp_policy_actor | default('tsj-guardian-bot') }}" } - - { key: "AW_RUS_WORKTIME_HEAL_CMD", value: "{{ tsj_bot_aw_rus_worktime_heal_cmd | default(\"sshpass -p '04091968' ssh -o PubkeyAuthentication=no -o StrictHostKeyChecking=no igor@10.10.10.13 'sudo -S /usr/local/bin/aw-worktime-autoheal.sh && sudo -S systemctl reset-failed aw-worktime-ui-bridge.service && sudo -S systemctl start aw-worktime-ui-bridge.service'\") }}" } - - { key: "AW_RUS_DLP_HEAL_CMD", value: "{{ tsj_bot_aw_rus_dlp_heal_cmd | default(\"sshpass -p '04091968' ssh -o PubkeyAuthentication=no -o StrictHostKeyChecking=no igor@10.10.10.13 'sudo -S systemctl restart activitywatch-server.service && sudo -S systemctl start activitywatch-dlp-aggregator.service || true && sudo -S /usr/local/bin/aw-health-check && sudo -S /usr/local/bin/dlp-health-check'\") }}" } + - { key: "AW_RUS_WORKTIME_HEAL_CMD", value: "{{ tsj_bot_aw_rus_worktime_heal_cmd | default(\"ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new igor@10.10.10.13 'sudo -n /usr/local/bin/aw-worktime-autoheal.sh && sudo -n systemctl reset-failed aw-worktime-ui-bridge.service && sudo -n systemctl start aw-worktime-ui-bridge.service'\") }}" } + - { key: "AW_RUS_DLP_HEAL_CMD", value: "{{ tsj_bot_aw_rus_dlp_heal_cmd | default(\"ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new igor@10.10.10.13 'sudo -n systemctl restart activitywatch-server.service && (sudo -n systemctl start activitywatch-dlp-aggregator.service || true) && sudo -n /usr/local/bin/aw-health-check && sudo -n /usr/local/bin/dlp-health-check'\") }}" } - { key: "AW_RUS_CASE_API_BASE", value: "{{ tsj_bot_aw_rus_case_api_base | default('http://10.10.10.13:5602') }}" } - { key: "AW_RUS_HAYABUSA_ENABLED", value: "{{ tsj_bot_aw_rus_hayabusa_enabled | default('true') }}" } - - { key: "AW_RUS_HAYABUSA_SSH_CMD", value: "{{ tsj_bot_aw_rus_hayabusa_ssh_cmd | default(\"sshpass -p '04091968' ssh -o PubkeyAuthentication=no -o StrictHostKeyChecking=no igor@10.10.10.13\") }}" } + - { key: "AW_RUS_HAYABUSA_SSH_CMD", value: "{{ tsj_bot_aw_rus_hayabusa_ssh_cmd | default(\"ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new igor@10.10.10.13\") }}" } - { key: "AW_RUS_HOST", value: "{{ tsj_bot_aw_rus_host | default('SHARKON2025') }}" } - { key: "AW_RUS_PRIMARY_USER", value: "{{ tsj_bot_aw_rus_primary_user | default('USER1') }}" } - { key: "AW_RUS_STALE_SEC", value: "{{ tsj_bot_aw_rus_stale_sec | default(900) }}" } + - { key: "AW_RUS_SLO_ENABLED", value: "{{ tsj_bot_aw_rus_slo_enabled | default('true') }}" } + - { key: "AW_RUS_SLO_ALERT_WINDOW", value: "{{ tsj_bot_aw_rus_slo_alert_window | default('24h') }}" } + - { key: "AW_RUS_SLO_MIN_SAMPLES", value: "{{ tsj_bot_aw_rus_slo_min_samples | default(4) }}" } + - { key: "AW_RUS_SLO_MAX_AGE_SEC", value: "{{ tsj_bot_aw_rus_slo_max_age_sec | default(90) }}" } + - { key: "AW_RUS_SLO_SUMMARY_CMD", value: "{{ tsj_bot_aw_rus_slo_summary_cmd | default(\"ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new igor@10.10.10.13 'cat /var/lib/activitywatch/slo/aw-slo-summary.json'\") }}" } - { key: "AW_RUS_WINDOWS_HOST", value: "{{ tsj_bot_aw_rus_windows_host | default(hostvars[(groups['aw_windows'] | first)].ansible_host | default('192.168.100.18')) }}" } - { key: "AW_RUS_WINDOWS_SSH_USER", value: "{{ tsj_bot_aw_rus_windows_ssh_user | default(hostvars[(groups['aw_windows'] | first)].ansible_user | default('Администратор')) }}" } - { key: "AW_RUS_WINDOWS_SSH_PASSWORD", value: "{{ tsj_bot_aw_rus_windows_ssh_password | default(hostvars[(groups['aw_windows'] | first)].ansible_password | default('')) }}" } @@ -239,6 +329,9 @@ - { key: "AW_RUS_WINDOWS_EMAIL_COLLECTOR_PATH", value: "{{ tsj_bot_aw_rus_windows_email_collector_path | default('C:\\ProgramData\\AWatch-rus\\email-outbound-collector.ps1') }}" } - { key: "AI_CHAT_WORKDIR", value: "{{ tsj_bot_ai_chat_workdir | default('/home/igor') }}" } - { key: "AI_EXEC_USER", value: "{{ tsj_bot_ai_exec_user | default('igor') }}" } + - { key: "INCIDENT_FAILURE_QUORUM_CHECKS", value: "{{ tsj_bot_incident_failure_quorum_checks | default(2) }}" } + - { key: "DETMIR_AI_STATE_FILE", value: "{{ tsj_bot_detmir_ai_state_file | default('/var/lib/detmir-ai/latest-state.json') }}" } + - { key: "TSJ_GUARDIAN_STATUS_BIN", value: "{{ tsj_bot_guardian_status_bin | default('/usr/local/bin/tsj-guardian-status') }}" } - { key: "TMUX_USER", value: "{{ tsj_bot_tmux_user | default('igor') }}" } - { key: "PFSENSE_ENV_PATH", value: "{{ tsj_bot_pfsense_env_path | default('/home/igor/.config/tsj-bot/pfsense.env.readonly') }}" } - { key: "PFSENSE_INVENTORY_PATH", value: "{{ tsj_bot_pfsense_inventory_path | default('/home/igor/.config/tsj-bot/inventory.md') }}" } @@ -272,6 +365,33 @@ - Reload systemd - Restart tsj bot + - name: Установить systemd unit watchdog бота + ansible.builtin.copy: + dest: "/etc/systemd/system/{{ tsj_bot_watchdog_service_name }}" + owner: root + group: root + mode: "0644" + content: | + [Unit] + Description=TSJ Guardian Bot Heartbeat Watchdog + After={{ tsj_bot_service_name }} gost-tg.service + Wants={{ tsj_bot_service_name }} gost-tg.service + + [Service] + Type=simple + User=root + ExecStart=/bin/bash -lc 'while true; do {{ tsj_bot_watchdog_dest }}; sleep 60; done' + StandardOutput=null + StandardError=null + Restart=always + RestartSec=5 + + [Install] + WantedBy=multi-user.target + notify: + - Reload systemd + - Restart tsj watchdog + - name: Проверить синтаксис Python скрипта бота ansible.builtin.command: "python3 -m py_compile {{ tsj_bot_script_dest }}" changed_when: false @@ -282,12 +402,24 @@ enabled: true state: started + - name: Включить и запустить watchdog бота + ansible.builtin.systemd: + name: "{{ tsj_bot_watchdog_service_name }}" + enabled: true + state: started + - name: Проверить что сервис активен ansible.builtin.command: "systemctl is-active {{ tsj_bot_service_name }}" register: tsj_bot_active changed_when: false failed_when: tsj_bot_active.stdout.strip() != "active" + - name: Проверить что watchdog активен + ansible.builtin.command: "systemctl is-active {{ tsj_bot_watchdog_service_name }}" + register: tsj_bot_watchdog_active + changed_when: false + failed_when: tsj_bot_watchdog_active.stdout.strip() != "active" + handlers: - name: Reload systemd ansible.builtin.systemd: @@ -297,3 +429,8 @@ ansible.builtin.systemd: name: "{{ tsj_bot_service_name }}" state: restarted + + - name: Restart tsj watchdog + ansible.builtin.systemd: + name: "{{ tsj_bot_watchdog_service_name }}" + state: restarted diff --git a/ansible/group_vars/all.example.yml b/ansible/group_vars/all.example.yml index 2bcd41e..6e5d732 100644 --- a/ansible/group_vars/all.example.yml +++ b/ansible/group_vars/all.example.yml @@ -32,6 +32,11 @@ aw_monitored_windows_hostname: "SHARKON2025" aw_rus_health_worktime_api_base: "http://127.0.0.1:5610" aw_rus_health_state_dir: "{{ aw_server_data_dir }}/health" aw_rus_health_validation_dir: "{{ aw_rus_health_state_dir }}/windows-validation" +aw_browser_smoke_enabled: true +aw_browser_smoke_engine: "chromium-cli" +aw_legacy_db_merge_enabled: false +aw_browser_smoke_timeout_ms: 20000 +aw_browser_smoke_render_timeout_ms: 15000 aw_hayabusa_auto_case_enabled: true aw_hayabusa_auto_case_min_severity: "medium" aw_hayabusa_telegram_enabled: true @@ -62,7 +67,7 @@ aw_worktime_from: "00:00" aw_worktime_to: "17:00" aw_worktime_start_of_day: "{{ aw_worktime_from }}" aw_server_always_active_pattern: "aw-watcher-window" -aw_server_landingpage: "/activity/SHARKON2025/view/" +aw_server_landingpage: "/#/activity/SHARKON2025/view/" aw_health_strict_fileops: 0 aw_dlp_policy_engine_enabled: true diff --git a/ansible/group_vars/all.yml b/ansible/group_vars/all.yml index af29ce2..bf3a9c6 100644 --- a/ansible/group_vars/all.yml +++ b/ansible/group_vars/all.yml @@ -74,5 +74,5 @@ aw_worktime_from: "00:00" aw_worktime_to: "17:00" aw_worktime_start_of_day: "{{ aw_worktime_from }}" aw_server_always_active_pattern: "aw-watcher-window" -aw_server_landingpage: "/activity/SHARKON2025/view/" +aw_server_landingpage: "/#/activity/SHARKON2025/view/" aw_health_strict_fileops: 0 diff --git a/ansible/group_vars/aw_windows.yml b/ansible/group_vars/aw_windows.yml index 8ca319d..21f26fe 100644 --- a/ansible/group_vars/aw_windows.yml +++ b/ansible/group_vars/aw_windows.yml @@ -59,7 +59,7 @@ aw_windows_evtx_channels: - Microsoft-Windows-TerminalServices-LocalSessionManager/Operational - Microsoft-Windows-TerminalServices-RemoteConnectionManager/Operational aw_windows_logon_marker_enabled: true -aw_windows_process_events_enabled: true +aw_windows_process_events_enabled: false aw_windows_skip_hardening: false aw_windows_rules_path: "{{ aw_windows_deploy_root }}\\windows\\web-category-rules.example.json" @@ -69,6 +69,11 @@ aw_windows_validation_remote_path: "{{ aw_windows_state_root }}\\aw_validate_ans aw_windows_validation_local_dir: "/tmp/aw-rus-validation-{{ lookup('env','USER') | default('ansible', true) }}" aw_windows_fail_on_validation_error: true +aw_windows_collector_guard_enabled: true +aw_windows_collector_guard_mode: "enforce" +aw_windows_collector_guard_service_name: "AWatchRusCollectorGuard" +aw_windows_collector_guard_loop_seconds: 60 + aw_windows_migration_enabled: false aw_windows_legacy_install_root: "C:\\Program Files\\ActivityWatch-Phase2" aw_windows_legacy_state_root: "C:\\ProgramData\\ActivityWatch-Phase2" diff --git a/ansible/group_vars/proxmox-bot.example.yml b/ansible/group_vars/proxmox-bot.example.yml index 5e6f490..cf8a1a9 100644 --- a/ansible/group_vars/proxmox-bot.example.yml +++ b/ansible/group_vars/proxmox-bot.example.yml @@ -8,6 +8,7 @@ tsj_bot_runtime_root: "/opt/infra-admin" # Optional bot tuning tsj_bot_check_interval_sec: 60 +tsj_bot_incident_failure_quorum_checks: 2 tsj_bot_operator_timeout_sec: 900 tsj_bot_retry_autorecovery_every_sec: 300 tsj_bot_telegram_proxy_url: "http://127.0.0.1:11090" @@ -56,6 +57,11 @@ tsj_bot_aw_rus_hayabusa_ssh_cmd: "sshpass -p 'CHANGE_ME' ssh -o PubkeyAuthentica tsj_bot_aw_rus_host: "SHARKON2025" tsj_bot_aw_rus_primary_user: "USER1" tsj_bot_aw_rus_stale_sec: 900 +tsj_bot_aw_rus_slo_enabled: "true" +tsj_bot_aw_rus_slo_alert_window: "24h" +tsj_bot_aw_rus_slo_min_samples: 4 +tsj_bot_aw_rus_slo_max_age_sec: 90 +tsj_bot_aw_rus_slo_summary_cmd: "sshpass -p 'CHANGE_ME' ssh -o PubkeyAuthentication=no -o StrictHostKeyChecking=no igor@10.10.10.13 'cat /var/lib/activitywatch/slo/aw-slo-summary.json'" tsj_bot_aw_rus_windows_host: "192.168.100.18" tsj_bot_aw_rus_windows_ssh_user: "Администратор" tsj_bot_aw_rus_windows_ssh_password: "CHANGE_ME" diff --git a/ansible/group_vars/windows.example.yml b/ansible/group_vars/windows.example.yml index ae3eee2..9f6ad99 100644 --- a/ansible/group_vars/windows.example.yml +++ b/ansible/group_vars/windows.example.yml @@ -50,7 +50,7 @@ aw_windows_evtx_channels: - Microsoft-Windows-TerminalServices-LocalSessionManager/Operational - Microsoft-Windows-TerminalServices-RemoteConnectionManager/Operational aw_windows_logon_marker_enabled: true -aw_windows_process_events_enabled: true +aw_windows_process_events_enabled: false aw_windows_skip_hardening: false aw_windows_rules_path: "{{ aw_windows_deploy_root }}\\windows\\web-category-rules.example.json" @@ -60,6 +60,13 @@ aw_windows_validation_remote_path: "{{ aw_windows_state_root }}\\aw_validate_ans aw_windows_validation_local_dir: "/tmp/aw-rus-validation-{{ lookup('env','USER') | default('ansible', true) }}" aw_windows_fail_on_validation_error: true +# Collector Guard supervises collectors, but ActivityWatch Recovery must stay enabled +# as a fallback and as the launch-task bootstrap path for managed RDP sessions. +aw_windows_collector_guard_enabled: true +aw_windows_collector_guard_mode: "enforce" +aw_windows_collector_guard_service_name: "AWatchRusCollectorGuard" +aw_windows_collector_guard_loop_seconds: 60 + # Безопасная миграция текущего прода со старых путей в единый профиль AWatch-rus. aw_windows_migration_enabled: true aw_windows_legacy_install_root: "C:\\Program Files\\ActivityWatch-Phase2" diff --git a/ansible/roles/dlp-integrations/tasks/main.yml b/ansible/roles/dlp-integrations/tasks/main.yml index ec1e3bd..fe0e340 100644 --- a/ansible/roles/dlp-integrations/tasks/main.yml +++ b/ansible/roles/dlp-integrations/tasks/main.yml @@ -68,6 +68,117 @@ - { src: "webhook-sender.service", dest: "/etc/systemd/system/aw-dlp-webhook-sender.service" } - { src: "webhook-sender.timer", dest: "/etc/systemd/system/aw-dlp-webhook-sender.timer" } +- name: Check local Rust CEF exporter + ansible.builtin.stat: + path: "{{ playbook_dir }}/../adk-rust/target/release/dlp-cef-exporter" + delegate_to: localhost + register: dlp_cef_exporter_rust_binary + become: false + +- name: Install Rust CEF exporter + ansible.builtin.copy: + src: "{{ playbook_dir }}/../adk-rust/target/release/dlp-cef-exporter" + dest: /usr/local/bin/dlp-cef-exporter-rust + owner: root + group: root + mode: "0755" + when: dlp_cef_exporter_rust_binary.stat.exists | default(false) + +- name: Ensure CEF exporter drop-in directory + ansible.builtin.file: + path: /etc/systemd/system/aw-dlp-cef-exporter.service.d + state: directory + owner: root + group: root + mode: "0755" + when: dlp_cef_exporter_rust_binary.stat.exists | default(false) + +- name: Switch CEF exporter to Rust + ansible.builtin.copy: + dest: /etc/systemd/system/aw-dlp-cef-exporter.service.d/20-rust-switch.conf + owner: root + group: root + mode: "0644" + content: | + [Service] + ExecStart= + ExecStart=/usr/local/bin/dlp-cef-exporter-rust + when: dlp_cef_exporter_rust_binary.stat.exists | default(false) + +- name: Check local Rust syslog forwarder + ansible.builtin.stat: + path: "{{ playbook_dir }}/../adk-rust/target/release/dlp-syslog-forwarder" + delegate_to: localhost + register: dlp_syslog_forwarder_rust_binary + become: false + +- name: Install Rust syslog forwarder + ansible.builtin.copy: + src: "{{ playbook_dir }}/../adk-rust/target/release/dlp-syslog-forwarder" + dest: /usr/local/bin/dlp-syslog-forwarder-rust + owner: root + group: root + mode: "0755" + when: dlp_syslog_forwarder_rust_binary.stat.exists | default(false) + +- name: Ensure syslog forwarder drop-in directory + ansible.builtin.file: + path: /etc/systemd/system/aw-dlp-syslog-forwarder.service.d + state: directory + owner: root + group: root + mode: "0755" + when: dlp_syslog_forwarder_rust_binary.stat.exists | default(false) + +- name: Switch syslog forwarder to Rust + ansible.builtin.copy: + dest: /etc/systemd/system/aw-dlp-syslog-forwarder.service.d/20-rust-switch.conf + owner: root + group: root + mode: "0644" + content: | + [Service] + ExecStart= + ExecStart=/usr/local/bin/dlp-syslog-forwarder-rust + when: dlp_syslog_forwarder_rust_binary.stat.exists | default(false) + +- name: Check local Rust webhook sender + ansible.builtin.stat: + path: "{{ playbook_dir }}/../adk-rust/target/release/dlp-webhook-sender" + delegate_to: localhost + register: dlp_webhook_sender_rust_binary + become: false + +- name: Install Rust webhook sender + ansible.builtin.copy: + src: "{{ playbook_dir }}/../adk-rust/target/release/dlp-webhook-sender" + dest: /usr/local/bin/dlp-webhook-sender-rust + owner: root + group: root + mode: "0755" + when: dlp_webhook_sender_rust_binary.stat.exists | default(false) + +- name: Ensure webhook sender drop-in directory + ansible.builtin.file: + path: /etc/systemd/system/aw-dlp-webhook-sender.service.d + state: directory + owner: root + group: root + mode: "0755" + when: dlp_webhook_sender_rust_binary.stat.exists | default(false) + +- name: Switch webhook sender to Rust + ansible.builtin.copy: + dest: /etc/systemd/system/aw-dlp-webhook-sender.service.d/20-rust-switch.conf + owner: root + group: root + mode: "0644" + content: | + [Service] + ExecStart= + ExecStart=/usr/local/bin/dlp-webhook-sender-rust + when: dlp_webhook_sender_rust_binary.stat.exists | default(false) + - name: Reload systemd ansible.builtin.systemd: daemon_reload: true diff --git a/ansible/templates/proxmox-web-gateway-index.html.j2 b/ansible/templates/proxmox-web-gateway-index.html.j2 index 7e69fa4..c77b3aa 100644 --- a/ansible/templates/proxmox-web-gateway-index.html.j2 +++ b/ansible/templates/proxmox-web-gateway-index.html.j2 @@ -78,6 +78,14 @@ transform: translateY(-2px); border-color: var(--accent); } + .card-disabled { + cursor: default; + opacity: 0.72; + } + .card-disabled:hover { + transform: none; + border-color: var(--line); + } .badge { display: inline-flex; align-items: center; @@ -124,9 +132,9 @@

Proxmox Web Gateway

Единая стартовая точка для web-сервисов контура на Proxmox host - 10.10.10.2. Gateway сознательно работает как - безопасный redirector: приложения не ломаются под subpath, а оператор - получает один адрес входа. + 10.10.10.2. Внешний вход закрыт gateway-auth, + а основные операторские страницы идут через reverse proxy без выдачи + внутренних адресов наружу.

Публичное имя gateway: {{ proxmox_web_gateway_public_hostname }} @@ -139,12 +147,21 @@

{% for route in proxmox_web_gateway_routes %} - - {{ route.category }} +{% set external_enabled = route.external_enabled | default(false) %} +{% if external_enabled and route.proxy_path is defined %} + +{% else %} + +{% endif %} {% endfor %}
diff --git a/ansible/templates/proxmox-web-gateway.conf.j2 b/ansible/templates/proxmox-web-gateway.conf.j2 index 4a91758..ea6aaeb 100644 --- a/ansible/templates/proxmox-web-gateway.conf.j2 +++ b/ansible/templates/proxmox-web-gateway.conf.j2 @@ -1,3 +1,8 @@ +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + server { listen 80 default_server; listen [::]:80 default_server; @@ -24,25 +29,238 @@ server { ssl_protocols TLSv1.2 TLSv1.3; ssl_prefer_server_ciphers off; + auth_basic "{{ proxmox_web_gateway_auth_realm }}"; + auth_basic_user_file {{ proxmox_web_gateway_auth_file }}; + + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header Referrer-Policy "no-referrer" always; + add_header X-Robots-Tag "noindex, nofollow, noarchive" always; + add_header Strict-Transport-Security "max-age=31536000" always; + root {{ proxmox_web_gateway_root }}; index index.html; location = {{ proxmox_web_gateway_health_path }} { + auth_basic off; default_type text/plain; return 200 "ok\n"; } location = /robots.txt { + auth_basic off; default_type text/plain; return 200 "User-agent: *\nDisallow: /\n"; } -{% for route in proxmox_web_gateway_routes %} - location = /go/{{ route.slug }} { - return 302 {{ route.target_url }}; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_read_timeout 120s; + proxy_send_timeout 120s; + + location /api/0/ { + proxy_set_header Origin ""; + proxy_set_header Authorization ""; + proxy_pass http://10.10.10.13:5600; + proxy_redirect off; } + + location /r/grafana/ { + proxy_set_header Authorization ""; + proxy_pass http://10.10.10.11:3000/; + proxy_redirect http://10.10.10.11:3000/ /r/grafana/; + } + + location = /login { + proxy_set_header Authorization ""; + proxy_pass http://10.10.10.11:3000; + proxy_redirect off; + } + + location = /logout { + proxy_set_header Authorization ""; + proxy_pass http://10.10.10.11:3000; + proxy_redirect off; + } + + location /public/ { + proxy_set_header Origin ""; + proxy_set_header Authorization ""; + proxy_pass http://10.10.10.11:3000; + proxy_redirect off; + } + + location /api/live/ { + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header Origin "$scheme://$host"; + proxy_set_header Authorization ""; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_pass http://10.10.10.11:3000; + proxy_redirect off; + } + + location /api/ { + proxy_set_header Origin ""; + proxy_set_header Authorization ""; + proxy_pass http://10.10.10.11:3000; + proxy_redirect off; + } + + location /apis/ { + proxy_set_header Origin ""; + proxy_set_header Authorization ""; + proxy_pass http://10.10.10.11:3000; + proxy_redirect off; + } + + location /d/ { + proxy_set_header Authorization ""; + proxy_pass http://10.10.10.11:3000; + proxy_redirect off; + } + + location /dashboards { + proxy_set_header Authorization ""; + proxy_pass http://10.10.10.11:3000; + proxy_redirect off; + } + + location /dashboard/ { + proxy_set_header Authorization ""; + proxy_pass http://10.10.10.11:3000; + proxy_redirect off; + } + + location /avatar/ { + proxy_set_header Authorization ""; + proxy_pass http://10.10.10.11:3000; + proxy_redirect off; + } + + location /profile/ { + proxy_set_header Authorization ""; + proxy_pass http://10.10.10.11:3000; + proxy_redirect off; + } + + location /org/ { + proxy_set_header Authorization ""; + proxy_pass http://10.10.10.11:3000; + proxy_redirect off; + } + + location /user/ { + proxy_set_header Authorization ""; + proxy_pass http://10.10.10.11:3000; + proxy_redirect off; + } + + location /plugins/ { + proxy_set_header Authorization ""; + proxy_pass http://10.10.10.11:3000; + proxy_redirect off; + } + + location /explore { + proxy_set_header Authorization ""; + proxy_pass http://10.10.10.11:3000; + proxy_redirect off; + } + + location /alerting/ { + proxy_set_header Authorization ""; + proxy_pass http://10.10.10.11:3000; + proxy_redirect off; + } + + location /connections/ { + proxy_set_header Authorization ""; + proxy_pass http://10.10.10.11:3000; + proxy_redirect off; + } + + location /datasources/ { + proxy_set_header Authorization ""; + proxy_pass http://10.10.10.11:3000; + proxy_redirect off; + } + + location /r/aw/ { + proxy_set_header Authorization ""; + proxy_pass http://10.10.10.13:5600/; + proxy_redirect http://10.10.10.13:5600/ /r/aw/; + } + + location /reports/worktime/ { + proxy_set_header Authorization ""; + proxy_pass http://10.10.10.13:5610; + proxy_redirect off; + } + + location = /dark.css { + proxy_set_header Authorization ""; + proxy_pass http://10.10.10.13:5600; + proxy_redirect off; + } + + location /css/ { + proxy_set_header Authorization ""; + proxy_pass http://10.10.10.13:5600; + proxy_redirect off; + } + + location = /js/aw-worktime-panel.js { + proxy_set_header Authorization ""; + proxy_pass http://10.10.10.13:5600; + proxy_redirect off; + sub_filter_once off; + sub_filter_types application/javascript text/javascript; + sub_filter 'http://10.10.10.13:5610' ''; + } + + location /js/ { + proxy_set_header Authorization ""; + proxy_pass http://10.10.10.13:5600; + proxy_redirect off; + } + + location /img/ { + proxy_set_header Authorization ""; + proxy_pass http://10.10.10.13:5600; + proxy_redirect off; + } + + location /fonts/ { + auth_basic off; + proxy_set_header Origin ""; + proxy_set_header Authorization ""; + proxy_pass http://10.10.10.13:5600; + proxy_redirect off; + } + +{% for route in proxmox_web_gateway_routes %} +{% if route.proxy_target_url is defined %} + location = {{ route.proxy_path }} { + proxy_set_header Authorization ""; + proxy_pass {{ route.proxy_target_url }}; + proxy_redirect off; + } + +{% endif %} {% endfor %} + location /go/ { + try_files /index.html =404; + } + location / { try_files $uri $uri/ /index.html; } diff --git a/aw-server/aw-browser-smoke.service b/aw-server/aw-browser-smoke.service new file mode 100644 index 0000000..41b74e9 --- /dev/null +++ b/aw-server/aw-browser-smoke.service @@ -0,0 +1,28 @@ +[Unit] +Description=AW-RUS browser-visible WebUI smoke check +After=network-online.target activitywatch-server.service aw-worktime-api.service +Wants=network-online.target activitywatch-server.service aw-worktime-api.service + +[Service] +Type=oneshot +EnvironmentFile=/etc/activitywatch/aw-server.env +Environment=NODE_PATH=/usr/share/nodejs +Environment=AW_BROWSER_SMOKE_ENGINE=chromium-cli +Environment=HOME=/var/lib/activitywatch/browser-smoke +Environment=XDG_CONFIG_HOME=/var/lib/activitywatch/browser-smoke/.config +Environment=XDG_CACHE_HOME=/var/lib/activitywatch/browser-smoke/.cache +WorkingDirectory=/var/lib/activitywatch/browser-smoke +ExecStart=/usr/bin/node /usr/local/lib/aw-rus-browser-smoke/aw-webui-browser-smoke.mjs +TimeoutStartSec=180 +User=activitywatch +Group=activitywatch +Nice=15 +CPUQuota=25% +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true +ReadWritePaths=/var/lib/activitywatch/browser-smoke +StandardOutput=journal +StandardError=journal +SyslogIdentifier=aw-browser-smoke diff --git a/aw-server/aw-browser-smoke.timer b/aw-server/aw-browser-smoke.timer new file mode 100644 index 0000000..d956c91 --- /dev/null +++ b/aw-server/aw-browser-smoke.timer @@ -0,0 +1,12 @@ +[Unit] +Description=Run AW-RUS browser-visible WebUI smoke check hourly + +[Timer] +OnCalendar=hourly +AccuracySec=5min +RandomizedDelaySec=10min +Persistent=false +Unit=aw-browser-smoke.service + +[Install] +WantedBy=timers.target diff --git a/aw-server/aw-db-maintenance.service b/aw-server/aw-db-maintenance.service new file mode 100644 index 0000000..24f2188 --- /dev/null +++ b/aw-server/aw-db-maintenance.service @@ -0,0 +1,8 @@ +[Unit] +Description=ActivityWatch SQLite guarded maintenance +After=activitywatch-server.service + +[Service] +Type=oneshot +EnvironmentFile=-/etc/activitywatch/aw-server.env +ExecStart=/usr/local/bin/aw-db-maintenance --apply --json diff --git a/aw-server/aw-db-maintenance.timer b/aw-server/aw-db-maintenance.timer new file mode 100644 index 0000000..42d9bff --- /dev/null +++ b/aw-server/aw-db-maintenance.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Weekly ActivityWatch SQLite guarded maintenance + +[Timer] +OnCalendar=Sun *-*-* 03:30:00 +Persistent=true +RandomizedDelaySec=15m + +[Install] +WantedBy=timers.target diff --git a/aw-server/aw-dlp-influx-exporter.timer b/aw-server/aw-dlp-influx-exporter.timer index 9375117..31d6e40 100644 --- a/aw-server/aw-dlp-influx-exporter.timer +++ b/aw-server/aw-dlp-influx-exporter.timer @@ -2,9 +2,9 @@ Description=Run AW DLP InfluxDB exporter every 10 minutes [Timer] -OnBootSec=4min -OnUnitActiveSec=10min +OnCalendar=*:5/10:20 AccuracySec=1min +RandomizedDelaySec=30s Unit=aw-dlp-influx-exporter.service [Install] diff --git a/aw-server/aw-hayabusa-drop.service b/aw-server/aw-hayabusa-drop.service index 77ee804..a19e340 100644 --- a/aw-server/aw-hayabusa-drop.service +++ b/aw-server/aw-hayabusa-drop.service @@ -6,7 +6,7 @@ Wants=network-online.target activitywatch-server.service aw-dlp-case-management. [Service] Type=oneshot EnvironmentFile=-/etc/activitywatch/aw-server.env -ExecStart=/usr/bin/python3 /usr/local/bin/aw-hayabusa-autoprocess +ExecStart=/usr/local/bin/aw-hayabusa-autoprocess User=root Group=root StandardOutput=journal diff --git a/aw-server/aw-health-check-wrapper.sh b/aw-server/aw-health-check-wrapper.sh new file mode 100644 index 0000000..98e6321 --- /dev/null +++ b/aw-server/aw-health-check-wrapper.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ -x /usr/local/bin/aw-health-check-rust ]]; then + exec /usr/local/bin/aw-health-check-rust "$@" +fi + +exec /opt/activitywatch/aw-rus-ops/health-check.sh "$@" diff --git a/aw-server/aw-prune-local-state.sh b/aw-server/aw-prune-local-state.sh index c2c6afd..0455a45 100644 --- a/aw-server/aw-prune-local-state.sh +++ b/aw-server/aw-prune-local-state.sh @@ -1,42 +1,11 @@ #!/usr/bin/env bash set -euo pipefail -AW_DATA_DIR="${AW_DATA_DIR:-/var/lib/activitywatch}" -BACKUP_DIR="${AW_BACKUP_DIR:-${AW_DATA_DIR}/backups}" -KEEP_DAYS="${AW_BACKUP_RETENTION_DAYS:-7}" -KEEP_LAST_DB="${AW_BACKUP_KEEP_LAST_DB:-2}" -KEEP_LAST_JSON="${AW_BACKUP_KEEP_LAST_JSON:-2}" +if [[ -x /usr/local/bin/aw-prune-local-state-rust ]]; then + if [[ $# -eq 0 ]]; then + exec /usr/local/bin/aw-prune-local-state-rust --apply + fi + exec /usr/local/bin/aw-prune-local-state-rust "$@" +fi -prune_group() { - local keep_last="$1" - local keep_days="$2" - shift 2 - local files=() - local idx=0 - local cutoff - cutoff="$(date -d "-${keep_days} days" +%s)" - mapfile -t files < <(find "$@" -maxdepth 1 -type f -printf '%T@ %p\n' 2>/dev/null | sort -nr | awk '{ $1=""; sub(/^ /,""); print }') - for path in "${files[@]}"; do - idx=$((idx + 1)) - if [ "$idx" -le "$keep_last" ]; then - continue - fi - [ -f "$path" ] || continue - if [ "$(stat -c %Y "$path")" -lt "$cutoff" ]; then - rm -f -- "$path" - fi - done -} - -mkdir -p "$BACKUP_DIR" - -prune_group "$KEEP_LAST_DB" "$KEEP_DAYS" "${BACKUP_DIR}/db" -prune_group "$KEEP_LAST_JSON" "$KEEP_DAYS" "$BACKUP_DIR" - -find /tmp -maxdepth 1 -type f \ - \( -name 'activitywatch-*.zip' -o -name 'hayabusa-*.zip' -o -name 'aw-hayabusa-profiles.txt' \) \ - -mtime +0 -delete 2>/dev/null || true - -find /tmp -maxdepth 1 -type f \ - \( -name 'aw-worktime-ui-bridge.py' -o -name 'views-default.json' -o -name 'apply_webui_ru_patch.out' \) \ - -mtime +1 -delete 2>/dev/null || true +exec /opt/activitywatch/aw-rus-ops/aw-prune-local-state.sh "$@" diff --git a/aw-server/aw-rus-healthd.py b/aw-server/aw-rus-healthd.py index 234b90c..07d0538 100644 --- a/aw-server/aw-rus-healthd.py +++ b/aw-server/aw-rus-healthd.py @@ -8,6 +8,7 @@ import socket import subprocess import sys import tempfile +import time from dataclasses import dataclass from datetime import UTC, datetime from pathlib import Path @@ -21,7 +22,12 @@ ENV_FILE = Path("/etc/activitywatch/aw-server.env") def load_env_file(path: Path) -> None: if not path.exists(): return - for raw_line in path.read_text(encoding="utf-8").splitlines(): + try: + lines = path.read_text(encoding="utf-8").splitlines() + except PermissionError: + # systemd EnvironmentFile has already injected the variables for service runs. + return + for raw_line in lines: line = raw_line.strip() if not line or line.startswith("#") or "=" not in line: continue @@ -55,9 +61,19 @@ def age_seconds(ts: datetime | None, now: datetime) -> int | None: return max(0, int((now - ts).total_seconds())) -def http_json(url: str, timeout: int = 10) -> Any: - with request.urlopen(url, timeout=timeout) as resp: - return json.loads(resp.read().decode("utf-8")) +def http_json(url: str, timeout: int = 20, attempts: int = 2, backoff_seconds: float = 0.5) -> Any: + last_exc: Exception | None = None + for attempt in range(max(1, attempts)): + try: + with request.urlopen(url, timeout=timeout) as resp: + return json.loads(resp.read().decode("utf-8")) + except Exception as exc: + last_exc = exc + if attempt + 1 >= max(1, attempts): + break + time.sleep(backoff_seconds * (2**attempt)) + assert last_exc is not None + raise last_exc def run_command(cmd: list[str]) -> tuple[int, str]: @@ -136,7 +152,7 @@ class Report: def latest_bucket_event(api_base: str, bucket_id: str) -> dict[str, Any] | None: - events = http_json(f"{api_base}/buckets/{bucket_id}/events?limit=20") + events = http_json(f"{api_base}/buckets/{bucket_id}/events?limit=1", timeout=25) if isinstance(events, list) and events: events = [item for item in events if isinstance(item, dict)] if not events: @@ -146,6 +162,16 @@ def latest_bucket_event(api_base: str, bucket_id: str) -> dict[str, Any] | None: return None +def bucket_metadata_ts(buckets: dict[str, Any], bucket_id: str) -> datetime | None: + bucket = buckets.get(bucket_id) if isinstance(buckets, dict) else None + if not isinstance(bucket, dict): + return None + metadata = bucket.get("metadata") + if not isinstance(metadata, dict): + return None + return parse_ts(metadata.get("end")) + + def host_activity_from_worktime(event: dict[str, Any] | None, max_age_seconds: int) -> dict[str, Any]: now = now_utc() if not event: @@ -189,6 +215,78 @@ def bucket_health( return "ok", f"fresh ({age}s)", details +def bucket_timestamp_health( + api_base: str, + buckets: dict[str, Any], + bucket_id: str, + max_age_seconds: int, + missing_status: str, + stale_status: str, +) -> tuple[str, str, dict[str, Any]]: + metadata_ts = bucket_metadata_ts(buckets, bucket_id) + if metadata_ts is not None: + age = age_seconds(metadata_ts, now_utc()) + timestamp = metadata_ts.isoformat().replace("+00:00", "Z") + details = { + "bucket": bucket_id, + "timestamp": timestamp, + "age_seconds": age, + "timestamp_source": "bucket_metadata.end", + } + if age is None: + return "warn", "timestamp parse failed", details + if age > max_age_seconds: + return stale_status, f"stale ({age}s)", details + return "ok", f"fresh ({age}s)", details + + return bucket_health(api_base, bucket_id, max_age_seconds, missing_status, stale_status) + + +def guard_bucket_health( + api_base: str, + host: str, + max_age_seconds: int, + required: bool, +) -> tuple[str, str, dict[str, Any]]: + bucket_id = f"aw-rus-collector-guard_{host}" + try: + event = latest_bucket_event(api_base, bucket_id) + except Exception as exc: + status = "fail" if required else "warn" + return status, f"guard bucket query failed: {exc}", {"bucket": bucket_id} + + if not event: + status = "fail" if required else "warn" + return status, "no guard heartbeat", {"bucket": bucket_id, "required": required} + + ts = parse_ts(event.get("timestamp")) + age = age_seconds(ts, now_utc()) + data = event.get("data") if isinstance(event.get("data"), dict) else {} + guard_status = str(data.get("status") or "unknown").lower() + details = { + "bucket": bucket_id, + "timestamp": event.get("timestamp"), + "age_seconds": age, + "required": required, + "guard_status": guard_status, + "mode": data.get("mode"), + "live_session_count": data.get("liveSessionCount"), + "problems": data.get("problems", []), + "actions": data.get("actions", []), + } + + stale_status = "fail" if required else "warn" + if age is None: + return "warn", "guard timestamp parse failed", details + if age > max_age_seconds: + return stale_status, f"guard stale ({age}s)", details + if guard_status in ("fail", "error"): + return "fail" if required else "warn", f"guard reports {guard_status}", details + if guard_status == "warn": + return "warn", "guard reports warn", details + return "ok", f"guard fresh ({age}s)", details + + def latest_validation_report(validation_dir: Path) -> Path | None: candidates = sorted( (path for path in validation_dir.glob("*-aw_validate_ansible.json") if path.is_file()), @@ -206,7 +304,20 @@ def write_atomic(path: Path, content: str) -> None: os.replace(tmp_name, path) -def check_wrapper(report: Report, name: str, cmd: list[str], json_mode: bool = False) -> None: +def chmod_if_possible(path: Path, mode: int) -> None: + try: + path.chmod(mode) + except OSError: + pass + + +def check_wrapper( + report: Report, + name: str, + cmd: list[str], + json_mode: bool = False, + failure_status: str = "fail", +) -> None: if not Path(cmd[0]).exists(): report.add(name, "warn", "binary missing", command=cmd) return @@ -221,7 +332,7 @@ def check_wrapper(report: Report, name: str, cmd: list[str], json_mode: bool = F return else: details["output"] = output - report.add(name, "ok" if rc == 0 else "fail", "passed" if rc == 0 else "failed", **details) + report.add(name, "ok" if rc == 0 else failure_status, "passed" if rc == 0 else "failed", **details) def main() -> int: @@ -240,6 +351,8 @@ def main() -> int: parser.add_argument("--session-max-age-seconds", type=int, default=int(env("AW_RUS_HEALTH_SESSION_MAX_AGE_SECONDS", "900"))) parser.add_argument("--interactive-max-age-seconds", type=int, default=int(env("AW_RUS_HEALTH_INTERACTIVE_MAX_AGE_SECONDS", "900"))) parser.add_argument("--session-events-max-age-seconds", type=int, default=int(env("AW_RUS_HEALTH_SESSION_EVENTS_MAX_AGE_SECONDS", "86400"))) + parser.add_argument("--guard-max-age-seconds", type=int, default=int(env("AW_RUS_HEALTH_GUARD_MAX_AGE_SECONDS", "300"))) + parser.add_argument("--guard-required", action="store_true", default=env("AW_RUS_HEALTH_GUARD_REQUIRED", "0").lower() in ("1", "true", "yes")) parser.add_argument("--validation-max-age-seconds", type=int, default=int(env("AW_RUS_HEALTH_VALIDATION_MAX_AGE_SECONDS", "259200"))) parser.add_argument("--tcp-timeout-seconds", type=float, default=float(env("AW_RUS_HEALTH_TCP_TIMEOUT_SECONDS", "3"))) parser.add_argument("--json", action="store_true") @@ -250,8 +363,14 @@ def main() -> int: if not aw_api_base.endswith("/api/0"): aw_api_base = aw_api_base.rstrip("/") + "/api/0" - check_wrapper(report, "wrapper:aw-health-check", ["/usr/local/bin/aw-health-check"]) - check_wrapper(report, "wrapper:dlp-health-check", ["/usr/local/bin/dlp-health-check", "--json"], json_mode=True) + check_wrapper(report, "wrapper:aw-health-check", ["/usr/local/bin/aw-health-check"], failure_status="warn") + check_wrapper( + report, + "wrapper:dlp-health-check", + ["/usr/local/bin/dlp-health-check", "--json"], + json_mode=True, + failure_status="warn", + ) try: info = http_json(f"{aw_api_base}/info") @@ -279,6 +398,14 @@ def main() -> int: buckets = {} host = args.rdp_hostname + guard_status, guard_summary, guard_details = guard_bucket_health( + aw_api_base, + host, + args.guard_max_age_seconds, + args.guard_required, + ) + report.add("bucket:collector-guard", guard_status, guard_summary, **guard_details) + worktime_bucket = f"aw-worktime-sessions_{host}" worktime_event = None if buckets: @@ -315,10 +442,15 @@ def main() -> int: ) details["interactive_required"] = interactive_required details["host_activity"] = activity + if not interactive_required and status != "ok": + details["inactive_summary"] = summary + status = "ok" + summary = "inactive: no active interactive users" report.add(label, status, summary, **details) - session_status, session_summary, session_details = bucket_health( + session_status, session_summary, session_details = bucket_timestamp_health( aw_api_base, + buckets, f"aw-session-events_{host}", args.session_events_max_age_seconds, missing_status="fail", @@ -362,8 +494,12 @@ def main() -> int: payload = report.as_dict() state_dir = Path(args.state_dir) - write_atomic(state_dir / "aw-rus-health.json", json.dumps(payload, ensure_ascii=False, indent=2) + "\n") - write_atomic(state_dir / "aw-rus-health.txt", report.render_text() + "\n") + health_json_path = state_dir / "aw-rus-health.json" + health_txt_path = state_dir / "aw-rus-health.txt" + write_atomic(health_json_path, json.dumps(payload, ensure_ascii=False, indent=2) + "\n") + write_atomic(health_txt_path, report.render_text() + "\n") + chmod_if_possible(health_json_path, 0o644) + chmod_if_possible(health_txt_path, 0o644) if args.json: print(json.dumps(payload, ensure_ascii=False, indent=2)) diff --git a/aw-server/aw-rus-healthd.timer b/aw-server/aw-rus-healthd.timer index bc6f25b..08e74a0 100644 --- a/aw-server/aw-rus-healthd.timer +++ b/aw-server/aw-rus-healthd.timer @@ -2,10 +2,11 @@ Description=Run AW-RUS unified health orchestrator every 2 minutes [Timer] -OnBootSec=2min -OnUnitActiveSec=2min +OnCalendar=*:0/2:25 +AccuracySec=15s +RandomizedDelaySec=15s Unit=aw-rus-healthd.service -Persistent=true +Persistent=false [Install] WantedBy=timers.target diff --git a/aw-server/aw-server.env.example b/aw-server/aw-server.env.example index 58b1309..a44ffd7 100755 --- a/aw-server/aw-server.env.example +++ b/aw-server/aw-server.env.example @@ -43,7 +43,7 @@ AW_HEALTH_CHECK_ENABLED=true AW_HEALTH_CHECK_INTERVAL=60 AW_EXPECT_START_OF_DAY=00:00 AW_EXPECT_ALWAYS_ACTIVE_PATTERN=aw-watcher-window -AW_EXPECT_LANDINGPAGE=/activity/SHARKON2025/view/ +AW_EXPECT_LANDINGPAGE=/#/activity/SHARKON2025/view/ AW_HEALTH_STRICT_FILEOPS=0 AW_MONITORED_WINDOWS_HOST=192.168.100.18 AW_MONITORED_WINDOWS_HOSTNAME=SHARKON2025 @@ -51,6 +51,19 @@ AW_RUS_HEALTH_WORKTIME_API=http://127.0.0.1:5610 AW_RUS_HEALTH_STATE_DIR=/var/lib/activitywatch/health AW_RUS_HEALTH_VALIDATION_DIR=/var/lib/activitywatch/health/windows-validation AW_RUS_HEALTH_SESSION_EVENTS_MAX_AGE_SECONDS=86400 +AW_RUS_HEALTH_GUARD_MAX_AGE_SECONDS=300 +AW_RUS_HEALTH_GUARD_REQUIRED=1 +AW_RUS_SLO_AW_BASE=http://127.0.0.1:5600 +AW_RUS_SLO_WORKTIME_BASE=http://127.0.0.1:5610 +AW_RUS_SLO_TARGET_PERCENT=99.97 +AW_BROWSER_SMOKE_AW_BASE=http://127.0.0.1:5600 +AW_BROWSER_SMOKE_WORKTIME_BASE=http://127.0.0.1:5610 +AW_BROWSER_SMOKE_HOST=SHARKON2025 +AW_BROWSER_SMOKE_OUTPUT_DIR=/var/lib/activitywatch/browser-smoke +AW_BROWSER_SMOKE_KEEP_RUNS=24 +AW_BROWSER_SMOKE_ENGINE=chromium-cli +AW_BROWSER_SMOKE_TIMEOUT_MS=20000 +AW_BROWSER_SMOKE_RENDER_TIMEOUT_MS=15000 # Hayabusa auto-case / alerting AW_HAYABUSA_AUTO_CASE_ENABLED=true diff --git a/aw-server/aw-slo-monitor-rust-switch.conf b/aw-server/aw-slo-monitor-rust-switch.conf new file mode 100644 index 0000000..8d6835e --- /dev/null +++ b/aw-server/aw-slo-monitor-rust-switch.conf @@ -0,0 +1,3 @@ +[Service] +ExecStart= +ExecStart=/usr/local/bin/aw-slo-monitor-rust diff --git a/aw-server/aw-slo-monitor.py b/aw-server/aw-slo-monitor.py new file mode 100644 index 0000000..854d6ff --- /dev/null +++ b/aw-server/aw-slo-monitor.py @@ -0,0 +1,465 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import tempfile +import time +from datetime import UTC, datetime, timedelta +from pathlib import Path +from typing import Any +from urllib import request + + +ENV_FILE = Path("/etc/activitywatch/aw-server.env") +DEFAULT_STATE_DIR = Path("/var/lib/activitywatch/slo") +DEFAULT_HEALTHD_CMD = "/usr/local/bin/aw-rus-healthd.py --json" +DEFAULT_HEALTHD_STATE_FILE = Path("/var/lib/activitywatch/health/aw-rus-health.json") + + +def load_env_file(path: Path) -> None: + if not path.exists(): + return + try: + lines = path.read_text(encoding="utf-8").splitlines() + except PermissionError: + # systemd EnvironmentFile has already loaded the variables for the service. + return + for raw_line in lines: + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + os.environ.setdefault(key.strip(), value.strip().strip("'").strip('"')) + + +def env(name: str, default: str) -> str: + value = os.environ.get(name) + return value if value not in (None, "") else default + + +def now_utc() -> datetime: + return datetime.now(UTC) + + +def parse_ts(value: str) -> datetime: + return datetime.fromisoformat(value.replace("Z", "+00:00")).astimezone(UTC) + + +def iso(dt: datetime) -> str: + return dt.astimezone(UTC).isoformat().replace("+00:00", "Z") + + +def write_atomic(path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=path.parent, delete=False) as handle: + handle.write(content) + tmp_name = handle.name + os.replace(tmp_name, path) + + +def chmod_if_possible(path: Path, mode: int) -> None: + try: + path.chmod(mode) + except OSError: + pass + + +def chown_like_parent_if_possible(path: Path) -> None: + try: + parent_stat = path.parent.stat() + os.chown(path, parent_stat.st_uid, parent_stat.st_gid) + except OSError: + pass + + +def fetch_url(url: str, timeout_seconds: float, *, accept: str | None = None, attempts: int = 1) -> dict[str, Any]: + started = now_utc() + last_error = "" + for attempt in range(1, max(1, attempts) + 1): + try: + req = request.Request(url, headers={"Accept": accept}) if accept else url + with request.urlopen(req, timeout=timeout_seconds) as resp: + body = resp.read() + status = int(resp.status) + content_type = resp.headers.get("Content-Type", "") + ok = 200 <= status < 300 + return { + "ok": ok, + "status": status, + "body_bytes": len(body), + "content_type": content_type, + "body": body, + "attempts": attempt, + "latency_ms": int((now_utc() - started).total_seconds() * 1000), + "url": url, + } + except Exception as exc: + last_error = str(exc) + if attempt < max(1, attempts): + time.sleep(1) + return { + "ok": False, + "error": last_error, + "body_bytes": 0, + "attempts": max(1, attempts), + "latency_ms": int((now_utc() - started).total_seconds() * 1000), + "url": url, + } + + +def public_probe_result(result: dict[str, Any]) -> dict[str, Any]: + return {key: value for key, value in result.items() if key != "body"} + + +def http_probe(url: str, timeout_seconds: float) -> dict[str, Any]: + return public_probe_result(fetch_url(url, timeout_seconds, attempts=2)) + + +def html_probe( + url: str, + timeout_seconds: float, + *, + min_bytes: int, + required_markers: tuple[str, ...], +) -> dict[str, Any]: + result = fetch_url(url, timeout_seconds, accept="text/html", attempts=2) + if not result.get("ok"): + return public_probe_result(result) + + body = result.get("body", b"") + text = body.decode("utf-8", errors="replace") if isinstance(body, bytes) else str(body) + missing = [marker for marker in required_markers if marker not in text] + content_type = str(result.get("content_type") or "") + if len(body) < min_bytes: + result["ok"] = False + result["error"] = f"body too small: {len(body)} < {min_bytes}" + elif "text/html" not in content_type.lower(): + result["ok"] = False + result["error"] = f"unexpected content-type: {content_type or 'unknown'}" + elif missing: + result["ok"] = False + result["error"] = "missing markers: " + ", ".join(missing) + result["missing_markers"] = missing + return public_probe_result(result) + + +def json_probe( + url: str, + timeout_seconds: float, + *, + expected_values: dict[str, Any], + required_keys: tuple[str, ...], +) -> dict[str, Any]: + result = fetch_url(url, timeout_seconds, accept="application/json", attempts=2) + if not result.get("ok"): + return public_probe_result(result) + + body = result.get("body", b"") + try: + payload = json.loads(body.decode("utf-8") if isinstance(body, bytes) else str(body)) + except Exception as exc: + result["ok"] = False + result["error"] = f"invalid json: {exc}" + return public_probe_result(result) + + if not isinstance(payload, dict): + result["ok"] = False + result["error"] = "json root is not object" + return public_probe_result(result) + + missing_keys = [key for key in required_keys if key not in payload] + mismatched = { + key: {"expected": expected, "actual": payload.get(key)} + for key, expected in expected_values.items() + if payload.get(key) != expected + } + if missing_keys: + result["ok"] = False + result["error"] = "missing json keys: " + ", ".join(missing_keys) + result["missing_keys"] = missing_keys + elif mismatched: + result["ok"] = False + result["error"] = "unexpected json values" + result["mismatched_values"] = mismatched + else: + result["json_keys"] = sorted(payload.keys()) + return public_probe_result(result) + + +def run_healthd(command: str, timeout_seconds: int) -> dict[str, Any]: + try: + proc = subprocess.run( + command, + shell=True, + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + timeout=timeout_seconds, + ) + except subprocess.TimeoutExpired as exc: + return { + "ok": False, + "returncode": None, + "error": f"timeout after {timeout_seconds}s", + "output_tail": (exc.stdout or "")[-1000:] if isinstance(exc.stdout, str) else "", + } + + payload: dict[str, Any] = {} + try: + payload = json.loads(proc.stdout or "{}") + except json.JSONDecodeError: + pass + return { + "ok": proc.returncode == 0 and bool(payload.get("ok")), + "returncode": proc.returncode, + "counts": payload.get("counts", {}), + "payload": payload if isinstance(payload, dict) else {}, + "output_tail": (proc.stdout or "")[-1000:], + } + + +def read_healthd_state(path: Path, max_age_seconds: int) -> dict[str, Any]: + try: + payload = json.loads(path.read_text(encoding="utf-8")) + generated = parse_ts(str(payload["generated_at_utc"])) + age = max(0, int((now_utc() - generated).total_seconds())) + except Exception as exc: + return {"ok": False, "error": str(exc), "path": str(path)} + return { + "ok": bool(payload.get("ok")) and age <= max_age_seconds, + "counts": payload.get("counts", {}), + "age_seconds": age, + "path": str(path), + "payload": payload if isinstance(payload, dict) else {}, + } + + +def load_samples(path: Path, cutoff: datetime) -> list[dict[str, Any]]: + samples: list[dict[str, Any]] = [] + if not path.exists(): + return samples + try: + lines = path.read_text(encoding="utf-8").splitlines() + except PermissionError: + return samples + for line in lines: + if not line.strip(): + continue + try: + item = json.loads(line) + ts = parse_ts(str(item["ts"])) + except Exception: + continue + if ts >= cutoff: + samples.append(item) + return samples + + +def append_and_trim_sample(path: Path, sample: dict[str, Any], retention_seconds: int) -> list[dict[str, Any]]: + cutoff = parse_ts(sample["ts"]) - timedelta(seconds=retention_seconds) + samples = load_samples(path, cutoff) + samples.append(sample) + content = "".join(json.dumps(item, ensure_ascii=False, separators=(",", ":")) + "\n" for item in samples) + write_atomic(path, content) + chmod_if_possible(path, 0o644) + chown_like_parent_if_possible(path) + return samples + + +def summarize_window( + samples: list[dict[str, Any]], + *, + now: datetime, + window_seconds: int, + sample_interval_seconds: int, + target_percent: float, +) -> dict[str, Any]: + cutoff = now - timedelta(seconds=window_seconds) + window = [] + for item in samples: + try: + if parse_ts(str(item["ts"])) >= cutoff: + window.append(item) + except Exception: + continue + + total = len(window) + good = sum(1 for item in window if item.get("ok") is True) + bad = total - good + availability = round((good / total) * 100, 5) if total else None + observed_bad_seconds = bad * sample_interval_seconds + budget_seconds = int(window_seconds * ((100.0 - target_percent) / 100.0)) + budget_remaining_seconds = budget_seconds - observed_bad_seconds + status = "unknown" + if total: + status = "ok" if budget_remaining_seconds >= 0 else "burning" + return { + "window_seconds": window_seconds, + "samples": total, + "good_samples": good, + "bad_samples": bad, + "availability_percent": availability, + "target_percent": target_percent, + "observed_bad_seconds": observed_bad_seconds, + "budget_seconds": budget_seconds, + "budget_remaining_seconds": budget_remaining_seconds, + "status": status, + } + + +def render_summary_text(summary: dict[str, Any]) -> str: + lines = [ + "=== AW-RUS SLO ===", + f"Timestamp: {summary['generated_at_utc']}", + f"Target: {summary['target_percent']}%", + "", + ] + for name, data in summary["windows"].items(): + availability = data["availability_percent"] + availability_text = "n/a" if availability is None else f"{availability:.5f}%" + remaining = data["budget_remaining_seconds"] + lines.append( + f"{name}: {data['status']} availability={availability_text} " + f"samples={data['samples']} bad={data['bad_samples']} " + f"bad_seconds={data['observed_bad_seconds']} budget_remaining_seconds={remaining}" + ) + lines.append("") + lines.append(f"Current sample: {'OK' if summary['current_sample']['ok'] else 'FAIL'}") + for name, probe in summary["current_sample"].get("probes", {}).items(): + marker = "OK" if probe.get("ok") else "FAIL" + detail = probe.get("status", probe.get("error", "")) + lines.append(f"- {name}: {marker} {detail}") + return "\n".join(lines) + + +def build_sample(args: argparse.Namespace) -> dict[str, Any]: + ts = iso(now_utc()) + if args.healthd_mode == "run": + health = run_healthd(args.healthd_cmd, args.health_timeout_seconds) + else: + health = read_healthd_state(Path(args.healthd_state_file), args.healthd_state_max_age_seconds) + probes = { + "aw_webui_index": html_probe( + args.aw_webui_url, + args.http_timeout_seconds, + min_bytes=1000, + required_markers=("ActivityWatch", 'id="app"', "ru-patch-v5.js"), + ), + "worktime_today_html": html_probe( + args.worktime_today_html_url, + args.http_timeout_seconds, + min_bytes=5000, + required_markers=("AW-rus", ""), + ), + "worktime_management_html": html_probe( + args.worktime_management_html_url, + args.http_timeout_seconds, + min_bytes=5000, + required_markers=("AW-rus", ""), + ), + "worktime_today_csv": http_probe(args.worktime_today_csv_url, args.http_timeout_seconds), + "worktime_management_json": json_probe( + args.worktime_management_json_url, + args.http_timeout_seconds, + expected_values={"host": args.host}, + required_keys=("generated_at_utc", "host", "summary", "rows", "workday"), + ), + } + ok = bool(health.get("ok")) and all(probe.get("ok") for probe in probes.values()) + return { + "ts": ts, + "ok": ok, + "healthd_ok": bool(health.get("ok")), + "healthd_counts": health.get("counts", {}), + "probes": probes, + } + + +def main() -> int: + load_env_file(ENV_FILE) + parser = argparse.ArgumentParser(description="AW-RUS rolling SLO sampler") + parser.add_argument("--state-dir", default=env("AW_RUS_SLO_STATE_DIR", str(DEFAULT_STATE_DIR))) + parser.add_argument("--healthd-cmd", default=env("AW_RUS_SLO_HEALTHD_CMD", DEFAULT_HEALTHD_CMD)) + parser.add_argument("--healthd-mode", choices=["state", "run"], default=env("AW_RUS_SLO_HEALTHD_MODE", "state")) + parser.add_argument("--healthd-state-file", default=env("AW_RUS_SLO_HEALTHD_STATE_FILE", str(DEFAULT_HEALTHD_STATE_FILE))) + parser.add_argument("--healthd-state-max-age-seconds", type=int, default=int(env("AW_RUS_SLO_HEALTHD_STATE_MAX_AGE_SECONDS", "180"))) + parser.add_argument("--aw-base", default=env("AW_RUS_SLO_AW_BASE", env("AW_SERVER_URL", "http://127.0.0.1:5600"))) + parser.add_argument("--worktime-base", default=env("AW_RUS_SLO_WORKTIME_BASE", env("AW_RUS_HEALTH_WORKTIME_API", "http://127.0.0.1:5610"))) + parser.add_argument("--host", default=env("AW_RUS_SLO_HOST", env("AW_MONITORED_WINDOWS_HOSTNAME", "SHARKON2025"))) + parser.add_argument("--target-percent", type=float, default=float(env("AW_RUS_SLO_TARGET_PERCENT", "99.97"))) + parser.add_argument("--sample-interval-seconds", type=int, default=int(env("AW_RUS_SLO_SAMPLE_INTERVAL_SECONDS", "15"))) + parser.add_argument("--retention-days", type=int, default=int(env("AW_RUS_SLO_RETENTION_DAYS", "35"))) + parser.add_argument("--http-timeout-seconds", type=float, default=float(env("AW_RUS_SLO_HTTP_TIMEOUT_SECONDS", "15"))) + parser.add_argument("--health-timeout-seconds", type=int, default=int(env("AW_RUS_SLO_HEALTH_TIMEOUT_SECONDS", "90"))) + parser.add_argument("--json", action="store_true") + args = parser.parse_args() + + aw_base = args.aw_base.rstrip("/") + worktime_base = args.worktime_base.rstrip("/") + args.aw_webui_url = f"{aw_base}/" + args.worktime_today_html_url = f"{worktime_base}/reports/worktime/today?format=html&day=today&host={args.host}&allow_stale=1" + args.worktime_management_html_url = f"{worktime_base}/reports/worktime/management?format=html&day=today&host={args.host}&allow_stale=1" + args.worktime_today_csv_url = f"{worktime_base}/reports/worktime/today?format=csv&day=today&host={args.host}&allow_stale=1" + args.worktime_management_json_url = f"{worktime_base}/reports/worktime/management?format=json&day=today&host={args.host}&allow_stale=1" + + state_dir = Path(args.state_dir) + sample_path = state_dir / "aw-slo-samples.jsonl" + sample = build_sample(args) + samples = append_and_trim_sample( + sample_path, + sample, + retention_seconds=max(1, args.retention_days) * 86400, + ) + + generated_at = now_utc() + summary = { + "generated_at_utc": iso(generated_at), + "target_percent": args.target_percent, + "sample_interval_seconds": args.sample_interval_seconds, + "current_sample": sample, + "windows": { + "24h": summarize_window( + samples, + now=generated_at, + window_seconds=86400, + sample_interval_seconds=args.sample_interval_seconds, + target_percent=args.target_percent, + ), + "7d": summarize_window( + samples, + now=generated_at, + window_seconds=7 * 86400, + sample_interval_seconds=args.sample_interval_seconds, + target_percent=args.target_percent, + ), + "30d": summarize_window( + samples, + now=generated_at, + window_seconds=30 * 86400, + sample_interval_seconds=args.sample_interval_seconds, + target_percent=args.target_percent, + ), + }, + } + summary_json_path = state_dir / "aw-slo-summary.json" + summary_txt_path = state_dir / "aw-slo-summary.txt" + write_atomic(summary_json_path, json.dumps(summary, ensure_ascii=False, indent=2) + "\n") + text = render_summary_text(summary) + write_atomic(summary_txt_path, text + "\n") + chmod_if_possible(summary_json_path, 0o644) + chmod_if_possible(summary_txt_path, 0o644) + chown_like_parent_if_possible(summary_json_path) + chown_like_parent_if_possible(summary_txt_path) + if args.json: + print(json.dumps(summary, ensure_ascii=False, indent=2)) + else: + print(text) + return 0 if sample["ok"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/aw-server/aw-slo-monitor.service b/aw-server/aw-slo-monitor.service new file mode 100644 index 0000000..a1bd724 --- /dev/null +++ b/aw-server/aw-slo-monitor.service @@ -0,0 +1,16 @@ +[Unit] +Description=AW-RUS SLO sampler +After=network-online.target activitywatch-server.service aw-worktime-api.service aw-rus-healthd.service +Wants=network-online.target activitywatch-server.service aw-worktime-api.service + +[Service] +Type=oneshot +EnvironmentFile=/etc/activitywatch/aw-server.env +ExecStart=/usr/bin/python3 /usr/local/bin/aw-slo-monitor.py +SuccessExitStatus=1 +User=activitywatch +Group=activitywatch +Nice=5 +StandardOutput=journal +StandardError=journal +SyslogIdentifier=aw-slo-monitor diff --git a/aw-server/aw-slo-monitor.timer b/aw-server/aw-slo-monitor.timer new file mode 100644 index 0000000..b5e2ce3 --- /dev/null +++ b/aw-server/aw-slo-monitor.timer @@ -0,0 +1,12 @@ +[Unit] +Description=Run AW-RUS SLO sampler every minute + +[Timer] +OnCalendar=*:0/1:50 +AccuracySec=10s +RandomizedDelaySec=10s +Persistent=false +Unit=aw-slo-monitor.service + +[Install] +WantedBy=timers.target diff --git a/aw-server/aw-worktime-api.py b/aw-server/aw-worktime-api.py index f12324a..caba965 100644 --- a/aw-server/aw-worktime-api.py +++ b/aw-server/aw-worktime-api.py @@ -1,5 +1,7 @@ #!/usr/bin/env python3 +import base64 import csv +import hashlib import html import io import importlib.util @@ -8,6 +10,7 @@ import os import sys import tempfile import threading +import time import urllib.request from datetime import datetime, timezone, timedelta from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer @@ -49,18 +52,30 @@ MANAGER_ALIASES_JSON = Path(os.environ.get("AW_WORKTIME_MANAGER_ALIASES_JSON", " MANAGER_EXCLUDE_USERS = {item.strip().lower() for item in os.environ.get("AW_WORKTIME_MANAGER_EXCLUDE_USERS", "").split(",") if item.strip()} EVENTS_CACHE_TTL_SECONDS = max(0, int(os.environ.get("AW_WORKTIME_EVENTS_CACHE_TTL_SECONDS", "30"))) WORKTIME_EVENTS_LIMIT = max(1000, int(os.environ.get("AW_WORKTIME_EVENTS_LIMIT", "50000"))) +AW_HTTP_TIMEOUT_SECONDS = max(0.5, float(os.environ.get("AW_WORKTIME_AW_HTTP_TIMEOUT_SECONDS", "5"))) +REPORT_CACHE_TTL_SECONDS = max(0, int(os.environ.get("AW_WORKTIME_REPORT_CACHE_TTL_SECONDS", "60"))) +REPORT_STALE_TTL_SECONDS = max(REPORT_CACHE_TTL_SECONDS, int(os.environ.get("AW_WORKTIME_REPORT_STALE_TTL_SECONDS", "900"))) +REPORT_DISK_CACHE_DIR = Path(os.environ.get("AW_WORKTIME_REPORT_DISK_CACHE_DIR", "/var/lib/activitywatch/worktime-report-cache")) +REPORT_DISK_STALE_TTL_SECONDS = max(REPORT_STALE_TTL_SECONDS, int(os.environ.get("AW_WORKTIME_REPORT_DISK_STALE_TTL_SECONDS", "86400"))) +REPORT_BUSY_WAIT_SECONDS = max(0.0, float(os.environ.get("AW_WORKTIME_REPORT_BUSY_WAIT_SECONDS", "8"))) +REPORT_BUSY_WAIT_INTERVAL_SECONDS = max(0.05, float(os.environ.get("AW_WORKTIME_REPORT_BUSY_WAIT_INTERVAL_SECONDS", "0.2"))) +REPORT_MAX_CONCURRENT = max(1, int(os.environ.get("AW_WORKTIME_REPORT_MAX_CONCURRENT", "1"))) TRUE_ACTIVE_EVIDENCE_WINDOW_SECONDS = max(30, int(os.environ.get("AW_WORKTIME_TRUE_ACTIVE_EVIDENCE_WINDOW_SECONDS", "180"))) TRUE_ACTIVE_MAX_EVENT_SECONDS = max(30, int(os.environ.get("AW_WORKTIME_TRUE_ACTIVE_MAX_EVENT_SECONDS", "600"))) MODULE_PATH = Path(__file__).resolve() _ALIASES_CACHE = {"mtime": None, "users": {}, "owners": {}, "raw": {}} _EVENTS_CACHE_LOCK = threading.Lock() _EVENTS_CACHE = {} +_REPORT_CACHE_LOCK = threading.Lock() +_REPORT_RESPONSE_CACHE = {} +_REPORT_REFRESHING = set() +_REPORT_BUILD_SEMAPHORE = threading.BoundedSemaphore(REPORT_MAX_CONCURRENT) _MANAGEMENT_BUILD_LOCKS_LOCK = threading.Lock() _MANAGEMENT_BUILD_LOCKS = {} def get(u): - with urllib.request.urlopen(u, timeout=30) as r: + with urllib.request.urlopen(u, timeout=AW_HTTP_TIMEOUT_SECONDS) as r: return json.loads(r.read().decode()) @@ -784,15 +799,21 @@ def aggregate_hourly_rows(events, start, end, host): def fetch_events_for_date(host, report_date): bounds = get_report_bounds(report_date) bucket_id = get_sessions_bucket_id(host) - events = fetch_bucket_events(bucket_id, host) + events = fetch_bucket_events(bucket_id, host, start=bounds["start"], end=bounds["end"]) return bounds, events -def fetch_bucket_events(bucket_id, host): +def fetch_bucket_events(bucket_id, host, start=None, end=None): now = now_utc() + cache_key = bucket_id + params = {"limit": WORKTIME_EVENTS_LIMIT} + if start is not None and end is not None: + params["start"] = to_iso_utc(start) + params["end"] = to_iso_utc(end) + cache_key = f"{bucket_id}|{params['start']}|{params['end']}" if EVENTS_CACHE_TTL_SECONDS > 0: with _EVENTS_CACHE_LOCK: - cached = _EVENTS_CACHE.get(bucket_id) + cached = _EVENTS_CACHE.get(cache_key) if cached is not None and age_seconds(cached["stored_at"], now=now) <= EVENTS_CACHE_TTL_SECONDS: return cached["events"] try: @@ -801,13 +822,13 @@ def fetch_bucket_events(bucket_id, host): log_warning(f"bucket lookup failed for host={host} bucket={bucket_id} aw_base={AW}") return [] try: - events = get(f"{AW}/buckets/{bucket_id}/events?limit={WORKTIME_EVENTS_LIMIT}") + events = get(f"{AW}/buckets/{bucket_id}/events?{urlencode(params)}") except Exception: log_warning(f"events fetch failed for host={host} bucket={bucket_id} aw_base={AW}") return [] if EVENTS_CACHE_TTL_SECONDS > 0: with _EVENTS_CACHE_LOCK: - _EVENTS_CACHE[bucket_id] = {"stored_at": now, "events": events} + _EVENTS_CACHE[cache_key] = {"stored_at": now, "events": events} return events @@ -840,16 +861,22 @@ def build_report_summary(rows): def latest_bucket_event(bucket_id): try: - events = get(f"{AW}/buckets/{bucket_id}/events?limit=20") + events = get(f"{AW}/buckets/{bucket_id}/events?limit=1") + except Exception: + events = None + if isinstance(events, list) and events: + valid = [item for item in events if isinstance(item, dict)] + if valid: + valid.sort(key=lambda item: item.get("timestamp") or "", reverse=True) + return valid[0] + try: + bucket = get(f"{AW}/buckets/{bucket_id}") except Exception: return None - if not isinstance(events, list) or not events: - return None - valid = [item for item in events if isinstance(item, dict)] - if not valid: - return None - valid.sort(key=lambda item: item.get("timestamp") or "", reverse=True) - return valid[0] + end = ((bucket or {}).get("metadata") or {}).get("end") + if end: + return {"timestamp": end, "duration": 0, "data": {"source": "bucket_metadata"}} + return None def _priority_rank(priority): @@ -1219,6 +1246,7 @@ def _source_status_label(status): "ok": "fresh", "warn": "stale", "fail": "missing", + "inactive": "inactive", }.get(status, status) @@ -1273,7 +1301,224 @@ def save_management_cache(host, report_date, payload): write_atomic_json(management_cache_path(host, report_date), payload) -def build_source_freshness(host): +def make_report_cache_key(path, fmt, host, report_date, selected_day="", owner_filter="", department_filter=""): + return "|".join( + [ + str(path), + str(fmt), + resolve_host(host), + report_date.isoformat(), + str(selected_day or ""), + normalize_management_filter(owner_filter), + normalize_management_filter(department_filter), + ] + ) + + +def resolve_report_format(params, accept_header=""): + requested = str(params.get("format", [""])[0] or "").lower() + if requested in {"csv", "html", "json"}: + return requested + accept = str(accept_header or "").lower() + if "text/html" in accept and "application/json" not in accept: + return "html" + return "json" + + +def report_disk_cache_path(key): + digest = hashlib.sha256(key.encode("utf-8")).hexdigest() + return REPORT_DISK_CACHE_DIR / f"{digest}.json" + + +def load_report_response_disk_cache(key): + if REPORT_DISK_STALE_TTL_SECONDS <= 0: + return None + path = report_disk_cache_path(key) + if not path.exists(): + return None + try: + payload = json.loads(path.read_text(encoding="utf-8")) + stored_epoch = float(payload["stored_epoch"]) + age = max(0, int(time.time() - stored_epoch)) + if age > REPORT_DISK_STALE_TTL_SECONDS: + return None + data = base64.b64decode(payload["data_b64"].encode("ascii")) + content_type = str(payload["content_type"]) + except Exception as exc: + log_warning(f"failed to load report disk cache key={key}: {exc}") + return None + return { + "stored_monotonic": time.monotonic() - age, + "generated_at_utc": payload.get("generated_at_utc", ""), + "data": data, + "content_type": content_type, + "age_seconds": age, + "stale": True, + "cache_source": "disk", + } + + +def save_report_response_disk_cache(key, data, content_type, generated_at_utc): + if REPORT_DISK_STALE_TTL_SECONDS <= 0: + return + payload = { + "key": key, + "stored_epoch": time.time(), + "generated_at_utc": generated_at_utc, + "content_type": content_type, + "data_b64": base64.b64encode(data).decode("ascii"), + } + try: + write_atomic_json(report_disk_cache_path(key), payload) + except Exception as exc: + log_warning(f"failed to save report disk cache key={key}: {exc}") + + +def get_report_response_cache(key, *, allow_stale=False): + if REPORT_CACHE_TTL_SECONDS <= 0 and not allow_stale: + return None + now = time.monotonic() + with _REPORT_CACHE_LOCK: + cached = _REPORT_RESPONSE_CACHE.get(key) + if cached is not None: + age = now - cached["stored_monotonic"] + max_age = REPORT_STALE_TTL_SECONDS if allow_stale else REPORT_CACHE_TTL_SECONDS + if max_age > 0 and age <= max_age: + result = dict(cached) + result["age_seconds"] = int(age) + result["stale"] = age > REPORT_CACHE_TTL_SECONDS + result["cache_source"] = "memory" + return result + if allow_stale: + return load_report_response_disk_cache(key) + return None + + +def save_report_response_cache(key, data, content_type): + if REPORT_STALE_TTL_SECONDS <= 0: + return + generated_at_utc = now_utc().isoformat().replace("+00:00", "Z") + with _REPORT_CACHE_LOCK: + _REPORT_RESPONSE_CACHE[key] = { + "stored_monotonic": time.monotonic(), + "generated_at_utc": generated_at_utc, + "data": data, + "content_type": content_type, + } + save_report_response_disk_cache(key, data, content_type, generated_at_utc) + + +def trigger_report_background_refresh(key, path, params): + refresh_params = {name: list(values) for name, values in params.items()} + with _REPORT_CACHE_LOCK: + if key in _REPORT_REFRESHING: + return False + _REPORT_REFRESHING.add(key) + + def refresh(): + try: + if not _REPORT_BUILD_SEMAPHORE.acquire(blocking=False): + return + try: + data, content_type = build_worktime_report_response(path, refresh_params) + save_report_response_cache(key, data, content_type) + except Exception as exc: + log_warning(f"background report refresh failed path={path}: {exc}") + finally: + _REPORT_BUILD_SEMAPHORE.release() + finally: + with _REPORT_CACHE_LOCK: + _REPORT_REFRESHING.discard(key) + + thread = threading.Thread(target=refresh, name="worktime-report-refresh", daemon=True) + thread.start() + return True + + +def send_cached_report(handler, cached, *, reason): + handler.send_response(200) + handler.send_header("Content-Type", cached["content_type"]) + handler.send_header("Content-Length", str(len(cached["data"]))) + handler.send_header("X-AW-Worktime-Cache", "stale" if cached.get("stale") else "fresh") + handler.send_header("X-AW-Worktime-Cache-Reason", reason) + handler.send_header("X-AW-Worktime-Cache-Age", str(cached.get("age_seconds", 0))) + handler.send_header("X-AW-Worktime-Cache-Source", cached.get("cache_source", "memory")) + handler.end_headers() + try: + handler.wfile.write(cached["data"]) + except (BrokenPipeError, ConnectionResetError): + return False + return True + + +def send_report_unavailable(handler, message): + payload = { + "ok": False, + "error": "report_unavailable", + "message": message, + "generated_at_utc": now_utc().isoformat().replace("+00:00", "Z"), + } + data = json.dumps(payload, ensure_ascii=False, indent=2).encode("utf-8") + send_bytes(handler, data, "application/json; charset=utf-8", status=503) + + +def send_worktime_report(handler, path, params, cache_key, *, host, report_date): + cached = get_report_response_cache(cache_key) + if cached is not None: + send_cached_report(handler, cached, reason="ttl") + return + + allow_immediate_stale = str(params.get("allow_stale", [""])[0]).lower() in {"1", "true", "yes"} + if allow_immediate_stale: + cached = get_report_response_cache(cache_key, allow_stale=True) + if cached is not None: + refresh_stale = str(params.get("refresh_stale", [""])[0]).lower() in {"1", "true", "yes"} + if refresh_stale: + trigger_report_background_refresh(cache_key, path, params) + send_cached_report(handler, cached, reason="stale-allowed") + return + + if not _REPORT_BUILD_SEMAPHORE.acquire(blocking=False): + if not allow_immediate_stale and REPORT_BUSY_WAIT_SECONDS > 0: + deadline = time.monotonic() + REPORT_BUSY_WAIT_SECONDS + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: + break + time.sleep(min(REPORT_BUSY_WAIT_INTERVAL_SECONDS, remaining)) + cached = get_report_response_cache(cache_key) + if cached is not None: + send_cached_report(handler, cached, reason="ttl-after-wait") + return + cached = get_report_response_cache(cache_key, allow_stale=True) + if cached is not None: + send_cached_report(handler, cached, reason="busy") + return + send_report_unavailable(handler, "report builder is busy and no cached response is available") + return + + try: + cached = get_report_response_cache(cache_key) + if cached is not None: + send_cached_report(handler, cached, reason="ttl-after-lock") + return + try: + data, content_type = build_worktime_report_response(path, params) + except Exception as exc: + log_warning(f"report build failed path={path} host={host} date={report_date}: {exc}") + cached = get_report_response_cache(cache_key, allow_stale=True) + if cached is not None: + send_cached_report(handler, cached, reason="build-error") + return + send_report_unavailable(handler, "report build failed and no cached response is available") + return + save_report_response_cache(cache_key, data, content_type) + send_bytes(handler, data, content_type) + finally: + _REPORT_BUILD_SEMAPHORE.release() + + +def build_source_freshness(host, interactive_required=True): source_specs = [ { "source_id": "worktime_sessions", @@ -1305,6 +1550,7 @@ def build_source_freshness(host): "bucket_candidates": [f"aw-watcher-window_{host}"], "max_age_seconds": MANAGER_CRITICAL_SOURCE_MAX_AGE_SECONDS, "required": True, + "interactive_only": True, "owner": "ops", }, { @@ -1321,6 +1567,7 @@ def build_source_freshness(host): "bucket_candidates": [f"aw-file-operations_{host}"], "max_age_seconds": MANAGER_CRITICAL_SOURCE_MAX_AGE_SECONDS, "required": True, + "interactive_only": True, "owner": "ops", }, { @@ -1379,6 +1626,9 @@ def build_source_freshness(host): else: status = "ok" summary = f"fresh ({age}s)" + if spec.get("interactive_only") and not interactive_required and status != "ok": + status = "inactive" + summary = "inactive: no active interactive users" detail = { "source_id": spec["source_id"], "label": spec["label"], @@ -1388,12 +1638,14 @@ def build_source_freshness(host): "timestamp": (matched_event or {}).get("timestamp", ""), "age_seconds": age, "required": spec["required"], + "interactive_only": bool(spec.get("interactive_only")), + "interactive_required": bool(interactive_required), "max_age_seconds": spec["max_age_seconds"], "summary": summary, "event_summary": _source_summary(matched_event), } sources.append(detail) - if status == "ok": + if status in {"ok", "inactive"}: continue priority = "critical" if spec["required"] else "medium" actions.append( @@ -1723,7 +1975,8 @@ def build_management_payload(rows, host, report_date, owner_filter="", departmen owner_filter = normalize_management_filter(owner_filter) department_filter = normalize_management_filter(department_filter) payload = _build_management_core(rows, host, report_date, owner_filter=owner_filter, department_filter=department_filter) - source_freshness, source_actions = build_source_freshness(resolve_host(host)) + interactive_required = int(payload.get("summary", {}).get("active_users", 0) or 0) > 0 + source_freshness, source_actions = build_source_freshness(resolve_host(host), interactive_required=interactive_required) payload["sources"] = source_freshness payload["trend"] = build_management_trend( resolve_host(host), @@ -1751,11 +2004,11 @@ def report_for_date(host, report_date): return aggregate_rows(events, bounds["start"], bounds["end"], host) -def management_report_for_date(host, report_date, owner_filter="", department_filter=""): +def management_report_for_date(host, report_date, owner_filter="", department_filter="", use_cache=True): owner_filter = normalize_management_filter(owner_filter) department_filter = normalize_management_filter(department_filter) if owner_filter or department_filter: - base_payload = management_report_for_date(host, report_date) + base_payload = management_report_for_date(host, report_date, use_cache=use_cache) filtered_payload = apply_management_filters_to_payload( base_payload, owner_filter=owner_filter, @@ -1766,14 +2019,17 @@ def management_report_for_date(host, report_date, owner_filter="", department_fi filtered_payload["trend"] = [] filtered_payload["trend_scope"] = "filtered_current_only" return filtered_payload - cached = load_management_cache(host, report_date) - if cached is not None: - return cached - lock = get_management_build_lock(host, report_date) - with lock: + cache_allowed = use_cache or report_date < datetime.now(REPORT_TZ).date() + if cache_allowed: cached = load_management_cache(host, report_date) if cached is not None: return cached + lock = get_management_build_lock(host, report_date) + with lock: + if cache_allowed: + cached = load_management_cache(host, report_date) + if cached is not None: + return cached bounds, events = fetch_events_for_date(host, report_date) rows = aggregate_rows_with_intervals(events, bounds["start"], bounds["end"], host) payload = build_management_payload(rows, host, report_date) @@ -2265,10 +2521,11 @@ def render_management_html(payload, selected_day=None): sources_html = [] for source in payload.get("sources", []): + source_priority_class = "low" if source["status"] in {"ok", "inactive"} else ("critical" if source["required"] else "medium") sources_html.append( "" f"{html.escape(source['label'])}" - f"{html.escape(source['status_label'])}" + f"{html.escape(source['status_label'])}" f"{html.escape(source['bucket_id'])}" f"{html.escape(source.get('timestamp') or '-')}" f"{html.escape(str(source.get('age_seconds')) if source.get('age_seconds') is not None else '-')}" @@ -2619,6 +2876,85 @@ def render_management_html(payload, selected_day=None): """ +def build_worktime_report_response(path, params): + fmt = resolve_report_format(params) + host = resolve_host(params.get("host", [DEFAULT_HOST])[0]) + day = params.get("day", ["today"])[0] + date_text = params.get("date", [None])[0] + owner_filter = normalize_management_filter(params.get("owner", [""])[0]) + department_filter = normalize_management_filter(params.get("department", [""])[0]) + report_date = resolve_report_date(day=day, date_text=date_text) + is_management = path == "/reports/worktime/management" + allow_immediate_stale = str(params.get("allow_stale", [""])[0]).lower() in {"1", "true", "yes"} + management_payload = ( + management_report_for_date( + host, + report_date, + owner_filter=owner_filter, + department_filter=department_filter, + use_cache=allow_immediate_stale, + ) + if is_management + else None + ) + rows = report_for_date_fresh(host, report_date) if not is_management else management_payload["rows"] + true_active_apps = [] if is_management else build_true_active_apps(host, report_date) + + if fmt == "csv": + if is_management: + out = io.StringIO() + writer = csv.DictWriter( + out, + fieldnames=["priority", "owner", "user_id", "action_id", "deadline_hint", "reason", "recommended_action"], + extrasaction="ignore", + ) + writer.writeheader() + writer.writerows(management_payload["actions"]) + return out.getvalue().encode(), "text/csv; charset=utf-8" + out = io.StringIO() + writer = csv.DictWriter( + out, + fieldnames=[ + "user", + "user_id", + "active_seconds", + "active_hhmm", + "first_activity", + "last_activity", + "idle_seconds", + "sessions_count", + "samples_count", + "active_samples", + ], + ) + writer.writeheader() + writer.writerows(rows) + return out.getvalue().encode(), "text/csv; charset=utf-8" + + if fmt == "html": + if is_management: + data = render_management_html(management_payload, selected_day=day if day in {"today", "yesterday"} else None).encode("utf-8") + else: + data = render_html(rows, host, report_date, selected_day=day if day in {"today", "yesterday"} else None, true_active_apps=true_active_apps).encode("utf-8") + return data, "text/html; charset=utf-8" + + if is_management: + data = json.dumps(management_payload, ensure_ascii=False, indent=2).encode("utf-8") + return data, "application/json; charset=utf-8" + + obj = { + "generated_at_utc": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), + "report_timezone": str(REPORT_TZ), + "host": host, + "report_date": report_date.isoformat(), + "bucket_id": get_sessions_bucket_id(host), + "rows": rows, + "true_active_apps": true_active_apps, + } + data = json.dumps(obj, ensure_ascii=False, indent=2).encode("utf-8") + return data, "application/json; charset=utf-8" + + def send_bytes(handler, data, content_type, status=200): handler.send_response(status) handler.send_header("Content-Type", content_type) @@ -2667,82 +3003,27 @@ class H(BaseHTTPRequestHandler): return params = parse_qs(parsed.query, keep_blank_values=False) - fmt = "json" - if params.get("format", ["json"])[0] == "csv": - fmt = "csv" - elif params.get("format", ["json"])[0] == "html": - fmt = "html" + fmt = resolve_report_format(params, self.headers.get("Accept", "")) + if not params.get("format") and fmt != "json": + params = {name: list(values) for name, values in params.items()} + params["format"] = [fmt] host = resolve_host(params.get("host", [DEFAULT_HOST])[0]) day = params.get("day", ["today"])[0] date_text = params.get("date", [None])[0] owner_filter = normalize_management_filter(params.get("owner", [""])[0]) department_filter = normalize_management_filter(params.get("department", [""])[0]) report_date = resolve_report_date(day=day, date_text=date_text) - is_management = parsed.path == "/reports/worktime/management" - management_payload = management_report_for_date(host, report_date, owner_filter=owner_filter, department_filter=department_filter) if is_management else None - rows = report_for_date_fresh(host, report_date) if not is_management else management_payload["rows"] - true_active_apps = [] if is_management else build_true_active_apps(host, report_date) + cache_key = make_report_cache_key( + parsed.path, + fmt, + host, + report_date, + selected_day=day if day in {"today", "yesterday"} else "", + owner_filter=owner_filter, + department_filter=department_filter, + ) - if fmt == "csv": - if is_management: - out = io.StringIO() - writer = csv.DictWriter( - out, - fieldnames=["priority", "owner", "user_id", "action_id", "deadline_hint", "reason", "recommended_action"], - extrasaction="ignore", - ) - writer.writeheader() - writer.writerows(management_payload["actions"]) - data = out.getvalue().encode() - send_bytes(self, data, "text/csv; charset=utf-8") - return - out = io.StringIO() - writer = csv.DictWriter( - out, - fieldnames=[ - "user", - "user_id", - "active_seconds", - "active_hhmm", - "first_activity", - "last_activity", - "idle_seconds", - "sessions_count", - "samples_count", - "active_samples", - ], - ) - writer.writeheader() - writer.writerows(rows) - data = out.getvalue().encode() - send_bytes(self, data, "text/csv; charset=utf-8") - return - - if fmt == "html": - if is_management: - data = render_management_html(management_payload, selected_day=day if day in {"today", "yesterday"} else None).encode("utf-8") - else: - data = render_html(rows, host, report_date, selected_day=day if day in {"today", "yesterday"} else None, true_active_apps=true_active_apps).encode("utf-8") - send_bytes(self, data, "text/html; charset=utf-8") - return - - if is_management: - obj = management_payload - data = json.dumps(obj, ensure_ascii=False, indent=2).encode("utf-8") - send_bytes(self, data, "application/json; charset=utf-8") - return - - obj = { - "generated_at_utc": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), - "report_timezone": str(REPORT_TZ), - "host": host, - "report_date": report_date.isoformat(), - "bucket_id": get_sessions_bucket_id(host), - "rows": rows, - "true_active_apps": true_active_apps, - } - data = json.dumps(obj, ensure_ascii=False, indent=2).encode("utf-8") - send_bytes(self, data, "application/json; charset=utf-8") + send_worktime_report(self, parsed.path, params, cache_key, host=host, report_date=report_date) class WorktimeHTTPServer(ThreadingHTTPServer): diff --git a/aw-server/aw-worktime-autoheal.timer b/aw-server/aw-worktime-autoheal.timer index f70199e..58b9c76 100644 --- a/aw-server/aw-worktime-autoheal.timer +++ b/aw-server/aw-worktime-autoheal.timer @@ -2,11 +2,10 @@ Description=Run AW Worktime Autoheal every 3 minutes [Timer] -OnBootSec=2min -OnUnitActiveSec=3min +OnCalendar=*:1/3:35 +AccuracySec=30s Unit=aw-worktime-autoheal.service -Persistent=true +Persistent=false [Install] WantedBy=timers.target - diff --git a/aw-server/aw-worktime-influx-exporter.timer b/aw-server/aw-worktime-influx-exporter.timer index a2091e6..967e581 100644 --- a/aw-server/aw-worktime-influx-exporter.timer +++ b/aw-server/aw-worktime-influx-exporter.timer @@ -2,9 +2,9 @@ Description=Run AW Worktime InfluxDB exporter every 10 minutes [Timer] -OnBootSec=3min -OnUnitActiveSec=10min +OnCalendar=*:4/10:05 AccuracySec=1min +RandomizedDelaySec=30s Unit=aw-worktime-influx-exporter.service [Install] diff --git a/aw-server/aw-worktime-prewarm.service b/aw-server/aw-worktime-prewarm.service new file mode 100644 index 0000000..a013770 --- /dev/null +++ b/aw-server/aw-worktime-prewarm.service @@ -0,0 +1,15 @@ +[Unit] +Description=AW Worktime Report Cache Prewarm +After=network-online.target activitywatch-server.service aw-worktime-api.service +Wants=network-online.target activitywatch-server.service aw-worktime-api.service + +[Service] +Type=oneshot +EnvironmentFile=/etc/activitywatch/aw-server.env +ExecStart=/usr/local/bin/aw-worktime-prewarm.sh +User=activitywatch +Group=activitywatch +StandardOutput=journal +StandardError=journal +SyslogIdentifier=aw-worktime-prewarm + diff --git a/aw-server/aw-worktime-prewarm.sh b/aw-server/aw-worktime-prewarm.sh new file mode 100644 index 0000000..7ae33e9 --- /dev/null +++ b/aw-server/aw-worktime-prewarm.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +set -euo pipefail + +WORKTIME_BASE_URL="${WORKTIME_BASE_URL:-http://127.0.0.1:5610}" +WORKTIME_PREWARM_TIMEOUT_SECONDS="${WORKTIME_PREWARM_TIMEOUT_SECONDS:-45}" +WORKTIME_PREWARM_HEALTH_TIMEOUT_SECONDS="${WORKTIME_PREWARM_HEALTH_TIMEOUT_SECONDS:-10}" +WORKTIME_PREWARM_READY_TIMEOUT_SECONDS="${WORKTIME_PREWARM_READY_TIMEOUT_SECONDS:-60}" +WORKTIME_PREWARM_READY_INTERVAL_SECONDS="${WORKTIME_PREWARM_READY_INTERVAL_SECONDS:-2}" +WORKTIME_PREWARM_HOST="${WORKTIME_PREWARM_HOST:-${AW_WORKTIME_HOST:-SHARKON2025}}" +WORKTIME_PREWARM_PROFILE="${WORKTIME_PREWARM_PROFILE:-full}" + +log() { + printf '%s %s\n' "$(date '+%F %T')" "$*" +} + +probe() { + local url="$1" + local timeout="$2" + local tmp + tmp="$(mktemp)" + local code + code="$(curl -sS --max-time "$timeout" -o /dev/null -D "$tmp" -w '%{http_code}' "$url" 2>/dev/null || true)" + if [[ "$code" =~ ^2 ]]; then + local cache + cache="$(tr '\r' '\n' < "$tmp" | awk 'tolower($0) ~ /^x-aw-worktime-cache:/ {print $2; exit}' || true)" + local reason + reason="$(tr '\r' '\n' < "$tmp" | awk 'tolower($0) ~ /^x-aw-worktime-cache-reason:/ {print $2; exit}' || true)" + rm -f "$tmp" + log "ok code=$code cache=${cache:-none} reason=${reason:-none} url=$url" + return 0 + fi + rm -f "$tmp" + log "warn code=${code:-000} url=$url" + return 1 +} + +wait_until_ready() { + local deadline + deadline=$(( $(date +%s) + WORKTIME_PREWARM_READY_TIMEOUT_SECONDS )) + while true; do + if probe "$WORKTIME_BASE_URL/health" "$WORKTIME_PREWARM_HEALTH_TIMEOUT_SECONDS"; then + return 0 + fi + if [[ "$(date +%s)" -ge "$deadline" ]]; then + return 1 + fi + sleep "$WORKTIME_PREWARM_READY_INTERVAL_SECONDS" + done +} + +if ! wait_until_ready; then + log "health readiness timed out; skip prewarm" + exit 0 +fi + +full_urls=( + "$WORKTIME_BASE_URL/reports/worktime/today?day=today&format=csv&host=$WORKTIME_PREWARM_HOST" + "$WORKTIME_BASE_URL/reports/worktime/today?day=today&format=json&host=$WORKTIME_PREWARM_HOST" + "$WORKTIME_BASE_URL/reports/worktime/today?day=today&format=html&host=$WORKTIME_PREWARM_HOST" + "$WORKTIME_BASE_URL/reports/worktime/management?day=today&format=csv&host=$WORKTIME_PREWARM_HOST" + "$WORKTIME_BASE_URL/reports/worktime/management?day=today&format=json&host=$WORKTIME_PREWARM_HOST" + "$WORKTIME_BASE_URL/reports/worktime/management?day=today&format=html&host=$WORKTIME_PREWARM_HOST" +) + +startup_urls=( + "$WORKTIME_BASE_URL/reports/worktime/today?day=today&format=csv&host=$WORKTIME_PREWARM_HOST" + "$WORKTIME_BASE_URL/reports/worktime/today?day=today&format=json&host=$WORKTIME_PREWARM_HOST" + "$WORKTIME_BASE_URL/reports/worktime/management?day=today&format=json&host=$WORKTIME_PREWARM_HOST" + "$WORKTIME_BASE_URL/reports/worktime/management?day=today&format=csv&host=$WORKTIME_PREWARM_HOST" +) + +case "$WORKTIME_PREWARM_PROFILE" in + full) + urls=("${full_urls[@]}") + ;; + startup) + urls=("${startup_urls[@]}") + ;; + *) + log "unknown profile=$WORKTIME_PREWARM_PROFILE" + exit 0 + ;; +esac + +failures=0 +for url in "${urls[@]}"; do + if ! probe "$url" "$WORKTIME_PREWARM_TIMEOUT_SECONDS"; then + failures=$((failures + 1)) + fi +done + +if [[ "$failures" -gt 0 ]]; then + log "completed profile=$WORKTIME_PREWARM_PROFILE with failures=$failures" +else + log "completed profile=$WORKTIME_PREWARM_PROFILE successfully" +fi + +exit 0 diff --git a/aw-server/aw-worktime-prewarm.timer b/aw-server/aw-worktime-prewarm.timer new file mode 100644 index 0000000..5167be5 --- /dev/null +++ b/aw-server/aw-worktime-prewarm.timer @@ -0,0 +1,11 @@ +[Unit] +Description=Run AW Worktime Report Cache Prewarm + +[Timer] +OnCalendar=*:2/5:40 +AccuracySec=30s +Persistent=false +Unit=aw-worktime-prewarm.service + +[Install] +WantedBy=timers.target diff --git a/aw-server/aw-worktime-ui-bridge.timer b/aw-server/aw-worktime-ui-bridge.timer index d56cb22..768215a 100644 --- a/aw-server/aw-worktime-ui-bridge.timer +++ b/aw-server/aw-worktime-ui-bridge.timer @@ -2,10 +2,10 @@ Description=Run AW Worktime UI bridge every 30 seconds [Timer] -OnBootSec=20s -OnUnitActiveSec=30s +OnCalendar=*:0/1:15 +AccuracySec=10s Unit=aw-worktime-ui-bridge.service -Persistent=true +Persistent=false [Install] WantedBy=timers.target diff --git a/aw-server/check-aw-data-wrapper.sh b/aw-server/check-aw-data-wrapper.sh new file mode 100644 index 0000000..8e4ab1f --- /dev/null +++ b/aw-server/check-aw-data-wrapper.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ -x /usr/local/bin/check-aw-data-rust ]]; then + if [[ -z "${AW_CHECK_SERVER:-}" && -z "${AW_SERVER_URL:-}" ]]; then + for arg in "$@"; do + if [[ "$arg" == "--server" || "$arg" == --server=* ]]; then + exec /usr/local/bin/check-aw-data-rust "$@" + fi + done + exec /usr/local/bin/check-aw-data-rust --server http://127.0.0.1:5600 "$@" + fi + exec /usr/local/bin/check-aw-data-rust "$@" +fi + +exec /opt/activitywatch/aw-rus-ops/check-aw-data.sh "$@" diff --git a/aw-server/dlp-content-analysis/aw-dlp-content-analyzer.sh b/aw-server/dlp-content-analysis/aw-dlp-content-analyzer.sh index 8cd33dd..ad42440 100644 --- a/aw-server/dlp-content-analysis/aw-dlp-content-analyzer.sh +++ b/aw-server/dlp-content-analysis/aw-dlp-content-analyzer.sh @@ -2,9 +2,14 @@ set -euo pipefail BASE_DIR="/opt/activitywatch/dlp-content-analysis" +RUST_ANALYZER="/usr/local/bin/aw-dlp-content-analyzer-rust" VENV_PY="$BASE_DIR/.venv/bin/python" ANALYZER="$BASE_DIR/content_analyzer.py" +if [ -x "$RUST_ANALYZER" ]; then + exec "$RUST_ANALYZER" "$@" +fi + if [ ! -x "$VENV_PY" ]; then echo "ERROR: content-analysis virtualenv is missing: $VENV_PY" >&2 exit 1 diff --git a/aw-server/dlp-integrations/cef-exporter.timer b/aw-server/dlp-integrations/cef-exporter.timer index 4582564..3f2d312 100644 --- a/aw-server/dlp-integrations/cef-exporter.timer +++ b/aw-server/dlp-integrations/cef-exporter.timer @@ -2,10 +2,11 @@ Description=Run AWatch DLP CEF Exporter every 5 minutes [Timer] -OnBootSec=2min -OnUnitActiveSec=5min +OnCalendar=*:6/10:30 +AccuracySec=30s +RandomizedDelaySec=30s Unit=aw-dlp-cef-exporter.service -Persistent=true +Persistent=false [Install] WantedBy=timers.target diff --git a/aw-server/dlp-integrations/syslog-forwarder.timer b/aw-server/dlp-integrations/syslog-forwarder.timer index 218a308..331ee11 100644 --- a/aw-server/dlp-integrations/syslog-forwarder.timer +++ b/aw-server/dlp-integrations/syslog-forwarder.timer @@ -2,9 +2,10 @@ Description=Run AWatch DLP syslog forwarder every 3 minutes [Timer] -OnBootSec=3min -OnUnitActiveSec=3min -Persistent=true +OnCalendar=*:7/10:40 +AccuracySec=30s +RandomizedDelaySec=30s +Persistent=false Unit=aw-dlp-syslog-forwarder.service [Install] diff --git a/aw-server/dlp-integrations/syslog_forwarder.py b/aw-server/dlp-integrations/syslog_forwarder.py index 8c32616..4ecec71 100644 --- a/aw-server/dlp-integrations/syslog_forwarder.py +++ b/aw-server/dlp-integrations/syslog_forwarder.py @@ -57,7 +57,7 @@ def iter_new_incidents(aw_base: str, state: dict[str, Any], per_bucket_limit: in for bid in bucket_ids: try: events = http_json(f"{aw_base}/buckets/{bid}/events?limit={int(per_bucket_limit)}") - except error.HTTPError as exc: + except (TimeoutError, OSError, error.URLError, error.HTTPError) as exc: LOG.warning("skip bucket %s: %s", bid, exc) continue prev = int(last_ids.get(bid, 0)) @@ -126,7 +126,11 @@ def main() -> None: app_name = str(cfg.get("app_name", "aw-dlp")) state = load_json(state_path) - incidents, max_ids = iter_new_incidents(aw_base=aw_base, state=state, per_bucket_limit=per_bucket_limit) + try: + incidents, max_ids = iter_new_incidents(aw_base=aw_base, state=state, per_bucket_limit=per_bucket_limit) + except (TimeoutError, OSError, error.URLError, error.HTTPError) as exc: + LOG.warning("skip syslog forwarder run: AW API unavailable: %s", exc) + return sent = 0 for event in incidents: diff --git a/aw-server/dlp-integrations/test_syslog_forwarder.py b/aw-server/dlp-integrations/test_syslog_forwarder.py new file mode 100644 index 0000000..446cbe3 --- /dev/null +++ b/aw-server/dlp-integrations/test_syslog_forwarder.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +import importlib.util +import json +import sys +from pathlib import Path + + +MODULE_PATH = Path(__file__).with_name("syslog_forwarder.py") +SPEC = importlib.util.spec_from_file_location("syslog_forwarder", MODULE_PATH) +MODULE = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = MODULE +SPEC.loader.exec_module(MODULE) + + +def test_iter_new_incidents_skips_timed_out_bucket(monkeypatch): + def fake_http_json(url, timeout=15): + if url.endswith("/buckets/"): + return {"aw-dlp-incidents_SHARKON2025": {}} + raise TimeoutError("timed out") + + monkeypatch.setattr(MODULE, "http_json", fake_http_json) + + incidents, max_ids = MODULE.iter_new_incidents( + aw_base="http://127.0.0.1:5600/api/0", + state={"last_ids": {"aw-dlp-incidents_SHARKON2025": 42}}, + per_bucket_limit=300, + ) + + assert incidents == [] + assert max_ids == {} + + +def test_main_skips_aw_api_timeout_without_overwriting_state(monkeypatch, tmp_path): + state_path = tmp_path / "syslog-forwarder-state.json" + original_state = {"last_ids": {"aw-dlp-incidents_SHARKON2025": 99}} + state_path.write_text(json.dumps(original_state), encoding="utf-8") + + monkeypatch.setattr( + MODULE, + "load_yaml", + lambda path: { + "aw_api_base": "http://127.0.0.1:5600/api/0", + "state_path": str(state_path), + }, + ) + monkeypatch.setattr( + MODULE, + "iter_new_incidents", + lambda aw_base, state, per_bucket_limit: (_ for _ in ()).throw(TimeoutError("timed out")), + ) + monkeypatch.setattr( + MODULE, + "save_json", + lambda path, payload: (_ for _ in ()).throw(AssertionError("state should not be saved on AW API timeout")), + ) + + MODULE.main() + + assert json.loads(state_path.read_text(encoding="utf-8")) == original_state diff --git a/aw-server/dlp-integrations/webhook-sender.timer b/aw-server/dlp-integrations/webhook-sender.timer index 29ed438..f64e60d 100644 --- a/aw-server/dlp-integrations/webhook-sender.timer +++ b/aw-server/dlp-integrations/webhook-sender.timer @@ -2,10 +2,11 @@ Description=Run AWatch DLP Webhook sender every 2 minutes [Timer] -OnBootSec=90s -OnUnitActiveSec=2min +OnCalendar=*:8/10:45 +AccuracySec=30s +RandomizedDelaySec=30s Unit=aw-dlp-webhook-sender.service -Persistent=true +Persistent=false [Install] WantedBy=timers.target diff --git a/aw-server/hayabusa/aw-hayabusa.sh b/aw-server/hayabusa/aw-hayabusa.sh index 4b017a2..b82a326 100644 --- a/aw-server/hayabusa/aw-hayabusa.sh +++ b/aw-server/hayabusa/aw-hayabusa.sh @@ -417,7 +417,7 @@ process_one_package() { fail "normalized zip extraction failed for ${package_path}" fi - local manifest_path host evtx_root archive_pkg_dir archive_pkg_path archive_extract_dir status report_dir + local manifest_path host evtx_root archive_pkg_dir archive_extract_dir status report_dir manifest_path="$(find_manifest_path "${stage_dir}")" host="${forced_host}" if [ -z "${host}" ] && [ -f "${package_path}.host" ]; then diff --git a/aw-server/health-check.sh b/aw-server/health-check.sh index 5fba2e8..7df5693 100644 --- a/aw-server/health-check.sh +++ b/aw-server/health-check.sh @@ -16,6 +16,10 @@ fi WORKTIME_HEALTH_BASE="${AW_RUS_HEALTH_WORKTIME_API:-${AW_WORKTIME_REPORT_BASE:-http://127.0.0.1:5610}}" WORKTIME_HEALTH_URL="${WORKTIME_HEALTH_BASE%/}/health" WORKTIME_HEALTH_TIMEOUT_SECONDS="${AW_RUS_HEALTH_WORKTIME_TIMEOUT_SECONDS:-15}" +AW_HEALTH_API_TIMEOUT_SECONDS="${AW_RUS_HEALTH_AW_TIMEOUT_SECONDS:-15}" +AW_HEALTH_API_ATTEMPTS="${AW_RUS_HEALTH_AW_ATTEMPTS:-3}" +SETTINGS_TIMEOUT_SECONDS="${AW_RUS_HEALTH_SETTINGS_TIMEOUT_SECONDS:-15}" +SETTINGS_ATTEMPTS="${AW_RUS_HEALTH_SETTINGS_ATTEMPTS:-3}" check_service() { local service=$1 @@ -59,8 +63,17 @@ check_api_endpoint() { read_setting_value() { local key=$1 - curl -fsS --max-time 10 "http://127.0.0.1:5600/api/0/settings/${key}" 2>/dev/null | \ - python3 -c 'import json,sys; print(json.load(sys.stdin))' + local attempt + for ((attempt=1; attempt<=SETTINGS_ATTEMPTS; attempt++)); do + if curl -fsS --connect-timeout 3 --max-time "$SETTINGS_TIMEOUT_SECONDS" "http://127.0.0.1:5600/api/0/settings/${key}" 2>/dev/null | \ + python3 -c 'import json,sys; print(json.load(sys.stdin))'; then + return 0 + fi + if (( attempt < SETTINGS_ATTEMPTS )); then + sleep 1 + fi + done + return 1 } check_expected_setting() { @@ -139,7 +152,7 @@ done echo # Check API endpoints -check_api_endpoint "http://127.0.0.1:5600/api/0/info" "activitywatch-server" +check_api_endpoint "http://127.0.0.1:5600/api/0/info" "activitywatch-server" "$AW_HEALTH_API_TIMEOUT_SECONDS" "$AW_HEALTH_API_ATTEMPTS" check_api_endpoint "$WORKTIME_HEALTH_URL" "aw-worktime-api" "$WORKTIME_HEALTH_TIMEOUT_SECONDS" 2 check_dlp_transport_freshness "http://127.0.0.1:5600/api/0" "900" "${AW_HEALTH_STRICT_FILEOPS:-0}" check_expected_setting "startOfDay" "${AW_EXPECT_START_OF_DAY:-}" "startOfDay" diff --git a/aw-server/test_aw_rus_healthd.py b/aw-server/test_aw_rus_healthd.py index 211e3e5..72fad82 100644 --- a/aw-server/test_aw_rus_healthd.py +++ b/aw-server/test_aw_rus_healthd.py @@ -38,6 +38,33 @@ def test_bucket_health_reports_missing_events_without_crash(monkeypatch): assert details["bucket"] == "aw-watcher-window_SHARKON2025" +def test_inactive_interactive_bucket_can_be_downgraded_to_ok(monkeypatch): + monkeypatch.setattr( + MODULE, + "latest_bucket_event", + lambda api_base, bucket_id: {"timestamp": "2026-05-30T07:00:00Z"}, + ) + monkeypatch.setattr(MODULE, "now_utc", lambda: MODULE.parse_ts("2026-05-30T12:00:00Z")) + activity = {"active": False, "fresh": True, "age_seconds": 5} + status, summary, details = MODULE.bucket_health( + "http://127.0.0.1:5600/api/0", + "aw-watcher-window_SHARKON2025", + 900, + missing_status="warn", + stale_status="warn", + ) + details["interactive_required"] = bool(activity["active"]) + details["host_activity"] = activity + if not activity["active"] and status != "ok": + details["inactive_summary"] = summary + status = "ok" + summary = "inactive: no active interactive users" + + assert status == "ok" + assert summary == "inactive: no active interactive users" + assert details["inactive_summary"] == "stale (18000s)" + + def test_bucket_health_marks_old_session_events_as_event_driven(monkeypatch): monkeypatch.setattr( MODULE, @@ -58,3 +85,127 @@ def test_bucket_health_marks_old_session_events_as_event_driven(monkeypatch): assert status == "ok" assert summary.startswith("event-driven (") assert details["age_seconds"] > 86400 + + +def test_bucket_timestamp_health_uses_bucket_metadata_without_event_query(monkeypatch): + def fail_if_called(api_base, bucket_id): + raise AssertionError("latest event query should not run when bucket metadata has end timestamp") + + monkeypatch.setattr(MODULE, "latest_bucket_event", fail_if_called) + monkeypatch.setattr(MODULE, "now_utc", lambda: MODULE.parse_ts("2026-05-30T17:05:00Z")) + + status, summary, details = MODULE.bucket_timestamp_health( + "http://127.0.0.1:5600/api/0", + {"aw-session-events_SHARKON2025": {"metadata": {"end": "2026-05-30T17:00:00Z"}}}, + "aw-session-events_SHARKON2025", + 86400, + missing_status="fail", + stale_status="warn", + ) + + assert status == "ok" + assert summary == "fresh (300s)" + assert details["timestamp_source"] == "bucket_metadata.end" + assert details["age_seconds"] == 300 + + +def test_bucket_timestamp_health_falls_back_to_event_query_without_metadata(monkeypatch): + monkeypatch.setattr( + MODULE, + "latest_bucket_event", + lambda api_base, bucket_id: {"timestamp": "2026-05-30T17:00:00Z"}, + ) + monkeypatch.setattr(MODULE, "now_utc", lambda: MODULE.parse_ts("2026-05-30T17:05:00Z")) + + status, summary, details = MODULE.bucket_timestamp_health( + "http://127.0.0.1:5600/api/0", + {"aw-session-events_SHARKON2025": {}}, + "aw-session-events_SHARKON2025", + 86400, + missing_status="fail", + stale_status="warn", + ) + + assert status == "ok" + assert summary == "fresh (300s)" + assert "timestamp_source" not in details + + +def test_guard_bucket_missing_is_warn_until_required(monkeypatch): + monkeypatch.setattr(MODULE, "latest_bucket_event", lambda api_base, bucket_id: None) + status, summary, details = MODULE.guard_bucket_health( + "http://127.0.0.1:5600/api/0", + "SHARKON2025", + 300, + required=False, + ) + assert status == "warn" + assert summary == "no guard heartbeat" + assert details["required"] is False + + +def test_guard_bucket_required_stale_is_fail(monkeypatch): + monkeypatch.setattr( + MODULE, + "latest_bucket_event", + lambda api_base, bucket_id: { + "timestamp": "2026-05-30T10:00:00Z", + "data": {"status": "ok", "mode": "enforce"}, + }, + ) + monkeypatch.setattr(MODULE, "now_utc", lambda: MODULE.parse_ts("2026-05-30T10:10:00Z")) + status, summary, details = MODULE.guard_bucket_health( + "http://127.0.0.1:5600/api/0", + "SHARKON2025", + 300, + required=True, + ) + assert status == "fail" + assert summary == "guard stale (600s)" + assert details["mode"] == "enforce" + + +def test_aw_wrapper_failure_can_be_advisory_warn(tmp_path): + report = MODULE.Report() + missing = tmp_path / "missing-wrapper" + MODULE.check_wrapper(report, "wrapper:test", [str(missing)], failure_status="warn") + assert report.results[0].status == "warn" + + +def test_dlp_wrapper_failure_can_be_advisory_warn(monkeypatch, tmp_path): + wrapper = tmp_path / "dlp-health-check" + wrapper.write_text("#!/bin/sh\nprintf '{\"ok\":false}'\nexit 1\n", encoding="utf-8") + wrapper.chmod(0o755) + monkeypatch.setattr(MODULE, "run_command", lambda cmd: (1, '{"ok": false}')) + + report = MODULE.Report() + MODULE.check_wrapper( + report, + "wrapper:dlp-health-check", + [str(wrapper), "--json"], + json_mode=True, + failure_status="warn", + ) + + assert report.results[0].status == "warn" + assert report.results[0].details["payload"] == {"ok": False} + + +def test_load_env_file_ignores_permission_denied(): + class DeniedPath: + def exists(self): + return True + + def read_text(self, encoding="utf-8"): + raise PermissionError("denied") + + MODULE.load_env_file(DeniedPath()) + + +def test_chmod_if_possible_sets_readable_mode(tmp_path): + path = tmp_path / "aw-rus-health.json" + path.write_text("{}", encoding="utf-8") + + MODULE.chmod_if_possible(path, 0o644) + + assert oct(path.stat().st_mode & 0o777) == "0o644" diff --git a/aw-server/test_aw_slo_monitor.py b/aw-server/test_aw_slo_monitor.py new file mode 100644 index 0000000..00b55ee --- /dev/null +++ b/aw-server/test_aw_slo_monitor.py @@ -0,0 +1,268 @@ +#!/usr/bin/env python3 +import importlib.util +import sys +import tempfile +from pathlib import Path + + +MODULE_PATH = Path(__file__).with_name("aw-slo-monitor.py") +SPEC = importlib.util.spec_from_file_location("aw_slo_monitor", MODULE_PATH) +MODULE = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = MODULE +SPEC.loader.exec_module(MODULE) + + +class FakeHeaders: + def __init__(self, values): + self.values = values + + def get(self, key, default=""): + return self.values.get(key, default) + + +class FakeResponse: + def __init__(self, body, status=200, content_type="text/plain"): + self.body = body + self.status = status + self.headers = FakeHeaders({"Content-Type": content_type}) + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def read(self): + return self.body + + +def test_summarize_window_calculates_9997_budget(): + now = MODULE.parse_ts("2026-05-30T12:00:00Z") + samples = [ + {"ts": "2026-05-30T11:58:00Z", "ok": True}, + {"ts": "2026-05-30T11:59:00Z", "ok": False}, + {"ts": "2026-05-30T12:00:00Z", "ok": True}, + ] + + summary = MODULE.summarize_window( + samples, + now=now, + window_seconds=86400, + sample_interval_seconds=60, + target_percent=99.97, + ) + + assert summary["samples"] == 3 + assert summary["good_samples"] == 2 + assert summary["bad_samples"] == 1 + assert summary["availability_percent"] == 66.66667 + assert summary["budget_seconds"] == 25 + assert summary["budget_remaining_seconds"] == -35 + assert summary["status"] == "burning" + + +def test_summarize_window_status_uses_remaining_error_budget_for_partial_window(): + now = MODULE.parse_ts("2026-05-30T12:00:00Z") + samples = [ + {"ts": "2026-05-30T11:59:30Z", "ok": False}, + {"ts": "2026-05-30T11:59:45Z", "ok": True}, + {"ts": "2026-05-30T12:00:00Z", "ok": True}, + ] + + summary = MODULE.summarize_window( + samples, + now=now, + window_seconds=86400, + sample_interval_seconds=15, + target_percent=99.97, + ) + + assert summary["availability_percent"] == 66.66667 + assert summary["budget_seconds"] == 25 + assert summary["budget_remaining_seconds"] == 10 + assert summary["status"] == "ok" + + +def test_append_and_trim_sample_keeps_retention_window(): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "samples.jsonl" + path.write_text( + '{"ts":"2026-05-01T00:00:00Z","ok":true}\n' + '{"ts":"2026-05-30T11:59:00Z","ok":true}\n', + encoding="utf-8", + ) + sample = {"ts": "2026-05-30T12:00:00Z", "ok": False} + + samples = MODULE.append_and_trim_sample(path, sample, retention_seconds=3600) + + assert [item["ts"] for item in samples] == [ + "2026-05-30T11:59:00Z", + "2026-05-30T12:00:00Z", + ] + assert "2026-05-01T00:00:00Z" not in path.read_text(encoding="utf-8") + + +def test_load_samples_treats_unreadable_file_as_empty(monkeypatch, tmp_path): + path = tmp_path / "samples.jsonl" + path.write_text('{"ts":"2026-05-30T12:00:00Z","ok":true}\n', encoding="utf-8") + original_read_text = MODULE.Path.read_text + + def denied(self, *args, **kwargs): + if self == path: + raise PermissionError("denied") + return original_read_text(self, *args, **kwargs) + + monkeypatch.setattr(MODULE.Path, "read_text", denied) + + assert MODULE.load_samples(path, MODULE.parse_ts("2026-05-30T11:00:00Z")) == [] + + +def test_render_summary_text_includes_budget_remaining(): + summary = { + "generated_at_utc": "2026-05-30T12:00:00Z", + "target_percent": 99.97, + "current_sample": { + "ok": True, + "probes": {"worktime_today_csv": {"ok": True, "status": 200}}, + }, + "windows": { + "24h": { + "status": "ok", + "availability_percent": 100.0, + "samples": 10, + "bad_samples": 0, + "observed_bad_seconds": 0, + "budget_remaining_seconds": 25, + } + }, + } + + text = MODULE.render_summary_text(summary) + + assert "Target: 99.97%" in text + assert "24h: ok availability=100.00000%" in text + assert "budget_remaining_seconds=25" in text + assert "- worktime_today_csv: OK 200" in text + + +def test_html_probe_requires_non_empty_html_with_markers(monkeypatch): + body = b'ActivityWatch
' + monkeypatch.setattr(MODULE.request, "urlopen", lambda req, timeout: FakeResponse(body, content_type="text/html; charset=utf-8")) + + result = MODULE.html_probe( + "http://127.0.0.1:5600/", + 5, + min_bytes=100, + required_markers=("ActivityWatch", 'id="app"', "ru-patch-v5.js"), + ) + + assert result["ok"] is True + assert result["status"] == 200 + assert result["body_bytes"] == len(body) + assert "body" not in result + + +def test_html_probe_fails_on_missing_marker(monkeypatch): + monkeypatch.setattr(MODULE.request, "urlopen", lambda req, timeout: FakeResponse(b"", content_type="text/html")) + + result = MODULE.html_probe( + "http://127.0.0.1:5600/", + 5, + min_bytes=1, + required_markers=("ActivityWatch",), + ) + + assert result["ok"] is False + assert result["missing_markers"] == ["ActivityWatch"] + + +def test_html_probe_retries_transient_fetch_error(monkeypatch): + calls = {"count": 0} + body = b"ActivityWatch" + + def fake_urlopen(req, timeout): + calls["count"] += 1 + if calls["count"] == 1: + raise TimeoutError("timed out") + return FakeResponse(body, content_type="text/html") + + monkeypatch.setattr(MODULE.request, "urlopen", fake_urlopen) + monkeypatch.setattr(MODULE.time, "sleep", lambda seconds: None) + + result = MODULE.html_probe( + "http://127.0.0.1:5600/", + 5, + min_bytes=1, + required_markers=("ActivityWatch",), + ) + + assert result["ok"] is True + assert result["attempts"] == 2 + assert calls["count"] == 2 + + +def test_json_probe_validates_shape_and_expected_values(monkeypatch): + body = b'{"generated_at_utc":"2026-05-30T12:00:00Z","host":"SHARKON2025","summary":{},"rows":[],"workday":{}}' + monkeypatch.setattr(MODULE.request, "urlopen", lambda req, timeout: FakeResponse(body, content_type="application/json")) + + result = MODULE.json_probe( + "http://127.0.0.1:5610/reports/worktime/management?format=json", + 5, + expected_values={"host": "SHARKON2025"}, + required_keys=("generated_at_utc", "host", "summary", "rows", "workday"), + ) + + assert result["ok"] is True + assert result["status"] == 200 + assert "body" not in result + + +def test_json_probe_fails_on_wrong_host(monkeypatch): + body = b'{"generated_at_utc":"2026-05-30T12:00:00Z","host":"OTHER","summary":{},"rows":[],"workday":{}}' + monkeypatch.setattr(MODULE.request, "urlopen", lambda req, timeout: FakeResponse(body, content_type="application/json")) + + result = MODULE.json_probe( + "http://127.0.0.1:5610/reports/worktime/management?format=json", + 5, + expected_values={"host": "SHARKON2025"}, + required_keys=("generated_at_utc", "host", "summary", "rows", "workday"), + ) + + assert result["ok"] is False + assert result["mismatched_values"]["host"]["actual"] == "OTHER" + + +def test_load_env_file_ignores_permission_denied(monkeypatch): + class DeniedPath: + def exists(self): + return True + + def read_text(self, encoding="utf-8"): + raise PermissionError("denied") + + MODULE.load_env_file(DeniedPath()) + + +def test_read_healthd_state_requires_fresh_ok_snapshot(monkeypatch, tmp_path): + path = tmp_path / "aw-rus-health.json" + path.write_text( + '{"generated_at_utc":"2026-05-30T12:00:00Z","ok":true,"counts":{"ok":1,"warn":0,"fail":0}}', + encoding="utf-8", + ) + monkeypatch.setattr(MODULE, "now_utc", lambda: MODULE.parse_ts("2026-05-30T12:01:00Z")) + + fresh = MODULE.read_healthd_state(path, max_age_seconds=180) + stale = MODULE.read_healthd_state(path, max_age_seconds=30) + + assert fresh["ok"] is True + assert fresh["age_seconds"] == 60 + assert stale["ok"] is False + + +def test_chmod_if_possible_sets_readable_mode(tmp_path): + path = tmp_path / "summary.json" + path.write_text("{}", encoding="utf-8") + + MODULE.chmod_if_possible(path, 0o644) + + assert oct(path.stat().st_mode & 0o777) == "0o644" diff --git a/aw-server/test_aw_worktime_api.py b/aw-server/test_aw_worktime_api.py index 7e0f031..02acff8 100644 --- a/aw-server/test_aw_worktime_api.py +++ b/aw-server/test_aw_worktime_api.py @@ -168,6 +168,48 @@ def test_build_true_active_apps_requires_foreground_not_afk_and_evidence(): assert by_app["Total Commander"]["last_action"] == "C:\\data\\report.xlsx" +def test_latest_bucket_event_falls_back_to_bucket_metadata_end(monkeypatch): + def fake_get(url): + if url.endswith("/events?limit=1"): + raise RuntimeError("events timeout") + if url.endswith("/buckets/aw-worktime-sessions_SHARKON2025"): + return {"metadata": {"end": "2026-05-30T10:38:07.530Z"}} + raise AssertionError(url) + + monkeypatch.setattr(MODULE, "get", fake_get) + + event = MODULE.latest_bucket_event("aw-worktime-sessions_SHARKON2025") + + assert event["timestamp"] == "2026-05-30T10:38:07.530Z" + assert event["data"]["source"] == "bucket_metadata" + + +def test_fetch_events_for_date_queries_aw_with_report_bounds(monkeypatch): + urls = [] + + def fake_get(url): + urls.append(url) + if url.endswith("/buckets/aw-worktime-sessions_SHARKON2025"): + return {"metadata": {"end": "2026-05-30T10:38:07.530Z"}} + if "/events?" in url: + return [] + raise AssertionError(url) + + monkeypatch.setattr(MODULE, "get", fake_get) + monkeypatch.setattr(MODULE, "REPORT_TZ", timezone.utc) + original_cache = MODULE._EVENTS_CACHE + try: + MODULE._EVENTS_CACHE = {} + MODULE.fetch_events_for_date("SHARKON2025", datetime(2026, 5, 30, tzinfo=timezone.utc).date()) + finally: + MODULE._EVENTS_CACHE = original_cache + + event_urls = [url for url in urls if "/events?" in url] + assert len(event_urls) == 1 + assert "start=2026-05-30T00%3A00%3A00Z" in event_urls[0] + assert "end=2026-05-30T23%3A59%3A59" in event_urls[0] + + def test_render_html_contains_true_active_apps_table(): html = MODULE.render_html( [], @@ -289,7 +331,7 @@ def test_build_management_payload_applies_alias_and_executive_summary(): "notes": "Дневной контур", } } - MODULE.build_source_freshness = lambda host: ([], []) + MODULE.build_source_freshness = lambda host, **kwargs: ([], []) MODULE.build_management_trend = lambda host, report_date, owner_filter="", department_filter="", **kwargs: [] rows = [ { @@ -350,7 +392,7 @@ def test_build_management_payload_filters_by_owner(): "руководитель смены": {"display_name": "Сменный руководитель", "title": "Руководитель смены 1С"} } MODULE.build_management_trend = lambda *args, **kwargs: [] - MODULE.build_source_freshness = lambda host: ([], []) + MODULE.build_source_freshness = lambda host, **kwargs: ([], []) rows = [ { "user": "user1", @@ -697,6 +739,44 @@ def test_build_source_freshness_uses_host_fileops_bucket_only(): MODULE.now_utc = original_now_utc +def test_build_source_freshness_marks_desktop_sources_inactive_without_active_users(): + original = MODULE.latest_bucket_event + original_now_utc = MODULE.now_utc + try: + def fake_latest(bucket_id): + if bucket_id in {"aw-watcher-window_SHARKON2025", "aw-file-operations_SHARKON2025"}: + return { + "timestamp": "2026-05-23T11:00:00Z", + "data": {"signalType": "collector_health", "queueDepth": 0, "sendFailures": 0, "eventsFlushed": 50}, + } + if bucket_id.startswith("aw-worktime-sessions_") or bucket_id.startswith("aw-rdp-window_") or bucket_id.startswith("aw-rdp-afk_"): + return { + "timestamp": "2026-05-23T12:00:00Z", + "data": {"status": "ok"}, + } + return None + + MODULE.latest_bucket_event = fake_latest + MODULE.now_utc = lambda: datetime(2026, 5, 23, 12, 5, 0, tzinfo=timezone.utc) + sources, actions = MODULE.build_source_freshness("SHARKON2025", interactive_required=False) + source_by_id = {source["source_id"]: source for source in sources} + assert source_by_id["watcher_window"]["status"] == "inactive" + assert source_by_id["watcher_window"]["status_label"] == "inactive" + assert source_by_id["file_operations"]["status"] == "inactive" + assert source_by_id["file_operations"]["status_label"] == "inactive" + assert not any(action["evidence"].get("source_id") in {"watcher_window", "file_operations"} for action in actions) + finally: + MODULE.latest_bucket_event = original + MODULE.now_utc = original_now_utc + + +def test_resolve_report_format_uses_html_for_browser_accept_without_breaking_api_default(): + assert MODULE.resolve_report_format({}) == "json" + assert MODULE.resolve_report_format({}, "text/html,application/xhtml+xml") == "html" + assert MODULE.resolve_report_format({"format": ["json"]}, "text/html") == "json" + assert MODULE.resolve_report_format({"format": ["csv"]}, "text/html") == "csv" + + def test_send_bytes_returns_false_on_broken_pipe(): handler = _FakeHandler(exc=BrokenPipeError()) ok = MODULE.send_bytes(handler, b"{}", "application/json; charset=utf-8") @@ -714,3 +794,285 @@ def test_send_bytes_writes_payload_when_client_is_connected(): assert handler.status == 201 assert handler.headers["Content-Length"] == "7" assert handler.wfile.data == b"payload" + + +def test_report_response_cache_returns_fresh_and_stale_entries(): + original_ttl = MODULE.REPORT_CACHE_TTL_SECONDS + original_stale_ttl = MODULE.REPORT_STALE_TTL_SECONDS + original_disk_dir = MODULE.REPORT_DISK_CACHE_DIR + original_disk_ttl = MODULE.REPORT_DISK_STALE_TTL_SECONDS + original_cache = MODULE._REPORT_RESPONSE_CACHE + original_busy_wait = MODULE.REPORT_BUSY_WAIT_SECONDS + try: + with tempfile.TemporaryDirectory() as tmp: + MODULE.REPORT_CACHE_TTL_SECONDS = 1 + MODULE.REPORT_STALE_TTL_SECONDS = 60 + MODULE.REPORT_DISK_CACHE_DIR = Path(tmp) + MODULE.REPORT_DISK_STALE_TTL_SECONDS = 3600 + MODULE.REPORT_BUSY_WAIT_SECONDS = 0 + MODULE._REPORT_RESPONSE_CACHE = {} + key = MODULE.make_report_cache_key("/reports/worktime/today", "json", "SHARKON2025", datetime(2026, 5, 14, tzinfo=timezone.utc).date()) + MODULE.save_report_response_cache(key, b"{}", "application/json; charset=utf-8") + fresh = MODULE.get_report_response_cache(key) + assert fresh is not None + assert fresh["stale"] is False + assert fresh["cache_source"] == "memory" + MODULE._REPORT_RESPONSE_CACHE[key]["stored_monotonic"] -= 10 + assert MODULE.get_report_response_cache(key) is None + stale = MODULE.get_report_response_cache(key, allow_stale=True) + assert stale is not None + assert stale["stale"] is True + finally: + MODULE.REPORT_CACHE_TTL_SECONDS = original_ttl + MODULE.REPORT_STALE_TTL_SECONDS = original_stale_ttl + MODULE.REPORT_DISK_CACHE_DIR = original_disk_dir + MODULE.REPORT_DISK_STALE_TTL_SECONDS = original_disk_ttl + MODULE._REPORT_RESPONSE_CACHE = original_cache + MODULE.REPORT_BUSY_WAIT_SECONDS = original_busy_wait + + +def test_report_response_cache_uses_disk_stale_after_memory_loss(): + original_ttl = MODULE.REPORT_CACHE_TTL_SECONDS + original_stale_ttl = MODULE.REPORT_STALE_TTL_SECONDS + original_disk_dir = MODULE.REPORT_DISK_CACHE_DIR + original_disk_ttl = MODULE.REPORT_DISK_STALE_TTL_SECONDS + original_cache = MODULE._REPORT_RESPONSE_CACHE + original_busy_wait = MODULE.REPORT_BUSY_WAIT_SECONDS + try: + with tempfile.TemporaryDirectory() as tmp: + MODULE.REPORT_CACHE_TTL_SECONDS = 1 + MODULE.REPORT_STALE_TTL_SECONDS = 60 + MODULE.REPORT_DISK_CACHE_DIR = Path(tmp) + MODULE.REPORT_DISK_STALE_TTL_SECONDS = 3600 + MODULE.REPORT_BUSY_WAIT_SECONDS = 0 + MODULE._REPORT_RESPONSE_CACHE = {} + key = MODULE.make_report_cache_key("/reports/worktime/today", "csv", "SHARKON2025", datetime(2026, 5, 14, tzinfo=timezone.utc).date()) + MODULE.save_report_response_cache(key, b"user,active_seconds\nuser1,60\n", "text/csv; charset=utf-8") + MODULE._REPORT_RESPONSE_CACHE = {} + + cached = MODULE.get_report_response_cache(key, allow_stale=True) + + assert cached is not None + assert cached["data"].startswith(b"user,active_seconds") + assert cached["content_type"] == "text/csv; charset=utf-8" + assert cached["stale"] is True + assert cached["cache_source"] == "disk" + finally: + MODULE.REPORT_CACHE_TTL_SECONDS = original_ttl + MODULE.REPORT_STALE_TTL_SECONDS = original_stale_ttl + MODULE.REPORT_DISK_CACHE_DIR = original_disk_dir + MODULE.REPORT_DISK_STALE_TTL_SECONDS = original_disk_ttl + MODULE._REPORT_RESPONSE_CACHE = original_cache + MODULE.REPORT_BUSY_WAIT_SECONDS = original_busy_wait + + +def test_send_cached_report_marks_cache_reason_and_age(): + handler = _FakeHandler() + cached = { + "data": b"cached", + "content_type": "application/json; charset=utf-8", + "age_seconds": 42, + "stale": True, + } + ok = MODULE.send_cached_report(handler, cached, reason="busy") + assert ok is True + assert handler.status == 200 + assert handler.headers["X-AW-Worktime-Cache"] == "stale" + assert handler.headers["X-AW-Worktime-Cache-Reason"] == "busy" + assert handler.headers["X-AW-Worktime-Cache-Age"] == "42" + assert handler.headers["X-AW-Worktime-Cache-Source"] == "memory" + assert handler.wfile.data == b"cached" + + +def test_send_report_unavailable_returns_503_json(): + handler = _FakeHandler() + MODULE.send_report_unavailable(handler, "busy") + payload = json.loads(handler.wfile.data.decode("utf-8")) + assert handler.status == 503 + assert payload["ok"] is False + assert payload["error"] == "report_unavailable" + + +def test_send_worktime_report_rebuilds_stale_cache_when_builder_is_available(monkeypatch): + original_ttl = MODULE.REPORT_CACHE_TTL_SECONDS + original_stale_ttl = MODULE.REPORT_STALE_TTL_SECONDS + original_disk_dir = MODULE.REPORT_DISK_CACHE_DIR + original_disk_ttl = MODULE.REPORT_DISK_STALE_TTL_SECONDS + original_cache = MODULE._REPORT_RESPONSE_CACHE + try: + with tempfile.TemporaryDirectory() as tmp: + MODULE.REPORT_CACHE_TTL_SECONDS = 1 + MODULE.REPORT_STALE_TTL_SECONDS = 60 + MODULE.REPORT_DISK_CACHE_DIR = Path(tmp) + MODULE.REPORT_DISK_STALE_TTL_SECONDS = 3600 + MODULE._REPORT_RESPONSE_CACHE = {} + report_date = datetime(2026, 5, 14, tzinfo=timezone.utc).date() + key = MODULE.make_report_cache_key("/reports/worktime/today", "html", "SHARKON2025", report_date) + MODULE.save_report_response_cache(key, b"stale", "text/html; charset=utf-8") + MODULE._REPORT_RESPONSE_CACHE[key]["stored_monotonic"] -= 10 + + calls = [] + + def fake_build(path, params): + calls.append((path, params)) + return b"fresh", "text/html; charset=utf-8" + + monkeypatch.setattr(MODULE, "build_worktime_report_response", fake_build) + handler = _FakeHandler() + + MODULE.send_worktime_report( + handler, + "/reports/worktime/today", + {"format": ["html"]}, + key, + host="SHARKON2025", + report_date=report_date, + ) + + assert calls == [("/reports/worktime/today", {"format": ["html"]})] + assert handler.status == 200 + assert handler.headers["Content-Type"] == "text/html; charset=utf-8" + assert "X-AW-Worktime-Cache" not in handler.headers + assert handler.wfile.data == b"fresh" + finally: + MODULE.REPORT_CACHE_TTL_SECONDS = original_ttl + MODULE.REPORT_STALE_TTL_SECONDS = original_stale_ttl + MODULE.REPORT_DISK_CACHE_DIR = original_disk_dir + MODULE.REPORT_DISK_STALE_TTL_SECONDS = original_disk_ttl + MODULE._REPORT_RESPONSE_CACHE = original_cache + + +def test_send_worktime_report_serves_stale_cache_only_when_builder_is_busy(monkeypatch): + original_ttl = MODULE.REPORT_CACHE_TTL_SECONDS + original_stale_ttl = MODULE.REPORT_STALE_TTL_SECONDS + original_disk_dir = MODULE.REPORT_DISK_CACHE_DIR + original_disk_ttl = MODULE.REPORT_DISK_STALE_TTL_SECONDS + original_cache = MODULE._REPORT_RESPONSE_CACHE + original_busy_wait = MODULE.REPORT_BUSY_WAIT_SECONDS + try: + with tempfile.TemporaryDirectory() as tmp: + MODULE.REPORT_CACHE_TTL_SECONDS = 1 + MODULE.REPORT_STALE_TTL_SECONDS = 60 + MODULE.REPORT_DISK_CACHE_DIR = Path(tmp) + MODULE.REPORT_DISK_STALE_TTL_SECONDS = 3600 + MODULE.REPORT_BUSY_WAIT_SECONDS = 0 + MODULE._REPORT_RESPONSE_CACHE = {} + report_date = datetime(2026, 5, 14, tzinfo=timezone.utc).date() + key = MODULE.make_report_cache_key("/reports/worktime/today", "html", "SHARKON2025", report_date) + MODULE.save_report_response_cache(key, b"stale", "text/html; charset=utf-8") + MODULE._REPORT_RESPONSE_CACHE[key]["stored_monotonic"] -= 10 + + semaphore = MODULE.threading.BoundedSemaphore(1) + assert semaphore.acquire(blocking=False) is True + monkeypatch.setattr(MODULE, "_REPORT_BUILD_SEMAPHORE", semaphore) + monkeypatch.setattr( + MODULE, + "build_worktime_report_response", + lambda path, params: (_ for _ in ()).throw(AssertionError("builder must not run while busy")), + ) + handler = _FakeHandler() + + MODULE.send_worktime_report( + handler, + "/reports/worktime/today", + {"format": ["html"]}, + key, + host="SHARKON2025", + report_date=report_date, + ) + + assert handler.status == 200 + assert handler.headers["X-AW-Worktime-Cache"] == "stale" + assert handler.headers["X-AW-Worktime-Cache-Reason"] == "busy" + assert handler.wfile.data == b"stale" + semaphore.release() + finally: + MODULE.REPORT_CACHE_TTL_SECONDS = original_ttl + MODULE.REPORT_STALE_TTL_SECONDS = original_stale_ttl + MODULE.REPORT_DISK_CACHE_DIR = original_disk_dir + MODULE.REPORT_DISK_STALE_TTL_SECONDS = original_disk_ttl + MODULE._REPORT_RESPONSE_CACHE = original_cache + MODULE.REPORT_BUSY_WAIT_SECONDS = original_busy_wait + + +def test_send_worktime_report_allows_explicit_machine_stale_without_blocking(monkeypatch): + original_ttl = MODULE.REPORT_CACHE_TTL_SECONDS + original_stale_ttl = MODULE.REPORT_STALE_TTL_SECONDS + original_disk_dir = MODULE.REPORT_DISK_CACHE_DIR + original_disk_ttl = MODULE.REPORT_DISK_STALE_TTL_SECONDS + original_cache = MODULE._REPORT_RESPONSE_CACHE + try: + with tempfile.TemporaryDirectory() as tmp: + MODULE.REPORT_CACHE_TTL_SECONDS = 1 + MODULE.REPORT_STALE_TTL_SECONDS = 60 + MODULE.REPORT_DISK_CACHE_DIR = Path(tmp) + MODULE.REPORT_DISK_STALE_TTL_SECONDS = 3600 + MODULE._REPORT_RESPONSE_CACHE = {} + report_date = datetime(2026, 5, 14, tzinfo=timezone.utc).date() + key = MODULE.make_report_cache_key("/reports/worktime/today", "csv", "SHARKON2025", report_date) + MODULE.save_report_response_cache(key, b"stale", "text/csv; charset=utf-8") + MODULE._REPORT_RESPONSE_CACHE[key]["stored_monotonic"] -= 10 + + refreshes = [] + monkeypatch.setattr( + MODULE, + "trigger_report_background_refresh", + lambda key, path, params: refreshes.append((key, path, params)) or True, + ) + monkeypatch.setattr( + MODULE, + "build_worktime_report_response", + lambda path, params: (_ for _ in ()).throw(AssertionError("explicit stale request must not block on build")), + ) + handler = _FakeHandler() + + MODULE.send_worktime_report( + handler, + "/reports/worktime/today", + {"format": ["csv"], "allow_stale": ["1"]}, + key, + host="SHARKON2025", + report_date=report_date, + ) + + assert refreshes == [] + assert handler.status == 200 + assert handler.headers["X-AW-Worktime-Cache"] == "stale" + assert handler.headers["X-AW-Worktime-Cache-Reason"] == "stale-allowed" + assert handler.wfile.data == b"stale" + finally: + MODULE.REPORT_CACHE_TTL_SECONDS = original_ttl + MODULE.REPORT_STALE_TTL_SECONDS = original_stale_ttl + MODULE.REPORT_DISK_CACHE_DIR = original_disk_dir + MODULE.REPORT_DISK_STALE_TTL_SECONDS = original_disk_ttl + MODULE._REPORT_RESPONSE_CACHE = original_cache + + +def test_management_report_for_date_bypasses_today_payload_cache_for_human_requests(monkeypatch): + original_ttl = MODULE.MANAGER_CACHE_TTL_SECONDS + original_dir = MODULE.MANAGER_CACHE_DIR + try: + with tempfile.TemporaryDirectory() as tmp: + MODULE.MANAGER_CACHE_TTL_SECONDS = 300 + MODULE.MANAGER_CACHE_DIR = Path(tmp) + report_date = datetime.now(timezone.utc).date() + monkeypatch.setattr(MODULE, "REPORT_TZ", timezone.utc) + monkeypatch.setattr(MODULE, "fetch_events_for_date", lambda host, date: ({"start": datetime.combine(date, datetime.min.time(), tzinfo=timezone.utc), "end": datetime.combine(date, datetime.max.time(), tzinfo=timezone.utc)}, [])) + monkeypatch.setattr(MODULE, "aggregate_rows_with_intervals", lambda events, start, end, host: []) + + generated = iter(["old", "fresh"]) + monkeypatch.setattr( + MODULE, + "build_management_payload", + lambda rows, host, date: {"generated_at_utc": next(generated), "rows": rows}, + ) + + cached = MODULE.management_report_for_date("SHARKON2025", report_date, use_cache=True) + fresh = MODULE.management_report_for_date("SHARKON2025", report_date, use_cache=False) + + assert cached["generated_at_utc"] == "old" + assert fresh["generated_at_utc"] == "fresh" + assert MODULE.load_management_cache("SHARKON2025", report_date)["generated_at_utc"] == "fresh" + finally: + MODULE.MANAGER_CACHE_TTL_SECONDS = original_ttl + MODULE.MANAGER_CACHE_DIR = original_dir diff --git a/check-aw-data.sh b/check-aw-data.sh index d3bd7b4..c230334 100644 --- a/check-aw-data.sh +++ b/check-aw-data.sh @@ -1,117 +1,17 @@ -#!/bin/bash -# check-aw-data.sh - Проверка сбора данных ActivityWatch с RDP-сервера SHARKON2025 -# Сервер: 10.10.10.13:5600 -# Хост-источник: 192.168.100.18 (SHARKON2025) +#!/usr/bin/env bash +set -euo pipefail -SERVER="http://10.10.10.13:5600" -HOSTNAME_FILTER="SHARKON2025" -NOW=$(date -u +%s) +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +TARGET_ROOT="${CARGO_TARGET_DIR:-$ROOT_DIR/adk-rust/target}" -classify_bucket_age() { - local bucket="$1" - local age_sec="$2" - - case "$bucket" in - aw-dlp-incidents|aw-dlp-review|aw-dlp-rules|aw-session-events) - if [ "$age_sec" -lt 86400 ]; then - printf '%s' "${GREEN}FRESH${NC}" - else - printf '%s' "${CYAN}EVENT-DRIVEN${NC}" - fi - ;; - *) - if [ "$age_sec" -lt 3600 ]; then - printf '%s' "${GREEN}FRESH${NC}" - elif [ "$age_sec" -lt 86400 ]; then - printf '%s' "${YELLOW}STALE${NC}" - else - printf '%s' "${RED}DEAD${NC}" - fi - ;; - esac -} - -# Цвета -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -NC='\033[0m' - -# Проверка доступности сервера -echo "=== ActivityWatch Data Check: $HOSTNAME_FILTER ===" -echo "" -echo -n "Server connectivity... " -RESP=$(no_proxy=10.10.10.13 curl -s --connect-timeout 10 --max-time 15 "$SERVER/api/0/info" 2>&1) -if [ $? -eq 0 ] && echo "$RESP" | jq -e '.version' > /dev/null 2>&1; then - VERSION=$(echo "$RESP" | jq -r '.version') - echo -e "${GREEN}OK${NC} (aw-server v$VERSION)" -else - echo -e "${RED}FAILED${NC} (cannot reach $SERVER)" - exit 1 -fi -echo "" - -# Проверка бакетов -echo "--- Buckets ---" -printf "%-45s %-8s %-22s %s\n" "BUCKET" "EVENTS" "LAST EVENT" "STATUS" -printf "%-45s %-8s %-22s %s\n" "---------------------------------------------" "--------" "----------------------" "------" - -BUCKETS=( - "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" -) - -for bucket in "${BUCKETS[@]}"; do - bucket_full="${bucket}_${HOSTNAME_FILTER}" - - # Получаем последний event - EVENT_DATA=$(no_proxy=10.10.10.13 curl -s --connect-timeout 10 --max-time 15 "$SERVER/api/0/buckets/$bucket_full/events?limit=1" 2>&1) - LAST_ID=$(echo "$EVENT_DATA" | jq '.[0].id // 0') - LAST_TS=$(echo "$EVENT_DATA" | jq -r '.[0].timestamp // "no events"') - - # Вычисляем возраст - if [ "$LAST_TS" != "no events" ] && [ -n "$LAST_TS" ]; then - EVENT_EPOCH=$(date -d "$LAST_TS" +%s 2>/dev/null || echo 0) - if [ "$EVENT_EPOCH" -gt 0 ]; then - AGE_SEC=$((NOW - EVENT_EPOCH)) - if [ $AGE_SEC -lt 3600 ]; then - AGE="$((AGE_SEC / 60))m ago" - elif [ $AGE_SEC -lt 86400 ]; then - AGE="$((AGE_SEC / 3600))h ago" - else - AGE="$((AGE_SEC / 86400))d ago" - fi - STATUS="$(classify_bucket_age "$bucket" "$AGE_SEC")" - else - AGE="unknown" - STATUS="${RED}?${NC}" - fi - else - AGE="none" - LAST_ID="0" - STATUS="${RED}EMPTY${NC}" +for candidate in \ + "$TARGET_ROOT/release/check-aw-data" \ + "$TARGET_ROOT/debug/check-aw-data" \ + "$ROOT_DIR/adk-rust/target/release/check-aw-data" \ + "$ROOT_DIR/adk-rust/target/debug/check-aw-data"; do + if [[ -x "$candidate" ]]; then + exec "$candidate" "$@" fi - - printf "%-45s %-8s %-22s %b\n" "$bucket_full" "$LAST_ID" "$LAST_TS ($AGE)" "$STATUS" done -echo "" - -# Проверка CORS -echo "--- CORS Check ---" -CORS_RESP=$(no_proxy=10.10.10.13 curl -s --connect-timeout 10 --max-time 15 -o /dev/null -w '%{http_code}' -H "Origin: http://10.10.10.13:5600" "$SERVER/api/0/settings/" 2>&1) -if [ "$CORS_RESP" = "200" ]; then - echo -e "${GREEN}CORS: OK${NC} (HTTP 200)" -else - echo -e "${RED}CORS: FAIL${NC} (HTTP $CORS_RESP)" -fi - -echo "" -echo "=== Check Complete ===" -echo "Timestamp: $(date -u '+%Y-%m-%d %H:%M:%S UTC')" +exec "$ROOT_DIR/scripts/legacy/check-aw-data.sh" "$@" diff --git a/check-aw-full.sh b/check-aw-full.sh index 56e5963..43eb40c 100644 --- a/check-aw-full.sh +++ b/check-aw-full.sh @@ -3,15 +3,45 @@ # Сервер: 10.10.10.13:5600 # RDP-хост: 192.168.100.18 (SHARKON2025) +if [[ "${CHECK_AW_FULL_FORCE_LEGACY:-0}" != "1" ]]; then + ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + for candidate in \ + "${CHECK_AW_FULL_RUST:-}" \ + "${CARGO_TARGET_DIR:-}/release/check-aw-full" \ + "$ROOT_DIR/adk-rust/target/release/check-aw-full" \ + "/usr/local/bin/check-aw-full"; do + if [[ -n "$candidate" && -x "$candidate" ]]; then + exec "$candidate" "$@" + fi + done +fi + SERVER="http://10.10.10.13:5600" HOSTNAME_FILTER="SHARKON2025" RDP_HOST="192.168.100.18" NOW=$(date -u +%s) +HOST_INACTIVE=false +GUARD_HEALTHY=false classify_bucket_age() { local bucket="$1" local age_sec="$2" + case "$bucket" in + aw-watcher-window) + if [ "$HOST_INACTIVE" = "true" ]; then + printf 'INACTIVE|%s' "${CYAN}INACTIVE${NC}" + return + fi + ;; + aw-dlp-endpoint-signals) + if [ "$HOST_INACTIVE" = "true" ] && [ "$GUARD_HEALTHY" = "true" ]; then + printf 'INACTIVE|%s' "${CYAN}INACTIVE${NC}" + return + fi + ;; + esac + case "$bucket" in aw-dlp-incidents|aw-dlp-review|aw-dlp-rules|aw-session-events) if [ "$age_sec" -lt 86400 ]; then @@ -32,6 +62,31 @@ classify_bucket_age() { esac } +classify_bucket_no_events() { + local bucket="$1" + + case "$bucket" in + aw-watcher-window) + if [ "$HOST_INACTIVE" = "true" ]; then + printf 'INACTIVE|%s' "${CYAN}INACTIVE${NC}" + return + fi + ;; + aw-dlp-endpoint-signals) + if [ "$HOST_INACTIVE" = "true" ] && [ "$GUARD_HEALTHY" = "true" ]; then + printf 'INACTIVE|%s' "${CYAN}INACTIVE${NC}" + return + fi + ;; + aw-dlp-incidents|aw-dlp-review|aw-dlp-rules|aw-session-events) + printf 'EVENT|%s' "${CYAN}EVENT-DRIVEN${NC}" + return + ;; + esac + + printf 'DEAD|%s' "${RED}EMPTY${NC}" +} + # Цвета RED='\033[0;31m' GREEN='\033[0;32m' @@ -63,6 +118,34 @@ else fi echo "" +# 1b. Context for inactive/event-driven classification +WORKTIME_EVENT_DATA=$(no_proxy=10.10.10.13 curl -s --connect-timeout 10 --max-time 15 "$SERVER/api/0/buckets/aw-worktime-sessions_$HOSTNAME_FILTER/events?limit=1" 2>&1) +WORKTIME_TS=$(echo "$WORKTIME_EVENT_DATA" | jq -r '.[0].timestamp // ""' 2>/dev/null) +WORKTIME_ACTIVE=$(echo "$WORKTIME_EVENT_DATA" | jq -r '.[0].data.active // false' 2>/dev/null) +if [ -n "$WORKTIME_TS" ]; then + WORKTIME_EPOCH=$(date -d "$WORKTIME_TS" +%s 2>/dev/null || echo 0) + if [ "$WORKTIME_EPOCH" -gt 0 ]; then + WORKTIME_AGE=$((NOW - WORKTIME_EPOCH)) + if [ "$WORKTIME_AGE" -lt 900 ] && [ "$WORKTIME_ACTIVE" != "true" ]; then + HOST_INACTIVE=true + fi + fi +fi + +GUARD_EVENT_DATA=$(no_proxy=10.10.10.13 curl -s --connect-timeout 10 --max-time 15 "$SERVER/api/0/buckets/aw-rus-collector-guard_$HOSTNAME_FILTER/events?limit=1" 2>&1) +GUARD_TS=$(echo "$GUARD_EVENT_DATA" | jq -r '.[0].timestamp // ""' 2>/dev/null) +GUARD_STATUS=$(echo "$GUARD_EVENT_DATA" | jq -r '.[0].data.status // ""' 2>/dev/null) +GUARD_PROBLEMS=$(echo "$GUARD_EVENT_DATA" | jq -r '([.[0].data.problems[]?] | length) // 0' 2>/dev/null) +if [ -n "$GUARD_TS" ]; then + GUARD_EPOCH=$(date -d "$GUARD_TS" +%s 2>/dev/null || echo 0) + if [ "$GUARD_EPOCH" -gt 0 ]; then + GUARD_AGE=$((NOW - GUARD_EPOCH)) + if [ "$GUARD_AGE" -lt 300 ] && [ "$GUARD_STATUS" = "ok" ] && [ "$GUARD_PROBLEMS" = "0" ]; then + GUARD_HEALTHY=true + fi + fi +fi + # 2. Проверка бакетов echo -e "${CYAN}--- 2. Data Buckets ---${NC}" printf " %-42s %-8s %-20s %s\n" "BUCKET" "EVENTS" "LAST EVENT" "STATUS" @@ -110,8 +193,9 @@ for entry in "${BUCKETS[@]}"; do else AGE="none" LAST_ID="0" - STATUS="${RED}EMPTY${NC}" - STATUS_KEY="DEAD" + CLASSIFICATION="$(classify_bucket_no_events "$bucket")" + STATUS_KEY="${CLASSIFICATION%%|*}" + STATUS="${CLASSIFICATION#*|}" fi printf " %-42s %-8s %-20s %b\n" "$label" "$LAST_ID" "$AGE" "$STATUS" @@ -157,7 +241,7 @@ for entry in "${BUCKETS[@]}"; do CLASSIFICATION="$(classify_bucket_age "$bucket" "$AGE_SEC")" STATUS_KEY="${CLASSIFICATION%%|*}" case "$STATUS_KEY" in - FRESH|EVENT) + FRESH|EVENT|INACTIVE) FRESH_COUNT=$((FRESH_COUNT + 1)) ;; STALE) @@ -169,8 +253,13 @@ for entry in "${BUCKETS[@]}"; do esac fi else - case "$bucket" in - aw-dlp-incidents|aw-dlp-review|aw-dlp-rules|aw-session-events) + CLASSIFICATION="$(classify_bucket_no_events "$bucket")" + STATUS_KEY="${CLASSIFICATION%%|*}" + case "$STATUS_KEY" in + FRESH|EVENT|INACTIVE) + FRESH_COUNT=$((FRESH_COUNT + 1)) + ;; + STALE) STALE_COUNT=$((STALE_COUNT + 1)) ;; *) diff --git a/clickhouse-1c/ai/test_company_intelligence_api.py b/clickhouse-1c/ai/test_company_intelligence_api.py index 3e7e5a5..d3ae701 100644 --- a/clickhouse-1c/ai/test_company_intelligence_api.py +++ b/clickhouse-1c/ai/test_company_intelligence_api.py @@ -7,7 +7,9 @@ import sys import tempfile import types import unittest +from datetime import UTC, datetime from pathlib import Path +from unittest.mock import patch sys.path.insert(0, str(Path(__file__).resolve().parent)) sys.modules.setdefault("clickhouse_connect", types.SimpleNamespace(get_client=lambda **_: None)) @@ -343,6 +345,11 @@ class CompanyIntelligenceApiTests(unittest.TestCase): self.assertIn("ФЕЛИЦТ ГРУПП 2026", html_page) def test_build_weekly_trend_report_and_render(self) -> None: + class FixedDatetime(datetime): + @classmethod + def now(cls, tz=None): + return cls(2026, 5, 22, 13, 0, 0, tzinfo=tz or UTC) + payloads = [ { "generated_at": "2026-05-21T12:00:00+00:00", @@ -391,7 +398,8 @@ class CompanyIntelligenceApiTests(unittest.TestCase): }, }, ] - report = api.build_weekly_trend_report(payloads, days=7) + with patch.object(api, "datetime", FixedDatetime): + report = api.build_weekly_trend_report(payloads, days=7) self.assertEqual(len(report["daily"]), 2) self.assertEqual(report["top_weekly_changes"][0]["company"], "ФЕЛИЦТ ГРУПП 2026") html_page = api.render_weekly_trend_html(report) diff --git a/docs/DETMIR_UNIFIED_OPERATING_MODEL_RU.md b/docs/DETMIR_UNIFIED_OPERATING_MODEL_RU.md index 2135a69..8a7c161 100644 --- a/docs/DETMIR_UNIFIED_OPERATING_MODEL_RU.md +++ b/docs/DETMIR_UNIFIED_OPERATING_MODEL_RU.md @@ -2,7 +2,7 @@ Дата фиксации: `2026-05-24` -Последнее runtime-уточнение: `2026-05-28` +Последнее runtime-уточнение: `2026-05-30` Этот файл предназначен как единая рабочая опора по `DetMir`: что именно входит в систему, где это живет, каким инструментарием проект надо планировать и сопровождать, и какой операционный контур считать промышленным. @@ -81,6 +81,26 @@ - Grafana dashboards без авторизации корректно редиректят на login, это не считается отказом; с сохраненной admin-учеткой проверены фактические страницы и datasource health; - gateway `/go/file1c-brief`, `/go/file1c-actions`, `/go/aw-ui` ведет на рабочие внутренние surface. +### 2.3 Runtime-семантика операторских сигналов от 2026-05-30 + +После Phase 8 операторские проверки должны читаться по смыслу статуса, а не только по возрасту последнего события. + +| Статус | Как трактовать | +|---|---| +| `FRESH` | Данные свежие, источник сейчас активен или недавно обновлялся. | +| `INACTIVE` | Нормальное состояние для desktop/window/DLP endpoint buckets, если worktime-сессия свежая, но интерактивной активности нет. Это не инцидент. | +| `EVENT-DRIVEN` | Нормальное состояние для buckets, которые пишутся только при событии: `aw-session-events_*`, `aw-dlp-incidents_*`, `aw-dlp-review_*`, `aw-dlp-rules_*`. Отсутствие новых событий само по себе не отказ. | +| `STALE` | Потенциальная деградация активного источника; проверять collector/session placement. | +| `DEAD` / `EMPTY` | Отказ или неинициализированный обязательный источник; требуется диагностика. | + +Операторские правила: + +- `./check-aw-full.sh` с `FRESH=8 STALE=0 DEAD=0` считается зеленым контуром, даже если отдельные строки показывают `INACTIVE` или `EVENT-DRIVEN`. +- `./check-aw-data.sh` должен использовать ту же семантику, что и full-check; `EVENT-DRIVEN` и `INACTIVE` не являются поводом для ручного recovery. +- SLO-строка бота `aw_rus_slo: recovered ... current_sample=OK ... budget_remaining_seconds<0` означает исторически сожженный error budget при здоровом текущем контуре. Это не активная авария. +- SLO становится инцидентом только при текущем `current_sample=FAIL` и исчерпанном бюджете или при stale SLO summary. +- `aw-browser-smoke.timer` хранит ограниченное число запусков через `AW_BROWSER_SMOKE_KEEP_RUNS` и ограничен `TimeoutStartSec=180`; рост `/var/lib/activitywatch/browser-smoke` выше нескольких сотен MB надо считать regression в retention/config. + ### 2.2 Стабилизация management report, bridge и recovery от 2026-05-28 До стабилизации слабые места были такими: diff --git a/docs/dlp-cross-os-native-enforcement.md b/docs/dlp-cross-os-native-enforcement.md new file mode 100644 index 0000000..1998138 --- /dev/null +++ b/docs/dlp-cross-os-native-enforcement.md @@ -0,0 +1,198 @@ +# Cross-OS Native DLP Enforcement + +## Цель + +Расширять AWatch-rus как легкую DLP-систему без тяжелого монолитного endpoint-agent. +Базовый принцип: сначала использовать естественные механизмы ОС и управляемых браузеров, +а собственный код держать тонким слоем политики, телеметрии и корреляции. + +Это не заменяет текущий Windows/RDP contour. Он остается основным production-контуром: + +- `AWatchRusCollectorGuard` следит за collector health; +- `aw-rus-healthd` принимает решение по свежести бакетов и активности хоста; +- `tsj-guardian-bot` наблюдает и лечит только реальные деградации; +- DLP policy engine остается центральным источником правил. + +## Единая модель политики + +Новые OS-specific механизмы должны сводиться к одному контракту: + +```json +{ + "nativeControls": { + "mode": "monitor", + "channels": { + "removableStorage": {"action": "audit"}, + "print": {"action": "audit"}, + "clipboard": {"action": "audit"}, + "browserUpload": {"action": "audit"}, + "appExecution": {"action": "audit"} + } + } +} +``` + +Допустимые действия: + +- `audit`: только событие в `aw-dlp-endpoint-signals_`; +- `warn`: событие плюс локальное уведомление пользователя; +- `block`: блокировка штатным механизмом ОС или managed browser; +- `blockWithOverride`: блокировка с управляемым исключением, если платформа это поддерживает; +- `disabled`: канал выключен. + +Для rollout по умолчанию используется `monitor/audit`. `block` включается только по одному +каналу и одной группе хостов после накопления baseline. + +На Windows это правило enforced в `dlp-endpoint-signals-collector.ps1`: если endpoint-правило +просит `action: "block"`, но `nativeControls.mode` не равен `enforce` или канал не разрешает +`block`, collector подавляет enforcement и пишет инцидент с `enforcementSuppressed=true`. + +## Windows + +Windows остается самой зрелой платформой для ближайшего enforcement. + +Предпочтительный порядок: + +1. USB/removable storage: + - мониторинг: текущий `dlp-endpoint-signals-collector.ps1`; + - enforcement: GPO/registry/device installation restrictions и `Set-Disk -IsReadOnly`; + - область блокировки: запись на removable media, не чтение. +2. Print: + - мониторинг: `Microsoft-Windows-PrintService/Operational`; + - enforcement: отмена print job штатным spooler API/CIM; + - rollout: сначала только документы с совпадением `documentRegex`. +3. Clipboard: + - мониторинг: текущий endpoint collector; + - enforcement: очистка clipboard только для high-confidence правил; + - риск: UX и ложные срабатывания, поэтому `block` не включать глобально. +4. App execution: + - enforcement: AppLocker или WDAC/App Control for Business; + - назначение: не “DLP content”, а сужение каналов утечки через запрещенные приложения. +5. Browser upload: + - легкий путь: managed browser policy + URL/category rules; + - сильный путь: расширение браузера или коммерческий DLP browser control, если нужен inline file upload block. + +Ограничение: AppLocker/WDAC управляют запуском кода, но не поведением уже запущенного приложения. +Поэтому они дополняют DLP, а не заменяют USB/print/browser enforcement. + +## macOS + +Для macOS нельзя идти через kernel extension как основной путь. Современный естественный +вариант - System Extensions и профиль управления через MDM. + +Предпочтительный порядок: + +1. Monitor-only agent: + - LaunchDaemon + Swift/Go helper; + - публикация событий в AW buckets; + - локальный health heartbeat по аналогии с `aw-rus-collector-guard_`. +2. Endpoint Security system extension: + - file open/write/exec telemetry; + - deny только после отдельного PoC и подписи/entitlements; + - обязательная MDM-подготовка approval profile. +3. Network Extension: + - для DNS/proxy/web egress контроля; + - лучше применять к managed domains, не как полный MITM по умолчанию. +4. MDM restrictions: + - screen capture, AirDrop, external media, profile-level restrictions; + - это самый легкий enforcement, если парк управляется MDM. + +Ограничение: без MDM и Apple Developer entitlements macOS enforcement будет хрупким. +Для неуправляемых Mac оставляем monitor-only. + +## Linux + +Linux должен быть легким и дистрибутивно-нейтральным. Не начинать с “универсального агента, +который перехватывает все syscalls”. + +Предпочтительный порядок: + +1. fanotify file gate: + - мониторинг и permission events для чувствительных каталогов; + - хороший MVP для removable mount points, home/project shares, export directories; + - блокировка через permission response до открытия файла. +2. auditd/journald collectors: + - дешево для exec, sudo, mount, removable media, ssh/scp hints; + - подходит для server/workstation baseline. +3. eBPF telemetry: + - использовать для observability: process, connect, file metadata; + - enforcement только через BPF LSM на поддерживаемых ядрах и после отдельной совместимости. +4. Desktop clipboard/print: + - monitor-only через DE-specific tools (`wl-paste`, `xclip`, CUPS logs); + - block режим только для управляемых рабочих станций. + +Ограничение: Linux enforcement сильно зависит от ядра, LSM stack и дистрибутива. Поэтому +первая production-версия должна быть fanotify + auditd, а eBPF оставить как расширяемый слой. + +## ChromeOS и managed browser + +Для ChromeOS не надо писать свой endpoint-agent. Если устройства управляются через Google +Admin Console, использовать ChromeOS Data Controls: + +- copy/paste; +- printing; +- screen capture/screen sharing; +- file open/upload/transfer; +- removable storage. + +Для Windows/macOS/Linux браузерный контур должен быть policy-first: + +- Edge/Chrome enterprise policies; +- URL/category lists из текущего DLP policy engine; +- расширение браузера только когда нужен inline upload/file decision, а не только telemetry. + +## Priority Backlog + +### P0 - сейчас + +- Привести все health-check скрипты к единой inactive/guard-aware классификации. +- Хранить `nativeControls` в policy document как forward-compatible секцию. +- Не включать новый `block` глобально; только `audit` и `warn`. + +### P1 - Windows production + +- USB write-block profile через GPO/PowerShell. +- Print cancel по `documentRegex`. +- Browser upload detection по managed URL categories. +- Guard heartbeat для applied nativeControls version/checksum. + +### P2 - Linux MVP + +- `aw-linux-file-gate` на fanotify для monitor/audit по каталогам. +- `aw-linux-audit-collector` для mount/usb/scp/sudo/process telemetry. +- systemd unit + heartbeat bucket. + +### P3 - macOS MVP + +- `aw-macos-monitor` LaunchDaemon без enforcement. +- MDM profile checklist. +- System Extension PoC только для managed Macs. + +### P4 - managed browser / ChromeOS + +- Транслятор DLP policy domains/categories в Chrome/Edge policy bundle. +- ChromeOS Data Controls mapping для организаций с Google Workspace. + +## Rollout Rules + +- Каждая новая платформа стартует в `monitor`. +- `block` разрешен только после минимум 7 дней clean baseline. +- Любой block должен писать: + - rule id; + - action; + - platform; + - native mechanism; + - enforcement result; + - override id, если применимо. +- Если local guard/heartbeat старше порога, центральный бот не делает широкий restart, + а переводит платформу в degraded и запускает platform-specific recovery. + +## Sources + +- Microsoft AppLocker overview: https://learn.microsoft.com/en-us/windows/security/application-security/application-control/app-control-for-business/applocker/applocker-overview +- Microsoft AppLocker policy design: https://learn.microsoft.com/en-za/windows/security/application-security/application-control/app-control-for-business/applocker/understand-applocker-policy-design-decisions +- Microsoft Purview Chrome DLP extension: https://learn.microsoft.com/en-us/purview/dlp-chrome-learn-about +- Apple system extensions deployment: https://support.apple.com/guide/deployment/system-extensions-in-macos-depa5fb8376f/web +- Linux fanotify manual: https://man7.org/linux/man-pages/man7/fanotify.7.html +- eBPF docs: https://docs.ebpf.io/ +- ChromeOS Data Controls: https://support.google.com/chrome/a/answer/11587610 diff --git a/docs/dlp-enforcement.md b/docs/dlp-enforcement.md index 2751da0..adcb066 100644 --- a/docs/dlp-enforcement.md +++ b/docs/dlp-enforcement.md @@ -13,10 +13,31 @@ Phase 2.5 расширяет DLP endpoint collector функциями **акт Во всех случаях пользователь получает Windows-уведомление (balloon notification) с описанием причины блокировки. +Для cross-OS развития используется отдельный native-first профиль: +`docs/dlp-cross-os-native-enforcement.md`. Он задает правило: Windows enforcement оставляем +на штатных GPO/AppLocker/Spooler/Storage механизмах, macOS строим через System Extensions/MDM, +Linux через fanotify/auditd/eBPF, ChromeOS через Data Controls. Тяжелый универсальный агент +не является целевой архитектурой. + ## Конфигурация политики Формат `dlp-policy.json` не изменился — поле `action` в правиле теперь поддерживает значение `"block"` наряду с `"alert"` (по умолчанию). +Важно: для production safety `action: "block"` в конкретном endpoint-правиле не включает +блокировку сам по себе. Реальное enforcement-действие выполняется только если: + +- `nativeControls.mode` равно `"enforce"`; +- `nativeControls.rollout.allowGlobalBlock=true`; +- и `nativeControls.channels..action` равно `"block"` или `"blockWithOverride"`. + +Если правило просит `block`, но native controls находятся в `monitor`, collector пишет инцидент +как `alert` и добавляет поля: + +- `requestedAction: "block"`; +- `enforcementMode`; +- `nativeChannelAction`; +- `enforcementSuppressed: true`. + ### Пример: блокировка USB записи ```json @@ -27,6 +48,17 @@ Phase 2.5 расширяет DLP endpoint collector функциями **акт "severity": "medium", "cooldownSeconds": 300 }, + "nativeControls": { + "mode": "enforce", + "rollout": { + "allowGlobalBlock": false + }, + "channels": { + "clipboard": {"action": "block"}, + "usb": {"action": "block"}, + "print": {"action": "block"} + } + }, "endpoint": { "usb": [ { diff --git a/docs/metagpt-aw-scout.md b/docs/metagpt-aw-scout.md new file mode 100644 index 0000000..611c3a5 --- /dev/null +++ b/docs/metagpt-aw-scout.md @@ -0,0 +1,58 @@ +# MetaGPT AW Scout + +`scripts/metagpt-aw-scout.sh` is the safe project wrapper for using the +MetaGPT-configured LLM provider with ActivityWatch-Russian tasks. + +Default mode is direct LLM scout through `/home/igor/.metagpt/config2.yaml`. +This avoids MetaGPT Browser/Editor tools, which are too noisy for operational +checklists. + +Optional MetaGPT team mode is still available: + +```bash +METAGPT_AW_ENGINE=team scripts/metagpt-aw-scout.sh smoke +``` + +Do not use full `--implement` mode with the free Groq tier for this project. It +pulls too much context and usually exceeds TPM limits. + +## Setup + +Use a valid provider key outside git: + +```bash +export GROQ_API_KEY="gsk_..." +``` + +The global wrapper `/home/igor/bin/metagpt-lab` can write the key into +`/home/igor/.metagpt/config2.yaml`. The scout script reads that config and does +not print secrets. + +## Presets + +```bash +scripts/metagpt-aw-scout.sh qa-rollback +scripts/metagpt-aw-scout.sh smoke +scripts/metagpt-aw-scout.sh grafana +scripts/metagpt-aw-scout.sh install-kit +scripts/metagpt-aw-scout.sh windows-i18n +``` + +Free-form task: + +```bash +scripts/metagpt-aw-scout.sh "Review risk of changing aw-worktime-ui-bridge foreground cache" +``` + +Reports are saved under: + +```text +.ai/metagpt/ +``` + +## Current Rule + +The LLM is only a scout. Codex/operator must verify every material claim against +local files, live services, Ansible output, Grafana, and ActivityWatch APIs +before changing production behavior. Use `METAGPT_AW_ENGINE=team` only for +experiments; direct mode is the operational default. diff --git a/docs/runbook.md b/docs/runbook.md index c170219..7f782b3 100755 --- a/docs/runbook.md +++ b/docs/runbook.md @@ -11,8 +11,9 @@ systemctl status nginx --no-pager nginx -t curl -I -sS -H 'Host: dm.iri1968.dpdns.org' http://127.0.0.1/ curl -k -fsS -H 'Host: dm.iri1968.dpdns.org' https://127.0.0.1/healthz -curl -k -I -sS -H 'Host: dm.iri1968.dpdns.org' https://127.0.0.1/go/proxmox-gui -curl -k -H 'Host: dm.iri1968.dpdns.org' -fsS https://127.0.0.1/ | grep -F 'dm.iri1968.dpdns.org' +curl -k -I -sS -H 'Host: dm.iri1968.dpdns.org' https://127.0.0.1/ | head +curl -k -u "$(awk -F= '/^user=/{u=$2}/^password=/{p=$2}END{print u\":\"p}' /root/proxmox-web-gateway.credentials)" \ + -H 'Host: dm.iri1968.dpdns.org' -fsS https://127.0.0.1/ | grep -F 'dm.iri1968.dpdns.org' ``` Playbook для повторного rollout: @@ -22,6 +23,49 @@ ANSIBLE_HOST_KEY_CHECKING=False \ ansible-playbook -i ansible/inventory.ini ansible/deploy_proxmox_web_gateway.yml ``` +### Public gateway через pfSense + +Публичная схема: + +```text +Internet -> dm.iri1968.dpdns.org -> pfSense WAN 178.178.98.83 -> NAT 80/443 -> nginx 10.10.10.2 +``` + +Нормальное состояние: + +- `https://dm.iri1968.dpdns.org/healthz` -> `200 ok` без auth; +- `https://dm.iri1968.dpdns.org/` без auth -> `401`; +- `http://dm.iri1968.dpdns.org/healthz` -> `301` на HTTPS; +- после Basic Auth: + - `/` -> gateway index; + - `/r/file1c/brief` -> 1C brief; + - `/r/grafana/api/health` -> Grafana health; + - `/r/aw/api/0/info` -> AW server info. + +Gateway credential хранится только на `10.10.10.2`: + +```sh +sudo cat /root/proxmox-web-gateway.credentials +``` + +pfSense NAT backup перед автоматической правкой: + +```sh +ls -1t /opt/infra-admin/backups/pfsense-gateway-nat-*.json | head +``` + +Проверка pfSense REST/API и pending firewall changes: + +```sh +set -a +. /home/igor/.config/tsj-bot/pfsense.env.readonly +set +a +curl -ksS -H "X-API-Key: $PFSENSE_API_KEY" "$PFSENSE_URL/api/v2/firewall/apply" +``` + +NAT должен содержать `WAN tcp 443 -> 10.10.10.2:443` и `WAN tcp 80 -> 10.10.10.2:80`. +WAN rules должны содержать pass на `10.10.10.2:80` и `10.10.10.2:443`. + ### На Proxmox ```sh @@ -61,16 +105,41 @@ ss -ltnp | grep 5600 ```sh /usr/local/bin/aw-health-check +/usr/local/bin/dlp-health-check --json ``` Что проверяет дополнительно: - свежесть DLP bucket-ов (`aw-dlp-endpoint-signals_*`, `aw-file-operations_*`); - наличие transport/self-test telemetry (`queueDepth`, `eventsEnqueued`, `eventsFlushed`, `sendFailures`) в endpoint self-test; +- последние значения transport-счетчиков по каждому `aw-dlp-endpoint-signals_*` bucket; +- runtime-срез `aw-file-operations_*`: последние `collector_health`, счетчики очереди/отправки, последние операции без полного пути; +- runtime-срез `aw-dlp-incidents_*`: по умолчанию только metadata/age без тяжёлого чтения events; при ручном `--incident-sample-limit > 0` — количество реальных incident events в sample, self-test count, severity/action/rule rollup и до 5 последних incidents с коротким `message_excerpt`; - API-доступность базовых сервисов. Интерпретация: - `FAIL` — есть критичная проблема (service/API/stale transport); -- `WARN` — сигнал для оператора (например, bucket еще не активирован на хосте), но без hard-fail. +- `WARN` — сигнал для оператора (например, bucket еще не активирован на хосте, `queueDepth > 100`, новый рост `sendFailuresDelta >= 1`, нет `collector_health` в sample), но без hard-fail. +- `sendFailures` — накопительный счётчик collector-а; основной health-check предупреждает только по delta относительно сохранённого baseline в `AW_DLP_HEALTH_STATE_DIR`, чтобы старые восстановленные ошибки не висели вечным warning. + +Пороги можно переопределить без изменения collector-ов: + +```sh +/usr/local/bin/dlp-health-check --json \ + --endpoint-queue-warn-depth 100 \ + --endpoint-send-failure-warn-count 1 \ + --incident-sample-limit 0 \ + --fileops-sample-limit 20 \ + --fileops-queue-warn-depth 100 \ + --fileops-send-failure-warn-count 1 +``` + +Для разового разбора последних DLP incidents можно включить sampling вручную: + +```sh +/usr/local/bin/dlp-health-check --json --incident-sample-limit 20 +``` + +Если AW-server медленно отдаёт большой `aw-dlp-incidents_*` bucket, такой запуск может вернуть `incident-runtime: WARN` по timeout. Это не должно использоваться как hard-fail основного мониторинга. ## Проверка RU patch @@ -399,8 +468,8 @@ curl -fsS 'http://127.0.0.1:5600/api/0/buckets/aw-dlp-incidents_SHARKON2025/even 1) Проверить/обнулить очереди: ```powershell -$q1 = 'C:\ProgramData\AWatch-rus\file-operations-queue.jsonl' -$q2 = 'C:\ProgramData\AWatch-rus\dlp-endpoint-signals-queue.jsonl' +$q1 = 'C:\ProgramData\AWatch-rus\file-operations-queue*.jsonl' +$q2 = 'C:\ProgramData\AWatch-rus\dlp-endpoint-signals-queue*.jsonl' Get-Item $q1,$q2 | Select Name,Length,LastWriteTime ``` diff --git a/docs/strategic-dlp-roadmap.md b/docs/strategic-dlp-roadmap.md index ff5d184..43de9b6 100644 --- a/docs/strategic-dlp-roadmap.md +++ b/docs/strategic-dlp-roadmap.md @@ -16,6 +16,24 @@ | Deployment | Ansible + PowerShell ensemble + InnoSetup + Proxmox LXC | | Linux | Remote worker, console/SSH logger, web category logger | +## Cross-OS enforcement strategy update + +Новый целевой путь зафиксирован в `docs/dlp-cross-os-native-enforcement.md`: AWatch-rus +расширяется не тяжелым универсальным агентом, а нативными механизмами ОС и managed browser. + +Приоритет платформ: + +| Платформа | Легкий естественный путь | Enforcement-first канал | +|-----------|--------------------------|--------------------------| +| Windows | GPO/AppLocker/Spooler/Storage + текущие PowerShell collectors | USB write-block, print cancel | +| macOS | MDM + System Extensions/Endpoint Security, без kext | monitor-first, затем managed deny | +| Linux | fanotify + auditd, eBPF как telemetry/LSM extension | sensitive directory/removable gate | +| ChromeOS | Google Admin Data Controls | copy/paste, print, file transfer/upload | +| Managed browser | Edge/Chrome enterprise policy, extension только для inline upload | cloud upload policy | + +Общее правило rollout: `audit` -> `warn` -> точечный `block`; глобальный `block` запрещен +до baseline и guard heartbeat. + ## Ключевые разрывы до InfoWatch TM уровня ### 🔴 Критические (без них это не DLP, а мониторинг) diff --git a/docs/wiki/Server-Infrastructure.md b/docs/wiki/Server-Infrastructure.md index 1302239..b571f47 100644 --- a/docs/wiki/Server-Infrastructure.md +++ b/docs/wiki/Server-Infrastructure.md @@ -6,18 +6,37 @@ ## `aw-prune-local-state.sh` -Скрипт `aw-server/aw-prune-local-state.sh` устанавливается в: +Команда `aw-server/aw-prune-local-state.sh` устанавливается в: ```text /usr/local/bin/aw-prune-local-state.sh ``` +Сейчас это Rust-first wrapper. Если доступен +`/usr/local/bin/aw-prune-local-state-rust`, wrapper использует его; иначе +fallback идет на legacy shell script из +`/opt/activitywatch/aw-rus-ops/aw-prune-local-state.sh`. + Назначение: - чистит старые backup-файлы в `{{ aw_server_data_dir }}/backups`; - отдельно удерживает последние DB backups и JSON backups; - удаляет временные архивы из `/tmp`: `activitywatch-*.zip`, `hayabusa-*.zip`, `aw-hayabusa-profiles.txt`; - удаляет временные WebUI/worktime artifacts старше одного дня: `aw-worktime-ui-bridge.py`, `views-default.json`, `apply_webui_ru_patch.out`. +- чистит старые `browser-smoke` run-директории в + `{{ aw_server_data_dir }}/browser-smoke`, удерживая последние запуски. + +Rust binary по умолчанию работает в dry-run режиме. Production wrapper без +аргументов запускает его с `--apply`, чтобы сохранить поведение systemd timer. + +Safety policy: + +- не удалять основную ActivityWatch SQLite DB; +- не удалять rollback-critical backups: `switch-backups`, `before-rust`, + `rollback`; +- не удалять корневые state-каталоги; +- удалять только пути из явного allowlist: `backups`, `backups/db`, + `browser-smoke`, ограниченный набор временных файлов в `/tmp`. ## systemd unit и timer diff --git a/docs/wiki/Windows-Collector-Suite.md b/docs/wiki/Windows-Collector-Suite.md index a45b967..37d3254 100644 --- a/docs/wiki/Windows-Collector-Suite.md +++ b/docs/wiki/Windows-Collector-Suite.md @@ -18,10 +18,10 @@ ## Process events -Новый флаг: +Флаг: ```yaml -aw_windows_process_events_enabled: true +aw_windows_process_events_enabled: false ``` Он попадает в deployment config как: @@ -30,7 +30,7 @@ aw_windows_process_events_enabled: true sessionEvents.processEventsEnabled ``` -Когда флаг включен, Windows collector публикует process-level изменения в session events bucket. Это дает server-side слою больше контекста для active session detection и forensic review. +По умолчанию флаг выключен во всех путях deploy/recovery: постоянная публикация process-level изменений в `aw-session-events_` создает чрезмерный поток событий и быстро раздувает SQLite на AW server. Включать его следует только явно и временно для forensic/debug окна, после чего возвращать `false`. ## Localized Administrator diff --git a/docs/windows-deploy-startup-model.md b/docs/windows-deploy-startup-model.md index 8ec7c61..f4bd300 100644 --- a/docs/windows-deploy-startup-model.md +++ b/docs/windows-deploy-startup-model.md @@ -6,6 +6,12 @@ Use this model on `SHARKON2025`-style hosts with multiple user sessions. +- `AWatchRusCollectorGuard` service: + - runs under `LocalSystem` + - is the preferred local control plane for collector supervision + - in `shadow` mode only publishes state/heartbeat + - in `enforce` mode starts only session-appropriate collectors/tasks with cooldown and restart budget + - publishes `aw-rus-collector-guard_` - `ActivityWatch Launch [HOST_user]` tasks: - `AtLogOn` - `InteractiveToken` @@ -13,8 +19,9 @@ Use this model on `SHARKON2025`-style hosts with multiple user sessions. - `ActivityWatch Recovery` task: - `AtStartup` - `SYSTEM` + - stays enabled even when `AWatchRusCollectorGuard` is active - keeps only the global `worktime-session-collector` alive - - may re-trigger user launch tasks, but only for users whose sessions currently exist + - may re-trigger user launch tasks for managed live or disconnected sessions - interactive collectors/watcher binaries belong to the user-session path, not to Session 0 Collector ownership in this model: @@ -53,10 +60,17 @@ Do not mix the two startup models on the same RDP host: - no permanent standalone-service loop together with per-user launch/recovery tasks - no blind `Start-ScheduledTask` for all configured users - no validation rule that treats users without sessions as failed collector startup +- no bot-driven collector recovery as the primary control plane + +During migration, `AWatchRusCollectorGuard` may run in `shadow` mode beside the existing +recovery task. In `enforce` mode it becomes the primary control plane; `ActivityWatch Recovery` +still remains enabled as fallback/bootstrap and must not be disabled by deploy scripts. ## Hardening rules - start launch tasks only for users with real sessions +- treat managed disconnected RDP sessions as real recovery targets when their launch tasks exist - keep only one global `worktime-session-collector` - validate by session-aware expectations, not by “all configured users must currently run” +- use guard heartbeat and bucket freshness as health signals - keep `deploy_aw_windows.yml`, `deploy-ensemble.ps1`, `hardening-recovery.ps1`, and installer assumptions aligned diff --git a/install-kit-awindows-20260427-211240.tar.gz b/install-kit-awindows-20260427-211240.tar.gz index 42626af8e175a816fc236328b5bd3dfd18bc12d9..72b91d6d287a5852dc64ee9e0154f1bab6f245ec 100644 GIT binary patch literal 185718 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

&Vw1O*&P)P$HwxrD|@JVV7 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⪚y#&nYL-U(*{eium%VBBX`&o$fX!0LX0Y3u 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 literal 151236 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

`%vc@x`PMg0ISuPEXrebEJi$e8<}r{nl-JerfH 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̂L(T!N;g2=@Lk|_Prq_4-#A1!rql$9m- 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 z

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 literal 179640 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%0AP-UCiC-23W96Oi1rcyVl_`_+LWnL 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

o;DN$t}?(uZqzFz`#P=>P|M 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


{{5UrtKR{is|*>JL)swT1B-QgwTLL^oEBF+SyV7 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
OI%z5y-d|5bd2 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

3=n24ihmG;j@E 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 diff --git a/install-kit-awindows-20260427-211240/MANIFEST.txt b/install-kit-awindows-20260427-211240/MANIFEST.txt index 5010de7..35550c9 100644 --- a/install-kit-awindows-20260427-211240/MANIFEST.txt +++ b/install-kit-awindows-20260427-211240/MANIFEST.txt @@ -1,13 +1,13 @@ -f3dde1e6d1532804379faf7e395deaf95cf3e0b97769d425f8a69f4572de2a2f install-kit-awindows-20260427-211240/README-INSTALL-KIT.txt -edc20460f7be2ec676a6fd7c9687f808507b364b32915eb78b7a63b441b1ff84 install-kit-awindows-20260427-211240/ansible/README.md +1b776081c24ad8413c2f3e24aff3952949e7786a108a08ef45121536bcae9482 install-kit-awindows-20260427-211240/README-INSTALL-KIT.txt +e973ad9aa3824a6588ce4ef1b80ce3caec0085434a2755f9b657f09fb7435632 install-kit-awindows-20260427-211240/ansible/README.md 412bb766bbf0791c3593f38daa771d5d0aa58cc1f2d3c9010fcd4588d0fe87df install-kit-awindows-20260427-211240/ansible/deploy_aw_pfsense_poller.yml -0188480546b4b18937194f2b05a0f23da5dd8536eed4d60b7499a8170c36d08e install-kit-awindows-20260427-211240/ansible/deploy_aw_server.yml -953bb39572c520d0438a621c2b4c08315bf887b84ee9e795554e5592b54a1229 install-kit-awindows-20260427-211240/ansible/deploy_aw_windows.yml -0054e7ba5f342cd10db08f6bab4784d4816179d69f76898a4de293a4063ea14a install-kit-awindows-20260427-211240/ansible/group_vars/all.example.yml +951b6e22c285a95a3f45ead81fe98069eb48405368617a600f86240ccad65b70 install-kit-awindows-20260427-211240/ansible/deploy_aw_server.yml +7c1afd6088e7ec26842be4b4b8a0530780d1d7075f19f80d63eb08445857823f install-kit-awindows-20260427-211240/ansible/deploy_aw_windows.yml +d666eac9d94ad71842e21aaac5d23968a0c2213bb4edab899bf432d7ad417ddf install-kit-awindows-20260427-211240/ansible/group_vars/all.example.yml a896676303be9a47ed6b0c8cc07deb5a5432aebc97231e801fda985121c539eb install-kit-awindows-20260427-211240/ansible/group_vars/pfsense-poller.example.yml c5cab36645065815571c99f6d360f910dcccbb54b780c8bfd526a6cdc3684e19 install-kit-awindows-20260427-211240/ansible/group_vars/proxmox-matrix.example.yml 35a33c8a1c75ded5e85c6b79e0b3efde07959ff61ee5f66d83b7e0c2abe87fc5 install-kit-awindows-20260427-211240/ansible/group_vars/proxmox.example.yml -a1921715cf9904b7e3b61a1a5c4300382efe2f3bd70d73e4c8e995318fdd55e9 install-kit-awindows-20260427-211240/ansible/group_vars/windows.example.yml +9e4939d2d951870cacab6e42c5bf0ccd03350089dc0d32b9a681948ff96754d2 install-kit-awindows-20260427-211240/ansible/group_vars/windows.example.yml 7189b5205bd25313db54e5be027b0d066199e6ae34ad74be2095a1691adaf5e3 install-kit-awindows-20260427-211240/ansible/install_full_stack.yml fea0574d7eb98ce24a1e7025afb9837c6241180095d21eaa74892225305d05a3 install-kit-awindows-20260427-211240/ansible/inventory.example.ini 8a5e4923c0f581dd4fbb32549ee7ab45ba506260056da923ba86d9f1b1081714 install-kit-awindows-20260427-211240/ansible/provision_proxmox_ct_and_deploy_aw.yml @@ -15,33 +15,53 @@ fea0574d7eb98ce24a1e7025afb9837c6241180095d21eaa74892225305d05a3 install-kit-aw a74a49371e889dc3ea404534a939f32f2dac940d8902d20770590951ab67d532 install-kit-awindows-20260427-211240/ansible/tasks/provision_ct_and_deploy_aw.yml ba16fe9e267194459a6082045a387acb828a1d39a98e66b401ece5069ea62e64 install-kit-awindows-20260427-211240/aw-server/activitywatch-server.service fea5734c99b516b01bfe8ebbef59972d0ef553c09d7691790b08472ecfb9602d install-kit-awindows-20260427-211240/aw-server/apply_webui_ru_patch.sh +fabcdbb933b1d3b605c5ac1234af4e10495c09f18bd50931bf4a92081fcb5023 install-kit-awindows-20260427-211240/aw-server/aw-browser-smoke.service +e59d58d4cc6b2b0dceef5b1da14ca0a1b24a202ce576ee43028f44ffe43c5ef4 install-kit-awindows-20260427-211240/aw-server/aw-browser-smoke.timer ba88cc284d047f521427ac038c266624a6fe8493ce3e79bc27c172a2e70ac54a install-kit-awindows-20260427-211240/aw-server/aw-host-groups.json 509759461ce0918a2dc839832812cf5f4c77b1cea1e6ffdcab0146e02598df79 install-kit-awindows-20260427-211240/aw-server/aw-ru-patch.js -22d4ee27ffc73dcec3ba73c8ee65d12353ded1403f592ff52ef578e09dad4c43 install-kit-awindows-20260427-211240/aw-server/aw-rus-healthd.py +6072757b685430962f61f97f6de4b0ed6f6653454559974a6ee87936fc6c7571 install-kit-awindows-20260427-211240/aw-server/aw-rus-healthd.py 05b04b5f49e9c7783917e0861e3edd63f5d6db8638d6b33dbfd4dc0f1c16040f install-kit-awindows-20260427-211240/aw-server/aw-rus-healthd.service -bb0f1de91862da66b0b6d9bd41e8dfe181710196141cb43b00d9b41dab6caf96 install-kit-awindows-20260427-211240/aw-server/aw-rus-healthd.timer -b493fbf4cc15ccba44ea78d3ed17a0a6db31617f22d6aeb671677cbf11a36921 install-kit-awindows-20260427-211240/aw-server/aw-server.env.example +8ae02b42f4bc33d95268624bae79c2101298b7ec6644553a1e81f7841dda74d8 install-kit-awindows-20260427-211240/aw-server/aw-rus-healthd.timer +28950acbfeed3f3e5729e0227fd96785dd840330ed9b94a3d6ab0a6a855ca5ec install-kit-awindows-20260427-211240/aw-server/aw-server.env.example +bc8f88fa615a74fc69f80c0cc614ddea323156e5e49d3de28a9958295737afab install-kit-awindows-20260427-211240/aw-server/aw-slo-monitor.py +6db182e8e5237b1daaafdcd1003fccf7e5aaaabb7be8ab4226cb513f7b1c739b install-kit-awindows-20260427-211240/aw-server/aw-slo-monitor.service +257cdc18f088e8848b1bee69e7f8b99aa860a0f600eebd74365a40ed94aa5ecb install-kit-awindows-20260427-211240/aw-server/aw-slo-monitor.timer 98c0bed353bbda0fa7a69df23f3b008cb0e8e70cdff6cc63330d4caf79fd3280 install-kit-awindows-20260427-211240/aw-server/aw-sw-cleanup.js -e220a2eb830a018fa28c02872fabda54aed7f87dbb0e14141f1a8933a98064fe install-kit-awindows-20260427-211240/aw-server/aw-worktime-api.py +9597e8e12a3472b86b222f10c2e0a71c653efc41415771aeaab2e830c9424d3c install-kit-awindows-20260427-211240/aw-server/aw-worktime-api.py f764e566d70952acc1b4f2baeccd6b7888905bc6b98b36333336c0dc77c66694 install-kit-awindows-20260427-211240/aw-server/aw-worktime-api.service afbb4be301b4940ed9b7671be3441a48d53bee968d8870749442f81c6d066650 install-kit-awindows-20260427-211240/aw-server/aw-worktime-panel.js +c9169d01469b2f49eab0f0e9a483f883d4c367719fde51fb262d74c35e798c44 install-kit-awindows-20260427-211240/aw-server/aw-worktime-prewarm.service +1733c8c84b17ad89e0f3e5b27f649b968dedd52d9048f9bc2d92e7cc10daebd1 install-kit-awindows-20260427-211240/aw-server/aw-worktime-prewarm.sh +0cd38bae7ad1b4718d7decc8ef3ac932939a6e5f8c25e9e27d1d0ecf8cc328b9 install-kit-awindows-20260427-211240/aw-server/aw-worktime-prewarm.timer +2e3a0667f8a9eddcb4803c0ba274615092cdd3eec23c2dba3f494715dac91423 install-kit-awindows-20260427-211240/aw-server/health-check.sh ca7d1ed7665e225824d4ed7ef8456c81a6d97ef2c90cb3aa799d72e07b6bcbb8 install-kit-awindows-20260427-211240/aw-server/install_aw_server.sh aedffecfa24834968742cb2477faef80bf794345275a9679ac12c5a1f609acc2 install-kit-awindows-20260427-211240/aw-server/settings/classes-worktime.json 47c50054515506b72af2d6bca0ae959ca57861803e528991c230611e6a8893d6 install-kit-awindows-20260427-211240/aw-server/settings/views-default.json +4e5b23300ba5c9878b2c8ce6eea65043b7a4c5cb2d14619243cfca6d202aec9f install-kit-awindows-20260427-211240/scripts/aw-webui-browser-smoke.mjs +a851fba13a14a529f95eeb054c2f64dc108cf7a379c4ea35402e84c0b4a2733d install-kit-awindows-20260427-211240/scripts/aw-webui-browser-smoke.sh +da178cb5dfff3ae8465a6139048adc6757cb1dc23edb30d747d09a05e6b07aeb install-kit-awindows-20260427-211240/scripts/check_install_kit_vs_repo.sh +fe8d2b846a64f1357dda48b4c817c7919a3c7398921ddca2adf9a99711ac2613 install-kit-awindows-20260427-211240/scripts/quality-gate.sh +f634ea60040153fafcab6363bea8c442d05c1734e6a9d2e16844cf63a741df24 install-kit-awindows-20260427-211240/scripts/rebuild_install_kit.sh +b58c572edd12e4c3620c045f3382e7b9f74664ef5550302772a828a023f8f3fe install-kit-awindows-20260427-211240/scripts/validate_install_kit.sh +0b49e6db51d5abcecaeee0b85f186efd4f10d3360cf04e8aea2e4c7d4465dbda install-kit-awindows-20260427-211240/scripts/verify_innosetup_installer.sh dd2389e9cb199ed86d219120294fdaa64415cfbc683004d5f5db5d52aa758a92 install-kit-awindows-20260427-211240/server-configs-192.168.100.18/awatch-rus-admin.deployment-config.json 5279f9d677faed76a5f0248f9217ecc29eac977ab752552cab852f5b4b6715df install-kit-awindows-20260427-211240/server-configs-192.168.100.18/awatch-rus-u2u5.deployment-config.json 333fe336e51f9c69bd2559d18763da2b83df400fa374e540ed128ffb6765ab7e install-kit-awindows-20260427-211240/server-configs-192.168.100.18/awatch-rus-user1.deployment-config.json +e856d4b411b61dfc11e0a59b59026eff10972a47e7d8b28f6dfe367a2a5e192d install-kit-awindows-20260427-211240/windows/AWatchRusCollectorGuardService.cs 33aa34b89246d6c079ef9afe2f5cd153bd9d5946b69a175ff6fd678c77f61da5 install-kit-awindows-20260427-211240/windows/ActivityWatch.Windows.Common.psd1 -9e5e6a30bfb7789ee91ae656e306586a50509d9d416fc044408e9e6c19c61b37 install-kit-awindows-20260427-211240/windows/ActivityWatch.Windows.Common.psm1 +6d5a7dd92619bbf972e17e455a7cda65b53c790f6f0ede47e1196246bfe8b1cf install-kit-awindows-20260427-211240/windows/ActivityWatch.Windows.Common.psm1 +f4405a959a52e21185234463887a4fc09ea28e48d2412ac336f9715b46be9cb9 install-kit-awindows-20260427-211240/windows/aw-collector-guard.ps1 81e95c7e4b7336a2e1f0caed3ea99f6cdb9696b99380dc2148667b5fba577f68 install-kit-awindows-20260427-211240/windows/browser-domains-native-collector.ps1 -e390e00a4deff5ce45a08f5252f23c56b2655c8611e42bc7b2b6992f60bde038 install-kit-awindows-20260427-211240/windows/deploy-domain-users.ps1 -2074d9ebdda069c8bd579e688473656259714fe7e13b7537a7c464143195b525 install-kit-awindows-20260427-211240/windows/deploy-ensemble.ps1 -9f2637ede66b61a12e9edafa0b248a5fb496dd5175b288ebb71718330cac68d5 install-kit-awindows-20260427-211240/windows/deploy-single-user.ps1 -dc74947393b1851ca233b559ea77f29b1b12a1c4480fbb865f2b58b25f3ea7d3 install-kit-awindows-20260427-211240/windows/dlp-endpoint-signals-collector.ps1 -a4dad0745da95a69ee55b0216d4bde39c58acef8642380092938465653c61cf4 install-kit-awindows-20260427-211240/windows/dlp-policy.example.json +51eb13f689494c279ec321bbb68dfcb9b8ff81f22f11502b72c16895b65e0b65 install-kit-awindows-20260427-211240/windows/deploy-domain-users.ps1 +5bacbd48fdfc8d0d4479ba1af54cbbfe6057cc654409b385f487962576f4f1db install-kit-awindows-20260427-211240/windows/deploy-ensemble.ps1 +6ab4c496220bb46f064cc927a64ea853526a2c814182a32f39b432be6b32fe89 install-kit-awindows-20260427-211240/windows/deploy-single-user.ps1 +73f3328cde22798dafa645cdd78d648ccf6425d6577432ae8c95d24daa17dc94 install-kit-awindows-20260427-211240/windows/dlp-endpoint-signals-collector.ps1 +161c0ee7e8aa6fa172bf10b6c83f208aa5c8d555b6e6008d1b0a5de6e45d2c9d install-kit-awindows-20260427-211240/windows/dlp-policy.example.json +0df53518a43bb1f0af7fdff5b44ba859e797090dd08680b239571d2e8a478d47 install-kit-awindows-20260427-211240/windows/dlp-policy.native-cross-os.example.json 863727465497b474d13d2270d443ff96ccb6076f90a5ce3eb270bdf8088e02dc install-kit-awindows-20260427-211240/windows/email-outbound-collector.ps1 -44d941322d618d92c72c3d24fe619d71e7551b9cdb35abe19f700c0b74da3eb1 install-kit-awindows-20260427-211240/windows/hardening-recovery.ps1 +2c0e7a96f32d546ce3ff8da2059259e6e5141c97cefc76bccae4c87c502f00cf install-kit-awindows-20260427-211240/windows/hardening-recovery.ps1 +ea68b869182587b7be41bf1891401bdf93dda3b9af6028afa0e30d245c77b0f4 install-kit-awindows-20260427-211240/windows/install-collector-guard-service.ps1 5ef21a25d5e2da4eeaef17126e60f96f195f90f9dc17a776f8629334b904d096 install-kit-awindows-20260427-211240/windows/migrate-awatch-rus-paths.ps1 -672d76824b1ca17d4c65e3097cf9895160a7bbce30323ef02d195fc9624538da install-kit-awindows-20260427-211240/windows/validate-deployment.ps1 +d50228354f4c1f15b8ea9ccfef184a3a2e5cc832259ddbdc7afea4c083d03075 install-kit-awindows-20260427-211240/windows/validate-deployment.ps1 731098681d89b9af6f3872abd586ac3b1faba2d7f9340211e503f52ad0243b3f install-kit-awindows-20260427-211240/windows/web-category-rules.example.json 1b7c337967236474484e781dc8ac37543509b051513f01e1a3145369262f5389 install-kit-awindows-20260427-211240/windows/worktime-session-collector.ps1 diff --git a/install-kit-awindows-20260427-211240/README-INSTALL-KIT.txt b/install-kit-awindows-20260427-211240/README-INSTALL-KIT.txt index 8435107..e2a80a1 100644 --- a/install-kit-awindows-20260427-211240/README-INSTALL-KIT.txt +++ b/install-kit-awindows-20260427-211240/README-INSTALL-KIT.txt @@ -4,6 +4,7 @@ Includes: - windows/* (deploy scripts, collectors, common module, configs/examples) - ansible/* (Windows and AW server playbooks, examples, inventory, tasks) - aw-server/* (server installer, health orchestrator, RU patch loader, host groups, default settings) +- scripts/* (install-kit rebuild/validation, quality gates, browser/web smoke checks) - server-configs-192.168.100.18/* (working Windows/RDP config snapshots) Source: diff --git a/install-kit-awindows-20260427-211240/ansible/README.md b/install-kit-awindows-20260427-211240/ansible/README.md index 7a1acbb..d251432 100644 --- a/install-kit-awindows-20260427-211240/ansible/README.md +++ b/install-kit-awindows-20260427-211240/ansible/README.md @@ -122,8 +122,9 @@ Playbook: - выгружает полный `windows/*` toolkit на целевой хост в InnoSetup-compatible каталог `C:\Program Files\AWatch-rus\windows`, включая DLP и `worktime-session-collector.ps1`; - если найден legacy config `C:\ProgramData\ActivityWatch-Phase2\deployment-config.json`, выполняет безопасную миграцию через `migrate-awatch-rus-paths.ps1`: backup, остановка задач, перенос данных, переписывание путей, пересоздание scheduled tasks и validation; - выполняет `deploy-ensemble.ps1` (deploy + hardening/recovery) с policy/rules из AWatch-rus toolkit; -- после deploy принудительно запускает `ActivityWatch Recovery` и все `ActivityWatch Launch *` задачи; -- включает (`Enable-ScheduledTask`) `ActivityWatch Recovery` и все `ActivityWatch Launch *` задачи перед запуском (иначе WebUI может показывать `Active time: 0s`); +- после deploy принудительно запускает `ActivityWatch Recovery` и managed `ActivityWatch Launch *` задачи; +- включает (`Enable-ScheduledTask`) `ActivityWatch Recovery` и managed `ActivityWatch Launch *` задачи перед запуском (иначе WebUI может показывать `Active time: 0s`); +- оставляет `ActivityWatch Recovery` включённым даже при активном `AWatchRusCollectorGuard`: guard является основным контроллером, recovery остаётся fallback/bootstrap path; - выполняет API smoke-check bucket `aw-watcher-afk_` и ожидает свежие события; - выполняет API smoke-check bucket `aw-watcher-window_` и ожидает свежие события (по умолчанию включено); - запускает `validate-deployment.ps1`; diff --git a/install-kit-awindows-20260427-211240/ansible/deploy_aw_server.yml b/install-kit-awindows-20260427-211240/ansible/deploy_aw_server.yml index 371dac5..7b68704 100644 --- a/install-kit-awindows-20260427-211240/ansible/deploy_aw_server.yml +++ b/install-kit-awindows-20260427-211240/ansible/deploy_aw_server.yml @@ -14,6 +14,7 @@ aw_sw_cleanup_cache_bust: "{{ lookup('file', aw_repo_root + '/aw-server/aw-sw-cleanup.js') | hash('sha1') | truncate(12, true, '') }}" aw_worktime_classes: "{{ lookup('file', aw_repo_root + '/aw-server/settings/classes-worktime.json') | from_json }}" aw_default_views: "{{ lookup('file', aw_repo_root + '/aw-server/settings/views-default.json') | from_json }}" + aw_rust_release_dir: "{{ (lookup('env', 'CARGO_TARGET_DIR') | default(aw_repo_root + '/adk-rust/target', true)) + '/release' }}" tasks: - name: Установить базовые пакеты @@ -29,6 +30,15 @@ state: present update_cache: true + - name: Установить пакеты для browser smoke проверки + ansible.builtin.apt: + name: + - chromium + - nodejs + - node-playwright + state: present + when: aw_browser_smoke_enabled | default(true) | bool + - name: Создать системную группу сервиса ansible.builtin.group: name: "{{ aw_server_group }}" @@ -63,10 +73,13 @@ - "{{ aw_server_data_dir }}/.config/activitywatch" - "{{ aw_server_data_dir }}/.config/activitywatch/aw-server-rust" - "{{ aw_server_data_dir }}/backups" + - "{{ aw_server_data_dir }}/slo" + - "{{ aw_server_data_dir }}/browser-smoke" - "{{ aw_rus_health_state_dir }}" - "{{ aw_rus_health_validation_dir }}" - "{{ aw_server_log_dir }}" - /etc/activitywatch + - /usr/local/lib/aw-rus-browser-smoke - "{{ aw_bootstrap_dir }}" - name: Настроить каталоги ActivityWatch с владельцем сервиса @@ -90,6 +103,8 @@ - "{{ aw_server_data_dir }}/.config/activitywatch" - "{{ aw_server_data_dir }}/.config/activitywatch/aw-server-rust" - "{{ aw_server_data_dir }}/backups" + - "{{ aw_server_data_dir }}/slo" + - "{{ aw_server_data_dir }}/browser-smoke" - "{{ aw_rus_health_state_dir }}" - "{{ aw_rus_health_validation_dir }}" - "{{ aw_server_log_dir }}" @@ -102,6 +117,30 @@ group: root mode: "0755" + - name: Положить legacy prune script в server-side ops bundle + ansible.builtin.copy: + src: "{{ aw_repo_root }}/scripts/legacy/aw-prune-local-state.sh" + dest: /opt/activitywatch/aw-rus-ops/aw-prune-local-state.sh + owner: root + group: root + mode: "0755" + + - name: Проверить локальный Rust prune local state + ansible.builtin.stat: + path: "{{ aw_rust_release_dir }}/aw-prune-local-state" + delegate_to: localhost + register: aw_prune_local_state_rust_binary + become: false + + - name: Установить Rust prune local state + ansible.builtin.copy: + src: "{{ aw_rust_release_dir }}/aw-prune-local-state" + dest: /usr/local/bin/aw-prune-local-state-rust + owner: root + group: root + mode: "0755" + when: aw_prune_local_state_rust_binary.stat.exists | default(false) + - name: Ограничить рост journald на aw-server ansible.builtin.copy: dest: /etc/systemd/journald.conf.d/aw-rus-retention.conf @@ -376,20 +415,96 @@ dest: /opt/activitywatch/aw-server/apply_webui_ru_patch.sh mode: "0755" + - name: Создать server-side ops bundle для ранних health helpers + ansible.builtin.file: + path: /opt/activitywatch/aw-rus-ops + state: directory + owner: root + group: root + mode: "0755" + + - name: Установить wrapper aw-health-check до Influx проверок + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/aw-health-check-wrapper.sh" + dest: /usr/local/bin/aw-health-check + owner: root + group: root + mode: "0755" + + - name: Положить legacy aw-health-check до Influx проверок + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/health-check.sh" + dest: /opt/activitywatch/aw-rus-ops/health-check.sh + owner: root + group: root + mode: "0755" + + - name: Проверить локальный Rust aw-health-check до Influx проверок + ansible.builtin.stat: + path: "{{ aw_rust_release_dir }}/aw-health-check" + delegate_to: localhost + register: aw_health_check_rust_binary_early + become: false + + - name: Установить Rust aw-health-check до Influx проверок + ansible.builtin.copy: + src: "{{ aw_rust_release_dir }}/aw-health-check" + dest: /usr/local/bin/aw-health-check-rust + owner: root + group: root + mode: "0755" + when: aw_health_check_rust_binary_early.stat.exists | default(false) + + - name: Проверить локальный Rust aw-db-health до Influx проверок + ansible.builtin.stat: + path: "{{ aw_rust_release_dir }}/aw-db-health" + delegate_to: localhost + register: aw_db_health_rust_binary_early + become: false + + - name: Установить Rust aw-db-health до Influx проверок + ansible.builtin.copy: + src: "{{ aw_rust_release_dir }}/aw-db-health" + dest: /usr/local/bin/aw-db-health + owner: root + group: root + mode: "0755" + when: aw_db_health_rust_binary_early.stat.exists | default(false) + + - name: Прочитать текущий aw-server.env для сохранения Influx token + ansible.builtin.slurp: + path: /etc/activitywatch/aw-server.env + register: aw_existing_server_env_raw + failed_when: false + no_log: true + + - name: Подготовить effective Influx tokens без вывода секретов + ansible.builtin.set_fact: + aw_existing_server_env_text: "{{ aw_existing_server_env_raw.content | default('') | b64decode }}" + aw_existing_worktime_influx_token: "{{ (aw_existing_server_env_raw.content | default('') | b64decode | regex_search('(?m)^AW_WORKTIME_INFLUX_TOKEN=.*$') | default('', true) | regex_replace('^AW_WORKTIME_INFLUX_TOKEN=', '')) }}" + aw_existing_dlp_influx_token: "{{ (aw_existing_server_env_raw.content | default('') | b64decode | regex_search('(?m)^AW_DLP_INFLUX_TOKEN=.*$') | default('', true) | regex_replace('^AW_DLP_INFLUX_TOKEN=', '')) }}" + no_log: true + + - name: Выбрать effective Influx tokens + ansible.builtin.set_fact: + aw_effective_worktime_influx_token: "{{ (aw_worktime_influx_token | default('') | string) if ((aw_worktime_influx_token | default('') | string | length) > 0) else aw_existing_worktime_influx_token }}" + aw_effective_dlp_influx_token: "{{ (aw_dlp_influx_token | default('') | string) if ((aw_dlp_influx_token | default('') | string | length) > 0) else aw_existing_dlp_influx_token }}" + no_log: true + - name: Проверить Influx token для AW worktime exporter ansible.builtin.assert: that: - - aw_worktime_influx_token is defined - - aw_worktime_influx_token | length > 0 - fail_msg: "aw_worktime_influx_enabled=true, но aw_worktime_influx_token пуст. Exporter будет падать и Grafana не получит worktime-ряды." + - aw_effective_worktime_influx_token is defined + - aw_effective_worktime_influx_token | length > 0 + fail_msg: "aw_worktime_influx_enabled=true, но token пуст и в локальном env, и в текущем /etc/activitywatch/aw-server.env. Exporter будет падать и Grafana не получит worktime-ряды." when: aw_worktime_influx_enabled | default(false) | bool - name: Проверить Influx token для AW DLP exporter ansible.builtin.assert: that: - - aw_dlp_influx_token is defined - - aw_dlp_influx_token | length > 0 - fail_msg: "aw_dlp_influx_enabled=true, но aw_dlp_influx_token пуст. Exporter будет падать и Grafana не получит DLP-ряды." + - aw_effective_dlp_influx_token is defined + - aw_effective_dlp_influx_token | length > 0 + fail_msg: "aw_dlp_influx_enabled=true, но token пуст и в локальном env, и в текущем /etc/activitywatch/aw-server.env. Exporter будет падать и Grafana не получит DLP-ряды." when: aw_dlp_influx_enabled | default(false) | bool - name: Записать /etc/activitywatch/aw-server.env перед хотфиксами @@ -427,7 +542,7 @@ AW_WORKTIME_INFLUX_BUCKET={{ aw_worktime_influx_bucket | default('aw_metrics') }} AW_WORKTIME_INFLUX_HOSTS={{ aw_worktime_influx_hosts | default('SHARKON2025') }} AW_WORKTIME_INFLUX_DAYS={{ aw_worktime_influx_days | default('today,yesterday') }} - AW_WORKTIME_INFLUX_TOKEN={{ aw_worktime_influx_token | default('') }} + AW_WORKTIME_INFLUX_TOKEN={{ aw_effective_worktime_influx_token | default('') }} AW_DLP_INFLUX_ENABLED={{ 'true' if (aw_dlp_influx_enabled | default(false) | bool) else 'false' }} AW_DLP_INFLUX_URL={{ aw_dlp_influx_url | default('') }} AW_DLP_INFLUX_ORG={{ aw_dlp_influx_org | default('proxmox') }} @@ -435,7 +550,7 @@ AW_DLP_INFLUX_HOSTS={{ aw_dlp_influx_hosts | default('SHARKON2025') }} AW_DLP_INFLUX_LOOKBACK_DAYS={{ aw_dlp_influx_lookback_days | default(30) }} AW_DLP_INFLUX_EVENT_LIMIT={{ aw_dlp_influx_event_limit | default(2000) }} - AW_DLP_INFLUX_TOKEN={{ aw_dlp_influx_token | default('') }} + AW_DLP_INFLUX_TOKEN={{ aw_effective_dlp_influx_token | default('') }} AW_DLP_AW_API_BASE=http://127.0.0.1:5600/api/0 AW_DLP_CASE_API_BASE=http://127.0.0.1:5602/api/0/dlp/cases AW_EXPECT_START_OF_DAY={{ aw_worktime_start_of_day | default('') }} @@ -448,6 +563,19 @@ AW_RUS_HEALTH_STATE_DIR={{ aw_rus_health_state_dir }} AW_RUS_HEALTH_VALIDATION_DIR={{ aw_rus_health_validation_dir }} AW_RUS_HEALTH_SESSION_EVENTS_MAX_AGE_SECONDS={{ aw_rus_health_session_events_max_age_seconds | default(86400) }} + AW_RUS_HEALTH_GUARD_MAX_AGE_SECONDS={{ aw_rus_health_guard_max_age_seconds | default(300) }} + AW_RUS_HEALTH_GUARD_REQUIRED={{ 1 if (aw_rus_health_guard_required | default(true) | bool) else 0 }} + AW_RUS_SLO_STATE_DIR={{ aw_server_data_dir }}/slo + AW_RUS_SLO_AW_BASE=http://127.0.0.1:5600 + AW_RUS_SLO_WORKTIME_BASE={{ aw_rus_health_worktime_api_base | default('http://127.0.0.1:5610') }} + AW_RUS_SLO_TARGET_PERCENT={{ aw_rus_slo_target_percent | default('99.97') }} + AW_BROWSER_SMOKE_AW_BASE=http://127.0.0.1:5600 + AW_BROWSER_SMOKE_WORKTIME_BASE={{ aw_rus_health_worktime_api_base | default('http://127.0.0.1:5610') }} + AW_BROWSER_SMOKE_HOST={{ aw_monitored_windows_hostname }} + AW_BROWSER_SMOKE_OUTPUT_DIR={{ aw_server_data_dir }}/browser-smoke + AW_BROWSER_SMOKE_ENGINE={{ aw_browser_smoke_engine | default('chromium-cli') }} + AW_BROWSER_SMOKE_TIMEOUT_MS={{ aw_browser_smoke_timeout_ms | default(20000) }} + AW_BROWSER_SMOKE_RENDER_TIMEOUT_MS={{ aw_browser_smoke_render_timeout_ms | default(15000) }} AW_HAYABUSA_AUTO_CASE_ENABLED={{ 'true' if (aw_hayabusa_auto_case_enabled | default(true) | bool) else 'false' }} AW_HAYABUSA_AUTO_CASE_MIN_SEVERITY={{ aw_hayabusa_auto_case_min_severity | default('medium') }} AW_HAYABUSA_TELEGRAM_ENABLED={{ 'true' if (aw_hayabusa_telegram_enabled | default(false) | bool) else 'false' }} @@ -501,6 +629,50 @@ mode: "0644" when: aw_dlp_policy_engine_enabled | default(false) | bool + - name: Проверить локальный Rust DLP policy engine + ansible.builtin.stat: + path: "{{ aw_rust_release_dir }}/dlp-policy-engine" + delegate_to: localhost + register: aw_dlp_policy_engine_rust_binary + become: false + when: aw_dlp_policy_engine_enabled | default(false) | bool + + - name: Установить Rust DLP policy engine + ansible.builtin.copy: + src: "{{ aw_rust_release_dir }}/dlp-policy-engine" + dest: /usr/local/bin/aw-dlp-policy-engine-rust + owner: root + group: root + mode: "0755" + when: + - aw_dlp_policy_engine_enabled | default(false) | bool + - aw_dlp_policy_engine_rust_binary.stat.exists | default(false) + + - name: Создать drop-in каталог DLP policy engine + ansible.builtin.file: + path: /etc/systemd/system/aw-dlp-policy-engine.service.d + state: directory + owner: root + group: root + mode: "0755" + when: + - aw_dlp_policy_engine_enabled | default(false) | bool + - aw_dlp_policy_engine_rust_binary.stat.exists | default(false) + + - name: Переключить DLP policy engine на Rust + ansible.builtin.copy: + dest: /etc/systemd/system/aw-dlp-policy-engine.service.d/20-rust-switch.conf + owner: root + group: root + mode: "0644" + content: | + [Service] + ExecStart= + ExecStart=/usr/local/bin/aw-dlp-policy-engine-rust + when: + - aw_dlp_policy_engine_enabled | default(false) | bool + - aw_dlp_policy_engine_rust_binary.stat.exists | default(false) + - name: Создать каталог DLP content analysis ansible.builtin.file: path: /opt/activitywatch/dlp-content-analysis @@ -528,6 +700,25 @@ mode: "0755" when: aw_dlp_content_analysis_enabled | default(true) | bool + - name: Проверить локальный Rust DLP content analyzer + ansible.builtin.stat: + path: "{{ aw_rust_release_dir }}/dlp-content-analyzer" + delegate_to: localhost + register: dlp_content_analyzer_rust_binary + become: false + when: aw_dlp_content_analysis_enabled | default(true) | bool + + - name: Установить Rust DLP content analyzer + ansible.builtin.copy: + src: "{{ aw_rust_release_dir }}/dlp-content-analyzer" + dest: /usr/local/bin/aw-dlp-content-analyzer-rust + owner: root + group: root + mode: "0755" + when: + - aw_dlp_content_analysis_enabled | default(true) | bool + - dlp_content_analyzer_rust_binary.stat.exists | default(false) + - name: Создать virtualenv DLP content analysis ansible.builtin.command: cmd: python3 -m venv /opt/activitywatch/dlp-content-analysis/.venv @@ -619,6 +810,50 @@ mode: "0644" when: aw_dlp_integrations_enabled | default(true) | bool + - name: Проверить локальный Rust CEF exporter + ansible.builtin.stat: + path: "{{ aw_rust_release_dir }}/dlp-cef-exporter" + delegate_to: localhost + register: dlp_cef_exporter_rust_binary + become: false + when: aw_dlp_integrations_enabled | default(true) | bool + + - name: Установить Rust CEF exporter + ansible.builtin.copy: + src: "{{ aw_rust_release_dir }}/dlp-cef-exporter" + dest: /usr/local/bin/dlp-cef-exporter-rust + owner: root + group: root + mode: "0755" + when: + - aw_dlp_integrations_enabled | default(true) | bool + - dlp_cef_exporter_rust_binary.stat.exists | default(false) + + - name: Создать drop-in каталог CEF exporter + ansible.builtin.file: + path: /etc/systemd/system/aw-dlp-cef-exporter.service.d + state: directory + owner: root + group: root + mode: "0755" + when: + - aw_dlp_integrations_enabled | default(true) | bool + - dlp_cef_exporter_rust_binary.stat.exists | default(false) + + - name: Переключить CEF exporter на Rust + ansible.builtin.copy: + dest: /etc/systemd/system/aw-dlp-cef-exporter.service.d/20-rust-switch.conf + owner: root + group: root + mode: "0644" + content: | + [Service] + ExecStart= + ExecStart=/usr/local/bin/dlp-cef-exporter-rust + when: + - aw_dlp_integrations_enabled | default(true) | bool + - dlp_cef_exporter_rust_binary.stat.exists | default(false) + - name: Установить systemd unit syslog forwarder ansible.builtin.copy: src: "{{ aw_repo_root }}/aw-server/dlp-integrations/syslog-forwarder.service" @@ -637,6 +872,50 @@ mode: "0644" when: aw_dlp_integrations_enabled | default(true) | bool + - name: Проверить локальный Rust syslog forwarder + ansible.builtin.stat: + path: "{{ aw_rust_release_dir }}/dlp-syslog-forwarder" + delegate_to: localhost + register: dlp_syslog_forwarder_rust_binary + become: false + when: aw_dlp_integrations_enabled | default(true) | bool + + - name: Установить Rust syslog forwarder + ansible.builtin.copy: + src: "{{ aw_rust_release_dir }}/dlp-syslog-forwarder" + dest: /usr/local/bin/dlp-syslog-forwarder-rust + owner: root + group: root + mode: "0755" + when: + - aw_dlp_integrations_enabled | default(true) | bool + - dlp_syslog_forwarder_rust_binary.stat.exists | default(false) + + - name: Создать drop-in каталог syslog forwarder + ansible.builtin.file: + path: /etc/systemd/system/aw-dlp-syslog-forwarder.service.d + state: directory + owner: root + group: root + mode: "0755" + when: + - aw_dlp_integrations_enabled | default(true) | bool + - dlp_syslog_forwarder_rust_binary.stat.exists | default(false) + + - name: Переключить syslog forwarder на Rust + ansible.builtin.copy: + dest: /etc/systemd/system/aw-dlp-syslog-forwarder.service.d/20-rust-switch.conf + owner: root + group: root + mode: "0644" + content: | + [Service] + ExecStart= + ExecStart=/usr/local/bin/dlp-syslog-forwarder-rust + when: + - aw_dlp_integrations_enabled | default(true) | bool + - dlp_syslog_forwarder_rust_binary.stat.exists | default(false) + - name: Установить systemd unit webhook sender ansible.builtin.copy: src: "{{ aw_repo_root }}/aw-server/dlp-integrations/webhook-sender.service" @@ -655,6 +934,50 @@ mode: "0644" when: aw_dlp_integrations_enabled | default(true) | bool + - name: Проверить локальный Rust webhook sender + ansible.builtin.stat: + path: "{{ aw_rust_release_dir }}/dlp-webhook-sender" + delegate_to: localhost + register: dlp_webhook_sender_rust_binary + become: false + when: aw_dlp_integrations_enabled | default(true) | bool + + - name: Установить Rust webhook sender + ansible.builtin.copy: + src: "{{ aw_rust_release_dir }}/dlp-webhook-sender" + dest: /usr/local/bin/dlp-webhook-sender-rust + owner: root + group: root + mode: "0755" + when: + - aw_dlp_integrations_enabled | default(true) | bool + - dlp_webhook_sender_rust_binary.stat.exists | default(false) + + - name: Создать drop-in каталог webhook sender + ansible.builtin.file: + path: /etc/systemd/system/aw-dlp-webhook-sender.service.d + state: directory + owner: root + group: root + mode: "0755" + when: + - aw_dlp_integrations_enabled | default(true) | bool + - dlp_webhook_sender_rust_binary.stat.exists | default(false) + + - name: Переключить webhook sender на Rust + ansible.builtin.copy: + dest: /etc/systemd/system/aw-dlp-webhook-sender.service.d/20-rust-switch.conf + owner: root + group: root + mode: "0644" + content: | + [Service] + ExecStart= + ExecStart=/usr/local/bin/dlp-webhook-sender-rust + when: + - aw_dlp_integrations_enabled | default(true) | bool + - dlp_webhook_sender_rust_binary.stat.exists | default(false) + - name: Создать каталог DLP case management ansible.builtin.file: path: /opt/activitywatch/dlp-case-management @@ -710,6 +1033,50 @@ mode: "0644" when: aw_dlp_case_management_enabled | default(true) | bool + - name: Проверить локальный Rust DLP case management + ansible.builtin.stat: + path: "{{ aw_rust_release_dir }}/dlp-case-management" + delegate_to: localhost + register: aw_dlp_case_management_rust_binary + become: false + when: aw_dlp_case_management_enabled | default(true) | bool + + - name: Установить Rust DLP case management + ansible.builtin.copy: + src: "{{ aw_rust_release_dir }}/dlp-case-management" + dest: /usr/local/bin/aw-dlp-case-management-rust + owner: root + group: root + mode: "0755" + when: + - aw_dlp_case_management_enabled | default(true) | bool + - aw_dlp_case_management_rust_binary.stat.exists | default(false) + + - name: Создать drop-in каталог DLP case management + ansible.builtin.file: + path: /etc/systemd/system/aw-dlp-case-management.service.d + state: directory + owner: root + group: root + mode: "0755" + when: + - aw_dlp_case_management_enabled | default(true) | bool + - aw_dlp_case_management_rust_binary.stat.exists | default(false) + + - name: Переключить DLP case management на Rust + ansible.builtin.copy: + dest: /etc/systemd/system/aw-dlp-case-management.service.d/20-rust-switch.conf + owner: root + group: root + mode: "0644" + content: | + [Service] + ExecStart= + ExecStart=/usr/local/bin/aw-dlp-case-management-rust + when: + - aw_dlp_case_management_enabled | default(true) | bool + - aw_dlp_case_management_rust_binary.stat.exists | default(false) + - name: Создать каталоги DLP compliance ansible.builtin.file: path: "{{ item }}" @@ -753,6 +1120,50 @@ virtualenv: /opt/activitywatch/dlp-compliance/.venv when: aw_dlp_compliance_enabled | default(true) | bool + - name: Проверить локальный Rust DLP compliance + ansible.builtin.stat: + path: "{{ aw_rust_release_dir }}/dlp-compliance" + delegate_to: localhost + register: aw_dlp_compliance_rust_binary + become: false + when: aw_dlp_compliance_enabled | default(true) | bool + + - name: Установить Rust DLP compliance + ansible.builtin.copy: + src: "{{ aw_rust_release_dir }}/dlp-compliance" + dest: /usr/local/bin/aw-dlp-compliance-rust + owner: root + group: root + mode: "0755" + when: + - aw_dlp_compliance_enabled | default(true) | bool + - aw_dlp_compliance_rust_binary.stat.exists | default(false) + + - name: Создать drop-in каталог DLP compliance report scheduler + ansible.builtin.file: + path: /etc/systemd/system/aw-dlp-report-scheduler.service.d + state: directory + owner: root + group: root + mode: "0755" + when: + - aw_dlp_compliance_enabled | default(true) | bool + - aw_dlp_compliance_rust_binary.stat.exists | default(false) + + - name: Переключить DLP compliance report scheduler на Rust + ansible.builtin.copy: + dest: /etc/systemd/system/aw-dlp-report-scheduler.service.d/20-rust-switch.conf + owner: root + group: root + mode: "0644" + content: | + [Service] + ExecStart= + ExecStart=/usr/local/bin/aw-dlp-compliance-rust + when: + - aw_dlp_compliance_enabled | default(true) | bool + - aw_dlp_compliance_rust_binary.stat.exists | default(false) + - name: Установить dlp-admin-cli ansible.builtin.copy: src: "{{ aw_repo_root }}/scripts/dlp-admin-cli.py" @@ -761,6 +1172,21 @@ group: root mode: "0755" + - name: Проверить локальный Rust dlp-admin-cli + ansible.builtin.stat: + path: "{{ aw_rust_release_dir }}/dlp-admin-cli" + delegate_to: localhost + register: aw_dlp_admin_cli_rust_binary + + - name: Установить Rust dlp-admin-cli + ansible.builtin.copy: + src: "{{ aw_rust_release_dir }}/dlp-admin-cli" + dest: /usr/local/bin/dlp-admin-cli + owner: root + group: root + mode: "0755" + when: aw_dlp_admin_cli_rust_binary.stat.exists + - name: Установить скрипт AW worktime API ansible.builtin.copy: src: "{{ aw_repo_root }}/aw-server/aw-worktime-api.py" @@ -769,6 +1195,22 @@ group: root mode: "0755" + - name: Проверить локальный Rust AW worktime API + ansible.builtin.stat: + path: "{{ aw_rust_release_dir }}/worktime-api" + delegate_to: localhost + register: aw_worktime_api_rust_binary + become: false + + - name: Установить Rust AW worktime API + ansible.builtin.copy: + src: "{{ aw_rust_release_dir }}/worktime-api" + dest: /usr/local/bin/aw-worktime-api-rust + owner: root + group: root + mode: "0755" + when: aw_worktime_api_rust_binary.stat.exists | default(false) + - name: Установить systemd unit AW worktime API ansible.builtin.copy: src: "{{ aw_repo_root }}/aw-server/aw-worktime-api.service" @@ -777,6 +1219,27 @@ group: root mode: "0644" + - name: Создать drop-in каталог AW worktime API + ansible.builtin.file: + path: /etc/systemd/system/aw-worktime-api.service.d + state: directory + owner: root + group: root + mode: "0755" + when: aw_worktime_api_rust_binary.stat.exists | default(false) + + - name: Переключить AW worktime API на Rust + ansible.builtin.copy: + dest: /etc/systemd/system/aw-worktime-api.service.d/20-rust-switch.conf + owner: root + group: root + mode: "0644" + content: | + [Service] + ExecStart= + ExecStart=/usr/local/bin/aw-worktime-api-rust + when: aw_worktime_api_rust_binary.stat.exists | default(false) + - name: Установить скрипт AW worktime UI bridge ansible.builtin.copy: src: "{{ aw_repo_root }}/aw-server/aw-worktime-ui-bridge.py" @@ -785,6 +1248,22 @@ group: root mode: "0755" + - name: Проверить локальный Rust AW worktime UI bridge + ansible.builtin.stat: + path: "{{ aw_rust_release_dir }}/worktime-ui-bridge" + delegate_to: localhost + register: aw_worktime_ui_bridge_rust_binary + become: false + + - name: Установить Rust AW worktime UI bridge + ansible.builtin.copy: + src: "{{ aw_rust_release_dir }}/worktime-ui-bridge" + dest: /usr/local/bin/aw-worktime-ui-bridge-rust + owner: root + group: root + mode: "0755" + when: aw_worktime_ui_bridge_rust_binary.stat.exists | default(false) + - name: Установить скрипт AW worktime autoheal ansible.builtin.copy: src: "{{ aw_repo_root }}/aw-server/aw-worktime-autoheal.sh" @@ -793,6 +1272,46 @@ group: root mode: "0755" + - name: Проверить локальный Rust AW worktime autoheal + ansible.builtin.stat: + path: "{{ aw_rust_release_dir }}/worktime-autoheal" + delegate_to: localhost + register: aw_worktime_autoheal_rust_binary + become: false + + - name: Установить Rust AW worktime autoheal + ansible.builtin.copy: + src: "{{ aw_rust_release_dir }}/worktime-autoheal" + dest: /usr/local/bin/aw-worktime-autoheal-rust + owner: root + group: root + mode: "0755" + when: aw_worktime_autoheal_rust_binary.stat.exists | default(false) + + - name: Установить скрипт AW worktime prewarm + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/aw-worktime-prewarm.sh" + dest: /usr/local/bin/aw-worktime-prewarm.sh + owner: root + group: root + mode: "0755" + + - name: Проверить локальный Rust AW worktime prewarm + ansible.builtin.stat: + path: "{{ aw_rust_release_dir }}/worktime-prewarm" + delegate_to: localhost + register: aw_worktime_prewarm_rust_binary + become: false + + - name: Установить Rust AW worktime prewarm + ansible.builtin.copy: + src: "{{ aw_rust_release_dir }}/worktime-prewarm" + dest: /usr/local/bin/aw-worktime-prewarm-rust + owner: root + group: root + mode: "0755" + when: aw_worktime_prewarm_rust_binary.stat.exists | default(false) + - name: Установить скрипт AW worktime Influx exporter ansible.builtin.copy: src: "{{ aw_repo_root }}/aw-server/aw-worktime-influx-exporter.py" @@ -811,21 +1330,118 @@ mode: "0755" when: aw_dlp_influx_enabled | default(false) | bool + - name: Создать server-side ops bundle для legacy aw-health-check + ansible.builtin.file: + path: /opt/activitywatch/aw-rus-ops + state: directory + owner: root + group: root + mode: "0755" + - name: Установить скрипт aw-health-check ansible.builtin.copy: - src: "{{ aw_repo_root }}/aw-server/health-check.sh" + src: "{{ aw_repo_root }}/aw-server/aw-health-check-wrapper.sh" dest: /usr/local/bin/aw-health-check owner: root group: root mode: "0755" - - name: Установить скрипт dlp-health-check + - name: Положить legacy aw-health-check в server-side ops bundle + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/health-check.sh" + dest: /opt/activitywatch/aw-rus-ops/health-check.sh + owner: root + group: root + mode: "0755" + + - name: Проверить локальный Rust aw-health-check + ansible.builtin.stat: + path: "{{ aw_rust_release_dir }}/aw-health-check" + delegate_to: localhost + register: aw_health_check_rust_binary + become: false + + - name: Установить Rust aw-health-check + ansible.builtin.copy: + src: "{{ aw_rust_release_dir }}/aw-health-check" + dest: /usr/local/bin/aw-health-check-rust + owner: root + group: root + mode: "0755" + when: aw_health_check_rust_binary.stat.exists | default(false) + + - name: Проверить локальный Rust aw-db-health + ansible.builtin.stat: + path: "{{ aw_rust_release_dir }}/aw-db-health" + delegate_to: localhost + register: aw_db_health_rust_binary + become: false + + - name: Установить Rust aw-db-health + ansible.builtin.copy: + src: "{{ aw_rust_release_dir }}/aw-db-health" + dest: /usr/local/bin/aw-db-health + owner: root + group: root + mode: "0755" + when: aw_db_health_rust_binary.stat.exists | default(false) + + - name: Установить скрипт check-aw-data + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/check-aw-data-wrapper.sh" + dest: /usr/local/bin/check-aw-data + owner: root + group: root + mode: "0755" + + - name: Положить legacy check-aw-data в server-side ops bundle + ansible.builtin.copy: + src: "{{ aw_repo_root }}/scripts/legacy/check-aw-data.sh" + dest: /opt/activitywatch/aw-rus-ops/check-aw-data.sh + owner: root + group: root + mode: "0755" + + - name: Проверить локальный Rust check-aw-data + ansible.builtin.stat: + path: "{{ aw_rust_release_dir }}/check-aw-data" + delegate_to: localhost + register: check_aw_data_rust_binary + become: false + + - name: Установить Rust check-aw-data + ansible.builtin.copy: + src: "{{ aw_rust_release_dir }}/check-aw-data" + dest: /usr/local/bin/check-aw-data-rust + owner: root + group: root + mode: "0755" + when: check_aw_data_rust_binary.stat.exists | default(false) + + - name: Проверить локальный Rust dlp-health-check + ansible.builtin.stat: + path: "{{ aw_rust_release_dir }}/dlp-health-check" + delegate_to: localhost + register: dlp_health_check_rust_binary + become: false + + - name: Установить Rust dlp-health-check + ansible.builtin.copy: + src: "{{ aw_rust_release_dir }}/dlp-health-check" + dest: /usr/local/bin/dlp-health-check + owner: root + group: root + mode: "0755" + when: dlp_health_check_rust_binary.stat.exists | default(false) + + - name: Установить legacy Python dlp-health-check ansible.builtin.copy: src: "{{ aw_repo_root }}/scripts/dlp-health-check.py" dest: /usr/local/bin/dlp-health-check owner: root group: root mode: "0755" + when: not (dlp_health_check_rust_binary.stat.exists | default(false)) - name: Установить скрипт aw-rus-healthd ansible.builtin.copy: @@ -835,6 +1451,39 @@ group: root mode: "0755" + - name: Установить скрипт aw-slo-monitor + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/aw-slo-monitor.py" + dest: /usr/local/bin/aw-slo-monitor.py + owner: root + group: root + mode: "0755" + + - name: Проверить локальный Rust AW-RUS SLO monitor + ansible.builtin.stat: + path: "{{ aw_rust_release_dir }}/aw-slo-monitor" + delegate_to: localhost + register: aw_slo_monitor_rust_binary + become: false + + - name: Установить Rust AW-RUS SLO monitor + ansible.builtin.copy: + src: "{{ aw_rust_release_dir }}/aw-slo-monitor" + dest: /usr/local/bin/aw-slo-monitor-rust + owner: root + group: root + mode: "0755" + when: aw_slo_monitor_rust_binary.stat.exists | default(false) + + - name: Установить browser smoke скрипт + ansible.builtin.copy: + src: "{{ aw_repo_root }}/scripts/aw-webui-browser-smoke.mjs" + dest: /usr/local/lib/aw-rus-browser-smoke/aw-webui-browser-smoke.mjs + owner: root + group: root + mode: "0755" + when: aw_browser_smoke_enabled | default(true) | bool + - name: Установить systemd unit AW worktime UI bridge ansible.builtin.copy: src: "{{ aw_repo_root }}/aw-server/aw-worktime-ui-bridge.service" @@ -843,6 +1492,27 @@ group: root mode: "0644" + - name: Создать drop-in каталог AW worktime UI bridge + ansible.builtin.file: + path: /etc/systemd/system/aw-worktime-ui-bridge.service.d + state: directory + owner: root + group: root + mode: "0755" + when: aw_worktime_ui_bridge_rust_binary.stat.exists | default(false) + + - name: Переключить AW worktime UI bridge на Rust + ansible.builtin.copy: + dest: /etc/systemd/system/aw-worktime-ui-bridge.service.d/20-rust-switch.conf + owner: root + group: root + mode: "0644" + content: | + [Service] + ExecStart= + ExecStart=/usr/local/bin/aw-worktime-ui-bridge-rust + when: aw_worktime_ui_bridge_rust_binary.stat.exists | default(false) + - name: Установить systemd timer AW worktime UI bridge ansible.builtin.copy: src: "{{ aw_repo_root }}/aw-server/aw-worktime-ui-bridge.timer" @@ -859,6 +1529,27 @@ group: root mode: "0644" + - name: Создать drop-in каталог AW worktime autoheal + ansible.builtin.file: + path: /etc/systemd/system/aw-worktime-autoheal.service.d + state: directory + owner: root + group: root + mode: "0755" + when: aw_worktime_autoheal_rust_binary.stat.exists | default(false) + + - name: Переключить AW worktime autoheal на Rust + ansible.builtin.copy: + dest: /etc/systemd/system/aw-worktime-autoheal.service.d/20-rust-switch.conf + owner: root + group: root + mode: "0644" + content: | + [Service] + ExecStart= + ExecStart=/usr/local/bin/aw-worktime-autoheal-rust + when: aw_worktime_autoheal_rust_binary.stat.exists | default(false) + - name: Установить systemd timer AW worktime autoheal ansible.builtin.copy: src: "{{ aw_repo_root }}/aw-server/aw-worktime-autoheal.timer" @@ -867,6 +1558,43 @@ group: root mode: "0644" + - name: Установить systemd unit AW worktime prewarm + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/aw-worktime-prewarm.service" + dest: /etc/systemd/system/aw-worktime-prewarm.service + owner: root + group: root + mode: "0644" + + - name: Создать drop-in каталог AW worktime prewarm + ansible.builtin.file: + path: /etc/systemd/system/aw-worktime-prewarm.service.d + state: directory + owner: root + group: root + mode: "0755" + when: aw_worktime_prewarm_rust_binary.stat.exists | default(false) + + - name: Переключить AW worktime prewarm на Rust + ansible.builtin.copy: + dest: /etc/systemd/system/aw-worktime-prewarm.service.d/20-rust-switch.conf + owner: root + group: root + mode: "0644" + content: | + [Service] + ExecStart= + ExecStart=/usr/local/bin/aw-worktime-prewarm-rust + when: aw_worktime_prewarm_rust_binary.stat.exists | default(false) + + - name: Установить systemd timer AW worktime prewarm + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/aw-worktime-prewarm.timer" + dest: /etc/systemd/system/aw-worktime-prewarm.timer + owner: root + group: root + mode: "0644" + - name: Установить systemd unit AW-RUS healthd ansible.builtin.copy: src: "{{ aw_repo_root }}/aw-server/aw-rus-healthd.service" @@ -883,6 +1611,58 @@ group: root mode: "0644" + - name: Установить systemd unit AW-RUS SLO monitor + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/aw-slo-monitor.service" + dest: /etc/systemd/system/aw-slo-monitor.service + owner: root + group: root + mode: "0644" + + - name: Создать drop-in каталог AW-RUS SLO monitor + ansible.builtin.file: + path: /etc/systemd/system/aw-slo-monitor.service.d + state: directory + owner: root + group: root + mode: "0755" + when: aw_slo_monitor_rust_binary.stat.exists | default(false) + + - name: Переключить AW-RUS SLO monitor на Rust + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/aw-slo-monitor-rust-switch.conf" + dest: /etc/systemd/system/aw-slo-monitor.service.d/20-rust-switch.conf + owner: root + group: root + mode: "0644" + when: aw_slo_monitor_rust_binary.stat.exists | default(false) + + - name: Установить systemd timer AW-RUS SLO monitor + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/aw-slo-monitor.timer" + dest: /etc/systemd/system/aw-slo-monitor.timer + owner: root + group: root + mode: "0644" + + - name: Установить systemd unit AW-RUS browser smoke + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/aw-browser-smoke.service" + dest: /etc/systemd/system/aw-browser-smoke.service + owner: root + group: root + mode: "0644" + when: aw_browser_smoke_enabled | default(true) | bool + + - name: Установить systemd timer AW-RUS browser smoke + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/aw-browser-smoke.timer" + dest: /etc/systemd/system/aw-browser-smoke.timer + owner: root + group: root + mode: "0644" + when: aw_browser_smoke_enabled | default(true) | bool + - name: Установить systemd unit AW worktime Influx exporter ansible.builtin.copy: src: "{{ aw_repo_root }}/aw-server/aw-worktime-influx-exporter.service" @@ -892,6 +1672,50 @@ mode: "0644" when: aw_worktime_influx_enabled | default(false) | bool + - name: Проверить локальный Rust AW worktime Influx exporter + ansible.builtin.stat: + path: "{{ aw_rust_release_dir }}/worktime-influx-exporter" + delegate_to: localhost + register: aw_worktime_influx_exporter_rust_binary + become: false + when: aw_worktime_influx_enabled | default(false) | bool + + - name: Установить Rust AW worktime Influx exporter + ansible.builtin.copy: + src: "{{ aw_rust_release_dir }}/worktime-influx-exporter" + dest: /usr/local/bin/aw-worktime-influx-exporter-rust + owner: root + group: root + mode: "0755" + when: + - aw_worktime_influx_enabled | default(false) | bool + - aw_worktime_influx_exporter_rust_binary.stat.exists | default(false) + + - name: Создать drop-in каталог AW worktime Influx exporter + ansible.builtin.file: + path: /etc/systemd/system/aw-worktime-influx-exporter.service.d + state: directory + owner: root + group: root + mode: "0755" + when: + - aw_worktime_influx_enabled | default(false) | bool + - aw_worktime_influx_exporter_rust_binary.stat.exists | default(false) + + - name: Переключить AW worktime Influx exporter на Rust + ansible.builtin.copy: + dest: /etc/systemd/system/aw-worktime-influx-exporter.service.d/20-rust-switch.conf + owner: root + group: root + mode: "0644" + content: | + [Service] + ExecStart= + ExecStart=/usr/local/bin/aw-worktime-influx-exporter-rust + when: + - aw_worktime_influx_enabled | default(false) | bool + - aw_worktime_influx_exporter_rust_binary.stat.exists | default(false) + - name: Установить systemd timer AW worktime Influx exporter ansible.builtin.copy: src: "{{ aw_repo_root }}/aw-server/aw-worktime-influx-exporter.timer" @@ -910,6 +1734,50 @@ mode: "0644" when: aw_dlp_influx_enabled | default(false) | bool + - name: Проверить локальный Rust AW DLP Influx exporter + ansible.builtin.stat: + path: "{{ aw_rust_release_dir }}/dlp-influx-exporter" + delegate_to: localhost + register: aw_dlp_influx_exporter_rust_binary + become: false + when: aw_dlp_influx_enabled | default(false) | bool + + - name: Установить Rust AW DLP Influx exporter + ansible.builtin.copy: + src: "{{ aw_rust_release_dir }}/dlp-influx-exporter" + dest: /usr/local/bin/aw-dlp-influx-exporter-rust + owner: root + group: root + mode: "0755" + when: + - aw_dlp_influx_enabled | default(false) | bool + - aw_dlp_influx_exporter_rust_binary.stat.exists | default(false) + + - name: Создать drop-in каталог AW DLP Influx exporter + ansible.builtin.file: + path: /etc/systemd/system/aw-dlp-influx-exporter.service.d + state: directory + owner: root + group: root + mode: "0755" + when: + - aw_dlp_influx_enabled | default(false) | bool + - aw_dlp_influx_exporter_rust_binary.stat.exists | default(false) + + - name: Переключить AW DLP Influx exporter на Rust + ansible.builtin.copy: + dest: /etc/systemd/system/aw-dlp-influx-exporter.service.d/20-rust-switch.conf + owner: root + group: root + mode: "0644" + content: | + [Service] + ExecStart= + ExecStart=/usr/local/bin/aw-dlp-influx-exporter-rust + when: + - aw_dlp_influx_enabled | default(false) | bool + - aw_dlp_influx_exporter_rust_binary.stat.exists | default(false) + - name: Установить systemd timer AW DLP Influx exporter ansible.builtin.copy: src: "{{ aw_repo_root }}/aw-server/aw-dlp-influx-exporter.timer" @@ -929,6 +1797,19 @@ enabled: true state: started + - name: Включить таймер AW-RUS SLO monitor + ansible.builtin.systemd: + name: aw-slo-monitor.timer + enabled: true + state: started + + - name: Включить таймер AW-RUS browser smoke + ansible.builtin.systemd: + name: aw-browser-smoke.timer + enabled: true + state: started + when: aw_browser_smoke_enabled | default(true) | bool + - name: Включить и перезапустить DLP policy engine ansible.builtin.systemd: name: aw-dlp-policy-engine.service @@ -1015,6 +1896,18 @@ state: started failed_when: false + - name: Включить и перезапустить AW worktime prewarm timer + ansible.builtin.systemd: + name: aw-worktime-prewarm.timer + enabled: true + state: restarted + + - name: Выполнить разовый прогон AW worktime prewarm + ansible.builtin.systemd: + name: aw-worktime-prewarm.service + state: started + failed_when: false + - name: Включить и перезапустить AW worktime Influx exporter timer ansible.builtin.systemd: name: aw-worktime-influx-exporter.timer @@ -1115,7 +2008,9 @@ ansible.builtin.systemd: name: activitywatch-server.service state: stopped - when: aw_legacy_root_db.stat.exists | default(false) + when: + - aw_legacy_db_merge_enabled | default(false) | bool + - aw_legacy_root_db.stat.exists | default(false) - name: Создать backup каталоги server DB ansible.builtin.file: @@ -1124,7 +2019,9 @@ owner: "{{ aw_server_user }}" group: "{{ aw_server_group }}" mode: "0755" - when: aw_legacy_root_db.stat.exists | default(false) + when: + - aw_legacy_db_merge_enabled | default(false) | bool + - aw_legacy_root_db.stat.exists | default(false) - name: Backup target DB перед merge ansible.builtin.copy: @@ -1135,6 +2032,7 @@ group: "{{ aw_server_group }}" mode: "0644" when: + - aw_legacy_db_merge_enabled | default(false) | bool - aw_legacy_root_db.stat.exists | default(false) - aw_target_db.stat.exists | default(false) @@ -1146,7 +2044,9 @@ owner: "{{ aw_server_user }}" group: "{{ aw_server_group }}" mode: "0644" - when: aw_legacy_root_db.stat.exists | default(false) + when: + - aw_legacy_db_merge_enabled | default(false) | bool + - aw_legacy_root_db.stat.exists | default(false) - name: Merge legacy root DB в target DB ansible.builtin.command: @@ -1162,6 +2062,7 @@ register: aw_merge_result failed_when: false when: + - aw_legacy_db_merge_enabled | default(false) | bool - aw_legacy_root_db.stat.exists | default(false) - aw_target_db.stat.exists | default(false) @@ -1169,6 +2070,7 @@ ansible.builtin.debug: msg: "{{ aw_merge_result.stdout | default(aw_merge_result.stderr | default('merge not executed')) }}" when: + - aw_legacy_db_merge_enabled | default(false) | bool - aw_legacy_root_db.stat.exists | default(false) - aw_target_db.stat.exists | default(false) @@ -1181,6 +2083,7 @@ group: "{{ aw_server_group }}" mode: "0644" when: + - aw_legacy_db_merge_enabled | default(false) | bool - aw_legacy_root_db.stat.exists | default(false) - aw_target_db.stat.exists | default(false) - aw_merge_result is defined @@ -1195,6 +2098,7 @@ group: "{{ aw_server_group }}" mode: "0644" when: + - aw_legacy_db_merge_enabled | default(false) | bool - aw_legacy_root_db.stat.exists | default(false) - not (aw_target_db.stat.exists | default(false)) @@ -1306,6 +2210,46 @@ [Install] WantedBy=multi-user.target + - name: Проверить локальный Rust DLP aggregator + ansible.builtin.stat: + path: "{{ aw_rust_release_dir }}/dlp-aggregator" + delegate_to: localhost + register: dlp_aggregator_rust_binary + become: false + + - name: Установить Rust DLP aggregator + ansible.builtin.copy: + src: "{{ aw_rust_release_dir }}/dlp-aggregator" + dest: /usr/local/bin/dlp-aggregator-rust + owner: root + group: root + mode: "0755" + when: dlp_aggregator_rust_binary.stat.exists | default(false) + + - name: Создать drop-in каталог DLP aggregator + ansible.builtin.file: + path: /etc/systemd/system/activitywatch-dlp-aggregator.service.d + state: directory + owner: root + group: root + mode: "0755" + when: dlp_aggregator_rust_binary.stat.exists | default(false) + + - name: Переключить DLP aggregator на Rust + ansible.builtin.copy: + dest: /etc/systemd/system/activitywatch-dlp-aggregator.service.d/20-rust-switch.conf + owner: root + group: root + mode: "0644" + content: | + [Service] + ExecStart= + ExecStart=/usr/local/bin/dlp-aggregator-rust \ + --aw-url http://127.0.0.1:{{ aw_server_port }}/api/0 \ + --sqlite-path {{ aw_server_data_dir }}/dlp_warehouse.sqlite \ + --state-path {{ aw_server_data_dir }}/dlp-aggregator-state.json + when: dlp_aggregator_rust_binary.stat.exists | default(false) + - name: Установить systemd timer для агрегатора ansible.builtin.copy: dest: /etc/systemd/system/activitywatch-dlp-aggregator.timer @@ -1314,9 +2258,10 @@ Description=Run ActivityWatch DLP Aggregator every 5 minutes [Timer] - OnBootSec=1min - OnUnitActiveSec=5min - AccuracySec=1s + OnCalendar=*:3/10:10 + AccuracySec=30s + RandomizedDelaySec=30s + Persistent=false [Install] WantedBy=timers.target @@ -1681,9 +2626,31 @@ group: root mode: "0755" + - name: Проверить локальный Rust helper link-case для Hayabusa + ansible.builtin.stat: + path: "{{ aw_rust_release_dir }}/aw-hayabusa-link-case-rust" + delegate_to: localhost + register: aw_hayabusa_link_case_rust_binary + become: false + + - name: Установить Rust helper link-case для Hayabusa + ansible.builtin.copy: + src: "{{ aw_rust_release_dir }}/aw-hayabusa-link-case-rust" + dest: /usr/local/bin/aw-hayabusa-link-case-rust + owner: root + group: root + mode: "0755" + when: aw_hayabusa_link_case_rust_binary.stat.exists | default(false) + - name: Установить helper link-case для Hayabusa ansible.builtin.copy: - src: "{{ aw_repo_root }}/aw-server/hayabusa/aw-hayabusa-link-case.py" + content: | + #!/usr/bin/env bash + set -euo pipefail + if [[ -x /usr/local/bin/aw-hayabusa-link-case-rust ]]; then + exec /usr/local/bin/aw-hayabusa-link-case-rust "$@" + fi + exec /usr/bin/python3 /opt/activitywatch/aw-rus-ops/hayabusa/aw-hayabusa-link-case.py "$@" dest: /usr/local/bin/aw-hayabusa-link-case owner: root group: root @@ -1697,9 +2664,31 @@ group: root mode: "0755" + - name: Проверить локальный Rust helper from-windows для Hayabusa + ansible.builtin.stat: + path: "{{ aw_rust_release_dir }}/aw-hayabusa-from-windows-rust" + delegate_to: localhost + register: aw_hayabusa_from_windows_rust_binary + become: false + + - name: Установить Rust helper from-windows для Hayabusa + ansible.builtin.copy: + src: "{{ aw_rust_release_dir }}/aw-hayabusa-from-windows-rust" + dest: /usr/local/bin/aw-hayabusa-from-windows-rust + owner: root + group: root + mode: "0755" + when: aw_hayabusa_from_windows_rust_binary.stat.exists | default(false) + - name: Установить helper from-windows для Hayabusa ansible.builtin.copy: - src: "{{ aw_repo_root }}/aw-server/hayabusa/aw-hayabusa-from-windows.py" + content: | + #!/usr/bin/env bash + set -euo pipefail + if [[ -x /usr/local/bin/aw-hayabusa-from-windows-rust ]]; then + exec /usr/local/bin/aw-hayabusa-from-windows-rust "$@" + fi + exec /usr/bin/python3 /opt/activitywatch/aw-rus-ops/hayabusa/aw-hayabusa-from-windows.py "$@" dest: /usr/local/bin/aw-hayabusa-from-windows owner: root group: root @@ -1721,9 +2710,31 @@ group: root mode: "0644" + - name: Проверить локальный Rust helper autoprocess для Hayabusa + ansible.builtin.stat: + path: "{{ aw_rust_release_dir }}/aw-hayabusa-autoprocess-rust" + delegate_to: localhost + register: aw_hayabusa_autoprocess_rust_binary + become: false + + - name: Установить Rust helper autoprocess для Hayabusa + ansible.builtin.copy: + src: "{{ aw_rust_release_dir }}/aw-hayabusa-autoprocess-rust" + dest: /usr/local/bin/aw-hayabusa-autoprocess-rust + owner: root + group: root + mode: "0755" + when: aw_hayabusa_autoprocess_rust_binary.stat.exists | default(false) + - name: Установить helper autoprocess для Hayabusa ansible.builtin.copy: - src: "{{ aw_repo_root }}/aw-server/hayabusa/aw-hayabusa-autoprocess.py" + content: | + #!/usr/bin/env bash + set -euo pipefail + if [[ -x /usr/local/bin/aw-hayabusa-autoprocess-rust ]]; then + exec /usr/local/bin/aw-hayabusa-autoprocess-rust "$@" + fi + exec /usr/bin/python3 /opt/activitywatch/aw-rus-ops/hayabusa/aw-hayabusa-autoprocess.py "$@" dest: /usr/local/bin/aw-hayabusa-autoprocess owner: root group: root @@ -1737,9 +2748,31 @@ group: root mode: "0755" + - name: Проверить локальный Rust helper case-alert для Hayabusa + ansible.builtin.stat: + path: "{{ aw_rust_release_dir }}/aw-hayabusa-case-alert-rust" + delegate_to: localhost + register: aw_hayabusa_case_alert_rust_binary + become: false + + - name: Установить Rust helper case-alert для Hayabusa + ansible.builtin.copy: + src: "{{ aw_rust_release_dir }}/aw-hayabusa-case-alert-rust" + dest: /usr/local/bin/aw-hayabusa-case-alert-rust + owner: root + group: root + mode: "0755" + when: aw_hayabusa_case_alert_rust_binary.stat.exists | default(false) + - name: Установить helper case-alert для Hayabusa ansible.builtin.copy: - src: "{{ aw_repo_root }}/aw-server/hayabusa/aw-hayabusa-case-alert.py" + content: | + #!/usr/bin/env bash + set -euo pipefail + if [[ -x /usr/local/bin/aw-hayabusa-case-alert-rust ]]; then + exec /usr/local/bin/aw-hayabusa-case-alert-rust "$@" + fi + exec /usr/bin/python3 /opt/activitywatch/aw-rus-ops/hayabusa/aw-hayabusa-case-alert.py "$@" dest: /usr/local/bin/aw-hayabusa-case-alert owner: root group: root diff --git a/install-kit-awindows-20260427-211240/ansible/deploy_aw_windows.yml b/install-kit-awindows-20260427-211240/ansible/deploy_aw_windows.yml index eec5eeb..e71a3a0 100644 --- a/install-kit-awindows-20260427-211240/ansible/deploy_aw_windows.yml +++ b/install-kit-awindows-20260427-211240/ansible/deploy_aw_windows.yml @@ -50,7 +50,7 @@ aw_windows_incident_artifacts_root: "{{ aw_windows_state_root }}\\incident-artifacts" aw_windows_forensics_root: "{{ aw_windows_state_root }}\\forensics\\evtx-exports" aw_windows_logon_marker_enabled: true - aw_windows_process_events_enabled: true + aw_windows_process_events_enabled: false aw_windows_skip_hardening: false aw_windows_rules_path: "{{ aw_windows_deploy_root }}\\windows\\web-category-rules.example.json" aw_windows_policy_path: "{{ aw_windows_deploy_root }}\\windows\\dlp-policy.example.json" @@ -58,6 +58,10 @@ aw_windows_validation_local_dir: "/tmp/aw-rus-validation-{{ lookup('env','USER') | default('ansible', true) }}" aw_windows_launch_task_pattern: "ActivityWatch Launch *" aw_windows_recovery_task_name: "ActivityWatch Recovery" + aw_windows_collector_guard_enabled: true + aw_windows_collector_guard_mode: "enforce" + aw_windows_collector_guard_service_name: "AWatchRusCollectorGuard" + aw_windows_collector_guard_loop_seconds: 60 aw_windows_force_task_restart: true aw_windows_api_smoke_check_enabled: true aw_windows_api_smoke_check_bucket: "" @@ -181,6 +185,9 @@ - deploy-domain-users.ps1 - deploy-ensemble.ps1 - hardening-recovery.ps1 + - AWatchRusCollectorGuardService.cs + - aw-collector-guard.ps1 + - install-collector-guard-service.ps1 - rebuild-worktime-tasks.ps1 - audit-cryptopro.ps1 - validate-deployment.ps1 @@ -371,27 +378,50 @@ Start-Sleep -Seconds 2 + $modulePath = "{{ aw_windows_deploy_root }}\windows\ActivityWatch.Windows.Common.psm1" + Import-Module $modulePath -Force + # Ensure tasks are enabled (some environments keep them disabled, causing "0s" in WebUI). try { Enable-ScheduledTask -TaskName "{{ aw_windows_recovery_task_name }}" -ErrorAction SilentlyContinue | Out-Null } catch {} $config = Get-Content -Raw -LiteralPath "{{ aw_windows_state_root }}\deployment-config.json" | ConvertFrom-Json - foreach ($taskDef in @($config.userTasks)) { - try { Enable-ScheduledTask -TaskName ([string]$taskDef.launchTaskName) -ErrorAction SilentlyContinue | Out-Null } catch {} + $configPaths = Get-ActivityWatchRecoveryConfigPaths -PrimaryConfigPath "{{ aw_windows_state_root }}\deployment-config.json" + $taskDefs = @(Get-ActivityWatchRecoveryTaskDefinitions -ConfigPaths $configPaths) + $sessionRecords = @(Get-ActivityWatchSessionRecords) + + foreach ($taskDef in @($taskDefs)) { + try { Enable-ScheduledTask -TaskName ([string]$taskDef.taskName) -ErrorAction SilentlyContinue | Out-Null } catch {} } $recoveryTask = Get-ScheduledTask -TaskName "{{ aw_windows_recovery_task_name }}" -ErrorAction SilentlyContinue if ($recoveryTask -and $recoveryTask.State -notin @('Running', 'Queued')) { Start-ScheduledTask -TaskName "{{ aw_windows_recovery_task_name }}" } - foreach ($taskDef in @($config.userTasks)) { - $launchTask = Get-ScheduledTask -TaskName ([string]$taskDef.launchTaskName) -ErrorAction SilentlyContinue + foreach ($taskDef in @($taskDefs)) { + if (-not (Test-ActivityWatchUserHasManagedSession -UserId ([string]$taskDef.userId) -SessionRecords $sessionRecords -IncludeLive -IncludeDisconnected)) { + continue + } + $launchTask = Get-ScheduledTask -TaskName ([string]$taskDef.taskName) -ErrorAction SilentlyContinue if ($launchTask -and $launchTask.State -notin @('Running', 'Queued')) { - Start-ScheduledTask -TaskName ([string]$taskDef.launchTaskName) -ErrorAction SilentlyContinue + Start-ScheduledTask -TaskName ([string]$taskDef.taskName) -ErrorAction SilentlyContinue } } + - name: Установить session-aware AWatch-rus Collector Guard service + when: aw_windows_collector_guard_enabled | bool + ansible.windows.win_powershell: + script: | + $ErrorActionPreference = 'Stop' + $guardParams = @{ + ConfigPath = "{{ aw_windows_state_root }}\deployment-config.json" + Mode = "{{ aw_windows_collector_guard_mode }}" + ServiceName = "{{ aw_windows_collector_guard_service_name }}" + LoopSeconds = {{ aw_windows_collector_guard_loop_seconds | int }} + } + & "{{ aw_windows_deploy_root }}\windows\install-collector-guard-service.ps1" @guardParams + - name: Получить Windows hostname для AW smoke-check bucket when: - aw_windows_api_smoke_check_enabled | bool diff --git a/install-kit-awindows-20260427-211240/ansible/group_vars/all.example.yml b/install-kit-awindows-20260427-211240/ansible/group_vars/all.example.yml index 2bcd41e..6e5d732 100644 --- a/install-kit-awindows-20260427-211240/ansible/group_vars/all.example.yml +++ b/install-kit-awindows-20260427-211240/ansible/group_vars/all.example.yml @@ -32,6 +32,11 @@ aw_monitored_windows_hostname: "SHARKON2025" aw_rus_health_worktime_api_base: "http://127.0.0.1:5610" aw_rus_health_state_dir: "{{ aw_server_data_dir }}/health" aw_rus_health_validation_dir: "{{ aw_rus_health_state_dir }}/windows-validation" +aw_browser_smoke_enabled: true +aw_browser_smoke_engine: "chromium-cli" +aw_legacy_db_merge_enabled: false +aw_browser_smoke_timeout_ms: 20000 +aw_browser_smoke_render_timeout_ms: 15000 aw_hayabusa_auto_case_enabled: true aw_hayabusa_auto_case_min_severity: "medium" aw_hayabusa_telegram_enabled: true @@ -62,7 +67,7 @@ aw_worktime_from: "00:00" aw_worktime_to: "17:00" aw_worktime_start_of_day: "{{ aw_worktime_from }}" aw_server_always_active_pattern: "aw-watcher-window" -aw_server_landingpage: "/activity/SHARKON2025/view/" +aw_server_landingpage: "/#/activity/SHARKON2025/view/" aw_health_strict_fileops: 0 aw_dlp_policy_engine_enabled: true diff --git a/install-kit-awindows-20260427-211240/ansible/group_vars/windows.example.yml b/install-kit-awindows-20260427-211240/ansible/group_vars/windows.example.yml index ae3eee2..9f6ad99 100644 --- a/install-kit-awindows-20260427-211240/ansible/group_vars/windows.example.yml +++ b/install-kit-awindows-20260427-211240/ansible/group_vars/windows.example.yml @@ -50,7 +50,7 @@ aw_windows_evtx_channels: - Microsoft-Windows-TerminalServices-LocalSessionManager/Operational - Microsoft-Windows-TerminalServices-RemoteConnectionManager/Operational aw_windows_logon_marker_enabled: true -aw_windows_process_events_enabled: true +aw_windows_process_events_enabled: false aw_windows_skip_hardening: false aw_windows_rules_path: "{{ aw_windows_deploy_root }}\\windows\\web-category-rules.example.json" @@ -60,6 +60,13 @@ aw_windows_validation_remote_path: "{{ aw_windows_state_root }}\\aw_validate_ans aw_windows_validation_local_dir: "/tmp/aw-rus-validation-{{ lookup('env','USER') | default('ansible', true) }}" aw_windows_fail_on_validation_error: true +# Collector Guard supervises collectors, but ActivityWatch Recovery must stay enabled +# as a fallback and as the launch-task bootstrap path for managed RDP sessions. +aw_windows_collector_guard_enabled: true +aw_windows_collector_guard_mode: "enforce" +aw_windows_collector_guard_service_name: "AWatchRusCollectorGuard" +aw_windows_collector_guard_loop_seconds: 60 + # Безопасная миграция текущего прода со старых путей в единый профиль AWatch-rus. aw_windows_migration_enabled: true aw_windows_legacy_install_root: "C:\\Program Files\\ActivityWatch-Phase2" diff --git a/install-kit-awindows-20260427-211240/aw-server/aw-browser-smoke.service b/install-kit-awindows-20260427-211240/aw-server/aw-browser-smoke.service new file mode 100644 index 0000000..41b74e9 --- /dev/null +++ b/install-kit-awindows-20260427-211240/aw-server/aw-browser-smoke.service @@ -0,0 +1,28 @@ +[Unit] +Description=AW-RUS browser-visible WebUI smoke check +After=network-online.target activitywatch-server.service aw-worktime-api.service +Wants=network-online.target activitywatch-server.service aw-worktime-api.service + +[Service] +Type=oneshot +EnvironmentFile=/etc/activitywatch/aw-server.env +Environment=NODE_PATH=/usr/share/nodejs +Environment=AW_BROWSER_SMOKE_ENGINE=chromium-cli +Environment=HOME=/var/lib/activitywatch/browser-smoke +Environment=XDG_CONFIG_HOME=/var/lib/activitywatch/browser-smoke/.config +Environment=XDG_CACHE_HOME=/var/lib/activitywatch/browser-smoke/.cache +WorkingDirectory=/var/lib/activitywatch/browser-smoke +ExecStart=/usr/bin/node /usr/local/lib/aw-rus-browser-smoke/aw-webui-browser-smoke.mjs +TimeoutStartSec=180 +User=activitywatch +Group=activitywatch +Nice=15 +CPUQuota=25% +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true +ReadWritePaths=/var/lib/activitywatch/browser-smoke +StandardOutput=journal +StandardError=journal +SyslogIdentifier=aw-browser-smoke diff --git a/install-kit-awindows-20260427-211240/aw-server/aw-browser-smoke.timer b/install-kit-awindows-20260427-211240/aw-server/aw-browser-smoke.timer new file mode 100644 index 0000000..d956c91 --- /dev/null +++ b/install-kit-awindows-20260427-211240/aw-server/aw-browser-smoke.timer @@ -0,0 +1,12 @@ +[Unit] +Description=Run AW-RUS browser-visible WebUI smoke check hourly + +[Timer] +OnCalendar=hourly +AccuracySec=5min +RandomizedDelaySec=10min +Persistent=false +Unit=aw-browser-smoke.service + +[Install] +WantedBy=timers.target diff --git a/install-kit-awindows-20260427-211240/aw-server/aw-rus-healthd.py b/install-kit-awindows-20260427-211240/aw-server/aw-rus-healthd.py index 234b90c..07d0538 100644 --- a/install-kit-awindows-20260427-211240/aw-server/aw-rus-healthd.py +++ b/install-kit-awindows-20260427-211240/aw-server/aw-rus-healthd.py @@ -8,6 +8,7 @@ import socket import subprocess import sys import tempfile +import time from dataclasses import dataclass from datetime import UTC, datetime from pathlib import Path @@ -21,7 +22,12 @@ ENV_FILE = Path("/etc/activitywatch/aw-server.env") def load_env_file(path: Path) -> None: if not path.exists(): return - for raw_line in path.read_text(encoding="utf-8").splitlines(): + try: + lines = path.read_text(encoding="utf-8").splitlines() + except PermissionError: + # systemd EnvironmentFile has already injected the variables for service runs. + return + for raw_line in lines: line = raw_line.strip() if not line or line.startswith("#") or "=" not in line: continue @@ -55,9 +61,19 @@ def age_seconds(ts: datetime | None, now: datetime) -> int | None: return max(0, int((now - ts).total_seconds())) -def http_json(url: str, timeout: int = 10) -> Any: - with request.urlopen(url, timeout=timeout) as resp: - return json.loads(resp.read().decode("utf-8")) +def http_json(url: str, timeout: int = 20, attempts: int = 2, backoff_seconds: float = 0.5) -> Any: + last_exc: Exception | None = None + for attempt in range(max(1, attempts)): + try: + with request.urlopen(url, timeout=timeout) as resp: + return json.loads(resp.read().decode("utf-8")) + except Exception as exc: + last_exc = exc + if attempt + 1 >= max(1, attempts): + break + time.sleep(backoff_seconds * (2**attempt)) + assert last_exc is not None + raise last_exc def run_command(cmd: list[str]) -> tuple[int, str]: @@ -136,7 +152,7 @@ class Report: def latest_bucket_event(api_base: str, bucket_id: str) -> dict[str, Any] | None: - events = http_json(f"{api_base}/buckets/{bucket_id}/events?limit=20") + events = http_json(f"{api_base}/buckets/{bucket_id}/events?limit=1", timeout=25) if isinstance(events, list) and events: events = [item for item in events if isinstance(item, dict)] if not events: @@ -146,6 +162,16 @@ def latest_bucket_event(api_base: str, bucket_id: str) -> dict[str, Any] | None: return None +def bucket_metadata_ts(buckets: dict[str, Any], bucket_id: str) -> datetime | None: + bucket = buckets.get(bucket_id) if isinstance(buckets, dict) else None + if not isinstance(bucket, dict): + return None + metadata = bucket.get("metadata") + if not isinstance(metadata, dict): + return None + return parse_ts(metadata.get("end")) + + def host_activity_from_worktime(event: dict[str, Any] | None, max_age_seconds: int) -> dict[str, Any]: now = now_utc() if not event: @@ -189,6 +215,78 @@ def bucket_health( return "ok", f"fresh ({age}s)", details +def bucket_timestamp_health( + api_base: str, + buckets: dict[str, Any], + bucket_id: str, + max_age_seconds: int, + missing_status: str, + stale_status: str, +) -> tuple[str, str, dict[str, Any]]: + metadata_ts = bucket_metadata_ts(buckets, bucket_id) + if metadata_ts is not None: + age = age_seconds(metadata_ts, now_utc()) + timestamp = metadata_ts.isoformat().replace("+00:00", "Z") + details = { + "bucket": bucket_id, + "timestamp": timestamp, + "age_seconds": age, + "timestamp_source": "bucket_metadata.end", + } + if age is None: + return "warn", "timestamp parse failed", details + if age > max_age_seconds: + return stale_status, f"stale ({age}s)", details + return "ok", f"fresh ({age}s)", details + + return bucket_health(api_base, bucket_id, max_age_seconds, missing_status, stale_status) + + +def guard_bucket_health( + api_base: str, + host: str, + max_age_seconds: int, + required: bool, +) -> tuple[str, str, dict[str, Any]]: + bucket_id = f"aw-rus-collector-guard_{host}" + try: + event = latest_bucket_event(api_base, bucket_id) + except Exception as exc: + status = "fail" if required else "warn" + return status, f"guard bucket query failed: {exc}", {"bucket": bucket_id} + + if not event: + status = "fail" if required else "warn" + return status, "no guard heartbeat", {"bucket": bucket_id, "required": required} + + ts = parse_ts(event.get("timestamp")) + age = age_seconds(ts, now_utc()) + data = event.get("data") if isinstance(event.get("data"), dict) else {} + guard_status = str(data.get("status") or "unknown").lower() + details = { + "bucket": bucket_id, + "timestamp": event.get("timestamp"), + "age_seconds": age, + "required": required, + "guard_status": guard_status, + "mode": data.get("mode"), + "live_session_count": data.get("liveSessionCount"), + "problems": data.get("problems", []), + "actions": data.get("actions", []), + } + + stale_status = "fail" if required else "warn" + if age is None: + return "warn", "guard timestamp parse failed", details + if age > max_age_seconds: + return stale_status, f"guard stale ({age}s)", details + if guard_status in ("fail", "error"): + return "fail" if required else "warn", f"guard reports {guard_status}", details + if guard_status == "warn": + return "warn", "guard reports warn", details + return "ok", f"guard fresh ({age}s)", details + + def latest_validation_report(validation_dir: Path) -> Path | None: candidates = sorted( (path for path in validation_dir.glob("*-aw_validate_ansible.json") if path.is_file()), @@ -206,7 +304,20 @@ def write_atomic(path: Path, content: str) -> None: os.replace(tmp_name, path) -def check_wrapper(report: Report, name: str, cmd: list[str], json_mode: bool = False) -> None: +def chmod_if_possible(path: Path, mode: int) -> None: + try: + path.chmod(mode) + except OSError: + pass + + +def check_wrapper( + report: Report, + name: str, + cmd: list[str], + json_mode: bool = False, + failure_status: str = "fail", +) -> None: if not Path(cmd[0]).exists(): report.add(name, "warn", "binary missing", command=cmd) return @@ -221,7 +332,7 @@ def check_wrapper(report: Report, name: str, cmd: list[str], json_mode: bool = F return else: details["output"] = output - report.add(name, "ok" if rc == 0 else "fail", "passed" if rc == 0 else "failed", **details) + report.add(name, "ok" if rc == 0 else failure_status, "passed" if rc == 0 else "failed", **details) def main() -> int: @@ -240,6 +351,8 @@ def main() -> int: parser.add_argument("--session-max-age-seconds", type=int, default=int(env("AW_RUS_HEALTH_SESSION_MAX_AGE_SECONDS", "900"))) parser.add_argument("--interactive-max-age-seconds", type=int, default=int(env("AW_RUS_HEALTH_INTERACTIVE_MAX_AGE_SECONDS", "900"))) parser.add_argument("--session-events-max-age-seconds", type=int, default=int(env("AW_RUS_HEALTH_SESSION_EVENTS_MAX_AGE_SECONDS", "86400"))) + parser.add_argument("--guard-max-age-seconds", type=int, default=int(env("AW_RUS_HEALTH_GUARD_MAX_AGE_SECONDS", "300"))) + parser.add_argument("--guard-required", action="store_true", default=env("AW_RUS_HEALTH_GUARD_REQUIRED", "0").lower() in ("1", "true", "yes")) parser.add_argument("--validation-max-age-seconds", type=int, default=int(env("AW_RUS_HEALTH_VALIDATION_MAX_AGE_SECONDS", "259200"))) parser.add_argument("--tcp-timeout-seconds", type=float, default=float(env("AW_RUS_HEALTH_TCP_TIMEOUT_SECONDS", "3"))) parser.add_argument("--json", action="store_true") @@ -250,8 +363,14 @@ def main() -> int: if not aw_api_base.endswith("/api/0"): aw_api_base = aw_api_base.rstrip("/") + "/api/0" - check_wrapper(report, "wrapper:aw-health-check", ["/usr/local/bin/aw-health-check"]) - check_wrapper(report, "wrapper:dlp-health-check", ["/usr/local/bin/dlp-health-check", "--json"], json_mode=True) + check_wrapper(report, "wrapper:aw-health-check", ["/usr/local/bin/aw-health-check"], failure_status="warn") + check_wrapper( + report, + "wrapper:dlp-health-check", + ["/usr/local/bin/dlp-health-check", "--json"], + json_mode=True, + failure_status="warn", + ) try: info = http_json(f"{aw_api_base}/info") @@ -279,6 +398,14 @@ def main() -> int: buckets = {} host = args.rdp_hostname + guard_status, guard_summary, guard_details = guard_bucket_health( + aw_api_base, + host, + args.guard_max_age_seconds, + args.guard_required, + ) + report.add("bucket:collector-guard", guard_status, guard_summary, **guard_details) + worktime_bucket = f"aw-worktime-sessions_{host}" worktime_event = None if buckets: @@ -315,10 +442,15 @@ def main() -> int: ) details["interactive_required"] = interactive_required details["host_activity"] = activity + if not interactive_required and status != "ok": + details["inactive_summary"] = summary + status = "ok" + summary = "inactive: no active interactive users" report.add(label, status, summary, **details) - session_status, session_summary, session_details = bucket_health( + session_status, session_summary, session_details = bucket_timestamp_health( aw_api_base, + buckets, f"aw-session-events_{host}", args.session_events_max_age_seconds, missing_status="fail", @@ -362,8 +494,12 @@ def main() -> int: payload = report.as_dict() state_dir = Path(args.state_dir) - write_atomic(state_dir / "aw-rus-health.json", json.dumps(payload, ensure_ascii=False, indent=2) + "\n") - write_atomic(state_dir / "aw-rus-health.txt", report.render_text() + "\n") + health_json_path = state_dir / "aw-rus-health.json" + health_txt_path = state_dir / "aw-rus-health.txt" + write_atomic(health_json_path, json.dumps(payload, ensure_ascii=False, indent=2) + "\n") + write_atomic(health_txt_path, report.render_text() + "\n") + chmod_if_possible(health_json_path, 0o644) + chmod_if_possible(health_txt_path, 0o644) if args.json: print(json.dumps(payload, ensure_ascii=False, indent=2)) diff --git a/install-kit-awindows-20260427-211240/aw-server/aw-rus-healthd.timer b/install-kit-awindows-20260427-211240/aw-server/aw-rus-healthd.timer index bc6f25b..08e74a0 100644 --- a/install-kit-awindows-20260427-211240/aw-server/aw-rus-healthd.timer +++ b/install-kit-awindows-20260427-211240/aw-server/aw-rus-healthd.timer @@ -2,10 +2,11 @@ Description=Run AW-RUS unified health orchestrator every 2 minutes [Timer] -OnBootSec=2min -OnUnitActiveSec=2min +OnCalendar=*:0/2:25 +AccuracySec=15s +RandomizedDelaySec=15s Unit=aw-rus-healthd.service -Persistent=true +Persistent=false [Install] WantedBy=timers.target diff --git a/install-kit-awindows-20260427-211240/aw-server/aw-server.env.example b/install-kit-awindows-20260427-211240/aw-server/aw-server.env.example index 58b1309..a44ffd7 100755 --- a/install-kit-awindows-20260427-211240/aw-server/aw-server.env.example +++ b/install-kit-awindows-20260427-211240/aw-server/aw-server.env.example @@ -43,7 +43,7 @@ AW_HEALTH_CHECK_ENABLED=true AW_HEALTH_CHECK_INTERVAL=60 AW_EXPECT_START_OF_DAY=00:00 AW_EXPECT_ALWAYS_ACTIVE_PATTERN=aw-watcher-window -AW_EXPECT_LANDINGPAGE=/activity/SHARKON2025/view/ +AW_EXPECT_LANDINGPAGE=/#/activity/SHARKON2025/view/ AW_HEALTH_STRICT_FILEOPS=0 AW_MONITORED_WINDOWS_HOST=192.168.100.18 AW_MONITORED_WINDOWS_HOSTNAME=SHARKON2025 @@ -51,6 +51,19 @@ AW_RUS_HEALTH_WORKTIME_API=http://127.0.0.1:5610 AW_RUS_HEALTH_STATE_DIR=/var/lib/activitywatch/health AW_RUS_HEALTH_VALIDATION_DIR=/var/lib/activitywatch/health/windows-validation AW_RUS_HEALTH_SESSION_EVENTS_MAX_AGE_SECONDS=86400 +AW_RUS_HEALTH_GUARD_MAX_AGE_SECONDS=300 +AW_RUS_HEALTH_GUARD_REQUIRED=1 +AW_RUS_SLO_AW_BASE=http://127.0.0.1:5600 +AW_RUS_SLO_WORKTIME_BASE=http://127.0.0.1:5610 +AW_RUS_SLO_TARGET_PERCENT=99.97 +AW_BROWSER_SMOKE_AW_BASE=http://127.0.0.1:5600 +AW_BROWSER_SMOKE_WORKTIME_BASE=http://127.0.0.1:5610 +AW_BROWSER_SMOKE_HOST=SHARKON2025 +AW_BROWSER_SMOKE_OUTPUT_DIR=/var/lib/activitywatch/browser-smoke +AW_BROWSER_SMOKE_KEEP_RUNS=24 +AW_BROWSER_SMOKE_ENGINE=chromium-cli +AW_BROWSER_SMOKE_TIMEOUT_MS=20000 +AW_BROWSER_SMOKE_RENDER_TIMEOUT_MS=15000 # Hayabusa auto-case / alerting AW_HAYABUSA_AUTO_CASE_ENABLED=true diff --git a/install-kit-awindows-20260427-211240/aw-server/aw-slo-monitor.py b/install-kit-awindows-20260427-211240/aw-server/aw-slo-monitor.py new file mode 100644 index 0000000..854d6ff --- /dev/null +++ b/install-kit-awindows-20260427-211240/aw-server/aw-slo-monitor.py @@ -0,0 +1,465 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import tempfile +import time +from datetime import UTC, datetime, timedelta +from pathlib import Path +from typing import Any +from urllib import request + + +ENV_FILE = Path("/etc/activitywatch/aw-server.env") +DEFAULT_STATE_DIR = Path("/var/lib/activitywatch/slo") +DEFAULT_HEALTHD_CMD = "/usr/local/bin/aw-rus-healthd.py --json" +DEFAULT_HEALTHD_STATE_FILE = Path("/var/lib/activitywatch/health/aw-rus-health.json") + + +def load_env_file(path: Path) -> None: + if not path.exists(): + return + try: + lines = path.read_text(encoding="utf-8").splitlines() + except PermissionError: + # systemd EnvironmentFile has already loaded the variables for the service. + return + for raw_line in lines: + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + os.environ.setdefault(key.strip(), value.strip().strip("'").strip('"')) + + +def env(name: str, default: str) -> str: + value = os.environ.get(name) + return value if value not in (None, "") else default + + +def now_utc() -> datetime: + return datetime.now(UTC) + + +def parse_ts(value: str) -> datetime: + return datetime.fromisoformat(value.replace("Z", "+00:00")).astimezone(UTC) + + +def iso(dt: datetime) -> str: + return dt.astimezone(UTC).isoformat().replace("+00:00", "Z") + + +def write_atomic(path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=path.parent, delete=False) as handle: + handle.write(content) + tmp_name = handle.name + os.replace(tmp_name, path) + + +def chmod_if_possible(path: Path, mode: int) -> None: + try: + path.chmod(mode) + except OSError: + pass + + +def chown_like_parent_if_possible(path: Path) -> None: + try: + parent_stat = path.parent.stat() + os.chown(path, parent_stat.st_uid, parent_stat.st_gid) + except OSError: + pass + + +def fetch_url(url: str, timeout_seconds: float, *, accept: str | None = None, attempts: int = 1) -> dict[str, Any]: + started = now_utc() + last_error = "" + for attempt in range(1, max(1, attempts) + 1): + try: + req = request.Request(url, headers={"Accept": accept}) if accept else url + with request.urlopen(req, timeout=timeout_seconds) as resp: + body = resp.read() + status = int(resp.status) + content_type = resp.headers.get("Content-Type", "") + ok = 200 <= status < 300 + return { + "ok": ok, + "status": status, + "body_bytes": len(body), + "content_type": content_type, + "body": body, + "attempts": attempt, + "latency_ms": int((now_utc() - started).total_seconds() * 1000), + "url": url, + } + except Exception as exc: + last_error = str(exc) + if attempt < max(1, attempts): + time.sleep(1) + return { + "ok": False, + "error": last_error, + "body_bytes": 0, + "attempts": max(1, attempts), + "latency_ms": int((now_utc() - started).total_seconds() * 1000), + "url": url, + } + + +def public_probe_result(result: dict[str, Any]) -> dict[str, Any]: + return {key: value for key, value in result.items() if key != "body"} + + +def http_probe(url: str, timeout_seconds: float) -> dict[str, Any]: + return public_probe_result(fetch_url(url, timeout_seconds, attempts=2)) + + +def html_probe( + url: str, + timeout_seconds: float, + *, + min_bytes: int, + required_markers: tuple[str, ...], +) -> dict[str, Any]: + result = fetch_url(url, timeout_seconds, accept="text/html", attempts=2) + if not result.get("ok"): + return public_probe_result(result) + + body = result.get("body", b"") + text = body.decode("utf-8", errors="replace") if isinstance(body, bytes) else str(body) + missing = [marker for marker in required_markers if marker not in text] + content_type = str(result.get("content_type") or "") + if len(body) < min_bytes: + result["ok"] = False + result["error"] = f"body too small: {len(body)} < {min_bytes}" + elif "text/html" not in content_type.lower(): + result["ok"] = False + result["error"] = f"unexpected content-type: {content_type or 'unknown'}" + elif missing: + result["ok"] = False + result["error"] = "missing markers: " + ", ".join(missing) + result["missing_markers"] = missing + return public_probe_result(result) + + +def json_probe( + url: str, + timeout_seconds: float, + *, + expected_values: dict[str, Any], + required_keys: tuple[str, ...], +) -> dict[str, Any]: + result = fetch_url(url, timeout_seconds, accept="application/json", attempts=2) + if not result.get("ok"): + return public_probe_result(result) + + body = result.get("body", b"") + try: + payload = json.loads(body.decode("utf-8") if isinstance(body, bytes) else str(body)) + except Exception as exc: + result["ok"] = False + result["error"] = f"invalid json: {exc}" + return public_probe_result(result) + + if not isinstance(payload, dict): + result["ok"] = False + result["error"] = "json root is not object" + return public_probe_result(result) + + missing_keys = [key for key in required_keys if key not in payload] + mismatched = { + key: {"expected": expected, "actual": payload.get(key)} + for key, expected in expected_values.items() + if payload.get(key) != expected + } + if missing_keys: + result["ok"] = False + result["error"] = "missing json keys: " + ", ".join(missing_keys) + result["missing_keys"] = missing_keys + elif mismatched: + result["ok"] = False + result["error"] = "unexpected json values" + result["mismatched_values"] = mismatched + else: + result["json_keys"] = sorted(payload.keys()) + return public_probe_result(result) + + +def run_healthd(command: str, timeout_seconds: int) -> dict[str, Any]: + try: + proc = subprocess.run( + command, + shell=True, + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + timeout=timeout_seconds, + ) + except subprocess.TimeoutExpired as exc: + return { + "ok": False, + "returncode": None, + "error": f"timeout after {timeout_seconds}s", + "output_tail": (exc.stdout or "")[-1000:] if isinstance(exc.stdout, str) else "", + } + + payload: dict[str, Any] = {} + try: + payload = json.loads(proc.stdout or "{}") + except json.JSONDecodeError: + pass + return { + "ok": proc.returncode == 0 and bool(payload.get("ok")), + "returncode": proc.returncode, + "counts": payload.get("counts", {}), + "payload": payload if isinstance(payload, dict) else {}, + "output_tail": (proc.stdout or "")[-1000:], + } + + +def read_healthd_state(path: Path, max_age_seconds: int) -> dict[str, Any]: + try: + payload = json.loads(path.read_text(encoding="utf-8")) + generated = parse_ts(str(payload["generated_at_utc"])) + age = max(0, int((now_utc() - generated).total_seconds())) + except Exception as exc: + return {"ok": False, "error": str(exc), "path": str(path)} + return { + "ok": bool(payload.get("ok")) and age <= max_age_seconds, + "counts": payload.get("counts", {}), + "age_seconds": age, + "path": str(path), + "payload": payload if isinstance(payload, dict) else {}, + } + + +def load_samples(path: Path, cutoff: datetime) -> list[dict[str, Any]]: + samples: list[dict[str, Any]] = [] + if not path.exists(): + return samples + try: + lines = path.read_text(encoding="utf-8").splitlines() + except PermissionError: + return samples + for line in lines: + if not line.strip(): + continue + try: + item = json.loads(line) + ts = parse_ts(str(item["ts"])) + except Exception: + continue + if ts >= cutoff: + samples.append(item) + return samples + + +def append_and_trim_sample(path: Path, sample: dict[str, Any], retention_seconds: int) -> list[dict[str, Any]]: + cutoff = parse_ts(sample["ts"]) - timedelta(seconds=retention_seconds) + samples = load_samples(path, cutoff) + samples.append(sample) + content = "".join(json.dumps(item, ensure_ascii=False, separators=(",", ":")) + "\n" for item in samples) + write_atomic(path, content) + chmod_if_possible(path, 0o644) + chown_like_parent_if_possible(path) + return samples + + +def summarize_window( + samples: list[dict[str, Any]], + *, + now: datetime, + window_seconds: int, + sample_interval_seconds: int, + target_percent: float, +) -> dict[str, Any]: + cutoff = now - timedelta(seconds=window_seconds) + window = [] + for item in samples: + try: + if parse_ts(str(item["ts"])) >= cutoff: + window.append(item) + except Exception: + continue + + total = len(window) + good = sum(1 for item in window if item.get("ok") is True) + bad = total - good + availability = round((good / total) * 100, 5) if total else None + observed_bad_seconds = bad * sample_interval_seconds + budget_seconds = int(window_seconds * ((100.0 - target_percent) / 100.0)) + budget_remaining_seconds = budget_seconds - observed_bad_seconds + status = "unknown" + if total: + status = "ok" if budget_remaining_seconds >= 0 else "burning" + return { + "window_seconds": window_seconds, + "samples": total, + "good_samples": good, + "bad_samples": bad, + "availability_percent": availability, + "target_percent": target_percent, + "observed_bad_seconds": observed_bad_seconds, + "budget_seconds": budget_seconds, + "budget_remaining_seconds": budget_remaining_seconds, + "status": status, + } + + +def render_summary_text(summary: dict[str, Any]) -> str: + lines = [ + "=== AW-RUS SLO ===", + f"Timestamp: {summary['generated_at_utc']}", + f"Target: {summary['target_percent']}%", + "", + ] + for name, data in summary["windows"].items(): + availability = data["availability_percent"] + availability_text = "n/a" if availability is None else f"{availability:.5f}%" + remaining = data["budget_remaining_seconds"] + lines.append( + f"{name}: {data['status']} availability={availability_text} " + f"samples={data['samples']} bad={data['bad_samples']} " + f"bad_seconds={data['observed_bad_seconds']} budget_remaining_seconds={remaining}" + ) + lines.append("") + lines.append(f"Current sample: {'OK' if summary['current_sample']['ok'] else 'FAIL'}") + for name, probe in summary["current_sample"].get("probes", {}).items(): + marker = "OK" if probe.get("ok") else "FAIL" + detail = probe.get("status", probe.get("error", "")) + lines.append(f"- {name}: {marker} {detail}") + return "\n".join(lines) + + +def build_sample(args: argparse.Namespace) -> dict[str, Any]: + ts = iso(now_utc()) + if args.healthd_mode == "run": + health = run_healthd(args.healthd_cmd, args.health_timeout_seconds) + else: + health = read_healthd_state(Path(args.healthd_state_file), args.healthd_state_max_age_seconds) + probes = { + "aw_webui_index": html_probe( + args.aw_webui_url, + args.http_timeout_seconds, + min_bytes=1000, + required_markers=("ActivityWatch", 'id="app"', "ru-patch-v5.js"), + ), + "worktime_today_html": html_probe( + args.worktime_today_html_url, + args.http_timeout_seconds, + min_bytes=5000, + required_markers=("AW-rus", ""), + ), + "worktime_management_html": html_probe( + args.worktime_management_html_url, + args.http_timeout_seconds, + min_bytes=5000, + required_markers=("AW-rus", ""), + ), + "worktime_today_csv": http_probe(args.worktime_today_csv_url, args.http_timeout_seconds), + "worktime_management_json": json_probe( + args.worktime_management_json_url, + args.http_timeout_seconds, + expected_values={"host": args.host}, + required_keys=("generated_at_utc", "host", "summary", "rows", "workday"), + ), + } + ok = bool(health.get("ok")) and all(probe.get("ok") for probe in probes.values()) + return { + "ts": ts, + "ok": ok, + "healthd_ok": bool(health.get("ok")), + "healthd_counts": health.get("counts", {}), + "probes": probes, + } + + +def main() -> int: + load_env_file(ENV_FILE) + parser = argparse.ArgumentParser(description="AW-RUS rolling SLO sampler") + parser.add_argument("--state-dir", default=env("AW_RUS_SLO_STATE_DIR", str(DEFAULT_STATE_DIR))) + parser.add_argument("--healthd-cmd", default=env("AW_RUS_SLO_HEALTHD_CMD", DEFAULT_HEALTHD_CMD)) + parser.add_argument("--healthd-mode", choices=["state", "run"], default=env("AW_RUS_SLO_HEALTHD_MODE", "state")) + parser.add_argument("--healthd-state-file", default=env("AW_RUS_SLO_HEALTHD_STATE_FILE", str(DEFAULT_HEALTHD_STATE_FILE))) + parser.add_argument("--healthd-state-max-age-seconds", type=int, default=int(env("AW_RUS_SLO_HEALTHD_STATE_MAX_AGE_SECONDS", "180"))) + parser.add_argument("--aw-base", default=env("AW_RUS_SLO_AW_BASE", env("AW_SERVER_URL", "http://127.0.0.1:5600"))) + parser.add_argument("--worktime-base", default=env("AW_RUS_SLO_WORKTIME_BASE", env("AW_RUS_HEALTH_WORKTIME_API", "http://127.0.0.1:5610"))) + parser.add_argument("--host", default=env("AW_RUS_SLO_HOST", env("AW_MONITORED_WINDOWS_HOSTNAME", "SHARKON2025"))) + parser.add_argument("--target-percent", type=float, default=float(env("AW_RUS_SLO_TARGET_PERCENT", "99.97"))) + parser.add_argument("--sample-interval-seconds", type=int, default=int(env("AW_RUS_SLO_SAMPLE_INTERVAL_SECONDS", "15"))) + parser.add_argument("--retention-days", type=int, default=int(env("AW_RUS_SLO_RETENTION_DAYS", "35"))) + parser.add_argument("--http-timeout-seconds", type=float, default=float(env("AW_RUS_SLO_HTTP_TIMEOUT_SECONDS", "15"))) + parser.add_argument("--health-timeout-seconds", type=int, default=int(env("AW_RUS_SLO_HEALTH_TIMEOUT_SECONDS", "90"))) + parser.add_argument("--json", action="store_true") + args = parser.parse_args() + + aw_base = args.aw_base.rstrip("/") + worktime_base = args.worktime_base.rstrip("/") + args.aw_webui_url = f"{aw_base}/" + args.worktime_today_html_url = f"{worktime_base}/reports/worktime/today?format=html&day=today&host={args.host}&allow_stale=1" + args.worktime_management_html_url = f"{worktime_base}/reports/worktime/management?format=html&day=today&host={args.host}&allow_stale=1" + args.worktime_today_csv_url = f"{worktime_base}/reports/worktime/today?format=csv&day=today&host={args.host}&allow_stale=1" + args.worktime_management_json_url = f"{worktime_base}/reports/worktime/management?format=json&day=today&host={args.host}&allow_stale=1" + + state_dir = Path(args.state_dir) + sample_path = state_dir / "aw-slo-samples.jsonl" + sample = build_sample(args) + samples = append_and_trim_sample( + sample_path, + sample, + retention_seconds=max(1, args.retention_days) * 86400, + ) + + generated_at = now_utc() + summary = { + "generated_at_utc": iso(generated_at), + "target_percent": args.target_percent, + "sample_interval_seconds": args.sample_interval_seconds, + "current_sample": sample, + "windows": { + "24h": summarize_window( + samples, + now=generated_at, + window_seconds=86400, + sample_interval_seconds=args.sample_interval_seconds, + target_percent=args.target_percent, + ), + "7d": summarize_window( + samples, + now=generated_at, + window_seconds=7 * 86400, + sample_interval_seconds=args.sample_interval_seconds, + target_percent=args.target_percent, + ), + "30d": summarize_window( + samples, + now=generated_at, + window_seconds=30 * 86400, + sample_interval_seconds=args.sample_interval_seconds, + target_percent=args.target_percent, + ), + }, + } + summary_json_path = state_dir / "aw-slo-summary.json" + summary_txt_path = state_dir / "aw-slo-summary.txt" + write_atomic(summary_json_path, json.dumps(summary, ensure_ascii=False, indent=2) + "\n") + text = render_summary_text(summary) + write_atomic(summary_txt_path, text + "\n") + chmod_if_possible(summary_json_path, 0o644) + chmod_if_possible(summary_txt_path, 0o644) + chown_like_parent_if_possible(summary_json_path) + chown_like_parent_if_possible(summary_txt_path) + if args.json: + print(json.dumps(summary, ensure_ascii=False, indent=2)) + else: + print(text) + return 0 if sample["ok"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/install-kit-awindows-20260427-211240/aw-server/aw-slo-monitor.service b/install-kit-awindows-20260427-211240/aw-server/aw-slo-monitor.service new file mode 100644 index 0000000..a1bd724 --- /dev/null +++ b/install-kit-awindows-20260427-211240/aw-server/aw-slo-monitor.service @@ -0,0 +1,16 @@ +[Unit] +Description=AW-RUS SLO sampler +After=network-online.target activitywatch-server.service aw-worktime-api.service aw-rus-healthd.service +Wants=network-online.target activitywatch-server.service aw-worktime-api.service + +[Service] +Type=oneshot +EnvironmentFile=/etc/activitywatch/aw-server.env +ExecStart=/usr/bin/python3 /usr/local/bin/aw-slo-monitor.py +SuccessExitStatus=1 +User=activitywatch +Group=activitywatch +Nice=5 +StandardOutput=journal +StandardError=journal +SyslogIdentifier=aw-slo-monitor diff --git a/install-kit-awindows-20260427-211240/aw-server/aw-slo-monitor.timer b/install-kit-awindows-20260427-211240/aw-server/aw-slo-monitor.timer new file mode 100644 index 0000000..b5e2ce3 --- /dev/null +++ b/install-kit-awindows-20260427-211240/aw-server/aw-slo-monitor.timer @@ -0,0 +1,12 @@ +[Unit] +Description=Run AW-RUS SLO sampler every minute + +[Timer] +OnCalendar=*:0/1:50 +AccuracySec=10s +RandomizedDelaySec=10s +Persistent=false +Unit=aw-slo-monitor.service + +[Install] +WantedBy=timers.target diff --git a/install-kit-awindows-20260427-211240/aw-server/aw-worktime-api.py b/install-kit-awindows-20260427-211240/aw-server/aw-worktime-api.py index f12324a..caba965 100644 --- a/install-kit-awindows-20260427-211240/aw-server/aw-worktime-api.py +++ b/install-kit-awindows-20260427-211240/aw-server/aw-worktime-api.py @@ -1,5 +1,7 @@ #!/usr/bin/env python3 +import base64 import csv +import hashlib import html import io import importlib.util @@ -8,6 +10,7 @@ import os import sys import tempfile import threading +import time import urllib.request from datetime import datetime, timezone, timedelta from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer @@ -49,18 +52,30 @@ MANAGER_ALIASES_JSON = Path(os.environ.get("AW_WORKTIME_MANAGER_ALIASES_JSON", " MANAGER_EXCLUDE_USERS = {item.strip().lower() for item in os.environ.get("AW_WORKTIME_MANAGER_EXCLUDE_USERS", "").split(",") if item.strip()} EVENTS_CACHE_TTL_SECONDS = max(0, int(os.environ.get("AW_WORKTIME_EVENTS_CACHE_TTL_SECONDS", "30"))) WORKTIME_EVENTS_LIMIT = max(1000, int(os.environ.get("AW_WORKTIME_EVENTS_LIMIT", "50000"))) +AW_HTTP_TIMEOUT_SECONDS = max(0.5, float(os.environ.get("AW_WORKTIME_AW_HTTP_TIMEOUT_SECONDS", "5"))) +REPORT_CACHE_TTL_SECONDS = max(0, int(os.environ.get("AW_WORKTIME_REPORT_CACHE_TTL_SECONDS", "60"))) +REPORT_STALE_TTL_SECONDS = max(REPORT_CACHE_TTL_SECONDS, int(os.environ.get("AW_WORKTIME_REPORT_STALE_TTL_SECONDS", "900"))) +REPORT_DISK_CACHE_DIR = Path(os.environ.get("AW_WORKTIME_REPORT_DISK_CACHE_DIR", "/var/lib/activitywatch/worktime-report-cache")) +REPORT_DISK_STALE_TTL_SECONDS = max(REPORT_STALE_TTL_SECONDS, int(os.environ.get("AW_WORKTIME_REPORT_DISK_STALE_TTL_SECONDS", "86400"))) +REPORT_BUSY_WAIT_SECONDS = max(0.0, float(os.environ.get("AW_WORKTIME_REPORT_BUSY_WAIT_SECONDS", "8"))) +REPORT_BUSY_WAIT_INTERVAL_SECONDS = max(0.05, float(os.environ.get("AW_WORKTIME_REPORT_BUSY_WAIT_INTERVAL_SECONDS", "0.2"))) +REPORT_MAX_CONCURRENT = max(1, int(os.environ.get("AW_WORKTIME_REPORT_MAX_CONCURRENT", "1"))) TRUE_ACTIVE_EVIDENCE_WINDOW_SECONDS = max(30, int(os.environ.get("AW_WORKTIME_TRUE_ACTIVE_EVIDENCE_WINDOW_SECONDS", "180"))) TRUE_ACTIVE_MAX_EVENT_SECONDS = max(30, int(os.environ.get("AW_WORKTIME_TRUE_ACTIVE_MAX_EVENT_SECONDS", "600"))) MODULE_PATH = Path(__file__).resolve() _ALIASES_CACHE = {"mtime": None, "users": {}, "owners": {}, "raw": {}} _EVENTS_CACHE_LOCK = threading.Lock() _EVENTS_CACHE = {} +_REPORT_CACHE_LOCK = threading.Lock() +_REPORT_RESPONSE_CACHE = {} +_REPORT_REFRESHING = set() +_REPORT_BUILD_SEMAPHORE = threading.BoundedSemaphore(REPORT_MAX_CONCURRENT) _MANAGEMENT_BUILD_LOCKS_LOCK = threading.Lock() _MANAGEMENT_BUILD_LOCKS = {} def get(u): - with urllib.request.urlopen(u, timeout=30) as r: + with urllib.request.urlopen(u, timeout=AW_HTTP_TIMEOUT_SECONDS) as r: return json.loads(r.read().decode()) @@ -784,15 +799,21 @@ def aggregate_hourly_rows(events, start, end, host): def fetch_events_for_date(host, report_date): bounds = get_report_bounds(report_date) bucket_id = get_sessions_bucket_id(host) - events = fetch_bucket_events(bucket_id, host) + events = fetch_bucket_events(bucket_id, host, start=bounds["start"], end=bounds["end"]) return bounds, events -def fetch_bucket_events(bucket_id, host): +def fetch_bucket_events(bucket_id, host, start=None, end=None): now = now_utc() + cache_key = bucket_id + params = {"limit": WORKTIME_EVENTS_LIMIT} + if start is not None and end is not None: + params["start"] = to_iso_utc(start) + params["end"] = to_iso_utc(end) + cache_key = f"{bucket_id}|{params['start']}|{params['end']}" if EVENTS_CACHE_TTL_SECONDS > 0: with _EVENTS_CACHE_LOCK: - cached = _EVENTS_CACHE.get(bucket_id) + cached = _EVENTS_CACHE.get(cache_key) if cached is not None and age_seconds(cached["stored_at"], now=now) <= EVENTS_CACHE_TTL_SECONDS: return cached["events"] try: @@ -801,13 +822,13 @@ def fetch_bucket_events(bucket_id, host): log_warning(f"bucket lookup failed for host={host} bucket={bucket_id} aw_base={AW}") return [] try: - events = get(f"{AW}/buckets/{bucket_id}/events?limit={WORKTIME_EVENTS_LIMIT}") + events = get(f"{AW}/buckets/{bucket_id}/events?{urlencode(params)}") except Exception: log_warning(f"events fetch failed for host={host} bucket={bucket_id} aw_base={AW}") return [] if EVENTS_CACHE_TTL_SECONDS > 0: with _EVENTS_CACHE_LOCK: - _EVENTS_CACHE[bucket_id] = {"stored_at": now, "events": events} + _EVENTS_CACHE[cache_key] = {"stored_at": now, "events": events} return events @@ -840,16 +861,22 @@ def build_report_summary(rows): def latest_bucket_event(bucket_id): try: - events = get(f"{AW}/buckets/{bucket_id}/events?limit=20") + events = get(f"{AW}/buckets/{bucket_id}/events?limit=1") + except Exception: + events = None + if isinstance(events, list) and events: + valid = [item for item in events if isinstance(item, dict)] + if valid: + valid.sort(key=lambda item: item.get("timestamp") or "", reverse=True) + return valid[0] + try: + bucket = get(f"{AW}/buckets/{bucket_id}") except Exception: return None - if not isinstance(events, list) or not events: - return None - valid = [item for item in events if isinstance(item, dict)] - if not valid: - return None - valid.sort(key=lambda item: item.get("timestamp") or "", reverse=True) - return valid[0] + end = ((bucket or {}).get("metadata") or {}).get("end") + if end: + return {"timestamp": end, "duration": 0, "data": {"source": "bucket_metadata"}} + return None def _priority_rank(priority): @@ -1219,6 +1246,7 @@ def _source_status_label(status): "ok": "fresh", "warn": "stale", "fail": "missing", + "inactive": "inactive", }.get(status, status) @@ -1273,7 +1301,224 @@ def save_management_cache(host, report_date, payload): write_atomic_json(management_cache_path(host, report_date), payload) -def build_source_freshness(host): +def make_report_cache_key(path, fmt, host, report_date, selected_day="", owner_filter="", department_filter=""): + return "|".join( + [ + str(path), + str(fmt), + resolve_host(host), + report_date.isoformat(), + str(selected_day or ""), + normalize_management_filter(owner_filter), + normalize_management_filter(department_filter), + ] + ) + + +def resolve_report_format(params, accept_header=""): + requested = str(params.get("format", [""])[0] or "").lower() + if requested in {"csv", "html", "json"}: + return requested + accept = str(accept_header or "").lower() + if "text/html" in accept and "application/json" not in accept: + return "html" + return "json" + + +def report_disk_cache_path(key): + digest = hashlib.sha256(key.encode("utf-8")).hexdigest() + return REPORT_DISK_CACHE_DIR / f"{digest}.json" + + +def load_report_response_disk_cache(key): + if REPORT_DISK_STALE_TTL_SECONDS <= 0: + return None + path = report_disk_cache_path(key) + if not path.exists(): + return None + try: + payload = json.loads(path.read_text(encoding="utf-8")) + stored_epoch = float(payload["stored_epoch"]) + age = max(0, int(time.time() - stored_epoch)) + if age > REPORT_DISK_STALE_TTL_SECONDS: + return None + data = base64.b64decode(payload["data_b64"].encode("ascii")) + content_type = str(payload["content_type"]) + except Exception as exc: + log_warning(f"failed to load report disk cache key={key}: {exc}") + return None + return { + "stored_monotonic": time.monotonic() - age, + "generated_at_utc": payload.get("generated_at_utc", ""), + "data": data, + "content_type": content_type, + "age_seconds": age, + "stale": True, + "cache_source": "disk", + } + + +def save_report_response_disk_cache(key, data, content_type, generated_at_utc): + if REPORT_DISK_STALE_TTL_SECONDS <= 0: + return + payload = { + "key": key, + "stored_epoch": time.time(), + "generated_at_utc": generated_at_utc, + "content_type": content_type, + "data_b64": base64.b64encode(data).decode("ascii"), + } + try: + write_atomic_json(report_disk_cache_path(key), payload) + except Exception as exc: + log_warning(f"failed to save report disk cache key={key}: {exc}") + + +def get_report_response_cache(key, *, allow_stale=False): + if REPORT_CACHE_TTL_SECONDS <= 0 and not allow_stale: + return None + now = time.monotonic() + with _REPORT_CACHE_LOCK: + cached = _REPORT_RESPONSE_CACHE.get(key) + if cached is not None: + age = now - cached["stored_monotonic"] + max_age = REPORT_STALE_TTL_SECONDS if allow_stale else REPORT_CACHE_TTL_SECONDS + if max_age > 0 and age <= max_age: + result = dict(cached) + result["age_seconds"] = int(age) + result["stale"] = age > REPORT_CACHE_TTL_SECONDS + result["cache_source"] = "memory" + return result + if allow_stale: + return load_report_response_disk_cache(key) + return None + + +def save_report_response_cache(key, data, content_type): + if REPORT_STALE_TTL_SECONDS <= 0: + return + generated_at_utc = now_utc().isoformat().replace("+00:00", "Z") + with _REPORT_CACHE_LOCK: + _REPORT_RESPONSE_CACHE[key] = { + "stored_monotonic": time.monotonic(), + "generated_at_utc": generated_at_utc, + "data": data, + "content_type": content_type, + } + save_report_response_disk_cache(key, data, content_type, generated_at_utc) + + +def trigger_report_background_refresh(key, path, params): + refresh_params = {name: list(values) for name, values in params.items()} + with _REPORT_CACHE_LOCK: + if key in _REPORT_REFRESHING: + return False + _REPORT_REFRESHING.add(key) + + def refresh(): + try: + if not _REPORT_BUILD_SEMAPHORE.acquire(blocking=False): + return + try: + data, content_type = build_worktime_report_response(path, refresh_params) + save_report_response_cache(key, data, content_type) + except Exception as exc: + log_warning(f"background report refresh failed path={path}: {exc}") + finally: + _REPORT_BUILD_SEMAPHORE.release() + finally: + with _REPORT_CACHE_LOCK: + _REPORT_REFRESHING.discard(key) + + thread = threading.Thread(target=refresh, name="worktime-report-refresh", daemon=True) + thread.start() + return True + + +def send_cached_report(handler, cached, *, reason): + handler.send_response(200) + handler.send_header("Content-Type", cached["content_type"]) + handler.send_header("Content-Length", str(len(cached["data"]))) + handler.send_header("X-AW-Worktime-Cache", "stale" if cached.get("stale") else "fresh") + handler.send_header("X-AW-Worktime-Cache-Reason", reason) + handler.send_header("X-AW-Worktime-Cache-Age", str(cached.get("age_seconds", 0))) + handler.send_header("X-AW-Worktime-Cache-Source", cached.get("cache_source", "memory")) + handler.end_headers() + try: + handler.wfile.write(cached["data"]) + except (BrokenPipeError, ConnectionResetError): + return False + return True + + +def send_report_unavailable(handler, message): + payload = { + "ok": False, + "error": "report_unavailable", + "message": message, + "generated_at_utc": now_utc().isoformat().replace("+00:00", "Z"), + } + data = json.dumps(payload, ensure_ascii=False, indent=2).encode("utf-8") + send_bytes(handler, data, "application/json; charset=utf-8", status=503) + + +def send_worktime_report(handler, path, params, cache_key, *, host, report_date): + cached = get_report_response_cache(cache_key) + if cached is not None: + send_cached_report(handler, cached, reason="ttl") + return + + allow_immediate_stale = str(params.get("allow_stale", [""])[0]).lower() in {"1", "true", "yes"} + if allow_immediate_stale: + cached = get_report_response_cache(cache_key, allow_stale=True) + if cached is not None: + refresh_stale = str(params.get("refresh_stale", [""])[0]).lower() in {"1", "true", "yes"} + if refresh_stale: + trigger_report_background_refresh(cache_key, path, params) + send_cached_report(handler, cached, reason="stale-allowed") + return + + if not _REPORT_BUILD_SEMAPHORE.acquire(blocking=False): + if not allow_immediate_stale and REPORT_BUSY_WAIT_SECONDS > 0: + deadline = time.monotonic() + REPORT_BUSY_WAIT_SECONDS + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: + break + time.sleep(min(REPORT_BUSY_WAIT_INTERVAL_SECONDS, remaining)) + cached = get_report_response_cache(cache_key) + if cached is not None: + send_cached_report(handler, cached, reason="ttl-after-wait") + return + cached = get_report_response_cache(cache_key, allow_stale=True) + if cached is not None: + send_cached_report(handler, cached, reason="busy") + return + send_report_unavailable(handler, "report builder is busy and no cached response is available") + return + + try: + cached = get_report_response_cache(cache_key) + if cached is not None: + send_cached_report(handler, cached, reason="ttl-after-lock") + return + try: + data, content_type = build_worktime_report_response(path, params) + except Exception as exc: + log_warning(f"report build failed path={path} host={host} date={report_date}: {exc}") + cached = get_report_response_cache(cache_key, allow_stale=True) + if cached is not None: + send_cached_report(handler, cached, reason="build-error") + return + send_report_unavailable(handler, "report build failed and no cached response is available") + return + save_report_response_cache(cache_key, data, content_type) + send_bytes(handler, data, content_type) + finally: + _REPORT_BUILD_SEMAPHORE.release() + + +def build_source_freshness(host, interactive_required=True): source_specs = [ { "source_id": "worktime_sessions", @@ -1305,6 +1550,7 @@ def build_source_freshness(host): "bucket_candidates": [f"aw-watcher-window_{host}"], "max_age_seconds": MANAGER_CRITICAL_SOURCE_MAX_AGE_SECONDS, "required": True, + "interactive_only": True, "owner": "ops", }, { @@ -1321,6 +1567,7 @@ def build_source_freshness(host): "bucket_candidates": [f"aw-file-operations_{host}"], "max_age_seconds": MANAGER_CRITICAL_SOURCE_MAX_AGE_SECONDS, "required": True, + "interactive_only": True, "owner": "ops", }, { @@ -1379,6 +1626,9 @@ def build_source_freshness(host): else: status = "ok" summary = f"fresh ({age}s)" + if spec.get("interactive_only") and not interactive_required and status != "ok": + status = "inactive" + summary = "inactive: no active interactive users" detail = { "source_id": spec["source_id"], "label": spec["label"], @@ -1388,12 +1638,14 @@ def build_source_freshness(host): "timestamp": (matched_event or {}).get("timestamp", ""), "age_seconds": age, "required": spec["required"], + "interactive_only": bool(spec.get("interactive_only")), + "interactive_required": bool(interactive_required), "max_age_seconds": spec["max_age_seconds"], "summary": summary, "event_summary": _source_summary(matched_event), } sources.append(detail) - if status == "ok": + if status in {"ok", "inactive"}: continue priority = "critical" if spec["required"] else "medium" actions.append( @@ -1723,7 +1975,8 @@ def build_management_payload(rows, host, report_date, owner_filter="", departmen owner_filter = normalize_management_filter(owner_filter) department_filter = normalize_management_filter(department_filter) payload = _build_management_core(rows, host, report_date, owner_filter=owner_filter, department_filter=department_filter) - source_freshness, source_actions = build_source_freshness(resolve_host(host)) + interactive_required = int(payload.get("summary", {}).get("active_users", 0) or 0) > 0 + source_freshness, source_actions = build_source_freshness(resolve_host(host), interactive_required=interactive_required) payload["sources"] = source_freshness payload["trend"] = build_management_trend( resolve_host(host), @@ -1751,11 +2004,11 @@ def report_for_date(host, report_date): return aggregate_rows(events, bounds["start"], bounds["end"], host) -def management_report_for_date(host, report_date, owner_filter="", department_filter=""): +def management_report_for_date(host, report_date, owner_filter="", department_filter="", use_cache=True): owner_filter = normalize_management_filter(owner_filter) department_filter = normalize_management_filter(department_filter) if owner_filter or department_filter: - base_payload = management_report_for_date(host, report_date) + base_payload = management_report_for_date(host, report_date, use_cache=use_cache) filtered_payload = apply_management_filters_to_payload( base_payload, owner_filter=owner_filter, @@ -1766,14 +2019,17 @@ def management_report_for_date(host, report_date, owner_filter="", department_fi filtered_payload["trend"] = [] filtered_payload["trend_scope"] = "filtered_current_only" return filtered_payload - cached = load_management_cache(host, report_date) - if cached is not None: - return cached - lock = get_management_build_lock(host, report_date) - with lock: + cache_allowed = use_cache or report_date < datetime.now(REPORT_TZ).date() + if cache_allowed: cached = load_management_cache(host, report_date) if cached is not None: return cached + lock = get_management_build_lock(host, report_date) + with lock: + if cache_allowed: + cached = load_management_cache(host, report_date) + if cached is not None: + return cached bounds, events = fetch_events_for_date(host, report_date) rows = aggregate_rows_with_intervals(events, bounds["start"], bounds["end"], host) payload = build_management_payload(rows, host, report_date) @@ -2265,10 +2521,11 @@ def render_management_html(payload, selected_day=None): sources_html = [] for source in payload.get("sources", []): + source_priority_class = "low" if source["status"] in {"ok", "inactive"} else ("critical" if source["required"] else "medium") sources_html.append( "" f"{html.escape(source['label'])}" - f"{html.escape(source['status_label'])}" + f"{html.escape(source['status_label'])}" f"{html.escape(source['bucket_id'])}" f"{html.escape(source.get('timestamp') or '-')}" f"{html.escape(str(source.get('age_seconds')) if source.get('age_seconds') is not None else '-')}" @@ -2619,6 +2876,85 @@ def render_management_html(payload, selected_day=None): """ +def build_worktime_report_response(path, params): + fmt = resolve_report_format(params) + host = resolve_host(params.get("host", [DEFAULT_HOST])[0]) + day = params.get("day", ["today"])[0] + date_text = params.get("date", [None])[0] + owner_filter = normalize_management_filter(params.get("owner", [""])[0]) + department_filter = normalize_management_filter(params.get("department", [""])[0]) + report_date = resolve_report_date(day=day, date_text=date_text) + is_management = path == "/reports/worktime/management" + allow_immediate_stale = str(params.get("allow_stale", [""])[0]).lower() in {"1", "true", "yes"} + management_payload = ( + management_report_for_date( + host, + report_date, + owner_filter=owner_filter, + department_filter=department_filter, + use_cache=allow_immediate_stale, + ) + if is_management + else None + ) + rows = report_for_date_fresh(host, report_date) if not is_management else management_payload["rows"] + true_active_apps = [] if is_management else build_true_active_apps(host, report_date) + + if fmt == "csv": + if is_management: + out = io.StringIO() + writer = csv.DictWriter( + out, + fieldnames=["priority", "owner", "user_id", "action_id", "deadline_hint", "reason", "recommended_action"], + extrasaction="ignore", + ) + writer.writeheader() + writer.writerows(management_payload["actions"]) + return out.getvalue().encode(), "text/csv; charset=utf-8" + out = io.StringIO() + writer = csv.DictWriter( + out, + fieldnames=[ + "user", + "user_id", + "active_seconds", + "active_hhmm", + "first_activity", + "last_activity", + "idle_seconds", + "sessions_count", + "samples_count", + "active_samples", + ], + ) + writer.writeheader() + writer.writerows(rows) + return out.getvalue().encode(), "text/csv; charset=utf-8" + + if fmt == "html": + if is_management: + data = render_management_html(management_payload, selected_day=day if day in {"today", "yesterday"} else None).encode("utf-8") + else: + data = render_html(rows, host, report_date, selected_day=day if day in {"today", "yesterday"} else None, true_active_apps=true_active_apps).encode("utf-8") + return data, "text/html; charset=utf-8" + + if is_management: + data = json.dumps(management_payload, ensure_ascii=False, indent=2).encode("utf-8") + return data, "application/json; charset=utf-8" + + obj = { + "generated_at_utc": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), + "report_timezone": str(REPORT_TZ), + "host": host, + "report_date": report_date.isoformat(), + "bucket_id": get_sessions_bucket_id(host), + "rows": rows, + "true_active_apps": true_active_apps, + } + data = json.dumps(obj, ensure_ascii=False, indent=2).encode("utf-8") + return data, "application/json; charset=utf-8" + + def send_bytes(handler, data, content_type, status=200): handler.send_response(status) handler.send_header("Content-Type", content_type) @@ -2667,82 +3003,27 @@ class H(BaseHTTPRequestHandler): return params = parse_qs(parsed.query, keep_blank_values=False) - fmt = "json" - if params.get("format", ["json"])[0] == "csv": - fmt = "csv" - elif params.get("format", ["json"])[0] == "html": - fmt = "html" + fmt = resolve_report_format(params, self.headers.get("Accept", "")) + if not params.get("format") and fmt != "json": + params = {name: list(values) for name, values in params.items()} + params["format"] = [fmt] host = resolve_host(params.get("host", [DEFAULT_HOST])[0]) day = params.get("day", ["today"])[0] date_text = params.get("date", [None])[0] owner_filter = normalize_management_filter(params.get("owner", [""])[0]) department_filter = normalize_management_filter(params.get("department", [""])[0]) report_date = resolve_report_date(day=day, date_text=date_text) - is_management = parsed.path == "/reports/worktime/management" - management_payload = management_report_for_date(host, report_date, owner_filter=owner_filter, department_filter=department_filter) if is_management else None - rows = report_for_date_fresh(host, report_date) if not is_management else management_payload["rows"] - true_active_apps = [] if is_management else build_true_active_apps(host, report_date) + cache_key = make_report_cache_key( + parsed.path, + fmt, + host, + report_date, + selected_day=day if day in {"today", "yesterday"} else "", + owner_filter=owner_filter, + department_filter=department_filter, + ) - if fmt == "csv": - if is_management: - out = io.StringIO() - writer = csv.DictWriter( - out, - fieldnames=["priority", "owner", "user_id", "action_id", "deadline_hint", "reason", "recommended_action"], - extrasaction="ignore", - ) - writer.writeheader() - writer.writerows(management_payload["actions"]) - data = out.getvalue().encode() - send_bytes(self, data, "text/csv; charset=utf-8") - return - out = io.StringIO() - writer = csv.DictWriter( - out, - fieldnames=[ - "user", - "user_id", - "active_seconds", - "active_hhmm", - "first_activity", - "last_activity", - "idle_seconds", - "sessions_count", - "samples_count", - "active_samples", - ], - ) - writer.writeheader() - writer.writerows(rows) - data = out.getvalue().encode() - send_bytes(self, data, "text/csv; charset=utf-8") - return - - if fmt == "html": - if is_management: - data = render_management_html(management_payload, selected_day=day if day in {"today", "yesterday"} else None).encode("utf-8") - else: - data = render_html(rows, host, report_date, selected_day=day if day in {"today", "yesterday"} else None, true_active_apps=true_active_apps).encode("utf-8") - send_bytes(self, data, "text/html; charset=utf-8") - return - - if is_management: - obj = management_payload - data = json.dumps(obj, ensure_ascii=False, indent=2).encode("utf-8") - send_bytes(self, data, "application/json; charset=utf-8") - return - - obj = { - "generated_at_utc": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), - "report_timezone": str(REPORT_TZ), - "host": host, - "report_date": report_date.isoformat(), - "bucket_id": get_sessions_bucket_id(host), - "rows": rows, - "true_active_apps": true_active_apps, - } - data = json.dumps(obj, ensure_ascii=False, indent=2).encode("utf-8") - send_bytes(self, data, "application/json; charset=utf-8") + send_worktime_report(self, parsed.path, params, cache_key, host=host, report_date=report_date) class WorktimeHTTPServer(ThreadingHTTPServer): diff --git a/install-kit-awindows-20260427-211240/aw-server/aw-worktime-prewarm.service b/install-kit-awindows-20260427-211240/aw-server/aw-worktime-prewarm.service new file mode 100644 index 0000000..a013770 --- /dev/null +++ b/install-kit-awindows-20260427-211240/aw-server/aw-worktime-prewarm.service @@ -0,0 +1,15 @@ +[Unit] +Description=AW Worktime Report Cache Prewarm +After=network-online.target activitywatch-server.service aw-worktime-api.service +Wants=network-online.target activitywatch-server.service aw-worktime-api.service + +[Service] +Type=oneshot +EnvironmentFile=/etc/activitywatch/aw-server.env +ExecStart=/usr/local/bin/aw-worktime-prewarm.sh +User=activitywatch +Group=activitywatch +StandardOutput=journal +StandardError=journal +SyslogIdentifier=aw-worktime-prewarm + diff --git a/install-kit-awindows-20260427-211240/aw-server/aw-worktime-prewarm.sh b/install-kit-awindows-20260427-211240/aw-server/aw-worktime-prewarm.sh new file mode 100644 index 0000000..7ae33e9 --- /dev/null +++ b/install-kit-awindows-20260427-211240/aw-server/aw-worktime-prewarm.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +set -euo pipefail + +WORKTIME_BASE_URL="${WORKTIME_BASE_URL:-http://127.0.0.1:5610}" +WORKTIME_PREWARM_TIMEOUT_SECONDS="${WORKTIME_PREWARM_TIMEOUT_SECONDS:-45}" +WORKTIME_PREWARM_HEALTH_TIMEOUT_SECONDS="${WORKTIME_PREWARM_HEALTH_TIMEOUT_SECONDS:-10}" +WORKTIME_PREWARM_READY_TIMEOUT_SECONDS="${WORKTIME_PREWARM_READY_TIMEOUT_SECONDS:-60}" +WORKTIME_PREWARM_READY_INTERVAL_SECONDS="${WORKTIME_PREWARM_READY_INTERVAL_SECONDS:-2}" +WORKTIME_PREWARM_HOST="${WORKTIME_PREWARM_HOST:-${AW_WORKTIME_HOST:-SHARKON2025}}" +WORKTIME_PREWARM_PROFILE="${WORKTIME_PREWARM_PROFILE:-full}" + +log() { + printf '%s %s\n' "$(date '+%F %T')" "$*" +} + +probe() { + local url="$1" + local timeout="$2" + local tmp + tmp="$(mktemp)" + local code + code="$(curl -sS --max-time "$timeout" -o /dev/null -D "$tmp" -w '%{http_code}' "$url" 2>/dev/null || true)" + if [[ "$code" =~ ^2 ]]; then + local cache + cache="$(tr '\r' '\n' < "$tmp" | awk 'tolower($0) ~ /^x-aw-worktime-cache:/ {print $2; exit}' || true)" + local reason + reason="$(tr '\r' '\n' < "$tmp" | awk 'tolower($0) ~ /^x-aw-worktime-cache-reason:/ {print $2; exit}' || true)" + rm -f "$tmp" + log "ok code=$code cache=${cache:-none} reason=${reason:-none} url=$url" + return 0 + fi + rm -f "$tmp" + log "warn code=${code:-000} url=$url" + return 1 +} + +wait_until_ready() { + local deadline + deadline=$(( $(date +%s) + WORKTIME_PREWARM_READY_TIMEOUT_SECONDS )) + while true; do + if probe "$WORKTIME_BASE_URL/health" "$WORKTIME_PREWARM_HEALTH_TIMEOUT_SECONDS"; then + return 0 + fi + if [[ "$(date +%s)" -ge "$deadline" ]]; then + return 1 + fi + sleep "$WORKTIME_PREWARM_READY_INTERVAL_SECONDS" + done +} + +if ! wait_until_ready; then + log "health readiness timed out; skip prewarm" + exit 0 +fi + +full_urls=( + "$WORKTIME_BASE_URL/reports/worktime/today?day=today&format=csv&host=$WORKTIME_PREWARM_HOST" + "$WORKTIME_BASE_URL/reports/worktime/today?day=today&format=json&host=$WORKTIME_PREWARM_HOST" + "$WORKTIME_BASE_URL/reports/worktime/today?day=today&format=html&host=$WORKTIME_PREWARM_HOST" + "$WORKTIME_BASE_URL/reports/worktime/management?day=today&format=csv&host=$WORKTIME_PREWARM_HOST" + "$WORKTIME_BASE_URL/reports/worktime/management?day=today&format=json&host=$WORKTIME_PREWARM_HOST" + "$WORKTIME_BASE_URL/reports/worktime/management?day=today&format=html&host=$WORKTIME_PREWARM_HOST" +) + +startup_urls=( + "$WORKTIME_BASE_URL/reports/worktime/today?day=today&format=csv&host=$WORKTIME_PREWARM_HOST" + "$WORKTIME_BASE_URL/reports/worktime/today?day=today&format=json&host=$WORKTIME_PREWARM_HOST" + "$WORKTIME_BASE_URL/reports/worktime/management?day=today&format=json&host=$WORKTIME_PREWARM_HOST" + "$WORKTIME_BASE_URL/reports/worktime/management?day=today&format=csv&host=$WORKTIME_PREWARM_HOST" +) + +case "$WORKTIME_PREWARM_PROFILE" in + full) + urls=("${full_urls[@]}") + ;; + startup) + urls=("${startup_urls[@]}") + ;; + *) + log "unknown profile=$WORKTIME_PREWARM_PROFILE" + exit 0 + ;; +esac + +failures=0 +for url in "${urls[@]}"; do + if ! probe "$url" "$WORKTIME_PREWARM_TIMEOUT_SECONDS"; then + failures=$((failures + 1)) + fi +done + +if [[ "$failures" -gt 0 ]]; then + log "completed profile=$WORKTIME_PREWARM_PROFILE with failures=$failures" +else + log "completed profile=$WORKTIME_PREWARM_PROFILE successfully" +fi + +exit 0 diff --git a/install-kit-awindows-20260427-211240/aw-server/aw-worktime-prewarm.timer b/install-kit-awindows-20260427-211240/aw-server/aw-worktime-prewarm.timer new file mode 100644 index 0000000..5167be5 --- /dev/null +++ b/install-kit-awindows-20260427-211240/aw-server/aw-worktime-prewarm.timer @@ -0,0 +1,11 @@ +[Unit] +Description=Run AW Worktime Report Cache Prewarm + +[Timer] +OnCalendar=*:2/5:40 +AccuracySec=30s +Persistent=false +Unit=aw-worktime-prewarm.service + +[Install] +WantedBy=timers.target diff --git a/install-kit-awindows-20260427-211240/aw-server/health-check.sh b/install-kit-awindows-20260427-211240/aw-server/health-check.sh new file mode 100644 index 0000000..7df5693 --- /dev/null +++ b/install-kit-awindows-20260427-211240/aw-server/health-check.sh @@ -0,0 +1,173 @@ +#!/bin/bash +set -euo pipefail + +# Health check script for AW services +# Returns 0 if all services are healthy, 1 otherwise + +SERVICES=("activitywatch-server" "aw-worktime-api" "aw-worktime-ui-bridge") +UNHEALTHY_SERVICES=() +WARNINGS=() + +if [[ -f /etc/activitywatch/aw-server.env ]]; then + # shellcheck disable=SC1091 + source /etc/activitywatch/aw-server.env +fi + +WORKTIME_HEALTH_BASE="${AW_RUS_HEALTH_WORKTIME_API:-${AW_WORKTIME_REPORT_BASE:-http://127.0.0.1:5610}}" +WORKTIME_HEALTH_URL="${WORKTIME_HEALTH_BASE%/}/health" +WORKTIME_HEALTH_TIMEOUT_SECONDS="${AW_RUS_HEALTH_WORKTIME_TIMEOUT_SECONDS:-15}" +AW_HEALTH_API_TIMEOUT_SECONDS="${AW_RUS_HEALTH_AW_TIMEOUT_SECONDS:-15}" +AW_HEALTH_API_ATTEMPTS="${AW_RUS_HEALTH_AW_ATTEMPTS:-3}" +SETTINGS_TIMEOUT_SECONDS="${AW_RUS_HEALTH_SETTINGS_TIMEOUT_SECONDS:-15}" +SETTINGS_ATTEMPTS="${AW_RUS_HEALTH_SETTINGS_ATTEMPTS:-3}" + +check_service() { + local service=$1 + if [[ "$service" == "aw-worktime-ui-bridge" ]]; then + if systemctl is-active --quiet aw-worktime-ui-bridge.timer && systemctl is-enabled --quiet aw-worktime-ui-bridge.timer; then + echo "✓ aw-worktime-ui-bridge.timer is running and enabled" + else + echo "✗ aw-worktime-ui-bridge.timer is not active/enabled" + UNHEALTHY_SERVICES+=("aw-worktime-ui-bridge.timer") + fi + return + fi + if systemctl is-active --quiet "$service"; then + echo "✓ $service is running" + else + echo "✗ $service is not running" + UNHEALTHY_SERVICES+=("$service") + fi +} + +check_api_endpoint() { + local url=$1 + local service_name=$2 + local timeout_seconds=${3:-5} + local attempts=${4:-1} + local attempt + + for ((attempt=1; attempt<=attempts; attempt++)); do + if curl -fsS --connect-timeout 3 --max-time "$timeout_seconds" "$url" >/dev/null 2>&1; then + echo "✓ $service_name API endpoint is responding" + return + fi + if (( attempt < attempts )); then + sleep 1 + fi + done + + echo "✗ $service_name API endpoint is not responding" + UNHEALTHY_SERVICES+=("$service_name-api") +} + +read_setting_value() { + local key=$1 + local attempt + for ((attempt=1; attempt<=SETTINGS_ATTEMPTS; attempt++)); do + if curl -fsS --connect-timeout 3 --max-time "$SETTINGS_TIMEOUT_SECONDS" "http://127.0.0.1:5600/api/0/settings/${key}" 2>/dev/null | \ + python3 -c 'import json,sys; print(json.load(sys.stdin))'; then + return 0 + fi + if (( attempt < SETTINGS_ATTEMPTS )); then + sleep 1 + fi + done + return 1 +} + +check_expected_setting() { + local key=$1 + local expected=$2 + local label=${3:-$1} + + if [[ -z "$expected" ]]; then + echo "⚠ expected value for ${label} is not configured, skipping drift check" + WARNINGS+=("${key}-expected-missing") + return + fi + + local actual + if ! actual="$(read_setting_value "$key" 2>/dev/null)"; then + echo "✗ failed to read setting ${label}" + UNHEALTHY_SERVICES+=("setting-${key}") + return + fi + + if [[ "$actual" == "$expected" ]]; then + echo "✓ ${label} matches expected value (${expected})" + else + echo "✗ ${label} drift detected: actual='${actual}' expected='${expected}'" + UNHEALTHY_SERVICES+=("setting-${key}") + fi +} + +check_dlp_transport_freshness() { + local dlp_health="${DLP_HEALTH_BIN:-/usr/local/bin/dlp-health-check}" + local result + + if [[ ! -x "$dlp_health" ]]; then + echo "⚠ dlp-health-check is not available, skipping DLP transport freshness checks" + WARNINGS+=("dlp-health-check-missing") + return + fi + + result="$("$dlp_health" --json 2>/dev/null || true)" + if [[ -z "$result" ]]; then + echo "⚠ dlp-health-check did not return JSON, skipping DLP transport freshness checks" + WARNINGS+=("dlp-health-check-empty") + return + fi + + local ok + ok="$(printf '%s' "$result" | python3 -c 'import json,sys; data=json.load(sys.stdin); names={r["name"]:r for r in data.get("results", [])}; checks=["buckets:endpoint-signals","buckets:file-operations","endpoint-self-test-metrics"]; bad=[n for n in checks if names.get(n,{}).get("status")=="fail"]; print("1" if not bad else "0")' 2>/dev/null || echo "0")" + if [[ "$ok" == "1" ]]; then + echo "✓ DLP transport freshness check passed" + else + echo "✗ DLP transport freshness check failed" + UNHEALTHY_SERVICES+=("dlp-transport") + fi + + local errors warnings + errors="$(printf '%s' "$result" | python3 -c 'import json,sys; data=json.load(sys.stdin); out=[]; [out.append(f"{r.get(\"name\")}:{r.get(\"summary\")}") for r in data.get("results", []) if r.get("status")=="fail" and r.get("name") in ("buckets:endpoint-signals","buckets:file-operations","endpoint-self-test-metrics")]; print(", ".join(out))' 2>/dev/null || true)" + warnings="$(printf '%s' "$result" | python3 -c 'import json,sys; data=json.load(sys.stdin); out=[]; [out.append(f"{r.get(\"name\")}:{r.get(\"summary\")}") for r in data.get("results", []) if r.get("status")=="warn" and r.get("name") in ("buckets:endpoint-signals","buckets:file-operations","endpoint-self-test-metrics")]; print(", ".join(out))' 2>/dev/null || true)" + if [[ -n "$errors" ]]; then + echo " errors: $errors" + fi + if [[ -n "$warnings" ]]; then + echo " warnings: $warnings" + WARNINGS+=("$warnings") + fi +} + +echo "=== AW Services Health Check ===" +echo "Timestamp: $(date)" +echo + +# Check systemd services +for service in "${SERVICES[@]}"; do + check_service "$service" +done + +echo + +# Check API endpoints +check_api_endpoint "http://127.0.0.1:5600/api/0/info" "activitywatch-server" "$AW_HEALTH_API_TIMEOUT_SECONDS" "$AW_HEALTH_API_ATTEMPTS" +check_api_endpoint "$WORKTIME_HEALTH_URL" "aw-worktime-api" "$WORKTIME_HEALTH_TIMEOUT_SECONDS" 2 +check_dlp_transport_freshness "http://127.0.0.1:5600/api/0" "900" "${AW_HEALTH_STRICT_FILEOPS:-0}" +check_expected_setting "startOfDay" "${AW_EXPECT_START_OF_DAY:-}" "startOfDay" +check_expected_setting "always_active_pattern" "${AW_EXPECT_ALWAYS_ACTIVE_PATTERN:-}" "always_active_pattern" +check_expected_setting "landingpage" "${AW_EXPECT_LANDINGPAGE:-}" "landingpage" + +echo + +if [ ${#UNHEALTHY_SERVICES[@]} -eq 0 ]; then + echo "✓ All services are healthy" + if [ ${#WARNINGS[@]} -gt 0 ]; then + echo "⚠ Warnings: ${WARNINGS[*]}" + fi + exit 0 +else + echo "✗ Unhealthy services: ${UNHEALTHY_SERVICES[*]}" + exit 1 +fi diff --git a/install-kit-awindows-20260427-211240/scripts/aw-webui-browser-smoke.mjs b/install-kit-awindows-20260427-211240/scripts/aw-webui-browser-smoke.mjs new file mode 100644 index 0000000..08fe5bd --- /dev/null +++ b/install-kit-awindows-20260427-211240/scripts/aw-webui-browser-smoke.mjs @@ -0,0 +1,498 @@ +#!/usr/bin/env node +import { createRequire } from "node:module"; +import { execFile } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { promisify } from "node:util"; + +const require = createRequire(import.meta.url); +const execFileAsync = promisify(execFile); + +function loadPlaywright() { + const candidates = [ + "playwright", + "playwright-core", + process.env.PLAYWRIGHT_CORE_MODULE, + "/home/igor/.agents/skills/playwright/node_modules/playwright-core", + ].filter(Boolean); + const errors = []; + for (const candidate of candidates) { + try { + return require(candidate); + } catch (error) { + errors.push(`${candidate}: ${error.message}`); + } + } + return null; +} + +function firstExisting(candidates) { + return candidates.find((item) => item && fs.existsSync(item)) || ""; +} + +function env(name, fallback) { + const value = process.env[name]; + return value && value.trim() ? value.trim() : fallback; +} + +function normalizeBase(url) { + return url.replace(/\/+$/, ""); +} + +function safeName(value) { + return value.replace(/[^a-zA-Z0-9_.-]+/g, "-").replace(/^-|-$/g, ""); +} + +function envInt(name, fallback) { + const value = Number(env(name, String(fallback))); + return Number.isFinite(value) ? value : fallback; +} + +function isRunDirectoryName(name) { + return /^20\d{2}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z$/.test(name); +} + +function pruneOutputRuns(outRoot, keepRuns) { + if (keepRuns <= 0 || !fs.existsSync(outRoot)) { + return []; + } + const entries = fs + .readdirSync(outRoot, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && isRunDirectoryName(entry.name)) + .map((entry) => entry.name) + .sort(); + const stale = entries.slice(0, Math.max(0, entries.length - keepRuns)); + for (const name of stale) { + fs.rmSync(path.join(outRoot, name), { recursive: true, force: true }); + } + return stale; +} + +function decodeHtmlEntities(value) { + return value + .replace(/ /g, " ") + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'/g, "'"); +} + +function htmlToText(html) { + return decodeHtmlEntities( + html + .replace(//gi, " ") + .replace(//gi, " ") + .replace(/<[^>]+>/g, " ") + .replace(/\s+/g, " ") + .trim(), + ); +} + +function commandInPath(name) { + const dirs = (process.env.PATH || "").split(path.delimiter); + for (const dir of dirs) { + const candidate = path.join(dir, name); + if (fs.existsSync(candidate)) { + return candidate; + } + } + return ""; +} + +function findChromiumExecutable(explicitPath) { + return firstExisting([ + explicitPath, + "/home/igor/.cache/ms-playwright/chromium-1217/chrome-linux64/chrome", + "/home/igor/.cache/ms-playwright/chromium-1208/chrome-linux64/chrome", + "/home/igor/.cache/rod/browser/chromium-1321438/chrome", + commandInPath("chromium"), + commandInPath("chromium-browser"), + commandInPath("google-chrome"), + "/usr/bin/chromium", + "/usr/bin/chromium-browser", + "/usr/bin/google-chrome", + ]); +} + +function isBenignConsoleError(text) { + return text.includes("Failed to load resource: the server responded with a status of 404 (Not Found)"); +} + +function isBenignRequestFailure(failure) { + return failure.error === "net::ERR_ABORTED"; +} + +async function waitForTextMarkers(page, markers, timeoutMs) { + if (!markers.length) { + return; + } + try { + await page.waitForFunction( + (expected) => { + const text = `${document.title}\n${document.body ? document.body.innerText : ""}`; + return expected.every((marker) => text.includes(marker)); + }, + markers, + { timeout: timeoutMs }, + ); + } catch { + // The final assertion below reports the exact missing markers. + } +} + +async function runPageCheck(browser, spec, runDir) { + const page = await browser.newPage({ + viewport: { width: 1366, height: 768 }, + locale: "ru-RU", + timezoneId: "Europe/Moscow", + }); + const started = Date.now(); + const consoleErrors = []; + const pageErrors = []; + const requestFailures = []; + const badResponses = []; + const responses = []; + + page.on("console", (message) => { + if (message.type() === "error") { + const text = message.text(); + if (!isBenignConsoleError(text)) { + consoleErrors.push(text); + } + } + }); + page.on("pageerror", (error) => { + pageErrors.push(error.message); + }); + page.on("requestfailed", (request) => { + const url = request.url(); + if (spec.relevantHosts.some((host) => url.startsWith(host))) { + const failure = { url, error: request.failure()?.errorText || "unknown" }; + if (!isBenignRequestFailure(failure)) { + requestFailures.push(failure); + } + } + }); + page.on("response", (response) => { + const url = response.url(); + if (!spec.relevantHosts.some((host) => url.startsWith(host))) { + return; + } + const item = { url, status: response.status() }; + responses.push(item); + if (response.status() >= 400) { + badResponses.push(item); + } + }); + + let status = null; + let title = ""; + let bodyText = ""; + let screenshot = ""; + let error = ""; + try { + const response = await page.goto(spec.url, { waitUntil: "commit", timeout: spec.timeoutMs }); + status = response ? response.status() : null; + await waitForTextMarkers(page, spec.requiredText, spec.renderTimeoutMs); + await page.waitForTimeout(spec.settleMs); + title = await page.title(); + bodyText = await page.locator("body").innerText({ timeout: 5000 }); + screenshot = path.join(runDir, `${safeName(spec.name)}.png`); + await page.screenshot({ path: screenshot, fullPage: true }); + } catch (caught) { + error = caught && caught.message ? caught.message : String(caught); + try { + screenshot = path.join(runDir, `${safeName(spec.name)}-failed.png`); + await page.screenshot({ path: screenshot, fullPage: true }); + } catch { + screenshot = ""; + } + } finally { + await page.close(); + } + + const visibleText = `${title}\n${bodyText}`; + const missingMarkers = spec.requiredText.filter((marker) => !visibleText.includes(marker)); + const ok = + !error && + status !== null && + status >= 200 && + status < 300 && + bodyText.length >= spec.minBodyText && + missingMarkers.length === 0 && + consoleErrors.length === 0 && + pageErrors.length === 0 && + requestFailures.length === 0 && + badResponses.length === 0; + + return { + engine: "playwright", + name: spec.name, + ok, + url: spec.url, + status, + title, + body_text_length: bodyText.length, + missing_markers: missingMarkers, + console_errors: consoleErrors, + page_errors: pageErrors, + request_failures: requestFailures, + bad_responses: badResponses, + response_count: responses.length, + latency_ms: Date.now() - started, + screenshot, + error, + }; +} + +async function runChromiumCliPageCheck(chromiumPath, spec, runDir) { + const started = Date.now(); + const screenshot = path.join(runDir, `${safeName(spec.name)}.png`); + const profileDir = path.join(runDir, `${safeName(spec.name)}-profile`); + const screenshotRequired = spec.cliScreenshotRequired !== false; + fs.mkdirSync(profileDir, { recursive: true }); + const browserArgs = [ + "--headless", + "--no-sandbox", + "--noerrdialogs", + "--disable-gpu", + "--disable-crash-reporter", + "--disable-crashpad", + "--disable-dev-shm-usage", + `--user-data-dir=${profileDir}`, + `--virtual-time-budget=${spec.renderTimeoutMs + spec.settleMs}`, + ]; + + let status = null; + let error = ""; + let fetchedText = ""; + try { + const controller = AbortSignal.timeout(spec.timeoutMs); + const response = await fetch(spec.url, { signal: controller }); + status = response.status; + fetchedText = await response.text(); + } catch (caught) { + error = caught && caught.message ? caught.message : String(caught); + } + + let html = ""; + try { + const { stdout } = await execFileAsync(chromiumPath, [...browserArgs, "--dump-dom", spec.url], { + timeout: spec.timeoutMs + spec.renderTimeoutMs + spec.settleMs + 10000, + maxBuffer: 10 * 1024 * 1024, + }); + html = stdout || ""; + } catch (caught) { + const message = caught && caught.message ? caught.message : String(caught); + error = error ? `${error}; ${message}` : message; + } + + if (screenshotRequired) { + try { + await execFileAsync( + chromiumPath, + [...browserArgs, "--window-size=1366,768", `--screenshot=${screenshot}`, spec.url], + { + timeout: spec.timeoutMs + spec.renderTimeoutMs + spec.settleMs + 10000, + maxBuffer: 1024 * 1024, + }, + ); + } catch (caught) { + const message = caught && caught.message ? caught.message : String(caught); + error = error ? `${error}; screenshot: ${message}` : `screenshot: ${message}`; + } + } + + const rawHtml = html || fetchedText; + const titleMatch = rawHtml.match(/]*>([\s\S]*?)<\/title>/i); + const title = titleMatch ? decodeHtmlEntities(titleMatch[1].trim()) : ""; + const bodyText = htmlToText(rawHtml); + const visibleText = `${title}\n${bodyText}\n${rawHtml}`; + const requiredText = spec.cliRequiredText || spec.requiredText; + const minBodyText = spec.cliMinBodyText || spec.minBodyText; + const missingMarkers = requiredText.filter((marker) => !visibleText.includes(marker)); + const ok = + !error && + status !== null && + status >= 200 && + status < 300 && + bodyText.length >= minBodyText && + missingMarkers.length === 0 && + (!screenshotRequired || fs.existsSync(screenshot)); + + return { + engine: "chromium-cli", + name: spec.name, + ok, + url: spec.url, + status, + title, + body_text_length: bodyText.length, + missing_markers: missingMarkers, + console_errors: [], + page_errors: [], + request_failures: [], + bad_responses: [], + response_count: 0, + latency_ms: Date.now() - started, + screenshot_required: screenshotRequired, + screenshot: fs.existsSync(screenshot) ? screenshot : "", + error, + }; +} + +async function runPageCheckWithRetries(runOnce, spec, maxRetries) { + let result = await runOnce(spec); + for (let attempt = 1; !result.ok && attempt <= maxRetries; attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, 1000 * attempt)); + const retry = await runOnce({ ...spec, name: `${spec.name}_retry${attempt}` }); + retry.retry_of = spec.name; + retry.retry_attempt = attempt; + if (retry.ok) { + retry.name = spec.name; + retry.recovered_after_retry = attempt; + return retry; + } + result = retry; + result.retry_of = spec.name; + result.retry_attempt = attempt; + } + return result; +} + +async function main() { + const requestedEngine = env("AW_BROWSER_SMOKE_ENGINE", "auto"); + const playwright = requestedEngine === "chromium-cli" ? null : loadPlaywright(); + const awBase = normalizeBase(env("AW_BROWSER_SMOKE_AW_BASE", env("AW_SMOKE_AW_SERVER", "http://10.10.10.13:5600"))); + const worktimeBase = normalizeBase(env("AW_BROWSER_SMOKE_WORKTIME_BASE", env("AW_SMOKE_WORKTIME_API", "http://10.10.10.13:5610"))); + const host = env("AW_BROWSER_SMOKE_HOST", env("AW_SMOKE_SOURCE_HOSTNAME", "SHARKON2025")); + const timeoutMs = Number(env("AW_BROWSER_SMOKE_TIMEOUT_MS", "20000")); + const settleMs = Number(env("AW_BROWSER_SMOKE_SETTLE_MS", "6000")); + const renderTimeoutMs = Number(env("AW_BROWSER_SMOKE_RENDER_TIMEOUT_MS", "15000")); + const pageRetries = Number(env("AW_BROWSER_SMOKE_PAGE_RETRIES", "1")); + const keepRuns = envInt("AW_BROWSER_SMOKE_KEEP_RUNS", 24); + const outRoot = env("AW_BROWSER_SMOKE_OUTPUT_DIR", path.resolve("output", "browser-smoke")); + const runId = new Date().toISOString().replace(/[:.]/g, "-"); + const runDir = path.join(outRoot, runId); + fs.mkdirSync(outRoot, { recursive: true }); + const prunedRuns = pruneOutputRuns(outRoot, keepRuns); + fs.mkdirSync(runDir, { recursive: true }); + + const executablePath = findChromiumExecutable(env( + "PLAYWRIGHT_CHROMIUM_EXECUTABLE", + "", + )); + + const launchOptions = { + headless: true, + args: ["--no-sandbox", "--disable-dev-shm-usage"], + }; + if (executablePath) { + launchOptions.executablePath = executablePath; + } + + const relevantHosts = [awBase, worktimeBase]; + const specs = [ + { + name: "aw_webui_home", + url: `${awBase}/`, + relevantHosts, + requiredText: ["Активность", "Windows RDP", host, "DLP"], + cliRequiredText: ["ActivityWatch", "ru-patch-v5.js", "aw-report-links"], + cliMinBodyText: 100, + cliScreenshotRequired: false, + minBodyText: 500, + timeoutMs, + settleMs, + renderTimeoutMs, + }, + { + name: "worktime_today_html", + url: `${worktimeBase}/reports/worktime/today?format=html&day=today&host=${encodeURIComponent(host)}&allow_stale=1`, + relevantHosts, + requiredText: ["AW-rus", "Отчёт", "RDP"], + minBodyText: 500, + timeoutMs, + settleMs: 1000, + renderTimeoutMs, + }, + { + name: "worktime_management_html", + url: `${worktimeBase}/reports/worktime/management?format=html&day=today&host=${encodeURIComponent(host)}&allow_stale=1`, + relevantHosts, + requiredText: ["AW-rus", "Управленческий", "RDP"], + minBodyText: 500, + timeoutMs, + settleMs: 1000, + renderTimeoutMs, + }, + ]; + + const pages = []; + if (playwright) { + const { chromium } = playwright; + const browser = await chromium.launch(launchOptions); + try { + for (const spec of specs) { + pages.push(await runPageCheckWithRetries((item) => runPageCheck(browser, item, runDir), spec, pageRetries)); + } + } finally { + await browser.close(); + } + } else { + if (requestedEngine === "playwright") { + throw new Error("AW_BROWSER_SMOKE_ENGINE=playwright requested, but Playwright could not be loaded"); + } + if (!executablePath) { + throw new Error("Unable to load Playwright and no Chromium executable found"); + } + for (const spec of specs) { + pages.push(await runPageCheckWithRetries((item) => runChromiumCliPageCheck(executablePath, item, runDir), spec, pageRetries)); + } + } + + const ok = pages.every((page) => page.ok); + const result = { + ok, + engine: pages[0]?.engine || "unknown", + generated_at_utc: new Date().toISOString(), + aw_base: awBase, + worktime_base: worktimeBase, + host, + output_dir: runDir, + retention: { + keep_runs: keepRuns, + pruned_runs: prunedRuns, + }, + pages, + }; + const jsonPath = path.join(runDir, "result.json"); + const latestPath = path.join(outRoot, "latest-result.json"); + result.result_json = jsonPath; + result.latest_result_json = latestPath; + fs.writeFileSync(jsonPath, `${JSON.stringify(result, null, 2)}\n`, "utf8"); + fs.writeFileSync(latestPath, `${JSON.stringify(result, null, 2)}\n`, "utf8"); + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + return ok ? 0 : 2; +} + +main() + .then((code) => { + process.exitCode = code; + }) + .catch((error) => { + process.stdout.write( + `${JSON.stringify( + { + ok: false, + generated_at_utc: new Date().toISOString(), + error: error && error.message ? error.message : String(error), + }, + null, + 2, + )}\n`, + ); + process.exitCode = 2; + }); diff --git a/install-kit-awindows-20260427-211240/scripts/aw-webui-browser-smoke.sh b/install-kit-awindows-20260427-211240/scripts/aw-webui-browser-smoke.sh new file mode 100644 index 0000000..ebc8624 --- /dev/null +++ b/install-kit-awindows-20260427-211240/scripts/aw-webui-browser-smoke.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +if [[ -z "${NODE_PATH:-}" && -d /home/igor/.agents/skills/playwright/node_modules ]]; then + export NODE_PATH=/home/igor/.agents/skills/playwright/node_modules +fi + +exec node "$ROOT_DIR/scripts/aw-webui-browser-smoke.mjs" "$@" diff --git a/install-kit-awindows-20260427-211240/scripts/check_install_kit_vs_repo.sh b/install-kit-awindows-20260427-211240/scripts/check_install_kit_vs_repo.sh new file mode 100644 index 0000000..ff2600b --- /dev/null +++ b/install-kit-awindows-20260427-211240/scripts/check_install_kit_vs_repo.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +KIT_DIR="install-kit-awindows-20260427-211240" +TARGET_ROOT="${CARGO_TARGET_DIR:-$ROOT_DIR/adk-rust/target}" +RUST_BIN="${CHECK_INSTALL_KIT_VS_REPO_RUST:-}" + +rust_candidates=() +if [[ -n "$RUST_BIN" ]]; then + rust_candidates+=("$RUST_BIN") +fi +rust_candidates+=( + "$TARGET_ROOT/release/check-install-kit-vs-repo" + "$ROOT_DIR/adk-rust/target/release/check-install-kit-vs-repo" + "/usr/local/bin/check-install-kit-vs-repo" +) + +for candidate in "${rust_candidates[@]}"; do + if [[ -x "$candidate" ]]; then + exec "$candidate" --root "$ROOT_DIR" --kit-dir "$KIT_DIR" "$@" + fi +done + +python3 - <<'PY' +from pathlib import Path +import hashlib +import sys + +root=Path('.') +kit=Path('install-kit-awindows-20260427-211240') +if not kit.exists(): + raise SystemExit('Install kit directory not found') + +def sha(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + +all_compared=[] +mismatches=[] +missing_in_repo=[] +allowed_kit_only_prefixes=('server-configs-' ,) +allowed_kit_only_files={'README-INSTALL-KIT.txt'} + +for kp in sorted(p for p in kit.rglob('*') if p.is_file() and p.name!='MANIFEST.txt'): + rel=kp.relative_to(kit) + rp=root/rel + if not rp.exists(): + rel_str=str(rel) + if rel_str in allowed_kit_only_files or rel_str.startswith(allowed_kit_only_prefixes): + continue + missing_in_repo.append(str(rel)) + continue + all_compared.append(str(rel)) + if sha(kp)!=sha(rp): + mismatches.append(str(rel)) + +ps_mismatches=[ + p for p in mismatches + if p.startswith('windows/') and p.endswith(('.ps1', '.psm1', '.psd1')) +] + +print(f'Compared files: {len(all_compared)}') +print(f'Missing in repo: {len(missing_in_repo)}') +print(f'Mismatched content: {len(mismatches)}') +if missing_in_repo: + print('--- Missing in repo ---') + for p in missing_in_repo: + print(p) +if mismatches: + print('--- Mismatches ---') + for p in mismatches: + print(p) +print(f'PowerShell mismatches: {len(ps_mismatches)}') +if ps_mismatches: + print('--- PowerShell mismatches ---') + for p in ps_mismatches: + print(p) +if missing_in_repo or mismatches: + sys.exit(1) +PY diff --git a/install-kit-awindows-20260427-211240/scripts/quality-gate.sh b/install-kit-awindows-20260427-211240/scripts/quality-gate.sh new file mode 100644 index 0000000..7a4bcf4 --- /dev/null +++ b/install-kit-awindows-20260427-211240/scripts/quality-gate.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +TARGET_ROOT="${CARGO_TARGET_DIR:-$ROOT_DIR/adk-rust/target}" +RUST_BIN="${QUALITY_GATE_RUST:-}" + +rust_candidates=() +if [[ -n "$RUST_BIN" ]]; then + rust_candidates+=("$RUST_BIN") +fi +rust_candidates+=( + "$TARGET_ROOT/release/quality-gate" + "$ROOT_DIR/adk-rust/target/release/quality-gate" + "/usr/local/bin/quality-gate" +) + +for candidate in "${rust_candidates[@]}"; do + if [[ -x "$candidate" ]]; then + exec "$candidate" --root "$ROOT_DIR" "$@" + fi +done + +echo "[1/5] Bash syntax check" +find aw-server proxmox scripts -type f -name "*.sh" -print0 | xargs -0 -r -n1 bash -n + +echo "[2/5] Shellcheck (if available)" +if command -v shellcheck >/dev/null 2>&1; then + { + find aw-server proxmox -type f -name "*.sh" + printf '%s\n' scripts/aw-webui-browser-smoke.sh + } | xargs -r shellcheck -e SC1007,SC1090,SC2016 +else + echo "shellcheck not found, skipping." +fi + +echo "[3/5] Node syntax check (if node available)" +if command -v node >/dev/null 2>&1; then + node --check scripts/aw-webui-browser-smoke.mjs >/dev/null +else + echo "node not found, skipping." +fi + +echo "[4/5] PowerShell parse check (if pwsh available)" +if command -v pwsh >/dev/null 2>&1; then + pwsh -NoLogo -NoProfile -Command ' + $ErrorActionPreference = "Stop" + Get-ChildItem windows -Filter *.ps1 | ForEach-Object { + [void][System.Management.Automation.Language.Parser]::ParseFile($_.FullName,[ref]$null,[ref]$null) + } + [void][System.Management.Automation.Language.Parser]::ParseFile((Resolve-Path "windows/ActivityWatch.Windows.Common.psm1"),[ref]$null,[ref]$null) + [void][System.Management.Automation.Language.Parser]::ParseFile((Resolve-Path "windows/ActivityWatch.Windows.Common.psd1"),[ref]$null,[ref]$null) + ' + pwsh -NoLogo -NoProfile -File windows/aw-collector-guard.ps1 -SelfTest >/dev/null +else + echo "pwsh not found, skipping." +fi + + + +echo "[5/5] Ansible syntax check (if ansible-playbook available)" +if command -v ansible-playbook >/dev/null 2>&1; then + for playbook in ansible/*.yml; do + ansible-playbook --syntax-check "$playbook" -i ansible/inventory.example.ini >/dev/null + done +else + echo "ansible-playbook not found, skipping." +fi + +echo "quality-gate: OK" diff --git a/install-kit-awindows-20260427-211240/scripts/rebuild_install_kit.sh b/install-kit-awindows-20260427-211240/scripts/rebuild_install_kit.sh new file mode 100644 index 0000000..13936f0 --- /dev/null +++ b/install-kit-awindows-20260427-211240/scripts/rebuild_install_kit.sh @@ -0,0 +1,172 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +TARGET_ROOT="${CARGO_TARGET_DIR:-$ROOT_DIR/adk-rust/target}" +RUST_BIN="${REBUILD_INSTALL_KIT_RUST:-}" + +rust_candidates=() +if [[ -n "$RUST_BIN" ]]; then + rust_candidates+=("$RUST_BIN") +fi +rust_candidates+=( + "$TARGET_ROOT/release/rebuild-install-kit" + "$ROOT_DIR/adk-rust/target/release/rebuild-install-kit" + "/usr/local/bin/rebuild-install-kit" +) + +for candidate in "${rust_candidates[@]}"; do + if [[ -x "$candidate" ]]; then + exec "$candidate" --root "$ROOT_DIR" "$@" + fi +done + +KIT_DIR="install-kit-awindows-20260427-211240" +ZIP_ARCHIVE="${KIT_DIR}.zip" +TAR_ARCHIVE="${KIT_DIR}.tar.gz" +SERVER_CONFIG_DIR="${KIT_DIR}/server-configs-192.168.100.18" +OLD_SERVER_CONFIG_DIR="${KIT_DIR}/server-configs-192.168.100.21" +TMP_SERVER_CONFIG_DIR="$(mktemp -d)" +trap 'rm -rf "$TMP_SERVER_CONFIG_DIR"' EXIT + +copy_file() { + local src="$1" + local dest="$2" + mkdir -p "$(dirname "$dest")" + cp "$src" "$dest" +} + +sync_tree() { + local base="$1" + shift + for rel in "$@"; do + copy_file "$rel" "${KIT_DIR}/${rel}" + done +} + +if [[ -d "$OLD_SERVER_CONFIG_DIR" ]]; then + cp "$OLD_SERVER_CONFIG_DIR"/*.deployment-config.json "$TMP_SERVER_CONFIG_DIR"/ +fi +if [[ -d "$SERVER_CONFIG_DIR" ]]; then + cp "$SERVER_CONFIG_DIR"/*.deployment-config.json "$TMP_SERVER_CONFIG_DIR"/ +fi + +rm -rf "${KIT_DIR}/ansible" "${KIT_DIR}/aw-server" "${KIT_DIR}/windows" "${KIT_DIR}/scripts" "${KIT_DIR}/server-configs-"* + +ansible_files=( + ansible/README.md + ansible/deploy_aw_pfsense_poller.yml + ansible/deploy_aw_server.yml + ansible/deploy_aw_windows.yml + ansible/group_vars/all.example.yml + ansible/group_vars/pfsense-poller.example.yml + ansible/group_vars/proxmox-matrix.example.yml + ansible/group_vars/proxmox.example.yml + ansible/group_vars/windows.example.yml + ansible/install_full_stack.yml + ansible/inventory.example.ini + ansible/provision_proxmox_ct_and_deploy_aw.yml + ansible/provision_proxmox_ct_matrix_and_deploy_aw.yml + ansible/tasks/provision_ct_and_deploy_aw.yml +) + +aw_server_files=( + aw-server/activitywatch-server.service + aw-server/apply_webui_ru_patch.sh + aw-server/aw-host-groups.json + aw-server/aw-ru-patch.js + aw-server/aw-rus-healthd.py + aw-server/aw-rus-healthd.service + aw-server/aw-rus-healthd.timer + aw-server/aw-browser-smoke.service + aw-server/aw-browser-smoke.timer + aw-server/aw-slo-monitor.py + aw-server/aw-slo-monitor.service + aw-server/aw-slo-monitor.timer + aw-server/aw-server.env.example + aw-server/aw-sw-cleanup.js + aw-server/aw-worktime-api.py + aw-server/aw-worktime-api.service + aw-server/aw-worktime-prewarm.sh + aw-server/aw-worktime-prewarm.service + aw-server/aw-worktime-prewarm.timer + aw-server/aw-worktime-panel.js + aw-server/health-check.sh + aw-server/install_aw_server.sh + aw-server/settings/classes-worktime.json + aw-server/settings/views-default.json +) + +windows_files=( + windows/ActivityWatch.Windows.Common.psd1 + windows/ActivityWatch.Windows.Common.psm1 + windows/browser-domains-native-collector.ps1 + windows/deploy-domain-users.ps1 + windows/deploy-ensemble.ps1 + windows/deploy-single-user.ps1 + windows/AWatchRusCollectorGuardService.cs + windows/aw-collector-guard.ps1 + windows/install-collector-guard-service.ps1 + windows/dlp-endpoint-signals-collector.ps1 + windows/dlp-policy.example.json + windows/dlp-policy.native-cross-os.example.json + windows/email-outbound-collector.ps1 + windows/hardening-recovery.ps1 + windows/migrate-awatch-rus-paths.ps1 + windows/validate-deployment.ps1 + windows/web-category-rules.example.json + windows/worktime-session-collector.ps1 +) + +scripts_files=( + scripts/aw-webui-browser-smoke.mjs + scripts/aw-webui-browser-smoke.sh + scripts/check_install_kit_vs_repo.sh + scripts/quality-gate.sh + scripts/rebuild_install_kit.sh + scripts/validate_install_kit.sh + scripts/verify_innosetup_installer.sh +) + +sync_tree ansible "${ansible_files[@]}" +sync_tree aw-server "${aw_server_files[@]}" +sync_tree windows "${windows_files[@]}" +sync_tree scripts "${scripts_files[@]}" + +mkdir -p "$SERVER_CONFIG_DIR" +if compgen -G "$TMP_SERVER_CONFIG_DIR/*.deployment-config.json" >/dev/null; then + cp "$TMP_SERVER_CONFIG_DIR"/*.deployment-config.json "$SERVER_CONFIG_DIR"/ +fi + +cat > "${KIT_DIR}/README-INSTALL-KIT.txt" <<'EOF' +ActivityWatch DetMir Windows Install Kit + +Includes: +- windows/* (deploy scripts, collectors, common module, configs/examples) +- ansible/* (Windows and AW server playbooks, examples, inventory, tasks) +- aw-server/* (server installer, health orchestrator, RU patch loader, host groups, default settings) +- scripts/* (install-kit rebuild/validation, quality gates and browser/web smoke checks) +- server-configs-192.168.100.18/* (working Windows/RDP config snapshots) + +Source: +- Local project snapshot at build time. +EOF + +python3 - <<'PY' +from pathlib import Path +import hashlib + +root = Path('install-kit-awindows-20260427-211240') +manifest = root / 'MANIFEST.txt' +files = sorted(p for p in root.rglob('*') if p.is_file() and p.name != 'MANIFEST.txt') +with manifest.open('w', encoding='utf-8') as handle: + for path in files: + digest = hashlib.sha256(path.read_bytes()).hexdigest() + handle.write(f"{digest} {path.as_posix()}\n") +PY + +rm -f "$ZIP_ARCHIVE" "$TAR_ARCHIVE" +zip -rq "$ZIP_ARCHIVE" "$KIT_DIR" +tar -czf "$TAR_ARCHIVE" "$KIT_DIR" diff --git a/install-kit-awindows-20260427-211240/scripts/validate_install_kit.sh b/install-kit-awindows-20260427-211240/scripts/validate_install_kit.sh new file mode 100644 index 0000000..94b924a --- /dev/null +++ b/install-kit-awindows-20260427-211240/scripts/validate_install_kit.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +KIT_DIR="install-kit-awindows-20260427-211240" +MANIFEST="$KIT_DIR/MANIFEST.txt" +ZIP_ARCHIVE="install-kit-awindows-20260427-211240.zip" +TAR_ARCHIVE="install-kit-awindows-20260427-211240.tar.gz" +TARGET_ROOT="${CARGO_TARGET_DIR:-$ROOT_DIR/adk-rust/target}" +RUST_BIN="${VALIDATE_INSTALL_KIT_RUST:-}" + +rust_candidates=() +if [[ -n "$RUST_BIN" ]]; then + rust_candidates+=("$RUST_BIN") +fi +rust_candidates+=( + "$TARGET_ROOT/release/validate-install-kit" + "$ROOT_DIR/adk-rust/target/release/validate-install-kit" + "/usr/local/bin/validate-install-kit" +) + +for candidate in "${rust_candidates[@]}"; do + if [[ -x "$candidate" ]]; then + exec "$candidate" \ + --root "$ROOT_DIR" \ + --kit-dir "$KIT_DIR" \ + --zip-archive "$ZIP_ARCHIVE" \ + --tar-archive "$TAR_ARCHIVE" \ + "$@" + fi +done + +required_files=( + "$MANIFEST" + "$KIT_DIR/README-INSTALL-KIT.txt" + "$KIT_DIR/windows/deploy-ensemble.ps1" + "$KIT_DIR/windows/validate-deployment.ps1" + "$KIT_DIR/ansible/deploy_aw_windows.yml" + "$KIT_DIR/aw-server/install_aw_server.sh" + "$KIT_DIR/scripts/rebuild_install_kit.sh" + "$KIT_DIR/scripts/validate_install_kit.sh" + "$KIT_DIR/scripts/check_install_kit_vs_repo.sh" + "$KIT_DIR/scripts/quality-gate.sh" +) + +echo "[1/4] Required files presence" +for file in "${required_files[@]}"; do + [[ -f "$file" ]] || { echo "Missing required file: $file"; exit 1; } +done + +echo "[2/4] Manifest checksum verification" +sha256sum -c "$MANIFEST" >/dev/null + +echo "[3/4] Manifest completeness" +python3 - <<'PY' +from pathlib import Path +import sys +kit=Path('install-kit-awindows-20260427-211240') +manifest=kit/'MANIFEST.txt' +listed=[] +for line in manifest.read_text().splitlines(): + line=line.strip() + if not line: + continue + parts=line.split(' ',1) + if len(parts)!=2: + print(f'Invalid MANIFEST line: {line}') + sys.exit(1) + listed.append(parts[1]) +listed_set=set(listed) +actual_set={str(p) for p in kit.rglob('*') if p.is_file() and p.name!='MANIFEST.txt'} +missing=sorted(listed_set-actual_set) +extra=sorted(actual_set-listed_set) +if missing or extra: + print('Missing files listed in MANIFEST:', missing) + print('Files not listed in MANIFEST:', extra) + sys.exit(1) +print(f'MANIFEST complete: {len(actual_set)} files tracked') +PY + +echo "[4/4] Archive composition check" +python3 - <<'PY' +from pathlib import Path +import tarfile, zipfile, sys +kit_prefix='install-kit-awindows-20260427-211240/' +zip_path=Path('install-kit-awindows-20260427-211240.zip') +tar_path=Path('install-kit-awindows-20260427-211240.tar.gz') +if not zip_path.exists() or not tar_path.exists(): + print('Archives not found') + sys.exit(1) +with zipfile.ZipFile(zip_path) as z: + zip_files=sorted(i for i in z.namelist() if not i.endswith('/')) +with tarfile.open(tar_path, 'r:gz') as t: + tar_files=sorted(m.name for m in t.getmembers() if m.isfile()) +if zip_files != tar_files: + print('ZIP and TAR contents differ') + sys.exit(1) +if not all(f.startswith(kit_prefix) for f in zip_files): + print('Unexpected archive prefix layout') + sys.exit(1) +print(f'Archives match: {len(zip_files)} files') +PY + +echo "validate_install_kit: OK" diff --git a/install-kit-awindows-20260427-211240/scripts/verify_innosetup_installer.sh b/install-kit-awindows-20260427-211240/scripts/verify_innosetup_installer.sh new file mode 100644 index 0000000..fb89657 --- /dev/null +++ b/install-kit-awindows-20260427-211240/scripts/verify_innosetup_installer.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +INSTALLER="${1:-windows/installkit/innosetup/AWatch-rus-InstallKit.exe}" +WINEPREFIX_VERIFY="${WINEPREFIX_VERIFY:-/tmp/aw-inno-verify-wineprefix}" +TARGET_ROOT="${CARGO_TARGET_DIR:-$ROOT_DIR/adk-rust/target}" +RUST_BIN="${VERIFY_INNOSETUP_INSTALLER_RUST:-}" +INSTALL_DIR_WIN='C:\AWatchRusExtract' +INSTALL_DIR_UNIX="${WINEPREFIX_VERIFY}/drive_c/AWatchRusExtract" + +rust_candidates=() +if [[ -n "$RUST_BIN" ]]; then + rust_candidates+=("$RUST_BIN") +fi +rust_candidates+=( + "$TARGET_ROOT/release/verify-innosetup-installer" + "$ROOT_DIR/adk-rust/target/release/verify-innosetup-installer" + "/usr/local/bin/verify-innosetup-installer" +) + +for candidate in "${rust_candidates[@]}"; do + if [[ -x "$candidate" ]]; then + exec "$candidate" "$INSTALLER" --root "$ROOT_DIR" --wineprefix "$WINEPREFIX_VERIFY" "${@:2}" + fi +done + +if [[ ! -f "$INSTALLER" ]]; then + echo "Installer not found: $INSTALLER" >&2 + exit 1 +fi + +if ! command -v wine >/dev/null 2>&1; then + echo "wine not found" >&2 + exit 1 +fi + +rm -rf "$WINEPREFIX_VERIFY" +mkdir -p "$WINEPREFIX_VERIFY" +export WINEPREFIX="$WINEPREFIX_VERIFY" +export WINEDEBUG="${WINEDEBUG:--all}" + +wineboot -u >/dev/null 2>&1 +wine "$INSTALLER" /VERYSILENT /SUPPRESSMSGBOXES /NORESTART /SP- /TASKS="" /DIR="$INSTALL_DIR_WIN" >/dev/null 2>&1 +wineserver -w >/dev/null 2>&1 + +required_files=( + windows/AWatchRusCollectorGuardService.cs + windows/aw-collector-guard.ps1 + windows/install-collector-guard-service.ps1 + windows/dlp-policy.native-cross-os.example.json +) + +for rel in "${required_files[@]}"; do + extracted="${INSTALL_DIR_UNIX}/${rel}" + if [[ ! -f "$extracted" ]]; then + echo "Missing extracted file: $rel" >&2 + exit 1 + fi + if ! cmp -s "$rel" "$extracted"; then + echo "Extracted file differs from repo: $rel" >&2 + exit 1 + fi +done + +if ! grep -q 'collector guard self-test OK' "${INSTALL_DIR_UNIX}/windows/aw-collector-guard.ps1"; then + echo "Guard self-test marker missing in extracted installer payload" >&2 + exit 1 +fi + +echo "verify_innosetup_installer: OK" diff --git a/install-kit-awindows-20260427-211240/windows/AWatchRusCollectorGuardService.cs b/install-kit-awindows-20260427-211240/windows/AWatchRusCollectorGuardService.cs new file mode 100644 index 0000000..efcbdd1 --- /dev/null +++ b/install-kit-awindows-20260427-211240/windows/AWatchRusCollectorGuardService.cs @@ -0,0 +1,123 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.ServiceProcess; + +namespace AWatchRus +{ + public sealed class CollectorGuardService : ServiceBase + { + private Process child; + private readonly ServiceOptions options; + + public CollectorGuardService(ServiceOptions options) + { + this.options = options; + ServiceName = options.ServiceName; + CanStop = true; + CanShutdown = true; + } + + protected override void OnStart(string[] args) + { + Directory.CreateDirectory(Path.GetDirectoryName(options.LogPath)); + File.AppendAllText(options.LogPath, DateTime.Now.ToString("s") + " service starting" + Environment.NewLine); + + var psi = new ProcessStartInfo + { + FileName = options.PowerShellPath, + Arguments = string.Format( + "-NoProfile -ExecutionPolicy Bypass -File \"{0}\" -ConfigPath \"{1}\" -Mode {2} -LoopSeconds {3}", + options.ScriptPath, + options.ConfigPath, + options.Mode, + options.LoopSeconds), + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = false, + RedirectStandardError = false, + }; + child = Process.Start(psi); + File.AppendAllText(options.LogPath, DateTime.Now.ToString("s") + " child pid=" + child.Id + Environment.NewLine); + } + + protected override void OnStop() + { + StopChild("service stopping"); + } + + protected override void OnShutdown() + { + StopChild("system shutdown"); + } + + private void StopChild(string reason) + { + try + { + File.AppendAllText(options.LogPath, DateTime.Now.ToString("s") + " " + reason + Environment.NewLine); + if (child != null && !child.HasExited) + { + child.Kill(); + child.WaitForExit(10000); + } + } + catch (Exception ex) + { + try + { + File.AppendAllText(options.LogPath, DateTime.Now.ToString("s") + " stop error: " + ex.Message + Environment.NewLine); + } + catch + { + } + } + } + } + + public sealed class ServiceOptions + { + public string ServiceName = "AWatchRusCollectorGuard"; + public string PowerShellPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Windows), "System32\\WindowsPowerShell\\v1.0\\powershell.exe"); + public string ScriptPath = @"C:\Program Files\AWatch-rus\windows\aw-collector-guard.ps1"; + public string ConfigPath = @"C:\ProgramData\AWatch-rus\deployment-config.json"; + public string Mode = "shadow"; + public int LoopSeconds = 60; + public string LogPath = @"C:\ProgramData\AWatch-rus\logs\collector-guard-service.log"; + } + + internal static class Program + { + private static void Main(string[] args) + { + var options = Parse(args); + ServiceBase.Run(new CollectorGuardService(options)); + } + + private static ServiceOptions Parse(string[] args) + { + var options = new ServiceOptions(); + for (var i = 0; i < args.Length; i++) + { + var key = args[i].ToLowerInvariant(); + var value = i + 1 < args.Length ? args[i + 1] : null; + if (value == null || value.StartsWith("--", StringComparison.Ordinal)) + { + continue; + } + if (key == "--service-name") options.ServiceName = value; + else if (key == "--script") options.ScriptPath = value; + else if (key == "--config") options.ConfigPath = value; + else if (key == "--mode") options.Mode = value; + else if (key == "--loop") + { + int parsed; + if (int.TryParse(value, out parsed)) options.LoopSeconds = parsed; + } + else if (key == "--log") options.LogPath = value; + i++; + } + return options; + } + } +} diff --git a/install-kit-awindows-20260427-211240/windows/ActivityWatch.Windows.Common.psm1 b/install-kit-awindows-20260427-211240/windows/ActivityWatch.Windows.Common.psm1 index 3c68781..053a497 100755 --- a/install-kit-awindows-20260427-211240/windows/ActivityWatch.Windows.Common.psm1 +++ b/install-kit-awindows-20260427-211240/windows/ActivityWatch.Windows.Common.psm1 @@ -1,4 +1,4 @@ -Set-StrictMode -Version Latest +Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $script:ActivityWatchBuiltInAdministratorName = $null @@ -431,6 +431,22 @@ function Get-ActivityWatchTaskNameToken { return $buffer.ToString().Trim('_') } +function Test-ActivityWatchScheduledTaskExistsExact { + param([string]$TaskName) + + if ([string]::IsNullOrWhiteSpace($TaskName)) { + return $false + } + + try { + & schtasks.exe /Query /TN $TaskName *> $null + return ($LASTEXITCODE -eq 0) + } + catch { + return $false + } +} + function New-ActivityWatchUserTaskDefinitions { param( [Parameter(Mandatory = $true)] @@ -633,6 +649,119 @@ function Test-ActivityWatchUserHasLiveSession { return $false } +function Test-ActivityWatchSessionMatchesUserId { + param( + [Parameter(Mandatory = $true)] + [object]$SessionRecord, + [Parameter(Mandatory = $true)] + [string]$UserId + ) + + if ([string]::IsNullOrWhiteSpace($UserId) -or $null -eq $SessionRecord) { + return $false + } + + $sessionUser = [string]$SessionRecord.UserName + if ([string]::IsNullOrWhiteSpace($sessionUser)) { + return $false + } + + foreach ($candidate in @(Resolve-ActivityWatchUserCandidates -UserId $UserId)) { + if ($sessionUser -ieq $candidate -or + ('{0}\{1}' -f $env:COMPUTERNAME, $sessionUser) -ieq $candidate -or + ((-not [string]::IsNullOrWhiteSpace($env:USERDOMAIN)) -and ('{0}\{1}' -f $env:USERDOMAIN, $sessionUser) -ieq $candidate)) { + return $true + } + } + + return $false +} + +function Test-ActivityWatchUserHasManagedSession { + param( + [Parameter(Mandatory = $true)] + [string]$UserId, + [object[]]$SessionRecords, + [switch]$IncludeLive, + [switch]$IncludeDisconnected + ) + + foreach ($session in @($SessionRecords)) { + if ([int]$session.SessionId -le 0) { + continue + } + if ([string]::IsNullOrWhiteSpace([string]$session.UserName)) { + continue + } + if ([bool]$session.IsLive -and -not $IncludeLive.IsPresent) { + continue + } + if (-not [bool]$session.IsLive -and -not $IncludeDisconnected.IsPresent) { + continue + } + if (Test-ActivityWatchSessionMatchesUserId -SessionRecord $session -UserId $UserId) { + return $true + } + } + + return $false +} + +function Get-ActivityWatchManagedInteractiveSessions { + param( + [pscustomobject[]]$TaskDefinitions, + [object[]]$SessionRecords, + [switch]$IncludeLive, + [switch]$IncludeDisconnected + ) + + $result = New-Object System.Collections.Generic.List[object] + $seen = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) + + foreach ($taskDef in @($TaskDefinitions)) { + $userId = [string]$taskDef.userId + $taskName = [string]$taskDef.taskName + if ([string]::IsNullOrWhiteSpace($userId) -or [string]::IsNullOrWhiteSpace($taskName)) { + continue + } + + foreach ($session in @($SessionRecords)) { + if ([int]$session.SessionId -le 0) { + continue + } + if ([string]::IsNullOrWhiteSpace([string]$session.UserName)) { + continue + } + if ([bool]$session.IsLive -and -not $IncludeLive.IsPresent) { + continue + } + if (-not [bool]$session.IsLive -and -not $IncludeDisconnected.IsPresent) { + continue + } + if (-not (Test-ActivityWatchSessionMatchesUserId -SessionRecord $session -UserId $userId)) { + continue + } + + $key = '{0}|{1}|{2}' -f $taskName, [int]$session.SessionId, $userId + if (-not $seen.Add($key)) { + continue + } + + $result.Add([pscustomobject]@{ + TaskName = $taskName + UserId = $userId + SessionName = [string]$session.SessionName + SessionId = [int]$session.SessionId + State = [string]$session.State + UserName = [string]$session.UserName + IsLive = [bool]$session.IsLive + }) | Out-Null + } + } + + return @($result.ToArray()) +} + function Copy-ActivityWatchCollectorAssets { param( [Parameter(Mandatory = $true)] @@ -777,7 +906,7 @@ function New-ActivityWatchDeploymentConfig { [int]$EvtxRetentionDays = 14, [string[]]$EvtxChannels = @(), [bool]$LogonMarkerEnabled = $true, - [bool]$ProcessEventsEnabled = $true, + [bool]$ProcessEventsEnabled = $false, [Parameter(Mandatory = $true)] [string]$LaunchScriptPath, [Parameter(Mandatory = $true)] @@ -1410,6 +1539,10 @@ function Get-ActivityWatchRecoveryTaskDefinitions { foreach ($task in @($config.userTasks)) { $taskName = [string]$task.launchTaskName $userId = Normalize-ActivityWatchUserId -UserId ([string]$task.userId) + $canonicalTaskName = "ActivityWatch Launch [$((Get-ActivityWatchTaskNameToken -UserId $userId))]" + if ($canonicalTaskName -ne $taskName -and (Test-ActivityWatchScheduledTaskExistsExact -TaskName $canonicalTaskName)) { + $taskName = $canonicalTaskName + } if (-not [string]::IsNullOrWhiteSpace($taskName) -and -not $taskMap.Contains($taskName)) { $taskMap[$taskName] = [pscustomobject]@{ taskName = $taskName @@ -1679,13 +1812,24 @@ function Stop-ActivityWatchProcessesInNonLiveSessions { [Parameter(Mandatory = $true)] [object[]]$SessionRecords, [Parameter(Mandatory = $true)] - [pscustomobject]$Config + [pscustomobject]$Config, + [pscustomobject[]]$TaskDefinitions = @(), + [switch]$PreserveManagedSessions ) $stateRoot = if ($Config.paths.PSObject.Properties.Name -contains 'stateRoot') { [string]$Config.paths.stateRoot } else { Join-Path $env:ProgramData 'AWatch-rus' } + $preservedSessionIds = @() + if ($PreserveManagedSessions.IsPresent) { + $preservedSessionIds = @( + Get-ActivityWatchManagedInteractiveSessions -TaskDefinitions $TaskDefinitions -SessionRecords $SessionRecords -IncludeDisconnected | + ForEach-Object { [int]$_.SessionId } | + Sort-Object -Unique + ) + } + $sessionIds = @( $SessionRecords | - Where-Object { -not $_.IsLive -and $_.SessionId -gt 0 } | + Where-Object { -not $_.IsLive -and $_.SessionId -gt 0 -and ($preservedSessionIds -notcontains [int]$_.SessionId) } | ForEach-Object { [int]$_.SessionId } | Sort-Object -Unique ) @@ -1704,7 +1848,7 @@ function Stop-ActivityWatchProcessesInNonLiveSessions { } } - foreach ($session in @($SessionRecords | Where-Object { -not $_.IsLive -and $_.SessionId -gt 0 })) { + foreach ($session in @($SessionRecords | Where-Object { -not $_.IsLive -and $_.SessionId -gt 0 -and ($preservedSessionIds -notcontains [int]$_.SessionId) })) { Remove-ActivityWatchLogonMarkersForSession -StateRoot $stateRoot -SessionId ([int]$session.SessionId) -UserName ([string]$session.UserName) } @@ -1885,7 +2029,7 @@ function Invoke-ActivityWatchRecoveryLoop { $config = Read-ActivityWatchDeploymentConfig -Path $ConfigPath $taskDefs = Get-ActivityWatchRecoveryTaskDefinitions -ConfigPaths $configPaths $sessionRecords = Get-ActivityWatchSessionRecords - Stop-ActivityWatchProcessesInNonLiveSessions -SessionRecords $sessionRecords -Config $config + Stop-ActivityWatchProcessesInNonLiveSessions -SessionRecords $sessionRecords -Config $config -TaskDefinitions $taskDefs -PreserveManagedSessions $sessionRecords = Get-ActivityWatchSessionRecords $stateRoot = [string]$config.paths.stateRoot $sessionCollectorScript = if ($config.paths.PSObject.Properties.Name -contains 'sessionCollectorScript') { [string]$config.paths.sessionCollectorScript } else { Join-Path $stateRoot 'worktime-session-collector.ps1' } diff --git a/install-kit-awindows-20260427-211240/windows/aw-collector-guard.ps1 b/install-kit-awindows-20260427-211240/windows/aw-collector-guard.ps1 new file mode 100644 index 0000000..559995e --- /dev/null +++ b/install-kit-awindows-20260427-211240/windows/aw-collector-guard.ps1 @@ -0,0 +1,681 @@ +[CmdletBinding()] +param( + [string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json', + [ValidateSet('shadow', 'enforce')] + [string]$Mode = 'shadow', + [int]$LoopSeconds = 60, + [int]$InteractiveMaxAgeSeconds = 900, + [int]$HeadlessMaxAgeSeconds = 900, + [int]$RestartWindowSeconds = 600, + [int]$MaxRestarts = 3, + [int]$ActionCooldownSeconds = 300, + [int]$InteractiveActionCooldownSeconds = 60, + [switch]$Once, + [switch]$HeadlessEndpointEnabled, + [switch]$HeadlessFileOpsEnabled, + [switch]$SelfTest +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$modulePath = Join-Path $PSScriptRoot 'ActivityWatch.Windows.Common.psm1' +Import-Module $modulePath -Force + +function New-GuardLock { + param([string]$StateRoot) + + if (-not (Test-Path -LiteralPath $StateRoot)) { + New-Item -Path $StateRoot -ItemType Directory -Force | Out-Null + } + + $lockPath = Join-Path $StateRoot 'collector-guard.lock' + if (Test-Path -LiteralPath $lockPath) { + try { + $lockData = Get-Content -LiteralPath $lockPath -Raw | ConvertFrom-Json + $existingPid = [int]$lockData.pid + if ($existingPid -gt 0 -and (Get-Process -Id $existingPid -ErrorAction SilentlyContinue)) { + return $null + } + } + catch { + } + } + + $payload = @{ + pid = $PID + createdAt = (Get-Date).ToUniversalTime().ToString('o') + } | ConvertTo-Json -Compress + Set-Content -LiteralPath $lockPath -Value $payload -Encoding UTF8 + return $lockPath +} + +function Write-GuardLog { + param( + [string]$LogPath, + [string]$Message + ) + + try { + $directory = Split-Path -Path $LogPath -Parent + if ($directory -and -not (Test-Path -LiteralPath $directory)) { + New-Item -Path $directory -ItemType Directory -Force | Out-Null + } + Add-Content -LiteralPath $LogPath -Value ('{0} {1}' -f (Get-Date -Format s), $Message) + } + catch { + } +} + +function Get-AwApiBase { + param([pscustomobject]$Config) + + $scheme = if ($Config.server.PSObject.Properties.Name -contains 'scheme') { [string]$Config.server.scheme } else { 'http' } + $hostName = [string]$Config.server.host + $port = [int]$Config.server.port + return ('{0}://{1}:{2}/api/0' -f $scheme, $hostName, $port) +} + +function Invoke-AwJson { + param( + [Parameter(Mandatory = $true)] + [string]$Method, + [Parameter(Mandatory = $true)] + [string]$Uri, + [object]$Body + ) + + $params = @{ + Method = $Method + Uri = $Uri + TimeoutSec = 15 + ErrorAction = 'Stop' + } + if ($null -ne $Body) { + $params.Body = ($Body | ConvertTo-Json -Depth 16 -Compress) + $params.ContentType = 'application/json' + } + return Invoke-RestMethod @params +} + +function Ensure-AwBucket { + param( + [string]$ApiBase, + [string]$BucketId, + [string]$ClientName, + [string]$BucketType, + [string]$Hostname + ) + + try { + Invoke-AwJson -Method 'GET' -Uri "$ApiBase/buckets/$BucketId" | Out-Null + return $true + } + catch { + } + + try { + $body = @{ + client = $ClientName + type = $BucketType + hostname = $Hostname + } + Invoke-AwJson -Method 'POST' -Uri "$ApiBase/buckets/$BucketId" -Body $body | Out-Null + return $true + } + catch { + return $false + } +} + +function Get-LatestBucketAge { + param( + [string]$ApiBase, + [string]$BucketId + ) + + try { + $events = Invoke-AwJson -Method 'GET' -Uri "$ApiBase/buckets/$BucketId/events?limit=20" + $latest = @($events | Where-Object { $null -ne $_.timestamp } | Sort-Object timestamp -Descending | Select-Object -First 1) + if (-not $latest) { + return [pscustomobject]@{ bucket = $BucketId; found = $false; timestamp = $null; ageSeconds = $null } + } + $ts = [DateTimeOffset]::Parse([string]$latest.timestamp).UtcDateTime + $age = [Math]::Max(0, [int]((Get-Date).ToUniversalTime() - $ts).TotalSeconds) + return [pscustomobject]@{ bucket = $BucketId; found = $true; timestamp = [string]$latest.timestamp; ageSeconds = $age } + } + catch { + return [pscustomobject]@{ bucket = $BucketId; found = $false; timestamp = $null; ageSeconds = $null; error = $_.Exception.Message } + } +} + +function Send-GuardHeartbeat { + param( + [string]$ApiBase, + [string]$Hostname, + [object]$State, + [int]$PulseSeconds + ) + + $bucketId = "aw-rus-collector-guard_$Hostname" + if (-not (Ensure-AwBucket -ApiBase $ApiBase -BucketId $bucketId -ClientName 'aw-rus-collector-guard' -BucketType 'aw.rus.collector.guard' -Hostname $Hostname)) { + return $false + } + + $event = @{ + timestamp = (Get-Date).ToUniversalTime().ToString('o') + duration = 0 + data = $State + } + + try { + Invoke-AwJson -Method 'POST' -Uri "$ApiBase/buckets/$bucketId/heartbeat?pulsetime=$PulseSeconds" -Body $event | Out-Null + return $true + } + catch { + return $false + } +} + +function Read-GuardRuntime { + param([string]$Path) + + if (-not (Test-Path -LiteralPath $Path)) { + return [pscustomobject]@{ restartHistory = @{}; lastAction = @{}; quarantine = @{} } + } + try { + $state = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json + if ($null -eq $state.restartHistory) { $state | Add-Member -NotePropertyName restartHistory -NotePropertyValue @{} } + if ($null -eq $state.lastAction) { $state | Add-Member -NotePropertyName lastAction -NotePropertyValue @{} } + if ($null -eq $state.quarantine) { $state | Add-Member -NotePropertyName quarantine -NotePropertyValue @{} } + return $state + } + catch { + return [pscustomobject]@{ restartHistory = @{}; lastAction = @{}; quarantine = @{} } + } +} + +function Write-GuardRuntime { + param( + [string]$Path, + [object]$Runtime + ) + + $directory = Split-Path -Path $Path -Parent + if ($directory -and -not (Test-Path -LiteralPath $directory)) { + New-Item -Path $directory -ItemType Directory -Force | Out-Null + } + $Runtime | ConvertTo-Json -Depth 16 | Set-Content -LiteralPath $Path -Encoding UTF8 +} + +function Get-RuntimeMapValue { + param( + [object]$Map, + [string]$Key + ) + + if ($null -eq $Map) { + return $null + } + if ($Map -is [hashtable] -and $Map.ContainsKey($Key)) { + return $Map[$Key] + } + $propertyNames = @($Map.PSObject.Properties | ForEach-Object { $_.Name }) + if ($propertyNames -contains $Key) { + return $Map.$Key + } + return $null +} + +function Set-RuntimeMapValue { + param( + [object]$Map, + [string]$Key, + [object]$Value + ) + + if ($Map -is [hashtable]) { + $Map[$Key] = $Value + return + } + $propertyNames = @($Map.PSObject.Properties | ForEach-Object { $_.Name }) + if ($propertyNames -contains $Key) { + $Map.$Key = $Value + } + else { + $Map | Add-Member -NotePropertyName $Key -NotePropertyValue $Value -Force + } +} + +function Remove-RuntimeMapValue { + param( + [object]$Map, + [string]$Key + ) + + if ($null -eq $Map) { + return + } + if ($Map -is [hashtable]) { + if ($Map.ContainsKey($Key)) { + $Map.Remove($Key) + } + return + } + $property = $Map.PSObject.Properties[$Key] + if ($null -ne $property) { + $Map.PSObject.Properties.Remove($Key) + } +} + +function Reset-GuardActionBudget { + param( + [object]$Runtime, + [string]$Key + ) + + Remove-RuntimeMapValue -Map $Runtime.restartHistory -Key $Key + Remove-RuntimeMapValue -Map $Runtime.lastAction -Key $Key + Remove-RuntimeMapValue -Map $Runtime.quarantine -Key $Key +} + +function Invoke-GuardSelfTest { + $emptyObject = [pscustomobject]@{} + if ($null -ne (Get-RuntimeMapValue -Map $emptyObject -Key 'missing')) { + throw 'empty PSCustomObject should not return a missing runtime-map value' + } + Set-RuntimeMapValue -Map $emptyObject -Key 'headless:worktime-session' -Value 123 + if ((Get-RuntimeMapValue -Map $emptyObject -Key 'headless:worktime-session') -ne 123) { + throw 'failed to set runtime-map value on empty PSCustomObject' + } + + $hash = @{} + Set-RuntimeMapValue -Map $hash -Key 'headless:worktime-session' -Value @(1, 2) + $hashValue = @(Get-RuntimeMapValue -Map $hash -Key 'headless:worktime-session') + if ($hashValue.Count -ne 2) { + throw 'failed to round-trip runtime-map value on hashtable' + } + + $runtime = [pscustomobject]@{ restartHistory = [pscustomobject]@{}; lastAction = [pscustomobject]@{}; quarantine = [pscustomobject]@{} } + $allowed = Test-ActionAllowed -Runtime $runtime -Key 'headless:worktime-session' -CooldownSeconds 1 -WindowSeconds 60 -MaxCount 3 + if (-not $allowed.allowed) { + throw "expected action to be allowed, got $($allowed.reason)" + } + Register-GuardAction -Runtime $runtime -Key 'headless:worktime-session' + $blocked = Test-ActionAllowed -Runtime $runtime -Key 'headless:worktime-session' -CooldownSeconds 300 -WindowSeconds 60 -MaxCount 3 + if ($blocked.allowed -or $blocked.reason -ne 'cooldown') { + throw 'expected cooldown after registering guard action' + } + $budgetRuntime = [pscustomobject]@{ restartHistory = [pscustomobject]@{}; lastAction = [pscustomobject]@{}; quarantine = [pscustomobject]@{} } + foreach ($i in 1..3) { + Register-GuardAction -Runtime $budgetRuntime -Key 'task:test' + } + $budgetBlocked = Test-ActionAllowed -Runtime $budgetRuntime -Key 'task:test' -CooldownSeconds 0 -WindowSeconds 600 -MaxCount 3 + if ($budgetBlocked.allowed -or $budgetBlocked.reason -ne 'quarantine') { + throw 'expected quarantine when restart budget is exhausted' + } + Reset-GuardActionBudget -Runtime $budgetRuntime -Key 'task:test' + $budgetAllowed = Test-ActionAllowed -Runtime $budgetRuntime -Key 'task:test' -CooldownSeconds 0 -WindowSeconds 600 -MaxCount 3 + if (-not $budgetAllowed.allowed) { + throw 'expected reset action budget to clear quarantine' + } + + $oldComputerName = $env:COMPUTERNAME + try { + $env:COMPUTERNAME = 'SHARKON2025' + $sessionRecords = @( + [pscustomobject]@{ SessionName = 'USER5'; UserName = 'USER5'; SessionId = 2; State = 'Disc'; IsLive = $false }, + [pscustomobject]@{ SessionName = 'console'; UserName = ''; SessionId = 1; State = 'Conn'; IsLive = $true } + ) + $taskDefs = @( + [pscustomobject]@{ taskName = 'ActivityWatch Launch [SHARKON2025_user5]'; userId = 'SHARKON2025\user5' } + ) + if (-not (Test-ActivityWatchUserHasManagedSession -UserId 'SHARKON2025\user5' -SessionRecords $sessionRecords -IncludeDisconnected)) { + throw 'expected disconnected managed session to match task user' + } + if (Test-ActivityWatchUserHasManagedSession -UserId 'SHARKON2025\user5' -SessionRecords $sessionRecords -IncludeLive) { + throw 'disconnected managed session should not match live-only filter' + } + $managed = @(Get-ActivityWatchManagedInteractiveSessions -TaskDefinitions $taskDefs -SessionRecords $sessionRecords -IncludeDisconnected) + if ($managed.Count -ne 1 -or [int]$managed[0].SessionId -ne 2) { + throw 'failed to enumerate disconnected managed session' + } + } + finally { + if ($null -eq $oldComputerName) { + Remove-Item Env:COMPUTERNAME -ErrorAction SilentlyContinue + } + else { + $env:COMPUTERNAME = $oldComputerName + } + } + + Write-Output 'collector guard self-test OK' +} + +function Test-ActionAllowed { + param( + [object]$Runtime, + [string]$Key, + [int]$CooldownSeconds, + [int]$WindowSeconds, + [int]$MaxCount + ) + + $now = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() + $last = Get-RuntimeMapValue -Map $Runtime.lastAction -Key $Key + if ($null -ne $last -and ($now - [int64]$last) -lt $CooldownSeconds) { + return [pscustomobject]@{ allowed = $false; reason = 'cooldown' } + } + + $history = @(Get-RuntimeMapValue -Map $Runtime.restartHistory -Key $Key) + $history = @($history | Where-Object { ($now - [int64]$_) -le $WindowSeconds }) + Set-RuntimeMapValue -Map $Runtime.restartHistory -Key $Key -Value @($history) + if ($history.Count -ge $MaxCount) { + Set-RuntimeMapValue -Map $Runtime.quarantine -Key $Key -Value @{ + since = (Get-Date).ToUniversalTime().ToString('o') + reason = 'restart-budget-exhausted' + count = $history.Count + } + return [pscustomobject]@{ allowed = $false; reason = 'quarantine' } + } + + Remove-RuntimeMapValue -Map $Runtime.quarantine -Key $Key + return [pscustomobject]@{ allowed = $true; reason = 'ok' } +} + +function Register-GuardAction { + param( + [object]$Runtime, + [string]$Key + ) + + $now = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() + $history = @(Get-RuntimeMapValue -Map $Runtime.restartHistory -Key $Key) + $history += $now + Set-RuntimeMapValue -Map $Runtime.restartHistory -Key $Key -Value @($history) + Set-RuntimeMapValue -Map $Runtime.lastAction -Key $Key -Value $now +} + +function Get-CollectorProcessSnapshot { + param([pscustomobject]$Config) + + $scriptPaths = [ordered]@{} + foreach ($name in @('collectorScript', 'endpointCollectorScript', 'fileCollectorScript', 'emailCollectorScript', 'sessionCollectorScript')) { + if ($Config.paths.PSObject.Properties.Name -contains $name) { + $value = [string]$Config.paths.$name + if (-not [string]::IsNullOrWhiteSpace($value)) { + $scriptPaths[$name] = $value + } + } + } + + $powershellCollectors = @() + try { + $processes = @(Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | + Where-Object { $_.Name -ieq 'powershell.exe' -or $_.Name -ieq 'pwsh.exe' }) + foreach ($proc in $processes) { + $commandLine = [string]$proc.CommandLine + foreach ($entry in $scriptPaths.GetEnumerator()) { + if ($commandLine -match [Regex]::Escape([string]$entry.Value)) { + $powershellCollectors += [pscustomobject]@{ + name = [string]$entry.Key + processId = [int]$proc.ProcessId + sessionId = [int]$proc.SessionId + scriptPath = [string]$entry.Value + } + } + } + } + } + catch { + } + + $watchers = @() + try { + $watchers = @(Get-Process -Name 'aw-watcher-afk','aw-watcher-window' -ErrorAction SilentlyContinue | + Select-Object @{Name='name'; Expression={$_.Name}}, @{Name='processId'; Expression={$_.Id}}, @{Name='sessionId'; Expression={$_.SessionId}}) + } + catch { + $watchers = @() + } + + return [pscustomobject]@{ + watchers = @($watchers) + collectors = @($powershellCollectors) + } +} + +function Invoke-ExactTaskRun { + param([string]$TaskName) + + & schtasks.exe /Run /TN $TaskName | Out-Null + return ($LASTEXITCODE -eq 0) +} + +function Invoke-GuardCycle { + param( + [object]$Runtime, + [string]$RuntimePath, + [string]$LogPath + ) + + $config = Read-ActivityWatchDeploymentConfig -Path $ConfigPath + $stateRoot = if ($config.paths.PSObject.Properties.Name -contains 'stateRoot') { [string]$config.paths.stateRoot } else { Split-Path -Path $ConfigPath -Parent } + $hostname = if ($config.PSObject.Properties.Name -contains 'awHostname' -and -not [string]::IsNullOrWhiteSpace([string]$config.awHostname)) { [string]$config.awHostname } else { [string]$env:COMPUTERNAME } + $apiBase = Get-AwApiBase -Config $config + $configPaths = Get-ActivityWatchRecoveryConfigPaths -PrimaryConfigPath $ConfigPath + $taskDefs = @(Get-ActivityWatchRecoveryTaskDefinitions -ConfigPaths $configPaths) + $sessionRecords = @(Get-ActivityWatchSessionRecords) + $liveSessions = @(Get-ActivityWatchLiveInteractiveSessions -SessionRecords $sessionRecords) + $managedInteractiveSessions = @(Get-ActivityWatchManagedInteractiveSessions -TaskDefinitions $taskDefs -SessionRecords $sessionRecords -IncludeLive -IncludeDisconnected) + $processSnapshot = Get-CollectorProcessSnapshot -Config $config + $liveSessionIds = @($liveSessions | ForEach-Object { [int]$_.SessionId }) + $managedSessionIds = @($managedInteractiveSessions | ForEach-Object { [int]$_.SessionId } | Sort-Object -Unique) + + $bucketChecks = [ordered]@{} + foreach ($bucket in @( + "aw-worktime-sessions_$hostname", + "aw-watcher-afk_$hostname", + "aw-watcher-window_$hostname", + "aw-dlp-endpoint-signals_$hostname" + )) { + $bucketChecks[$bucket] = Get-LatestBucketAge -ApiBase $apiBase -BucketId $bucket + } + + $actions = New-Object System.Collections.Generic.List[object] + $problems = New-Object System.Collections.Generic.List[string] + + $worktimeAge = $bucketChecks["aw-worktime-sessions_$hostname"].ageSeconds + $sessionCollectorScript = if ($config.paths.PSObject.Properties.Name -contains 'sessionCollectorScript') { [string]$config.paths.sessionCollectorScript } else { Join-Path $stateRoot 'worktime-session-collector.ps1' } + $sessionCollectorRunning = Test-ActivityWatchCollectorRunningGlobal -ScriptPath $sessionCollectorScript + $headlessKey = 'headless:worktime-session' + $needsHeadlessAction = (-not $sessionCollectorRunning -or $null -eq $worktimeAge -or [int]$worktimeAge -gt $HeadlessMaxAgeSeconds) + if ($needsHeadlessAction) { + $key = $headlessKey + $allowed = Test-ActionAllowed -Runtime $Runtime -Key $key -CooldownSeconds $ActionCooldownSeconds -WindowSeconds $RestartWindowSeconds -MaxCount $MaxRestarts + if ($allowed.allowed) { + if ($Mode -eq 'enforce') { + Start-ActivityWatchCollectorScriptGlobalIfNeeded -ScriptPath $sessionCollectorScript -ConfigPath $ConfigPath + Register-GuardAction -Runtime $Runtime -Key $key + Write-GuardLog -LogPath $LogPath -Message "started $key" + $actions.Add([pscustomobject]@{ action = 'start'; target = $key; applied = $true }) | Out-Null + } + else { + $actions.Add([pscustomobject]@{ action = 'start'; target = $key; applied = $false; mode = 'shadow' }) | Out-Null + } + } + else { + $problems.Add("$key action blocked: $($allowed.reason)") | Out-Null + } + } + else { + Reset-GuardActionBudget -Runtime $Runtime -Key $headlessKey + } + + if ($Mode -eq 'enforce') { + Stop-ActivityWatchProcessesInNonLiveSessions -SessionRecords $sessionRecords -Config $config -TaskDefinitions $taskDefs -PreserveManagedSessions + } + + $interactiveStale = $false + foreach ($bucket in @("aw-watcher-afk_$hostname", "aw-watcher-window_$hostname", "aw-dlp-endpoint-signals_$hostname")) { + $age = $bucketChecks[$bucket].ageSeconds + if ($null -eq $age -or [int]$age -gt $InteractiveMaxAgeSeconds) { + $interactiveStale = $true + } + } + + $watchersInLiveSessions = @($processSnapshot.watchers | Where-Object { $liveSessionIds -contains [int]$_.sessionId }) + $watchersInManagedSessions = @($processSnapshot.watchers | Where-Object { $managedSessionIds -contains [int]$_.sessionId }) + $liveWatcherMissing = $false + if ($liveSessions.Count -gt 0) { + $hasAfk = @($watchersInLiveSessions | Where-Object { [string]$_.name -ieq 'aw-watcher-afk' }).Count -gt 0 + $hasWindow = @($watchersInLiveSessions | Where-Object { [string]$_.name -ieq 'aw-watcher-window' }).Count -gt 0 + $liveWatcherMissing = (-not $hasAfk) -or (-not $hasWindow) + } + $managedWatcherMissing = $false + if ($managedInteractiveSessions.Count -gt 0) { + $afkEnabled = if ($config.PSObject.Properties.Name -contains 'collectors' -and $config.collectors.PSObject.Properties.Name -contains 'afkEnabled') { [bool]$config.collectors.afkEnabled } else { $true } + $windowEnabled = if ($config.PSObject.Properties.Name -contains 'collectors' -and $config.collectors.PSObject.Properties.Name -contains 'windowEnabled') { [bool]$config.collectors.windowEnabled } else { $true } + foreach ($managedSession in @($managedInteractiveSessions)) { + $sessionId = [int]$managedSession.SessionId + $sessionWatchers = @($watchersInManagedSessions | Where-Object { [int]$_.sessionId -eq $sessionId }) + $hasManagedAfk = @($sessionWatchers | Where-Object { [string]$_.name -ieq 'aw-watcher-afk' }).Count -gt 0 + $hasManagedWindow = @($sessionWatchers | Where-Object { [string]$_.name -ieq 'aw-watcher-window' }).Count -gt 0 + if (($afkEnabled -and -not $hasManagedAfk) -or ($windowEnabled -and -not $hasManagedWindow)) { + $managedWatcherMissing = $true + break + } + } + } + + if ($managedInteractiveSessions.Count -eq 0 -and $liveSessions.Count -gt 0 -and $interactiveStale) { + $problems.Add('interactive buckets stale but no managed interactive sessions found') | Out-Null + } + + $needsInteractiveTaskAction = $managedInteractiveSessions.Count -gt 0 -and ( + $liveWatcherMissing -or + $managedWatcherMissing -or + ($interactiveStale -and $liveSessions.Count -gt 0) + ) + + if ($needsInteractiveTaskAction) { + foreach ($taskDef in $taskDefs) { + if (-not (Test-ActivityWatchUserHasManagedSession -UserId ([string]$taskDef.userId) -SessionRecords $sessionRecords -IncludeLive -IncludeDisconnected)) { + continue + } + $key = "task:$($taskDef.taskName)" + $allowed = Test-ActionAllowed -Runtime $Runtime -Key $key -CooldownSeconds $InteractiveActionCooldownSeconds -WindowSeconds $RestartWindowSeconds -MaxCount $MaxRestarts + if (-not $allowed.allowed) { + $problems.Add("$key action blocked: $($allowed.reason)") | Out-Null + continue + } + + if ($Mode -eq 'enforce') { + $ok = Invoke-ExactTaskRun -TaskName ([string]$taskDef.taskName) + if ($ok) { + Register-GuardAction -Runtime $Runtime -Key $key + } + Write-GuardLog -LogPath $LogPath -Message ("run {0} ok={1}" -f $key, $ok) + $actions.Add([pscustomobject]@{ action = 'run-task'; target = [string]$taskDef.taskName; applied = $true; ok = $ok }) | Out-Null + } + else { + $actions.Add([pscustomobject]@{ action = 'run-task'; target = [string]$taskDef.taskName; applied = $false; mode = 'shadow' }) | Out-Null + } + } + } + else { + foreach ($taskDef in $taskDefs) { + if (Test-ActivityWatchUserHasManagedSession -UserId ([string]$taskDef.userId) -SessionRecords $sessionRecords -IncludeLive -IncludeDisconnected) { + Reset-GuardActionBudget -Runtime $Runtime -Key "task:$($taskDef.taskName)" + } + } + } + + $status = 'ok' + if ($problems.Count -gt 0) { + $status = 'warn' + } + if ($managedInteractiveSessions.Count -gt 0 -and $interactiveStale -and $Mode -eq 'shadow') { + $status = 'warn' + } + + $sessionState = @( + foreach ($session in @($sessionRecords)) { + [pscustomobject]@{ + SessionName = [string]$session.SessionName + UserName = [string]$session.UserName + SessionId = [int]$session.SessionId + State = [string]$session.State + IsLive = [bool]$session.IsLive + } + } + ) + + $state = @{} + $state['status'] = $status + $state['mode'] = $Mode + $state['host'] = $hostname + $state['generatedAtUtc'] = (Get-Date).ToUniversalTime().ToString('o') + $state['pid'] = $PID + $state['sessions'] = @($sessionState) + $state['liveSessionCount'] = $liveSessions.Count + $state['managedSessionCount'] = $managedInteractiveSessions.Count + $state['managedSessions'] = @($managedInteractiveSessions) + $bucketState = @{} + foreach ($key in $bucketChecks.Keys) { + $bucketState[$key] = $bucketChecks[$key] + } + + $state['processes'] = $processSnapshot + $state['buckets'] = $bucketState + $state['actions'] = @($actions.ToArray()) + $state['problems'] = @($problems.ToArray()) + $state['quarantine'] = $Runtime.quarantine + + $statePath = Join-Path $stateRoot 'collector-guard-state.json' + $state | ConvertTo-Json -Depth 16 | Set-Content -LiteralPath $statePath -Encoding UTF8 + Write-GuardRuntime -Path $RuntimePath -Runtime $Runtime + [void](Send-GuardHeartbeat -ApiBase $apiBase -Hostname $hostname -State $state -PulseSeconds ([Math]::Max($LoopSeconds * 2, 60))) + return $state +} + +if ($SelfTest) { + Invoke-GuardSelfTest + exit 0 +} + +$initialConfig = Read-ActivityWatchDeploymentConfig -Path $ConfigPath +$initialStateRoot = if ($initialConfig.paths.PSObject.Properties.Name -contains 'stateRoot') { [string]$initialConfig.paths.stateRoot } else { Split-Path -Path $ConfigPath -Parent } +$initialLogsRoot = if ($initialConfig.paths.PSObject.Properties.Name -contains 'logsRoot') { [string]$initialConfig.paths.logsRoot } else { Join-Path $initialStateRoot 'logs' } +$logPath = Join-Path $initialLogsRoot 'collector-guard.log' +$runtimePath = Join-Path $initialStateRoot 'collector-guard-runtime.json' +$lockPath = New-GuardLock -StateRoot $initialStateRoot +if (-not $lockPath) { + Write-GuardLog -LogPath $logPath -Message 'another collector guard instance is already running' + exit 0 +} + +try { + $runtime = Read-GuardRuntime -Path $runtimePath + Write-GuardLog -LogPath $logPath -Message "collector guard started mode=$Mode loop=$LoopSeconds once=$($Once.IsPresent)" + while ($true) { + try { + Invoke-GuardCycle -Runtime $runtime -RuntimePath $runtimePath -LogPath $logPath | Out-Null + } + catch { + Write-GuardLog -LogPath $logPath -Message ("cycle error: {0}; at {1}" -f $_.Exception.Message, $_.ScriptStackTrace) + } + + if ($Once) { + break + } + Start-Sleep -Seconds ([Math]::Max($LoopSeconds, 15)) + } +} +finally { + if ($lockPath -and (Test-Path -LiteralPath $lockPath)) { + Remove-Item -LiteralPath $lockPath -Force -ErrorAction SilentlyContinue + } + Write-GuardLog -LogPath $logPath -Message 'collector guard stopped' +} diff --git a/install-kit-awindows-20260427-211240/windows/deploy-domain-users.ps1 b/install-kit-awindows-20260427-211240/windows/deploy-domain-users.ps1 index c8b66f7..4370b43 100755 --- a/install-kit-awindows-20260427-211240/windows/deploy-domain-users.ps1 +++ b/install-kit-awindows-20260427-211240/windows/deploy-domain-users.ps1 @@ -27,7 +27,7 @@ param( [int]$EvtxRetentionDays = 14, [string[]]$EvtxChannels = @(), [bool]$LogonMarkerEnabled = $true, - [bool]$ProcessEventsEnabled = $true, + [bool]$ProcessEventsEnabled = $false, [string]$AwHostname, [string]$CustomRulesPath, [string]$CustomPolicyPath, diff --git a/install-kit-awindows-20260427-211240/windows/deploy-ensemble.ps1 b/install-kit-awindows-20260427-211240/windows/deploy-ensemble.ps1 index cbcf689..e21413a 100644 --- a/install-kit-awindows-20260427-211240/windows/deploy-ensemble.ps1 +++ b/install-kit-awindows-20260427-211240/windows/deploy-ensemble.ps1 @@ -27,7 +27,7 @@ param( [int]$EvtxRetentionDays = 14, [string[]]$EvtxChannels = @(), [bool]$LogonMarkerEnabled = $true, - [bool]$ProcessEventsEnabled = $true, + [bool]$ProcessEventsEnabled = $false, [string]$AwHostname, [string]$CustomRulesPath, [string]$CustomPolicyPath, diff --git a/install-kit-awindows-20260427-211240/windows/deploy-single-user.ps1 b/install-kit-awindows-20260427-211240/windows/deploy-single-user.ps1 index 25b7221..eb6c739 100755 --- a/install-kit-awindows-20260427-211240/windows/deploy-single-user.ps1 +++ b/install-kit-awindows-20260427-211240/windows/deploy-single-user.ps1 @@ -25,7 +25,7 @@ param( [int]$EvtxRetentionDays = 14, [string[]]$EvtxChannels = @(), [bool]$LogonMarkerEnabled = $true, - [bool]$ProcessEventsEnabled = $true, + [bool]$ProcessEventsEnabled = $false, [string]$AwHostname, [string]$CustomRulesPath, [string]$CustomPolicyPath diff --git a/install-kit-awindows-20260427-211240/windows/dlp-endpoint-signals-collector.ps1 b/install-kit-awindows-20260427-211240/windows/dlp-endpoint-signals-collector.ps1 index 1350713..8fe1831 100644 --- a/install-kit-awindows-20260427-211240/windows/dlp-endpoint-signals-collector.ps1 +++ b/install-kit-awindows-20260427-211240/windows/dlp-endpoint-signals-collector.ps1 @@ -15,7 +15,8 @@ param( [int]$PolicyRefreshSeconds, [string]$PolicyCachePath, [string]$LogPath, - [int]$PollSeconds + [int]$PollSeconds, + [switch]$SelfTestSuppressedBlock ) Set-StrictMode -Version Latest @@ -30,6 +31,7 @@ catch { $script:TransportQueuePath = $null $script:TransportQueueLockPath = $null +$script:SessionId = [System.Diagnostics.Process]::GetCurrentProcess().SessionId $script:TransportMetrics = @{ eventsEnqueued = 0 eventsFlushed = 0 @@ -71,6 +73,21 @@ function Write-EndpointLog { } } +function Get-QueueNameToken { + param( + [string]$UserName, + [int]$SessionId + ) + $token = ('{0}-s{1}' -f $UserName, $SessionId) + foreach ($ch in [System.IO.Path]::GetInvalidFileNameChars()) { + $token = $token.Replace([string]$ch, '_') + } + if ([string]::IsNullOrWhiteSpace($token)) { + return ('session-{0}' -f $SessionId) + } + return $token +} + function Invoke-AwJsonPost { param( [Parameter(Mandatory = $true)][string]$Uri, @@ -129,11 +146,20 @@ function Invoke-AwJsonPost { function Initialize-TransportQueue { param([Parameter(Mandatory = $true)][string]$StateRoot) - $script:TransportQueuePath = Join-Path $StateRoot 'dlp-endpoint-signals-queue.jsonl' - $script:TransportQueueLockPath = Join-Path $StateRoot 'dlp-endpoint-signals-queue.lock' + $queueToken = Get-QueueNameToken -UserName $env:USERNAME -SessionId $script:SessionId + $script:TransportQueuePath = Join-Path $StateRoot ("dlp-endpoint-signals-queue-{0}.jsonl" -f $queueToken) + $script:TransportQueueLockPath = Join-Path $StateRoot ("dlp-endpoint-signals-queue-{0}.lock" -f $queueToken) if (-not (Test-Path -LiteralPath $script:TransportQueuePath)) { New-Item -Path $script:TransportQueuePath -ItemType File -Force | Out-Null } + $legacyQueuePath = Join-Path $StateRoot 'dlp-endpoint-signals-queue.jsonl' + if (Test-Path -LiteralPath $legacyQueuePath) { + $legacyItems = @(Get-Content -LiteralPath $legacyQueuePath -ErrorAction SilentlyContinue | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + if ($legacyItems.Count -gt 0) { + Add-Content -LiteralPath $script:TransportQueuePath -Value $legacyItems -Encoding UTF8 + Clear-Content -LiteralPath $legacyQueuePath -ErrorAction SilentlyContinue + } + } } function Get-TransportQueueLock { @@ -617,6 +643,17 @@ function Load-DlpPolicy { regexPack = $null ocrEnabled = $false } + nativeControls = [ordered]@{ + mode = 'monitor' + rollout = [ordered]@{ + allowGlobalBlock = $false + } + channels = [ordered]@{ + clipboard = [ordered]@{ action = 'audit' } + usb = [ordered]@{ action = 'audit' } + print = [ordered]@{ action = 'audit' } + } + } } $script:PolicySource = 'defaults' @@ -656,6 +693,28 @@ function Load-DlpPolicy { $script:Policy.contentAnalysis.ocrEnabled = [bool]$raw.contentAnalysis.ocrEnabled } } + if ($raw.nativeControls) { + $nativeProps = @($raw.nativeControls.PSObject.Properties.Name) + if ($nativeProps -contains 'mode' -and $raw.nativeControls.mode) { + $script:Policy.nativeControls.mode = ([string]$raw.nativeControls.mode).ToLowerInvariant() + } + if ($nativeProps -contains 'rollout' -and $raw.nativeControls.rollout) { + $rolloutProps = @($raw.nativeControls.rollout.PSObject.Properties.Name) + if ($rolloutProps -contains 'allowGlobalBlock') { + $script:Policy.nativeControls.rollout.allowGlobalBlock = [bool]$raw.nativeControls.rollout.allowGlobalBlock + } + } + if ($nativeProps -contains 'channels' -and $raw.nativeControls.channels) { + foreach ($channelName in @('clipboard', 'usb', 'print')) { + if (@($raw.nativeControls.channels.PSObject.Properties.Name) -contains $channelName) { + $channel = $raw.nativeControls.channels.$channelName + if ($channel -and (@($channel.PSObject.Properties.Name) -contains 'action') -and $channel.action) { + $script:Policy.nativeControls.channels[$channelName].action = ([string]$channel.action).ToLowerInvariant() + } + } + } + } + } $script:PolicySource = 'local' } catch { @@ -663,6 +722,68 @@ function Load-DlpPolicy { } } +function Resolve-DlpEffectiveAction { + param( + [Parameter(Mandatory = $true)][string]$RequestedAction, + [Parameter(Mandatory = $true)][ValidateSet('clipboard', 'usb', 'print')][string]$Channel + ) + + $requested = $RequestedAction.ToLowerInvariant() + $mode = ([string]$script:Policy.nativeControls.mode).ToLowerInvariant() + $allowGlobalBlock = [bool]$script:Policy.nativeControls.rollout.allowGlobalBlock + $channelAction = 'audit' + try { + $channelAction = ([string]$script:Policy.nativeControls.channels[$Channel].action).ToLowerInvariant() + } + catch { + $channelAction = 'audit' + } + + $suppressed = $false + $effective = $requested + if ($requested -eq 'block') { + $channelAllowsBlock = $channelAction -in @('block', 'blockwithoverride') + if ($mode -ne 'enforce' -or -not $allowGlobalBlock -or -not $channelAllowsBlock) { + $effective = 'alert' + $suppressed = $true + } + } + + return [pscustomobject]@{ + requestedAction = $requested + action = $effective + enforcementMode = $mode + nativeChannelAction = $channelAction + enforcementSuppressed = $suppressed + } +} + +function Invoke-SuppressedBlockSelfTest { + $decisions = @() + foreach ($channel in @('clipboard', 'usb', 'print')) { + $decisions += (Resolve-DlpEffectiveAction -RequestedAction 'block' -Channel $channel) + } + + $failed = @( + $decisions | + Where-Object { $_.action -eq 'block' -or -not [bool]$_.enforcementSuppressed } + ) + + $result = [ordered]@{ + ok = (@($failed).Count -eq 0) + test = 'suppressed-block-in-monitor' + policySource = $script:PolicySource + policyMode = $script:PolicyMode + decisions = @($decisions) + } + + $result | ConvertTo-Json -Depth 6 + if (-not $result.ok) { + exit 2 + } + exit 0 +} + function Test-ValidInn { param([string]$Value) $digits = ($Value -replace '\D', '') @@ -925,7 +1046,9 @@ function Evaluate-ClipboardRules { $fingerprint = "clipboard|$ruleId|$ClipboardHash|$env:USERNAME" if (-not (Should-EmitByCooldown -Fingerprint $fingerprint -CooldownSeconds ([Math]::Max($cooldown, 30)))) { continue } - $action = if ($rule.action) { [string]$rule.action } else { [string]$script:Policy.defaults.action } + $requestedAction = if ($rule.action) { [string]$rule.action } else { [string]$script:Policy.defaults.action } + $actionDecision = Resolve-DlpEffectiveAction -RequestedAction $requestedAction -Channel 'clipboard' + $action = [string]$actionDecision.action $severity = if ($rule.severity) { [string]$rule.severity } else { [string]$script:Policy.defaults.severity } $message = if ($rule.message) { [string]$rule.message } else { "Clipboard rule matched: $ruleId" } @@ -939,13 +1062,17 @@ function Evaluate-ClipboardRules { clipboardHash = $ClipboardHash clipboardLength = $ClipboardText.Length enforced = $enforced + requestedAction = [string]$actionDecision.requestedAction + enforcementMode = [string]$actionDecision.enforcementMode + nativeChannelAction = [string]$actionDecision.nativeChannelAction + enforcementSuppressed = [bool]$actionDecision.enforcementSuppressed dictionaryPack = $dictionaryPack regexPack = $regexPack dictionaryMatches = @($advanced.dictionaryMatches) regexMatches = @($advanced.regexMatches) ocrRequested = $ocrEnabled } - Write-EndpointLog ("incident clipboard rule={0} action={1} severity={2} enforced={3}" -f $ruleId, $action, $severity, $enforced) + Write-EndpointLog ("incident clipboard rule={0} requested={1} action={2} severity={3} enforced={4} suppressed={5}" -f $ruleId, $requestedAction, $action, $severity, $enforced, [bool]$actionDecision.enforcementSuppressed) } } @@ -965,7 +1092,9 @@ function Evaluate-UsbRules { $fingerprint = "usb|$ruleId|$DriveLetter|$env:USERNAME" if (-not (Should-EmitByCooldown -Fingerprint $fingerprint -CooldownSeconds ([Math]::Max($cooldown, 30)))) { continue } - $action = if ($rule.action) { [string]$rule.action } else { [string]$script:Policy.defaults.action } + $requestedAction = if ($rule.action) { [string]$rule.action } else { [string]$script:Policy.defaults.action } + $actionDecision = Resolve-DlpEffectiveAction -RequestedAction $requestedAction -Channel 'usb' + $action = [string]$actionDecision.action $severity = if ($rule.severity) { [string]$rule.severity } else { [string]$script:Policy.defaults.severity } $message = if ($rule.message) { [string]$rule.message } else { "USB rule matched: $ruleId" } @@ -979,8 +1108,12 @@ function Evaluate-UsbRules { driveLetter = $DriveLetter volumeName = $VolumeName enforced = $enforced + requestedAction = [string]$actionDecision.requestedAction + enforcementMode = [string]$actionDecision.enforcementMode + nativeChannelAction = [string]$actionDecision.nativeChannelAction + enforcementSuppressed = [bool]$actionDecision.enforcementSuppressed } - Write-EndpointLog ("incident usb rule={0} action={1} severity={2} drive={3} enforced={4}" -f $ruleId, $action, $severity, $DriveLetter, $enforced) + Write-EndpointLog ("incident usb rule={0} requested={1} action={2} severity={3} drive={4} enforced={5} suppressed={6}" -f $ruleId, $requestedAction, $action, $severity, $DriveLetter, $enforced, [bool]$actionDecision.enforcementSuppressed) } } @@ -1016,7 +1149,9 @@ function Evaluate-PrintRules { $fingerprint = "print|$ruleId|$PrinterName|$Owner|$env:USERNAME" if (-not (Should-EmitByCooldown -Fingerprint $fingerprint -CooldownSeconds ([Math]::Max($cooldown, 30)))) { continue } - $action = if ($rule.action) { [string]$rule.action } else { [string]$script:Policy.defaults.action } + $requestedAction = if ($rule.action) { [string]$rule.action } else { [string]$script:Policy.defaults.action } + $actionDecision = Resolve-DlpEffectiveAction -RequestedAction $requestedAction -Channel 'print' + $action = [string]$actionDecision.action $severity = if ($rule.severity) { [string]$rule.severity } else { [string]$script:Policy.defaults.severity } $message = if ($rule.message) { [string]$rule.message } else { "Print rule matched: $ruleId" } @@ -1031,13 +1166,17 @@ function Evaluate-PrintRules { documentName = $DocumentName owner = $Owner enforced = $enforced + requestedAction = [string]$actionDecision.requestedAction + enforcementMode = [string]$actionDecision.enforcementMode + nativeChannelAction = [string]$actionDecision.nativeChannelAction + enforcementSuppressed = [bool]$actionDecision.enforcementSuppressed dictionaryPack = $dictionaryPack regexPack = $regexPack dictionaryMatches = @($advanced.dictionaryMatches) regexMatches = @($advanced.regexMatches) ocrRequested = $ocrEnabled } - Write-EndpointLog ("incident print rule={0} action={1} severity={2} printer={3} enforced={4}" -f $ruleId, $action, $severity, $PrinterName, $enforced) + Write-EndpointLog ("incident print rule={0} requested={1} action={2} severity={3} printer={4} enforced={5} suppressed={6}" -f $ruleId, $requestedAction, $action, $severity, $PrinterName, $enforced, [bool]$actionDecision.enforcementSuppressed) } } @@ -1320,6 +1459,9 @@ $script:LastEventTime = $null Initialize-TransportQueue -StateRoot $resolvedStateRoot Initialize-DlpPolicy +if ($SelfTestSuppressedBlock) { + Invoke-SuppressedBlockSelfTest +} Write-EndpointLog ("endpoint collector started against {0}" -f $script:ApiBase) while ($true) { diff --git a/install-kit-awindows-20260427-211240/windows/dlp-policy.example.json b/install-kit-awindows-20260427-211240/windows/dlp-policy.example.json index 7c8c40f..828f234 100644 --- a/install-kit-awindows-20260427-211240/windows/dlp-policy.example.json +++ b/install-kit-awindows-20260427-211240/windows/dlp-policy.example.json @@ -6,6 +6,19 @@ "action": "log", "severity": "low" }, + "nativeControls": { + "mode": "monitor", + "rollout": { + "baselineDays": 7, + "requireGuardHeartbeat": true, + "allowGlobalBlock": false + }, + "channels": { + "clipboard": {"action": "audit"}, + "usb": {"action": "audit"}, + "print": {"action": "audit"} + } + }, "rules": [ { "id": "personal-web-during-workhours", diff --git a/install-kit-awindows-20260427-211240/windows/dlp-policy.native-cross-os.example.json b/install-kit-awindows-20260427-211240/windows/dlp-policy.native-cross-os.example.json new file mode 100644 index 0000000..b8710b0 --- /dev/null +++ b/install-kit-awindows-20260427-211240/windows/dlp-policy.native-cross-os.example.json @@ -0,0 +1,145 @@ +{ + "version": 1, + "defaults": { + "enabled": true, + "cooldownSeconds": 300, + "action": "alert", + "severity": "medium" + }, + "nativeControls": { + "mode": "monitor", + "rollout": { + "baselineDays": 7, + "requireGuardHeartbeat": true, + "allowGlobalBlock": false + }, + "channels": { + "removableStorage": { + "action": "audit", + "windows": { + "mechanisms": ["gpo-device-restrictions", "set-disk-readonly"], + "target": "write" + }, + "linux": { + "mechanisms": ["fanotify", "auditd"], + "target": "mountpoints" + }, + "macos": { + "mechanisms": ["mdm-restrictions", "endpoint-security"], + "target": "managed-devices" + }, + "chromeos": { + "mechanisms": ["data-controls"], + "target": "removable-storage" + } + }, + "print": { + "action": "audit", + "windows": { + "mechanisms": ["printservice-operational-log", "spooler-cancel-job"] + }, + "linux": { + "mechanisms": ["cups-logs"], + "enforcement": "monitor-only" + }, + "macos": { + "mechanisms": ["mdm-printing-restrictions"], + "enforcement": "managed-only" + }, + "chromeos": { + "mechanisms": ["data-controls"] + } + }, + "clipboard": { + "action": "audit", + "windows": { + "mechanisms": ["clipboard-monitor", "clear-clipboard"], + "blockScope": "high-confidence-only" + }, + "linux": { + "mechanisms": ["desktop-clipboard-monitor"], + "enforcement": "monitor-only" + }, + "macos": { + "mechanisms": ["endpoint-monitor", "mdm-restrictions"], + "enforcement": "monitor-first" + }, + "chromeos": { + "mechanisms": ["data-controls"] + } + }, + "browserUpload": { + "action": "audit", + "windows": { + "mechanisms": ["managed-browser-policy", "browser-extension"] + }, + "linux": { + "mechanisms": ["managed-browser-policy", "proxy-logs"] + }, + "macos": { + "mechanisms": ["managed-browser-policy", "network-extension"] + }, + "chromeos": { + "mechanisms": ["data-controls"] + } + }, + "appExecution": { + "action": "audit", + "windows": { + "mechanisms": ["applocker", "wdac"] + }, + "linux": { + "mechanisms": ["auditd", "fanotify", "bpf-lsm"], + "enforcement": "fanotify-or-lsm-only" + }, + "macos": { + "mechanisms": ["mdm-restrictions", "endpoint-security"] + }, + "chromeos": { + "mechanisms": ["admin-console-app-policy"] + } + } + } + }, + "endpoint": { + "clipboard": [ + { + "id": "clipboard-sensitive-keywords", + "enabled": true, + "cooldownSeconds": 300, + "action": "alert", + "severity": "high", + "message": "В буфере обнаружены чувствительные ключевые слова", + "minLength": 20, + "regexPatterns": [ + "(?i)парол(ь|и)", + "(?i)password", + "(?i)secret", + "(?i)cvv", + "(?i)паспорт" + ] + } + ], + "usb": [ + { + "id": "usb-media-connected", + "enabled": true, + "cooldownSeconds": 300, + "action": "alert", + "severity": "medium", + "message": "Подключен съемный носитель" + } + ], + "print": [ + { + "id": "print-sensitive-docs", + "enabled": true, + "cooldownSeconds": 300, + "action": "alert", + "severity": "high", + "message": "Печать документа с признаками чувствительных данных", + "documentRegex": "(?i)(salary|зарплат|passport|паспорт|договор|contract)" + } + ] + } +} diff --git a/install-kit-awindows-20260427-211240/windows/hardening-recovery.ps1 b/install-kit-awindows-20260427-211240/windows/hardening-recovery.ps1 index b28b76c..fb4b6ef 100755 --- a/install-kit-awindows-20260427-211240/windows/hardening-recovery.ps1 +++ b/install-kit-awindows-20260427-211240/windows/hardening-recovery.ps1 @@ -94,7 +94,7 @@ $effectiveEvtxExportRoot = if ($PSBoundParameters.ContainsKey('EvtxExportRoot') $effectiveEvtxRetentionDays = if ($PSBoundParameters.ContainsKey('EvtxRetentionDays')) { [int]$EvtxRetentionDays } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'forensics' -and $existingConfig.forensics.PSObject.Properties.Name -contains 'retentionDays') { [int]$existingConfig.forensics.retentionDays } else { 14 } $effectiveEvtxChannels = if ($PSBoundParameters.ContainsKey('EvtxChannels')) { @($EvtxChannels) } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'forensics' -and $existingConfig.forensics.PSObject.Properties.Name -contains 'evtxChannels') { @($existingConfig.forensics.evtxChannels) } else { @() } $effectiveLogonMarkerEnabled = if ($PSBoundParameters.ContainsKey('LogonMarkerEnabled')) { [bool]$LogonMarkerEnabled } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'sessionEvents' -and $existingConfig.sessionEvents.PSObject.Properties.Name -contains 'logonEnabled') { [bool]$existingConfig.sessionEvents.logonEnabled } else { $true } -$effectiveProcessEventsEnabled = if ($PSBoundParameters.ContainsKey('ProcessEventsEnabled')) { [bool]$ProcessEventsEnabled } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'sessionEvents' -and $existingConfig.sessionEvents.PSObject.Properties.Name -contains 'processEventsEnabled') { [bool]$existingConfig.sessionEvents.processEventsEnabled } else { $true } +$effectiveProcessEventsEnabled = if ($PSBoundParameters.ContainsKey('ProcessEventsEnabled')) { [bool]$ProcessEventsEnabled } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'sessionEvents' -and $existingConfig.sessionEvents.PSObject.Properties.Name -contains 'processEventsEnabled') { [bool]$existingConfig.sessionEvents.processEventsEnabled } else { $false } $effectiveAwHostname = if ($PSBoundParameters.ContainsKey('AwHostname') -and -not [string]::IsNullOrWhiteSpace($AwHostname)) { [string]$AwHostname } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'awHostname' -and -not [string]::IsNullOrWhiteSpace([string]$existingConfig.awHostname)) { [string]$existingConfig.awHostname } else { [string]$env:COMPUTERNAME } $effectiveVersion = if ($Version) { $Version } elseif ($existingConfig) { [string]$existingConfig.package.version } else { 'v0.13.2' } $effectivePolicyMode = if ($PSBoundParameters.ContainsKey('PolicyMode') -and $PolicyMode) { [string]$PolicyMode } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'policyEngine' -and $existingConfig.policyEngine.PSObject.Properties.Name -contains 'mode') { [string]$existingConfig.policyEngine.mode } else { 'local' } diff --git a/install-kit-awindows-20260427-211240/windows/install-collector-guard-service.ps1 b/install-kit-awindows-20260427-211240/windows/install-collector-guard-service.ps1 new file mode 100644 index 0000000..a6e270c --- /dev/null +++ b/install-kit-awindows-20260427-211240/windows/install-collector-guard-service.ps1 @@ -0,0 +1,88 @@ +[CmdletBinding()] +param( + [string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json', + [ValidateSet('shadow', 'enforce')] + [string]$Mode = 'shadow', + [string]$ServiceName = 'AWatchRusCollectorGuard', + [int]$LoopSeconds = 60, + [switch]$DisableRecoveryTask +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Assert-Admin { + $id = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = [Security.Principal.WindowsPrincipal]::new($id) + if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + throw 'Run as Administrator.' + } +} + +Assert-Admin + +$guardScriptPath = Join-Path $PSScriptRoot 'aw-collector-guard.ps1' +$serviceSourcePath = Join-Path $PSScriptRoot 'AWatchRusCollectorGuardService.cs' +$serviceExePath = Join-Path $PSScriptRoot 'AWatchRusCollectorGuardService.exe' +if (-not (Test-Path -LiteralPath $guardScriptPath)) { + throw "Collector guard script not found: $guardScriptPath" +} +if (-not (Test-Path -LiteralPath $serviceSourcePath)) { + throw "Collector guard service source not found: $serviceSourcePath" +} +if (-not (Test-Path -LiteralPath $ConfigPath)) { + throw "Config not found: $ConfigPath" +} + +$cscCandidates = @( + (Join-Path $env:WINDIR 'Microsoft.NET\Framework64\v4.0.30319\csc.exe'), + (Join-Path $env:WINDIR 'Microsoft.NET\Framework\v4.0.30319\csc.exe') +) +$csc = @($cscCandidates | Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1) +if (-not $csc) { + throw 'C# compiler not found. Install .NET Framework build tools or provide AWatchRusCollectorGuardService.exe.' +} + +$existing = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue +if ($existing) { + if ($existing.Status -ne 'Stopped') { + Stop-Service -Name $ServiceName -Force -ErrorAction SilentlyContinue + try { + $existing.WaitForStatus('Stopped', [TimeSpan]::FromSeconds(20)) + } + catch { + } + } + sc.exe delete $ServiceName | Out-Null + Start-Sleep -Seconds 2 +} + +& $csc /nologo /target:exe /optimize+ /out:$serviceExePath /reference:System.ServiceProcess.dll $serviceSourcePath | Out-Null +if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $serviceExePath)) { + throw "Failed to compile $serviceExePath" +} + +$logsRoot = Join-Path (Split-Path -Path $ConfigPath -Parent) 'logs' +$serviceLogPath = Join-Path $logsRoot 'collector-guard-service.log' +$binPath = "`"$serviceExePath`" --service-name `"$ServiceName`" --script `"$guardScriptPath`" --config `"$ConfigPath`" --mode $Mode --loop $LoopSeconds --log `"$serviceLogPath`"" + +New-Service -Name $ServiceName -BinaryPathName $binPath -DisplayName 'AWatch-rus Collector Guard' -StartupType Automatic | Out-Null +sc.exe description $ServiceName "Session-aware ActivityWatch collector guard for AWatch-rus" | Out-Null +sc.exe failure $ServiceName reset= 300 actions= restart/5000/restart/15000/restart/60000 | Out-Null + +if ($DisableRecoveryTask) { + Write-Warning 'DisableRecoveryTask is deprecated and ignored: ActivityWatch Recovery must remain enabled as collector guard fallback.' +} +else { + try { + Enable-ScheduledTask -TaskName 'ActivityWatch Recovery' -ErrorAction SilentlyContinue | Out-Null + } + catch { + } +} + +sc.exe start $ServiceName | Out-Null + +Write-Output "Collector guard service installed: $ServiceName" +Write-Output "Mode: $Mode" +Write-Output "Config: $ConfigPath" diff --git a/install-kit-awindows-20260427-211240/windows/validate-deployment.ps1 b/install-kit-awindows-20260427-211240/windows/validate-deployment.ps1 index 237bf69..bdfcb61 100644 --- a/install-kit-awindows-20260427-211240/windows/validate-deployment.ps1 +++ b/install-kit-awindows-20260427-211240/windows/validate-deployment.ps1 @@ -1,4 +1,4 @@ -[CmdletBinding()] +[CmdletBinding()] param( [string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json' ) @@ -38,7 +38,7 @@ $windowExpected = if ($config.PSObject.Properties.Name -contains 'collectors' -a $fileOpsExpected = if ($config.PSObject.Properties.Name -contains 'collectors' -and $config.collectors.PSObject.Properties.Name -contains 'fileOpsEnabled') { [bool]$config.collectors.fileOpsEnabled } else { $true } $sessionEventsConfig = if ($config.PSObject.Properties.Name -contains 'sessionEvents') { $config.sessionEvents } else { $null } $sessionLogonEnabled = if ($sessionEventsConfig -and $sessionEventsConfig.PSObject.Properties.Name -contains 'logonEnabled') { [bool]$sessionEventsConfig.logonEnabled } else { $false } -$sessionProcessEventsEnabled = if ($sessionEventsConfig -and $sessionEventsConfig.PSObject.Properties.Name -contains 'processEventsEnabled') { [bool]$sessionEventsConfig.processEventsEnabled } else { $true } +$sessionProcessEventsEnabled = if ($sessionEventsConfig -and $sessionEventsConfig.PSObject.Properties.Name -contains 'processEventsEnabled') { [bool]$sessionEventsConfig.processEventsEnabled } else { $false } $sessionEventsBucketId = if ($sessionEventsConfig -and $sessionEventsConfig.PSObject.Properties.Name -contains 'bucketPrefix' -and -not [string]::IsNullOrWhiteSpace([string]$sessionEventsConfig.bucketPrefix)) { ('{0}_{1}' -f [string]$sessionEventsConfig.bucketPrefix, $awHostname) } @@ -301,6 +301,94 @@ function Get-TransportQueueHealth { } } +function Get-TransportQueueGroupHealth { + param( + [Parameter(Mandatory = $true)] + [string]$Name, + [Parameter(Mandatory = $true)] + [string]$StateRoot, + [Parameter(Mandatory = $true)] + [string]$QueuePattern, + [Parameter(Mandatory = $true)] + [int]$StaleAfterSeconds, + [Parameter(Mandatory = $true)] + [int]$MaxDepth, + [int]$ActiveProcessCount = 0, + [bool]$Required = $true + ) + + $queues = @(Get-ChildItem -LiteralPath $StateRoot -Filter $QueuePattern -ErrorAction SilentlyContinue | Sort-Object Name) + if ($queues.Count -eq 0) { + return [pscustomobject]@{ + name = $Name + required = [bool]$Required + queuePattern = $QueuePattern + queueCount = 0 + queues = @() + depth = 0 + sizeBytes = 0 + activeProcessCount = [int]$ActiveProcessCount + staleAfterSeconds = [int]$StaleAfterSeconds + maxDepth = [int]$MaxDepth + ok = [bool](-not $Required) + } + } + + $items = @() + foreach ($queue in $queues) { + $lockPath = [System.IO.Path]::ChangeExtension($queue.FullName, '.lock') + $items += Get-TransportQueueHealth -Name $queue.BaseName -QueuePath $queue.FullName -LockPath $lockPath -StaleAfterSeconds $StaleAfterSeconds -MaxDepth $MaxDepth -ActiveProcessCount $ActiveProcessCount -Required $Required + } + + return [pscustomobject]@{ + name = $Name + required = [bool]$Required + queuePattern = $QueuePattern + queueCount = [int]$items.Count + queues = @($items) + depth = [int](($items | Measure-Object -Property depth -Sum).Sum) + sizeBytes = [int64](($items | Measure-Object -Property sizeBytes -Sum).Sum) + activeProcessCount = [int]$ActiveProcessCount + staleAfterSeconds = [int]$StaleAfterSeconds + maxDepth = [int]$MaxDepth + ok = [bool](-not ($items | Where-Object { -not $_.ok })) + } +} + +function Resolve-ActivityWatchLaunchTaskName { + param( + [Parameter(Mandatory = $true)] + [string]$TaskName + ) + + if ($TaskName -notmatch '\[[^\]]+_Administrator\]') { + return $TaskName + } + + $localizedCandidate = 'ActivityWatch Launch [{0}_Администратор]' -f $awHostname + $localizedTask = Get-ScheduledTask -ErrorAction SilentlyContinue | Where-Object { $_.TaskName -eq $localizedCandidate } | Select-Object -First 1 + if ($localizedTask) { + return $localizedCandidate + } + + try { + $builtinAdmin = Get-LocalUser -ErrorAction Stop | + Where-Object { [string]$_.SID -match '-500$' } | + Select-Object -First 1 + if ($builtinAdmin -and -not [string]::IsNullOrWhiteSpace([string]$builtinAdmin.Name)) { + $candidate = 'ActivityWatch Launch [{0}_{1}]' -f $awHostname, [string]$builtinAdmin.Name + $existing = Get-ScheduledTask -ErrorAction SilentlyContinue | Where-Object { $_.TaskName -eq $candidate } | Select-Object -First 1 + if ($existing) { + return $candidate + } + } + } + catch { + } + + return $TaskName +} + function Get-TaskSnapshot { param( [Parameter(Mandatory = $true)] @@ -337,14 +425,13 @@ function Get-TaskSnapshot { } catch { } - [pscustomobject]@{ taskName = [string]$task.TaskName present = $true enabled = [bool]$enabled state = [string]$task.State lastResult = if ($taskInfo) { [int64]$taskInfo.LastTaskResult } else { $null } - ok = [bool]($enabled) + ok = [bool]$enabled } } ) @@ -413,10 +500,14 @@ else { 0 } $sessionScopedCollectorsRequired = ($sessionScopedExpectedCount -gt 0) +$liveSessionScopedCollectorsRequired = ($liveSessionBoundUsers.Count -gt 0) + +$collectorGuardService = Get-Service -Name 'AWatchRusCollectorGuard' -ErrorAction SilentlyContinue +$collectorGuardActive = [bool]($collectorGuardService -and $collectorGuardService.Status -eq 'Running') $taskNames = @() if ($config.userTasks) { - $taskNames += @($config.userTasks | ForEach-Object { [string]$_.launchTaskName }) + $taskNames += @($config.userTasks | ForEach-Object { Resolve-ActivityWatchLaunchTaskName -TaskName ([string]$_.launchTaskName) }) } $taskNames += [string]$config.recovery.taskName $tasks = @(Get-TaskSnapshot -TaskNames $taskNames) @@ -438,24 +529,24 @@ foreach ($watcher in $runningWatchers) { $bucketChecks = @( Get-BucketHealth -BucketId ('aw-worktime-sessions_' + $awHostname) -MaxAgeSeconds $sessionFreshnessSeconds -Required $true -RequireFreshEvent $true ) -if ($sessionScopedCollectorsRequired -and $afkExpected) { +if ($liveSessionScopedCollectorsRequired -and $afkExpected) { $bucketChecks += Get-BucketHealth -BucketId ('aw-watcher-afk_' + $awHostname) -MaxAgeSeconds $freshnessSeconds -Required $true -RequireFreshEvent $false } -if ($sessionScopedCollectorsRequired -and $windowExpected) { +if ($liveSessionScopedCollectorsRequired -and $windowExpected) { $bucketChecks += Get-BucketHealth -BucketId ('aw-watcher-window_' + $awHostname) -MaxAgeSeconds $freshnessSeconds -Required $true -RequireFreshEvent $false } -if ($sessionScopedCollectorsRequired) { +if ($liveSessionScopedCollectorsRequired) { $bucketChecks += Get-BucketHealth -BucketId ('aw-dlp-endpoint-signals_' + $awHostname) -MaxAgeSeconds $endpointFreshnessSeconds -Required $true -RequireFreshEvent $true } -if ($sessionScopedCollectorsRequired -and $fileOpsExpected) { +if ($liveSessionScopedCollectorsRequired -and $fileOpsExpected) { $bucketChecks += Get-BucketHealth -BucketId ('aw-file-operations_' + $awHostname) -MaxAgeSeconds $transportStaleSeconds -Required $false -RequireFreshEvent $true } $queueChecks = @( - Get-TransportQueueHealth -Name 'endpoint' -QueuePath (Join-Path $stateRoot 'dlp-endpoint-signals-queue.jsonl') -LockPath (Join-Path $stateRoot 'dlp-endpoint-signals-queue.lock') -StaleAfterSeconds $transportStaleSeconds -MaxDepth $queueMaxDepth -ActiveProcessCount @($endpointCollectorProcesses).Count -Required $sessionScopedCollectorsRequired + Get-TransportQueueGroupHealth -Name 'endpoint' -StateRoot $stateRoot -QueuePattern 'dlp-endpoint-signals-queue*.jsonl' -StaleAfterSeconds $transportStaleSeconds -MaxDepth $queueMaxDepth -ActiveProcessCount @($endpointCollectorProcesses).Count -Required $liveSessionScopedCollectorsRequired ) if ($fileOpsExpected) { - $queueChecks += Get-TransportQueueHealth -Name 'fileops' -QueuePath (Join-Path $stateRoot 'file-operations-queue.jsonl') -LockPath (Join-Path $stateRoot 'file-operations-queue.lock') -StaleAfterSeconds $transportStaleSeconds -MaxDepth $queueMaxDepth -ActiveProcessCount @($fileCollectorProcesses).Count -Required $sessionScopedCollectorsRequired + $queueChecks += Get-TransportQueueGroupHealth -Name 'fileops' -StateRoot $stateRoot -QueuePattern 'file-operations-queue*.jsonl' -StaleAfterSeconds $transportStaleSeconds -MaxDepth $queueMaxDepth -ActiveProcessCount @($fileCollectorProcesses).Count -Required $liveSessionScopedCollectorsRequired } $printServiceOperationalEnabled = $false @@ -502,12 +593,14 @@ $result = [ordered]@{ } tasks = [ordered]@{ list = $tasks - ok = [bool]($tasks.Count -gt 0 -and -not ($tasks | Where-Object { -not $_.present -or -not $_.enabled })) + ok = [bool]($tasks.Count -gt 0 -and -not ($tasks | Where-Object { -not $_.ok })) } processes = [ordered]@{ liveSessionBoundUsers = $liveSessionBoundUsers sessionBoundUsers = $interactiveSessionBoundUsers sessionScopedExpectedCount = [int]$sessionScopedExpectedCount + liveSessionScopedCollectorsRequired = [bool]$liveSessionScopedCollectorsRequired + collectorGuardServiceActive = [bool]$collectorGuardActive watchers = @($runningWatchers) watcherDuplicates = @($watcherDuplicates) sessionCollectors = @($sessionCollectorProcesses) diff --git a/ops/detmir-ai/ai-tool b/ops/detmir-ai/ai-tool new file mode 100644 index 0000000..5d084bb --- /dev/null +++ b/ops/detmir-ai/ai-tool @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +set -euo pipefail + +cmd="$(basename "$0")" +polli_chat="${POLLI_CHAT:-/usr/local/bin/polli-chat}" + +need_text() { + if [ "$#" -lt 1 ]; then + echo "Нужен текст запроса. Пример: $cmd \"объясни ошибку\"" >&2 + exit 1 + fi +} + +key() { + python3 - <<'PY' +import json +from pathlib import Path +print(json.loads(Path.home().joinpath(".pollinations/credentials.json").read_text())["apiKey"]) +PY +} + +urlencode() { + python3 - "$*" <<'PY' +import sys, urllib.parse +print(urllib.parse.quote(sys.argv[1])) +PY +} + +case "$cmd" in + ai) + need_text "$@" + exec "$polli_chat" --model text.daily "$*" + ;; + ai-fast) + need_text "$@" + exec "$polli_chat" --model text.cheap "$*" + ;; + ai-smart) + need_text "$@" + exec "$polli_chat" --model text.heavy "$*" + ;; + ai-code) + need_text "$@" + exec "$polli_chat" --model text.coding_primary "$*" + ;; + ai-search) + need_text "$@" + exec "$polli_chat" --model text.search_reasoning "$*" + ;; + ai-report) + if [ "$#" -lt 1 ]; then + echo "Пример: ai-report /tmp/check-output.txt" >&2 + exit 1 + fi + file="$1" + [ -f "$file" ] || { echo "Файл не найден: $file" >&2; exit 1; } + { + echo "Сделай короткий рабочий отчет: состояние, что важно, следующие действия." + echo + sed -n '1,1400p' "$file" + } | "$polli_chat" --model text.daily + ;; + ai-models) + k="$(key)" + curl -fsSL 'https://gen.pollinations.ai/v1/models' \ + -H 'User-Agent: curl/8.5 detmir-proxmox' \ + -H "Authorization: Bearer ${k}" | + python3 -c 'import json,sys; data=json.load(sys.stdin); [print(item.get("id") or item.get("name") or item.get("model")) for item in data.get("data", []) if isinstance(item, dict)]' + ;; + ai-test) + echo "1) text:" + "$polli_chat" --model text.cheap --max-tokens 20 'Return exactly: text-ok' + echo + echo "2) embedding:" + k="$(key)" + curl -fsSL 'https://gen.pollinations.ai/v1/embeddings' \ + -H "Authorization: Bearer ${k}" \ + -H 'User-Agent: curl/8.5 detmir-proxmox' \ + -H 'Content-Type: application/json' \ + -d '{"model":"openai-3-small","input":"embedding test","dimensions":128}' | + python3 -c 'import json,sys; data=json.load(sys.stdin); emb=data["data"][0]["embedding"]; print("embedding-ok len=" + str(len(emb)))' + ;; + ai-image) + need_text "$@" + prompt="$1" + out="${2:-$HOME/ai-image-$(date +%Y%m%d-%H%M%S).jpg}" + model="${3:-zimage}" + encoded="$(urlencode "$prompt")" + curl -fsSL "https://gen.pollinations.ai/image/${encoded}?model=${model}&width=1024&height=1024" \ + -H 'User-Agent: curl/8.5 detmir-proxmox' \ + -H "Authorization: Bearer $(key)" \ + -o "$out" + echo "$out" + ;; + ai-voice) + need_text "$@" + text="$1" + out="${2:-$HOME/ai-voice-$(date +%Y%m%d-%H%M%S).mp3}" + voice="${3:-nova}" + encoded="$(urlencode "$text")" + curl -fsSL "https://gen.pollinations.ai/audio/${encoded}?voice=${voice}" \ + -H 'User-Agent: curl/8.5 detmir-proxmox' \ + -H "Authorization: Bearer $(key)" \ + -o "$out" + echo "$out" + ;; + *) + echo "Unknown AI tool name: $cmd" >&2 + exit 2 + ;; +esac diff --git a/ops/detmir-ai/detmir-ai b/ops/detmir-ai/detmir-ai new file mode 100644 index 0000000..ce374d7 --- /dev/null +++ b/ops/detmir-ai/detmir-ai @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +set -euo pipefail + +out_dir="${DETMIR_AI_OUT_DIR:-/var/tmp/detmir-ai-${USER:-$(id -un)}}" +mkdir -p "$out_dir" +stamp="$(date -u +%Y%m%d-%H%M%S)" +check_file="$out_dir/detmir-check-$stamp.json" +dlp_file="$out_dir/detmir-dlp-$stamp.json" +bundle_file="$out_dir/detmir-ai-bundle-$stamp.txt" + +check_rc=0 +dlp_rc=0 + +detmir-check --json >"$check_file" || check_rc=$? +detmir-dlp >"$dlp_file" || dlp_rc=$? + +{ + echo "Ты операторский AI-помощник DetMir. По фактам ниже дай короткий русский отчет." + echo "Структура ответа:" + echo "1. Состояние: OK/WARN/FAIL" + echo "2. Что важно" + echo "3. Что сделать дальше" + echo + echo "Правила:" + echo "- Не предлагай рестарты, если факты чистые." + echo "- Отличай event-driven bucket от dead/stale." + echo "- DLP sendFailures важны только при новом sendFailuresDelta или warn/fail." + echo + echo "=== detmir-check exit=$check_rc ===" + sed -n '1,1600p' "$check_file" + echo + echo "=== detmir-dlp exit=$dlp_rc ===" + sed -n '1,1600p' "$dlp_file" +} >"$bundle_file" + +echo "Files:" +echo " $check_file" +echo " $dlp_file" +echo " $bundle_file" +echo + +polli-chat --model text.daily --max-tokens 700 <"$bundle_file" + +if [ "$check_rc" -ne 0 ] || [ "$dlp_rc" -ne 0 ]; then + exit 2 +fi diff --git a/ops/detmir-ai/detmir-ai-profile.sh b/ops/detmir-ai/detmir-ai-profile.sh new file mode 100644 index 0000000..1d9c405 --- /dev/null +++ b/ops/detmir-ai/detmir-ai-profile.sh @@ -0,0 +1,8 @@ +# DetMir autonomous AI helpers. Installed on Proxmox host. +# Run `ai-test`, `detmir-check`, `detmir-dlp`, or `detmir-ai`. + +export POLLI_CHAT=/usr/local/bin/polli-chat + +alias detmir-report='detmir-ai' +alias detmir-auto-report='detmir-status' +alias detmir-models='ai-models' diff --git a/ops/detmir-ai/detmir-auto b/ops/detmir-ai/detmir-auto new file mode 100644 index 0000000..811eb2f --- /dev/null +++ b/ops/detmir-ai/detmir-auto @@ -0,0 +1,169 @@ +#!/usr/bin/env bash +set -euo pipefail + +state_dir="${DETMIR_AI_STATE_DIR:-/var/lib/detmir-ai}" +lock_dir="${DETMIR_AI_RUN_DIR:-${XDG_RUNTIME_DIR:-/tmp}}" +auto_heal="${DETMIR_AUTO_HEAL:-1}" +retain_days="${DETMIR_AI_RETAIN_DAYS:-14}" + +mkdir -p "$state_dir"/runs "$state_dir"/reports "$state_dir"/logs + +lock_file="$lock_dir/detmir-auto.lock" +exec 9>"$lock_file" +if ! flock -n 9; then + echo "detmir-auto: another run is active" + exit 0 +fi + +stamp="$(date -u +%Y%m%d-%H%M%S)" +run_dir_full="$state_dir/runs/$stamp" +mkdir -p "$run_dir_full" + +check_file="$run_dir_full/detmir-check.json" +dlp_file="$run_dir_full/detmir-dlp.json" +heal_log="$run_dir_full/heal.log" +bundle_file="$run_dir_full/bundle.txt" +report_file="$state_dir/reports/detmir-report-$stamp.md" +state_file="$state_dir/state-$stamp.json" + +run_check() { + local rc=0 + detmir-check --json >"$check_file" || rc=$? + echo "$rc" >"$run_dir_full/check.rc" +} + +run_dlp() { + local rc=0 + detmir-dlp >"$dlp_file" || rc=$? + echo "$rc" >"$run_dir_full/dlp.rc" +} + +summarize() { + python3 - "$check_file" "$dlp_file" "$run_dir_full/check.rc" "$run_dir_full/dlp.rc" <<'PY' +import json +import sys +from pathlib import Path + +check_path, dlp_path, check_rc_path, dlp_rc_path = map(Path, sys.argv[1:]) +check_rc = int(check_rc_path.read_text().strip()) +dlp_rc = int(dlp_rc_path.read_text().strip()) +summary = { + "check_rc": check_rc, + "dlp_rc": dlp_rc, + "check_ok": False, + "dlp_ok": False, + "severity": "FAIL" if check_rc or dlp_rc else "OK", + "needs_heal": bool(check_rc or dlp_rc), + "reasons": [], +} +try: + check = json.loads(check_path.read_text()) + summary["check_ok"] = bool(check.get("ok")) + cs = check.get("summary") or {} + summary["detmir_summary"] = cs + if cs.get("bucket_dead", 0) or cs.get("bucket_stale", 0) or cs.get("service_failures", 0): + summary["reasons"].append("detmir-check has stale/dead bucket or required service failure") +except Exception as exc: + summary["reasons"].append(f"detmir-check parse failed: {exc}") + +try: + dlp = json.loads(dlp_path.read_text()) + summary["dlp_ok"] = bool(dlp.get("ok")) + summary["dlp_counts"] = dlp.get("counts") + counts = dlp.get("counts") or {} + if counts.get("fail", 0) or counts.get("warn", 0): + summary["reasons"].append("dlp-health-check has warn/fail") +except Exception as exc: + summary["reasons"].append(f"detmir-dlp parse failed: {exc}") + +if summary["check_ok"] and summary["dlp_ok"]: + summary["severity"] = "OK" + summary["needs_heal"] = False +elif not summary["reasons"]: + summary["severity"] = "WARN" +else: + summary["severity"] = "FAIL" + +print(json.dumps(summary, ensure_ascii=False, indent=2)) +PY +} + +run_check +run_dlp +summary_before="$(summarize)" +printf '%s\n' "$summary_before" >"$run_dir_full/summary-before.json" + +if [ "$auto_heal" = "1" ] && python3 -c 'import json,sys; print("yes" if json.load(sys.stdin).get("needs_heal") else "no")' <<<"$summary_before" | grep -qx yes; then + { + echo "detmir-heal-safe started at $(date -u --iso-8601=seconds)" + detmir-heal-safe + echo "detmir-heal-safe finished at $(date -u --iso-8601=seconds)" + } >"$heal_log" 2>&1 || true + sleep 10 + run_check + run_dlp +else + echo "auto-heal skipped" >"$heal_log" +fi + +summary_after="$(summarize)" +printf '%s\n' "$summary_after" >"$state_file" + +{ + echo "Ты операторский AI-помощник DetMir. По фактам ниже дай короткий русский отчет." + echo "Структура ответа:" + echo "1. Состояние: OK/WARN/FAIL" + echo "2. Что важно" + echo "3. Что уже сделал автомат" + echo "4. Что сделать человеку, если нужно" + echo + echo "Правила:" + echo "- Не предлагай рестарты, если факты чистые." + echo "- Отличай event-driven bucket от dead/stale." + echo "- DLP sendFailures важны только при новом sendFailuresDelta или warn/fail." + echo "- Auto-heal умеет только серверные systemd-сервисы AW/DLP; Windows/RDP не трогает." + echo + echo "=== summary-before ===" + cat "$run_dir_full/summary-before.json" + echo + echo "=== summary-after ===" + cat "$state_file" + echo + echo "=== heal-log ===" + sed -n '1,300p' "$heal_log" + echo + echo "=== detmir-check ===" + sed -n '1,1600p' "$check_file" + echo + echo "=== detmir-dlp ===" + sed -n '1,1600p' "$dlp_file" +} >"$bundle_file" + +{ + echo "# DetMir Autonomous Report" + echo + echo "- generated_at_utc: $(date -u --iso-8601=seconds)" + echo "- run_dir: $run_dir_full" + echo + polli-chat --model text.daily --max-tokens 900 <"$bundle_file" || { + echo "Pollinations report failed; raw summary follows." + cat "$state_file" + } +} >"$report_file" + +ln -sfn "$run_dir_full" "$state_dir/latest-run" +ln -sfn "$report_file" "$state_dir/latest-report.md" +ln -sfn "$state_file" "$state_dir/latest-state.json" + +find "$state_dir/runs" -mindepth 1 -maxdepth 1 -type d -mtime +"$retain_days" -exec rm -rf {} + +find "$state_dir/reports" -type f -name 'detmir-report-*.md' -mtime +"$retain_days" -delete +find "$state_dir" -maxdepth 1 -type f -name 'state-*.json' -mtime +"$retain_days" -delete + +cat "$report_file" + +python3 - "$state_file" <<'PY' +import json +import sys +state = json.load(open(sys.argv[1])) +raise SystemExit(0 if state.get("severity") == "OK" else 2) +PY diff --git a/ops/detmir-ai/detmir-auto-rust-shadow.service b/ops/detmir-ai/detmir-auto-rust-shadow.service new file mode 100644 index 0000000..8f41532 --- /dev/null +++ b/ops/detmir-ai/detmir-auto-rust-shadow.service @@ -0,0 +1,20 @@ +[Unit] +Description=DetMir Rust autonomous shadow health loop +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +User=igor +Group=igor +ExecCondition=/bin/sh -c '! systemctl -q is-active detmir-auto.service' +Environment=DETMIR_AI_STATE_DIR=/var/lib/detmir-ai/shadow/detmir-auto-rust +Environment=DETMIR_AI_RUN_DIR=/var/lib/detmir-ai/shadow/detmir-auto-rust/locks +Environment=no_proxy=localhost,127.0.0.1,192.168.100.18,10.10.10.13,10.10.10.2,10.10.10.0/24,192.168.100.0/24 +Environment=NO_PROXY=localhost,127.0.0.1,192.168.100.18,10.10.10.13,10.10.10.2,10.10.10.0/24,192.168.100.0/24 +ExecStart=/usr/local/bin/detmir-auto-rust --no-heal --no-report --command-timeout-seconds 180 +SuccessExitStatus=2 +TimeoutStartSec=240 +Nice=5 +IOSchedulingClass=best-effort +IOSchedulingPriority=7 diff --git a/ops/detmir-ai/detmir-auto-rust-shadow.timer b/ops/detmir-ai/detmir-auto-rust-shadow.timer new file mode 100644 index 0000000..f7c7e64 --- /dev/null +++ b/ops/detmir-ai/detmir-auto-rust-shadow.timer @@ -0,0 +1,13 @@ +[Unit] +Description=Run DetMir Rust autonomous shadow loop every 15 minutes + +[Timer] +OnBootSec=7min +OnUnitActiveSec=30min +AccuracySec=1min +RandomizedDelaySec=5min +Persistent=true +Unit=detmir-auto-rust-shadow.service + +[Install] +WantedBy=timers.target diff --git a/ops/detmir-ai/detmir-auto.service b/ops/detmir-ai/detmir-auto.service new file mode 100644 index 0000000..6c49e36 --- /dev/null +++ b/ops/detmir-ai/detmir-auto.service @@ -0,0 +1,17 @@ +[Unit] +Description=DetMir autonomous health, AI report, and safe server-side recovery +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +User=igor +Group=igor +Environment=DETMIR_AUTO_HEAL=1 +Environment=DETMIR_AI_STATE_DIR=/var/lib/detmir-ai +Environment=DETMIR_AI_RUN_DIR=/var/lib/detmir-ai/locks +ExecStart=/usr/local/bin/detmir-auto +TimeoutStartSec=300 +Nice=5 +IOSchedulingClass=best-effort +IOSchedulingPriority=7 diff --git a/ops/detmir-ai/detmir-auto.timer b/ops/detmir-ai/detmir-auto.timer new file mode 100644 index 0000000..94b73b0 --- /dev/null +++ b/ops/detmir-ai/detmir-auto.timer @@ -0,0 +1,12 @@ +[Unit] +Description=Run DetMir autonomous health loop every 15 minutes + +[Timer] +OnBootSec=2min +OnUnitActiveSec=15min +AccuracySec=1min +Persistent=true +Unit=detmir-auto.service + +[Install] +WantedBy=timers.target diff --git a/ops/detmir-ai/detmir-check b/ops/detmir-ai/detmir-check new file mode 100644 index 0000000..5ae2749 --- /dev/null +++ b/ops/detmir-ai/detmir-check @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail + +exec "${DETMIR_CHECK_BIN:-/usr/local/bin/detmir-check}" "$@" diff --git a/ops/detmir-ai/detmir-dlp b/ops/detmir-ai/detmir-dlp new file mode 100644 index 0000000..50ca5b6 --- /dev/null +++ b/ops/detmir-ai/detmir-dlp @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail + +exec "${DETMIR_DLP_BIN:-/usr/local/bin/detmir-dlp}" "$@" diff --git a/ops/detmir-ai/detmir-heal-safe b/ops/detmir-ai/detmir-heal-safe new file mode 100644 index 0000000..a60eccd --- /dev/null +++ b/ops/detmir-ai/detmir-heal-safe @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +set -euo pipefail + +aw_host="${DETMIR_AW_SSH_HOST:-igor@10.10.10.13}" + +ssh -o BatchMode=yes \ + -o ConnectTimeout=10 \ + -o StrictHostKeyChecking=accept-new \ + "$aw_host" \ + 'bash -s' <<'REMOTE' +set -euo pipefail + +services=( + activitywatch-server.service + aw-worktime-api.service + aw-worktime-ui-bridge.service + activitywatch-dlp-aggregator.service +) + +sudo -n systemctl reset-failed "${services[@]}" >/dev/null 2>&1 || true + +for service in activitywatch-server.service aw-worktime-api.service aw-worktime-ui-bridge.service; do + if ! systemctl is-active --quiet "$service"; then + echo "restart $service" + sudo -n systemctl restart "$service" + else + echo "active $service" + fi +done + +if systemctl list-unit-files activitywatch-dlp-aggregator.service >/dev/null 2>&1; then + if ! systemctl is-active --quiet activitywatch-dlp-aggregator.service; then + echo "start activitywatch-dlp-aggregator.service" + sudo -n systemctl start activitywatch-dlp-aggregator.service || true + else + echo "active activitywatch-dlp-aggregator.service" + fi +fi + +sudo -n /usr/local/bin/dlp-health-check --json >/tmp/detmir-heal-dlp-health.json || true +REMOTE diff --git a/ops/detmir-ai/detmir-status b/ops/detmir-ai/detmir-status new file mode 100644 index 0000000..906061f --- /dev/null +++ b/ops/detmir-ai/detmir-status @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail + +exec "${DETMIR_STATUS_BIN:-/usr/local/bin/detmir-status}" "$@" diff --git a/ops/detmir-ai/polli-chat b/ops/detmir-ai/polli-chat new file mode 100644 index 0000000..dd38008 --- /dev/null +++ b/ops/detmir-ai/polli-chat @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import os +import sys +from pathlib import Path +from urllib import error, request + + +DEFAULT_BASE_URL = "https://gen.pollinations.ai" +DEFAULT_KEY_PATH = Path.home() / ".pollinations" / "credentials.json" + +ALIASES = { + "daily": "gpt-5.4-mini", + "cheap": "gemini-flash-lite-3.1", + "heavy": "gpt-5.5", + "coding_primary": "qwen-coder", + "coding_backup": "qwen-coder-large", + "search": "perplexity-fast", + "search_reasoning": "perplexity-reasoning", + "vision_primary": "qwen-vision", + "vision_heavy": "qwen-vision-pro", + "guardrail": "qwen-safety", + "text.daily": "gpt-5.4-mini", + "text.cheap": "gemini-flash-lite-3.1", + "text.heavy": "gpt-5.5", + "text.coding_primary": "qwen-coder", + "text.coding_backup": "qwen-coder-large", + "text.search": "perplexity-fast", + "text.search_reasoning": "perplexity-reasoning", + "text.vision_primary": "qwen-vision", + "text.vision_heavy": "qwen-vision-pro", + "text.guardrail": "qwen-safety", + "embeddings.primary": "openai-3-small", + "embeddings.quality": "openai-3-large", + "image.fast_preview": "zimage", + "image.background_change": "kontext", + "image.final_quality": "gptimage-large", + "video.primary": "ltx-2", + "video.backup": "wan", + "audio.tts_primary": "qwen-tts-instruct", + "audio.tts_fast": "qwen-tts", + "audio.tts_premium": "elevenlabs", +} + + +def load_api_key() -> str: + inline = os.getenv("POLLINATIONS_API_KEY", "").strip() + if inline: + return inline + key_path = Path(os.getenv("POLLINATIONS_KEY_PATH", str(DEFAULT_KEY_PATH))).expanduser() + data = json.loads(key_path.read_text(encoding="utf-8")) + key = str(data.get("apiKey", "")).strip() + if not key: + raise RuntimeError(f"apiKey not found in {key_path}") + return key + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Pollinations OpenAI-compatible chat helper.") + parser.add_argument("prompt", nargs="?", help="Prompt. If omitted, stdin is used.") + parser.add_argument("--model", default="text.daily", help="Model alias or exact model id.") + parser.add_argument("--system", default="", help="Optional system prompt.") + parser.add_argument("--temperature", type=float, default=0.2) + parser.add_argument("--max-tokens", type=int, default=1200) + parser.add_argument("--json", action="store_true", help="Print raw JSON.") + parser.add_argument("--list-aliases", action="store_true") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + if args.list_aliases: + print(json.dumps(ALIASES, ensure_ascii=False, indent=2, sort_keys=True)) + return 0 + + prompt = args.prompt if args.prompt is not None else sys.stdin.read() + prompt = prompt.strip() + if not prompt: + raise RuntimeError("prompt is empty") + + model = ALIASES.get(args.model.strip(), args.model.strip()) + messages = [] + if args.system.strip(): + messages.append({"role": "system", "content": args.system.strip()}) + messages.append({"role": "user", "content": prompt}) + + base_url = os.getenv("POLLINATIONS_BASE_URL", DEFAULT_BASE_URL).rstrip("/") + payload = { + "model": model, + "messages": messages, + "temperature": args.temperature, + "max_tokens": args.max_tokens, + } + req = request.Request( + f"{base_url}/v1/chat/completions", + data=json.dumps(payload).encode("utf-8"), + headers={ + "Authorization": f"Bearer {load_api_key()}", + "Content-Type": "application/json", + "Accept": "application/json", + "User-Agent": "curl/8.5 detmir-proxmox", + }, + method="POST", + ) + try: + with request.urlopen(req, timeout=180) as resp: + body = resp.read().decode("utf-8", errors="replace") + except error.HTTPError as exc: + body = exc.read().decode("utf-8", errors="replace") + raise RuntimeError(f"pollinations http {exc.code}: {body[:2000]}") from exc + + data = json.loads(body) + if args.json: + print(json.dumps(data, ensure_ascii=False, indent=2)) + return 0 + + choices = data.get("choices") or [] + if not choices: + raise RuntimeError(f"no choices in response: {body[:2000]}") + message = choices[0].get("message") or {} + content = message.get("content") + if isinstance(content, list): + text = "\n".join(str(part.get("text", "")) for part in content if isinstance(part, dict)) + else: + text = str(content or "") + print(text.strip()) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except Exception as exc: + print(f"polli-chat error: {exc}", file=sys.stderr) + raise SystemExit(1) diff --git a/proxmox/test_tsj_guardian_bot.py b/proxmox/test_tsj_guardian_bot.py index 0bb74e3..31e77f0 100644 --- a/proxmox/test_tsj_guardian_bot.py +++ b/proxmox/test_tsj_guardian_bot.py @@ -9,6 +9,9 @@ class DummyState: def __init__(self): self.pending_incident = None self.last_warning_signature = "" + self.failure_streak_signature = "" + self.failure_streak_count = 0 + self.failure_streak_first_ts = 0 def save(self): pass @@ -18,6 +21,7 @@ def make_bot(check_rc, check_out, heal_rc, heal_out): bot = object.__new__(MODULE.TSJGuardianBot) bot.state = DummyState() bot.retry_autoheal_sec = 300 + bot.incident_failure_quorum_checks = 1 bot.exit_on_autoheal_success = True bot.fs_immediate_ai_on_critical = False bot.operator_timeout = 900 @@ -37,6 +41,42 @@ def make_bot(check_rc, check_out, heal_rc, heal_out): return bot +def make_slo_bot(summary): + bot = object.__new__(MODULE.TSJGuardianBot) + bot.aw_rus_slo_enabled = True + bot.aw_rus_slo_alert_window = "24h" + bot.aw_rus_slo_min_samples = 4 + bot.aw_rus_slo_max_age_sec = 90 + bot.aw_rus_slo_summary_cmd = "cat /tmp/slo.json" + bot._logs = [] + bot._log = lambda level, msg: bot._logs.append((level, msg)) + bot._run_shell = lambda cmd, timeout_sec=30: (0, MODULE.json.dumps(summary)) + bot._aw_rus_probe_should_run = lambda failures: True + bot._aw_rus_dlp_probe = lambda: (_ for _ in ()).throw(AssertionError("raw probe should not run")) + return bot + + +def make_slo_cycle_bot(summary): + bot = make_bot(check_rc=0, check_out="", heal_rc=0, heal_out="") + bot.aw_rus_slo_enabled = True + bot.aw_rus_slo_alert_window = "24h" + bot.aw_rus_slo_min_samples = 4 + bot.aw_rus_slo_max_age_sec = 90 + bot.aw_rus_slo_summary_cmd = "cat /tmp/slo.json" + bot._run_shell = lambda cmd, timeout_sec=420: (0, MODULE.json.dumps(summary)) + bot._aw_rus_probe_should_run = lambda failures: True + bot._aw_rus_dlp_probe = lambda: (_ for _ in ()).throw(AssertionError("raw probe should not run during SLO drill")) + bot._background_aw_rus_failures = MODULE.TSJGuardianBot._background_aw_rus_failures.__get__( + bot, + MODULE.TSJGuardianBot, + ) + bot._extract_aw_rus_failure_keys = MODULE.TSJGuardianBot._extract_aw_rus_failure_keys.__get__( + bot, + MODULE.TSJGuardianBot, + ) + return bot + + class TsjGuardianBotTests(unittest.TestCase): def test_new_incident_auto_resolved_before_notification_stays_silent(self): check_out = "2026-05-24 10:01:18 [FAIL] node_13: curl failed: http://10.10.10.13:5600/\n" @@ -49,6 +89,37 @@ class TsjGuardianBotTests(unittest.TestCase): self.assertEqual(bot._notifications, []) self.assertTrue(any("auto-resolved before operator notification" in msg for _, msg in bot._logs)) + def test_transient_failure_is_suppressed_until_quorum(self): + check_out = "2026-05-24 10:01:18 [FAIL] node_13: curl failed: http://10.10.10.13:5600/\n" + heal_out = "2026-05-24 10:01:40 [FAIL] node_13: curl failed: http://10.10.10.13:5600/\n" + bot = make_bot(check_rc=1, check_out=check_out, heal_rc=1, heal_out=heal_out) + bot.incident_failure_quorum_checks = 2 + + bot._handle_check_cycle() + + self.assertIsNone(bot.state.pending_incident) + self.assertEqual(bot.state.failure_streak_count, 1) + self.assertEqual(bot._notifications, []) + + bot._handle_check_cycle() + + self.assertIsNotNone(bot.state.pending_incident) + self.assertEqual(bot.state.failure_streak_count, 0) + self.assertEqual(len(bot._notifications), 2) + self.assertIn("Обнаружен инцидент", bot._notifications[0]) + + def test_filesystem_critical_bypasses_failure_quorum(self): + check_out = "2026-05-24 10:01:18 [FAIL] filesystem_usage: /var 96%\n" + heal_out = "2026-05-24 10:01:40 [FAIL] filesystem_usage: /var 96%\n" + bot = make_bot(check_rc=1, check_out=check_out, heal_rc=1, heal_out=heal_out) + bot.incident_failure_quorum_checks = 3 + + bot._handle_check_cycle() + + self.assertIsNotNone(bot.state.pending_incident) + self.assertEqual(bot.state.failure_streak_count, 0) + self.assertTrue(bot._notifications) + def test_new_incident_notifies_only_after_autoheal_failure(self): check_out = "2026-05-24 10:01:18 [FAIL] node_13: curl failed: http://10.10.10.13:5600/\n" heal_out = "2026-05-24 10:01:40 [FAIL] node_13: curl failed: http://10.10.10.13:5600/\n" @@ -93,6 +164,201 @@ class TsjGuardianBotTests(unittest.TestCase): self.assertEqual(bot._notifications, []) self.assertTrue(any("auto-resolved before operator notification" in msg for _, msg in bot._logs)) + def test_slo_budget_exhaustion_becomes_aw_failure(self): + summary = { + "generated_at_utc": MODULE.datetime.now(MODULE.timezone.utc).isoformat().replace("+00:00", "Z"), + "current_sample": {"ok": False}, + "windows": { + "24h": { + "availability_percent": 99.96, + "samples": 10, + "bad_samples": 2, + "budget_remaining_seconds": -5, + "status": "burning", + } + }, + } + bot = make_slo_bot(summary) + + lines, failures = MODULE.TSJGuardianBot._aw_rus_slo_lines_and_failures(bot) + rendered, keys = MODULE.TSJGuardianBot._background_aw_rus_failures(bot, []) + + self.assertEqual(failures, ["slo"]) + self.assertTrue(any("error budget exhausted" in line for line in lines)) + self.assertEqual(keys, ["slo"]) + self.assertIn("aw-rus:slo", rendered[0]) + + def test_slo_recovered_budget_exhaustion_does_not_become_aw_failure(self): + summary = { + "generated_at_utc": MODULE.datetime.now(MODULE.timezone.utc).isoformat().replace("+00:00", "Z"), + "current_sample": {"ok": True}, + "windows": { + "24h": { + "availability_percent": 99.96, + "samples": 10, + "bad_samples": 2, + "budget_remaining_seconds": -5, + "status": "burning", + } + }, + } + bot = make_slo_bot(summary) + + lines, failures = MODULE.TSJGuardianBot._aw_rus_slo_lines_and_failures(bot) + rendered, keys = MODULE.TSJGuardianBot._background_aw_rus_failures(bot, []) + + self.assertEqual(failures, []) + self.assertTrue(any("RECOVERED error budget exhausted" in line for line in lines)) + self.assertEqual(keys, []) + self.assertEqual(rendered, []) + + def test_slo_status_line_marks_historical_burn_as_recovered_when_current_sample_ok(self): + summary = { + "generated_at_utc": MODULE.datetime.now(MODULE.timezone.utc).isoformat().replace("+00:00", "Z"), + "current_sample": {"ok": True}, + "windows": { + "24h": { + "availability_percent": 98.43, + "samples": 100, + "bad_samples": 3, + "budget_remaining_seconds": -20, + "status": "burning", + } + }, + } + bot = make_slo_bot(summary) + + line = MODULE.TSJGuardianBot._aw_rus_slo_status_line(bot) + + self.assertIn("aw_rus_slo: recovered", line) + self.assertIn("current_sample=OK", line) + self.assertIn("budget_remaining_seconds=-20", line) + + def test_slo_status_line_marks_current_failure_as_fail(self): + summary = { + "generated_at_utc": MODULE.datetime.now(MODULE.timezone.utc).isoformat().replace("+00:00", "Z"), + "current_sample": {"ok": False}, + "windows": { + "24h": { + "availability_percent": 98.43, + "samples": 100, + "bad_samples": 3, + "budget_remaining_seconds": -20, + "status": "burning", + } + }, + } + bot = make_slo_bot(summary) + + line = MODULE.TSJGuardianBot._aw_rus_slo_status_line(bot) + + self.assertIn("aw_rus_slo: fail", line) + self.assertIn("current_sample=FAIL", line) + + def test_slo_warmup_does_not_alert_before_min_samples(self): + summary = { + "generated_at_utc": MODULE.datetime.now(MODULE.timezone.utc).isoformat().replace("+00:00", "Z"), + "current_sample": {"ok": False}, + "windows": { + "24h": { + "availability_percent": 0.0, + "samples": 1, + "bad_samples": 1, + "budget_remaining_seconds": 10, + "status": "burning", + } + }, + } + bot = make_slo_bot(summary) + + lines, failures = MODULE.TSJGuardianBot._aw_rus_slo_lines_and_failures(bot) + + self.assertEqual(failures, []) + self.assertTrue(any("WARMUP" in line for line in lines)) + + def test_slo_failure_has_no_direct_autoheal_target(self): + summary = { + "generated_at_utc": MODULE.datetime.now(MODULE.timezone.utc).isoformat().replace("+00:00", "Z"), + "current_sample": {"ok": False}, + "windows": { + "24h": { + "availability_percent": 99.96, + "samples": 10, + "bad_samples": 2, + "budget_remaining_seconds": -5, + "status": "burning", + } + }, + } + bot = make_slo_bot(summary) + + ok, report, _after_lines, after_failures = MODULE.TSJGuardianBot._perform_aw_rus_autoheal(bot, ["slo"]) + + self.assertFalse(ok) + self.assertEqual(after_failures, ["slo"]) + self.assertTrue(any("no direct autoheal target" in line for line in report)) + + def test_slo_stale_autoheal_message_points_to_sampler(self): + summary = { + "generated_at_utc": "2026-05-30T10:00:00Z", + "current_sample": {"ok": True}, + "windows": {"24h": {"samples": 10, "bad_samples": 0, "budget_remaining_seconds": 25, "status": "ok"}}, + } + bot = make_slo_bot(summary) + + ok, report, after_lines, after_failures = MODULE.TSJGuardianBot._perform_aw_rus_autoheal(bot, ["slo"]) + + self.assertFalse(ok) + self.assertEqual(after_failures, ["slo"]) + self.assertTrue(any("SLO summary stale" in line for line in report)) + self.assertTrue(any("slo-summary: STALE" in line for line in after_lines)) + + def test_slo_current_failure_drill_creates_incident_and_operator_notifications(self): + summary = { + "generated_at_utc": MODULE.datetime.now(MODULE.timezone.utc).isoformat().replace("+00:00", "Z"), + "current_sample": {"ok": False}, + "windows": { + "24h": { + "availability_percent": 99.96, + "samples": 10, + "bad_samples": 2, + "budget_remaining_seconds": -5, + "status": "burning", + } + }, + } + bot = make_slo_cycle_bot(summary) + + bot._handle_check_cycle() + + self.assertIsNotNone(bot.state.pending_incident) + self.assertIn("aw-rus:slo", "\n".join(bot.state.pending_incident.failures)) + self.assertEqual(len(bot._notifications), 2) + self.assertIn("Обнаружен инцидент", bot._notifications[0]) + self.assertIn("Авто-лечение неуспешно", bot._notifications[1]) + + def test_slo_recovered_historical_burn_drill_stays_silent(self): + summary = { + "generated_at_utc": MODULE.datetime.now(MODULE.timezone.utc).isoformat().replace("+00:00", "Z"), + "current_sample": {"ok": True}, + "windows": { + "24h": { + "availability_percent": 99.96, + "samples": 10, + "bad_samples": 2, + "budget_remaining_seconds": -5, + "status": "burning", + } + }, + } + bot = make_slo_cycle_bot(summary) + + bot._handle_check_cycle() + + self.assertIsNone(bot.state.pending_incident) + self.assertEqual(bot._notifications, []) + self.assertTrue(any(msg == "Check OK" for _, msg in bot._logs)) + class TelegramApiDocumentTests(unittest.TestCase): def test_send_document_rejects_empty_payload(self): @@ -288,8 +554,27 @@ class AwRusDlpProbeTests(unittest.TestCase): "aw-file-operations_10.10.10.13": {"metadata": {"end": fresh_fileops}}, } ) + if "aw-rus-collector-guard_SHARKON2025/events" in url: + return response( + [ + { + "timestamp": fresh_worktime, + "data": {"status": "ok", "mode": "shadow", "actions": [], "problems": []}, + } + ] + ) if "aw-worktime-sessions_SHARKON2025/events" in url: return response([{"timestamp": fresh_worktime, "data": {"active": True}}]) + if "aw-watcher-window_SHARKON2025/events" in url: + return response([{"timestamp": fresh_watcher, "data": {}}]) + if "aw-watcher-afk_SHARKON2025/events" in url: + return response([{"timestamp": fresh_watcher, "data": {}}]) + if "aw-dlp-endpoint-signals_SHARKON2025/events" in url: + return response([{"timestamp": stale_endpoint, "data": {"signalType": "self_test"}}]) + if "aw-file-operations_SHARKON2025/events" in url: + return response([{"timestamp": fresh_fileops, "data": {}}]) + if "aw-file-operations_10.10.10.13/events" in url: + return response([{"timestamp": fresh_fileops, "data": {}}]) if url.endswith("/buckets/aw-watcher-window_SHARKON2025"): return response({"metadata": {"end": fresh_watcher}}) if url.endswith("/buckets/aw-watcher-afk_SHARKON2025"): @@ -323,6 +608,840 @@ class AwRusDlpProbeTests(unittest.TestCase): self.assertNotIn("dlp-endpoint", failures) self.assertTrue(any("file-operations fresh; endpoint collector degraded" in line for line in lines)) + @mock.patch("proxmox.tsj_guardian_bot.requests.get") + def test_inactive_host_and_healthy_guard_downgrades_endpoint_stale_to_warn(self, get_mock): + real_datetime = MODULE.datetime + now = real_datetime(2026, 5, 30, 3, 20, tzinfo=MODULE.timezone.utc) + stale_endpoint = "2026-05-30T02:40:00.000Z" + fresh_guard = "2026-05-30T03:19:30.000Z" + fresh_worktime = "2026-05-30T03:19:40.000Z" + fresh_watcher = "2026-05-30T03:18:00.000Z" + + def response(payload): + resp = mock.Mock() + resp.raise_for_status.return_value = None + resp.json.return_value = payload + return resp + + def fake_get(url, timeout=20): + if url.endswith("/buckets"): + return response({"aw-worktime-sessions_SHARKON2025": {"metadata": {"end": fresh_worktime}}}) + if "aw-rus-collector-guard_SHARKON2025/events" in url: + return response( + [ + { + "timestamp": fresh_guard, + "data": {"status": "ok", "mode": "shadow", "actions": [], "problems": []}, + } + ] + ) + if "aw-worktime-sessions_SHARKON2025/events" in url: + return response([{"timestamp": fresh_worktime, "data": {"active": False}}]) + if "aw-watcher-window_SHARKON2025/events" in url: + return response([{"timestamp": fresh_watcher, "data": {}}]) + if "aw-watcher-afk_SHARKON2025/events" in url: + return response([{"timestamp": fresh_watcher, "data": {}}]) + if "aw-dlp-endpoint-signals_SHARKON2025/events" in url: + return response([{"timestamp": stale_endpoint, "data": {"signalType": "self_test"}}]) + if "aw-file-operations_SHARKON2025/events" in url: + return response([]) + if "aw-file-operations_10.10.10.13/events" in url: + return response([]) + if url.endswith("/buckets/aw-file-operations_SHARKON2025"): + return response({"metadata": {"end": stale_endpoint}}) + if url.endswith("/buckets/aw-file-operations_10.10.10.13"): + return response({"metadata": {"end": stale_endpoint}}) + raise AssertionError(f"unexpected url {url}") + + get_mock.side_effect = fake_get + + bot = object.__new__(MODULE.TSJGuardianBot) + bot.aw_rus_api_base = "http://10.10.10.13:5600/api/0" + bot.aw_rus_worktime_base = "http://10.10.10.13:5610" + bot.aw_rus_host = "SHARKON2025" + bot.aw_rus_stale_sec = 900 + bot.aw_rus_primary_user = "user1" + bot._fetch_worktime_today_csv = lambda timeout_sec=20, attempts=2: ( + "user,user_id,active_seconds\n" + "user1,SHARKON2025\\\\user1,0\n" + ) + + with mock.patch("proxmox.tsj_guardian_bot.datetime") as dt_mock: + dt_mock.now.return_value = now + dt_mock.fromisoformat.side_effect = lambda value: real_datetime.fromisoformat(value) + lines, failures = MODULE.TSJGuardianBot._aw_rus_dlp_probe(bot) + + self.assertNotIn("dlp-endpoint", failures) + self.assertTrue(any("collector-guard: OK" in line for line in lines)) + self.assertTrue(any("dlp-endpoint: WARN" in line and "guard healthy" in line for line in lines)) + + @mock.patch("proxmox.tsj_guardian_bot.requests.get") + def test_worktime_report_timeout_is_warn_when_session_bucket_is_fresh(self, get_mock): + real_datetime = MODULE.datetime + now = real_datetime(2026, 5, 30, 3, 20, tzinfo=MODULE.timezone.utc) + fresh_guard = "2026-05-30T03:19:30.000Z" + fresh_worktime = "2026-05-30T03:19:40.000Z" + fresh_watcher = "2026-05-30T03:18:00.000Z" + + def response(payload): + resp = mock.Mock() + resp.raise_for_status.return_value = None + resp.json.return_value = payload + return resp + + def fake_get(url, timeout=20): + if url.endswith("/buckets"): + return response({"aw-worktime-sessions_SHARKON2025": {"metadata": {"end": fresh_worktime}}}) + if "aw-rus-collector-guard_SHARKON2025/events" in url: + return response( + [ + { + "timestamp": fresh_guard, + "data": {"status": "ok", "mode": "shadow", "actions": [], "problems": []}, + } + ] + ) + if "aw-worktime-sessions_SHARKON2025/events" in url: + return response([{"timestamp": fresh_worktime, "data": {"active": False}}]) + if "aw-watcher-window_SHARKON2025/events" in url: + return response([{"timestamp": fresh_watcher, "data": {}}]) + if "aw-watcher-afk_SHARKON2025/events" in url: + return response([{"timestamp": fresh_watcher, "data": {}}]) + if "aw-dlp-endpoint-signals_SHARKON2025/events" in url: + return response([{"timestamp": fresh_watcher, "data": {"signalType": "self_test"}}]) + if "aw-file-operations_SHARKON2025/events" in url: + return response([]) + if "aw-file-operations_10.10.10.13/events" in url: + return response([]) + if url.endswith("/buckets/aw-file-operations_SHARKON2025"): + return response({"metadata": {"end": fresh_watcher}}) + if url.endswith("/buckets/aw-file-operations_10.10.10.13"): + return response({"metadata": {"end": fresh_watcher}}) + raise AssertionError(f"unexpected url {url}") + + get_mock.side_effect = fake_get + + bot = object.__new__(MODULE.TSJGuardianBot) + bot.aw_rus_api_base = "http://10.10.10.13:5600/api/0" + bot.aw_rus_worktime_base = "http://10.10.10.13:5610" + bot.aw_rus_host = "SHARKON2025" + bot.aw_rus_stale_sec = 900 + bot.aw_rus_primary_user = "user1" + bot._fetch_worktime_today_csv = mock.Mock(side_effect=MODULE.requests.exceptions.ReadTimeout("read timeout")) + + with mock.patch("proxmox.tsj_guardian_bot.datetime") as dt_mock: + dt_mock.now.return_value = now + dt_mock.fromisoformat.side_effect = lambda value: real_datetime.fromisoformat(value) + lines, failures = MODULE.TSJGuardianBot._aw_rus_dlp_probe(bot) + + self.assertNotIn("worktime", failures) + self.assertTrue(any("worktime: WARN report unavailable" in line for line in lines)) + + def test_endpoint_failure_uses_windows_heal_not_server_dlp_heal(self): + bot = object.__new__(MODULE.TSJGuardianBot) + calls = [] + + def windows_heal(include_watchers, include_worktime, include_dlp=False): + calls.append(("windows", include_watchers, include_worktime, include_dlp)) + return True, ["windows-ok"] + + def dlp_heal(targets): + calls.append(("dlp", tuple(targets))) + return True, ["dlp-ok"] + + bot._aw_rus_windows_collectors_heal = windows_heal + bot._aw_rus_dlp_heal = dlp_heal + bot._aw_rus_worktime_heal = lambda: (True, ["worktime-ok"]) + bot._aw_rus_dlp_probe = lambda: (["after"], []) + + with mock.patch("proxmox.tsj_guardian_bot.time.sleep"): + ok, report, after_lines, after_failures = MODULE.TSJGuardianBot._perform_aw_rus_autoheal( + bot, + ["dlp-endpoint"], + ) + + self.assertTrue(ok) + self.assertEqual(calls, [("windows", False, False, True)]) + self.assertIn("windows-ok", report) + self.assertEqual(after_lines, ["after"]) + self.assertEqual(after_failures, []) + + def test_aw_autoheal_prefers_rust_plan_backend(self): + bot = object.__new__(MODULE.TSJGuardianBot) + calls = [] + bot._logs = [] + bot._log = lambda level, msg: bot._logs.append((level, msg)) + + def windows_heal(include_watchers, include_worktime, include_dlp=False): + calls.append(("windows", include_watchers, include_worktime, include_dlp)) + return True, ["windows-ok"] + + def dlp_heal(targets): + calls.append(("dlp", tuple(targets))) + return True, ["dlp-ok"] + + bot._aw_rus_windows_collectors_heal = windows_heal + bot._aw_rus_dlp_heal = dlp_heal + bot._aw_rus_worktime_heal = lambda: (calls.append(("worktime",)) or (True, ["worktime-ok"])) + bot._aw_rus_dlp_probe = lambda: (["after"], []) + with MODULE.tempfile.TemporaryDirectory() as tmp: + bin_path = Path(tmp) / "tsj-guardian-status" + bin_path.write_text("#!/bin/sh\n", encoding="utf-8") + bot.tsj_guardian_status_bin = str(bin_path) + captured = {} + + def fake_run_argv(argv, timeout_sec=180, input_text="", env_extra=None, cwd=None): + captured["argv"] = argv + captured["payload"] = MODULE.json.loads(input_text) + return MODULE.subprocess.CompletedProcess( + argv, + 0, + MODULE.json.dumps( + { + "failures": ["watcher-window", "dlp-fileops-server", "worktime"], + "slo_only": False, + "slo_stale": False, + "include_watchers": True, + "include_worktime": True, + "include_windows_dlp": False, + "server_dlp_failures": ["dlp-fileops-server"], + "run_windows_heal": True, + "run_server_dlp_heal": True, + "run_worktime_heal": True, + "sleep_after_seconds": 5, + "report_triggers": [ + "- heal trigger: Windows session collectors degraded, starting remediation", + "- heal trigger: server-side DLP degraded, starting remediation", + "- heal trigger: worktime/watchers degraded, rebuilding server-side worktime views", + ], + "direct_autoheal_target": True, + } + ), + "", + ) + + bot._run_argv = fake_run_argv + with mock.patch("proxmox.tsj_guardian_bot.time.sleep") as sleep_mock: + ok, report, after_lines, after_failures = MODULE.TSJGuardianBot._perform_aw_rus_autoheal( + bot, + ["watcher-window", "dlp-fileops-server", "worktime"], + ) + + self.assertTrue(ok) + self.assertIn("--autoheal-plan-decision", captured["argv"]) + self.assertEqual(captured["payload"]["failures"], ["watcher-window", "dlp-fileops-server", "worktime"]) + self.assertEqual( + calls, + [ + ("windows", True, True, False), + ("dlp", ("dlp-fileops-server",)), + ("worktime",), + ], + ) + sleep_mock.assert_called_once_with(5) + self.assertIn("windows-ok", report) + self.assertIn("dlp-ok", report) + self.assertIn("worktime-ok", report) + self.assertEqual(after_lines, ["after"]) + self.assertEqual(after_failures, []) + + def test_slo_autoheal_prefers_rust_plan_backend(self): + bot = make_slo_bot( + { + "generated_at_utc": "2026-05-30T10:00:00Z", + "current_sample": {"ok": True}, + "windows": {"24h": {"samples": 10, "bad_samples": 0, "budget_remaining_seconds": 25, "status": "ok"}}, + } + ) + with MODULE.tempfile.TemporaryDirectory() as tmp: + bin_path = Path(tmp) / "tsj-guardian-status" + bin_path.write_text("#!/bin/sh\n", encoding="utf-8") + bot.tsj_guardian_status_bin = str(bin_path) + + def fake_run_argv(argv, timeout_sec=180, input_text="", env_extra=None, cwd=None): + payload = MODULE.json.loads(input_text) + if "--autoheal-plan-decision" not in argv: + raise AssertionError(f"unexpected argv {argv}") + return MODULE.subprocess.CompletedProcess( + argv, + 0, + MODULE.json.dumps( + { + "failures": payload["failures"], + "slo_only": True, + "slo_stale": True, + "include_watchers": False, + "include_worktime": False, + "include_windows_dlp": False, + "server_dlp_failures": [], + "run_windows_heal": False, + "run_server_dlp_heal": False, + "run_worktime_heal": False, + "sleep_after_seconds": 0, + "report_triggers": ["from-rust-slo-trigger"], + "direct_autoheal_target": False, + } + ), + "", + ) + + bot._run_argv = fake_run_argv + ok, report, after_lines, after_failures = MODULE.TSJGuardianBot._perform_aw_rus_autoheal(bot, ["slo"]) + + self.assertFalse(ok) + self.assertEqual(report, ["from-rust-slo-trigger"]) + self.assertEqual(after_failures, ["slo"]) + self.assertTrue(any("slo-summary: STALE" in line for line in after_lines)) + + +class CodexExecSafetyTests(unittest.TestCase): + def test_codex_exec_does_not_forward_bearer_to_sudo(self): + bot = object.__new__(MODULE.TSJGuardianBot) + bot.ai_exec_user = "igor" + bot.ai_chat_workdir = "/home/igor" + bot.ai_chat_sandbox = "workspace-write" + bot.codex_model = "gpt-test" + captured = {} + + def fake_run_argv(argv, timeout_sec=180, input_text="", env_extra=None, cwd=None): + captured["argv"] = argv + captured["env_extra"] = env_extra or {} + captured["cwd"] = cwd + return MODULE.subprocess.CompletedProcess(argv, 1, "", "401 Unauthorized token_invalidated") + + bot._run_argv = fake_run_argv + with mock.patch.dict(MODULE.os.environ, {"PFSENSE_MCP_BEARER": "secret-token"}, clear=False): + rc, out, reply = MODULE.TSJGuardianBot._run_codex_exec_prompt(bot, "hello", timeout_sec=5) + + self.assertEqual(rc, 1) + self.assertIn("401 Unauthorized", out) + self.assertEqual(reply, "") + self.assertEqual(captured["cwd"], "/home/igor") + self.assertEqual(captured["env_extra"], {}) + self.assertNotIn("--preserve-env=PFSENSE_MCP_BEARER", captured["argv"]) + self.assertNotIn("secret-token", " ".join(captured["argv"])) + + def test_codex_auth_error_is_operator_safe(self): + message = MODULE.TSJGuardianBot._summarize_exec_error( + "HTTP error: 401 Unauthorized token_invalidated refresh_token_reused", + 1, + ) + self.assertIn("повторная авторизация Codex", message) + + def test_detmir_status_line_prefers_rust_backend(self): + bot = object.__new__(MODULE.TSJGuardianBot) + bot._logs = [] + bot._log = lambda level, msg: bot._logs.append((level, msg)) + with MODULE.tempfile.TemporaryDirectory() as tmp: + bin_path = Path(tmp) / "tsj-guardian-status" + bin_path.write_text("#!/bin/sh\n", encoding="utf-8") + bot.tsj_guardian_status_bin = str(bin_path) + bot.detmir_state_file = Path(tmp) / "latest-state.json" + captured = {} + + def fake_run_argv(argv, timeout_sec=180, input_text="", env_extra=None, cwd=None): + captured["argv"] = argv + return MODULE.subprocess.CompletedProcess(argv, 0, "- detmir_auto: OK from-rust\n", "") + + bot._run_argv = fake_run_argv + line = MODULE.TSJGuardianBot._detmir_auto_status_line(bot) + + self.assertEqual(line, "- detmir_auto: OK from-rust") + self.assertEqual(captured["argv"][0], str(bin_path)) + self.assertEqual(captured["argv"][1], "--state") + + def test_aw_slo_status_line_prefers_rust_backend(self): + bot = object.__new__(MODULE.TSJGuardianBot) + bot._logs = [] + bot._log = lambda level, msg: bot._logs.append((level, msg)) + bot.aw_rus_slo_summary_cmd = "cat /tmp/slo.json" + bot.aw_rus_slo_alert_window = "24h" + with MODULE.tempfile.TemporaryDirectory() as tmp: + bin_path = Path(tmp) / "tsj-guardian-status" + bin_path.write_text("#!/bin/sh\n", encoding="utf-8") + bot.tsj_guardian_status_bin = str(bin_path) + captured = {} + + def fake_run_argv(argv, timeout_sec=180, input_text="", env_extra=None, cwd=None): + captured["argv"] = argv + return MODULE.subprocess.CompletedProcess( + argv, + 0, + "- aw_rus_slo: recovered 24h current_sample=OK availability=99.00000% samples=4 budget_remaining_seconds=-1\n", + "", + ) + + bot._run_argv = fake_run_argv + line = MODULE.TSJGuardianBot._aw_rus_slo_status_line(bot) + + self.assertIn("aw_rus_slo: recovered", line) + self.assertIn("--aw-slo-status-line", captured["argv"]) + self.assertIn("--aw-slo-summary-command", captured["argv"]) + + def test_status_text_prefers_rust_backend(self): + bot = object.__new__(MODULE.TSJGuardianBot) + bot._logs = [] + bot._log = lambda level, msg: bot._logs.append((level, msg)) + bot.infra_admin_root = "/opt/infra-admin" + bot.detmir_state_file = Path("/var/lib/detmir-ai/latest-state.json") + bot.state_file = "/opt/infra-admin/.state/tsj_guardian_state.json" + bot.updates_rollback_file = Path("/opt/infra-admin/.state/rollback.json") + bot.aw_rus_slo_summary_cmd = "cat /tmp/slo.json" + bot.aw_rus_slo_alert_window = "24h" + with MODULE.tempfile.TemporaryDirectory() as tmp: + bin_path = Path(tmp) / "tsj-guardian-status" + bin_path.write_text("#!/bin/sh\n", encoding="utf-8") + bot.tsj_guardian_status_bin = str(bin_path) + captured = {} + + def fake_run_argv(argv, timeout_sec=180, input_text="", env_extra=None, cwd=None): + captured["argv"] = argv + captured["timeout_sec"] = timeout_sec + return MODULE.subprocess.CompletedProcess(argv, 0, "Статус: инцидентов нет.\n- from-rust\n", "") + + bot._run_argv = fake_run_argv + text = MODULE.TSJGuardianBot._cmd_status(bot) + + self.assertIn("- from-rust", text) + self.assertIn("--status-text", captured["argv"]) + self.assertIn("--bot-state", captured["argv"]) + self.assertIn("--rollback-file", captured["argv"]) + self.assertIn("--pfsense-status-command", captured["argv"]) + self.assertGreaterEqual(captured["timeout_sec"], 90) + + def test_status_text_falls_back_when_rust_backend_fails(self): + bot = object.__new__(MODULE.TSJGuardianBot) + bot._logs = [] + bot._log = lambda level, msg: bot._logs.append((level, msg)) + bot.infra_admin_root = "/opt/infra-admin" + bot.detmir_state_file = Path("/tmp/missing-detmir-state.json") + bot.state_file = "/tmp/missing-bot-state.json" + bot.updates_rollback_file = Path("/tmp/missing-rollback.json") + bot.aw_rus_slo_summary_cmd = "" + bot.aw_rus_slo_alert_window = "24h" + bot.state = DummyState() + bot.state.pending_pfsense_change = None + bot.state.pending_openvpn_config = None + bot.state.pending_proxmox_selection = None + bot.state.pending_proxmox_restore = None + bot.state.pending_update_install_confirm = False + bot.state.pending_rollback_confirm = False + bot.state.last_openvpn_expiry_signature = "" + bot._pfsense_security_status_lines = lambda: "- pfsense_security: ok" + bot._aw_rus_slo_status_line = lambda: "- aw_rus_slo: ok" + bot._detmir_auto_status_line = lambda: "- detmir_auto: OK" + bot._rollback_pending_count = lambda: 0 + with MODULE.tempfile.TemporaryDirectory() as tmp: + bin_path = Path(tmp) / "tsj-guardian-status" + bin_path.write_text("#!/bin/sh\n", encoding="utf-8") + bot.tsj_guardian_status_bin = str(bin_path) + bot._run_argv = lambda *args, **kwargs: MODULE.subprocess.CompletedProcess(args[0], 1, "", "boom") + text = MODULE.TSJGuardianBot._cmd_status(bot) + + self.assertIn("Статус: инцидентов нет.", text) + self.assertIn("- detmir_auto: OK", text) + self.assertTrue(any("status-text failed" in msg for _, msg in bot._logs)) + + def test_suggestions_prefer_rust_decision_backend(self): + bot = object.__new__(MODULE.TSJGuardianBot) + bot._logs = [] + bot._log = lambda level, msg: bot._logs.append((level, msg)) + with MODULE.tempfile.TemporaryDirectory() as tmp: + bin_path = Path(tmp) / "tsj-guardian-status" + bin_path.write_text("#!/bin/sh\n", encoding="utf-8") + bot.tsj_guardian_status_bin = str(bin_path) + captured = {} + + def fake_run_argv(argv, timeout_sec=180, input_text="", env_extra=None, cwd=None): + captured["argv"] = argv + captured["payload"] = MODULE.json.loads(input_text) + return MODULE.subprocess.CompletedProcess( + argv, + 0, + MODULE.json.dumps({"suggestions": ["from-rust"]}), + "", + ) + + bot._run_argv = fake_run_argv + suggestions = MODULE.TSJGuardianBot._suggestions_from_failures(bot, ["[FAIL] x"]) + + self.assertEqual(suggestions, ["from-rust"]) + self.assertIn("--incident-suggestions", captured["argv"]) + self.assertEqual(captured["payload"]["failures"], ["[FAIL] x"]) + + def test_defer_transient_incident_prefers_rust_decision_backend(self): + bot = object.__new__(MODULE.TSJGuardianBot) + bot.state = DummyState() + bot.incident_failure_quorum_checks = 2 + bot._logs = [] + bot._log = lambda level, msg: bot._logs.append((level, msg)) + with MODULE.tempfile.TemporaryDirectory() as tmp: + bin_path = Path(tmp) / "tsj-guardian-status" + bin_path.write_text("#!/bin/sh\n", encoding="utf-8") + bot.tsj_guardian_status_bin = str(bin_path) + captured = {} + + def fake_run_argv(argv, timeout_sec=180, input_text="", env_extra=None, cwd=None): + captured["argv"] = argv + captured["payload"] = MODULE.json.loads(input_text) + return MODULE.subprocess.CompletedProcess( + argv, + 0, + MODULE.json.dumps( + { + "defer": True, + "failure_streak_signature": "sig", + "failure_streak_count": 1, + "failure_streak_first_ts": 1000, + "reset_failure_streak": False, + "log_line": "Suppressing transient incident streak=1/2 failures=1", + } + ), + "", + ) + + bot._run_argv = fake_run_argv + defer = MODULE.TSJGuardianBot._defer_transient_new_incident(bot, ["[FAIL] x"]) + + self.assertTrue(defer) + self.assertIn("--incident-defer-decision", captured["argv"]) + self.assertEqual(bot.state.failure_streak_signature, "sig") + self.assertEqual(bot.state.failure_streak_count, 1) + self.assertTrue(any("Suppressing transient incident" in msg for _, msg in bot._logs)) + + def test_timeout_escalation_prefers_rust_decision_backend(self): + bot = object.__new__(MODULE.TSJGuardianBot) + bot.state = DummyState() + bot.state.pending_incident = MODULE.PendingIncident( + incident_id="inc-1", + created_ts=1, + failures=["f1"], + suggestions=[], + last_autoheal_ts=0, + autoheal_attempts=0, + operator_acked=False, + escalated_to_ai=False, + fallback_executed=False, + ) + bot.operator_timeout = 900 + bot._logs = [] + bot._log = lambda level, msg: bot._logs.append((level, msg)) + bot._notifications = [] + bot._notify = lambda msg: bot._notifications.append(msg) + bot._escalate_to_ai = lambda: True + bot._run_server_fallback = lambda: True + with MODULE.tempfile.TemporaryDirectory() as tmp: + bin_path = Path(tmp) / "tsj-guardian-status" + bin_path.write_text("#!/bin/sh\n", encoding="utf-8") + bot.tsj_guardian_status_bin = str(bin_path) + captured = {} + + def fake_run_argv(argv, timeout_sec=180, input_text="", env_extra=None, cwd=None): + captured["argv"] = argv + captured["payload"] = MODULE.json.loads(input_text) + return MODULE.subprocess.CompletedProcess( + argv, + 0, + MODULE.json.dumps( + { + "should_escalate": True, + "should_fallback": True, + "timed_out": True, + "operator_acked": False, + "age_seconds": 1000, + "reason": "operator_timeout_reached", + } + ), + "", + ) + + bot._run_argv = fake_run_argv + MODULE.TSJGuardianBot._evaluate_timeout_escalation(bot) + + self.assertIn("--escalation-decision", captured["argv"]) + self.assertTrue(bot.state.pending_incident.escalated_to_ai) + self.assertTrue(bot.state.pending_incident.fallback_executed) + self.assertIn("Оператор не ответил. Выполнена эскалация.", bot._notifications) + self.assertIn("Сервер выполнил автономный fallback-план.", bot._notifications) + + def test_run_operator_action_prefers_rust_routing(self): + bot = object.__new__(MODULE.TSJGuardianBot) + bot.state = DummyState() + bot._logs = [] + bot._log = lambda level, msg: bot._logs.append((level, msg)) + bot._escalate_to_ai = lambda: True + with MODULE.tempfile.TemporaryDirectory() as tmp: + bin_path = Path(tmp) / "tsj-guardian-status" + bin_path.write_text("#!/bin/sh\n", encoding="utf-8") + bot.tsj_guardian_status_bin = str(bin_path) + captured = {} + + def fake_run_argv(argv, timeout_sec=180, input_text="", env_extra=None, cwd=None): + captured["argv"] = argv + captured["payload"] = MODULE.json.loads(input_text) + return MODULE.subprocess.CompletedProcess( + argv, + 0, + MODULE.json.dumps( + { + "requested_action": "techsupport", + "canonical_action": "support", + "handler": "ai_escalation", + "allowed": True, + "requires_confirmation": False, + "risk_level": "medium", + "reason": "allowed", + "message": None, + "state_update_hints": [], + } + ), + "", + ) + + bot._run_argv = fake_run_argv + result = MODULE.TSJGuardianBot._run_operator_action(bot, "techsupport") + + self.assertEqual(result, "/run support result=ok") + self.assertIn("--operator-action-decision", captured["argv"]) + self.assertEqual(captured["payload"]["action"], "techsupport") + self.assertTrue(any("Operator action routed by Rust" in msg for _, msg in bot._logs)) + + def test_run_operator_action_uses_rust_confirmation_guard(self): + bot = object.__new__(MODULE.TSJGuardianBot) + bot.state = DummyState() + bot.state.pending_update_install_confirm = False + bot.state.pending_rollback_confirm = False + bot._logs = [] + bot._log = lambda level, msg: bot._logs.append((level, msg)) + bot._run_shell = lambda *args, **kwargs: (_ for _ in ()).throw( + AssertionError("blocked action must not execute shell") + ) + with MODULE.tempfile.TemporaryDirectory() as tmp: + bin_path = Path(tmp) / "tsj-guardian-status" + bin_path.write_text("#!/bin/sh\n", encoding="utf-8") + bot.tsj_guardian_status_bin = str(bin_path) + + def fake_run_argv(argv, timeout_sec=180, input_text="", env_extra=None, cwd=None): + return MODULE.subprocess.CompletedProcess( + argv, + 0, + MODULE.json.dumps( + { + "requested_action": "updates-install-confirm", + "canonical_action": "updates-install-confirm", + "handler": "updates_install_apply", + "allowed": False, + "requires_confirmation": True, + "risk_level": "high", + "reason": "missing_update_install_confirmation", + "message": "Нет ожидающего запроса на установку. Сначала нажмите \"Установить критичные и важные обновления\".", + "state_update_hints": [], + } + ), + "", + ) + + bot._run_argv = fake_run_argv + result = MODULE.TSJGuardianBot._run_operator_action(bot, "updates-install-confirm") + + self.assertIn("Нет ожидающего запроса на установку", result) + self.assertIn("Установить критичные и важные обновления", result) + + def test_pfsense_first_confirm_prefers_rust_confirmation_backend(self): + bot = object.__new__(MODULE.TSJGuardianBot) + bot.state = DummyState() + bot.state.pending_pfsense_change = MODULE.PendingPfSenseChange( + request_id="pf-1", + created_ts=1000, + operator_request="add firewall rule", + stage="awaiting_first_confirm", + confirm_code="123456", + first_confirmed_ts=0, + ) + bot.pfsense_change_confirm_ttl_sec = 900 + bot._logs = [] + bot._log = lambda level, msg: bot._logs.append((level, msg)) + with MODULE.tempfile.TemporaryDirectory() as tmp: + bin_path = Path(tmp) / "tsj-guardian-status" + bin_path.write_text("#!/bin/sh\n", encoding="utf-8") + bot.tsj_guardian_status_bin = str(bin_path) + captured = {} + + def fake_run_argv(argv, timeout_sec=180, input_text="", env_extra=None, cwd=None): + captured["argv"] = argv + captured["payload"] = MODULE.json.loads(input_text) + return MODULE.subprocess.CompletedProcess( + argv, + 0, + MODULE.json.dumps( + { + "kind": "pfsense", + "action": "first_confirm", + "present": True, + "expired": False, + "allowed": True, + "clear_pending": False, + "next_stage": "awaiting_second_confirm", + "first_confirmed_ts": 1200, + "reason": "first_confirmed", + "message": None, + } + ), + "", + ) + + bot._run_argv = fake_run_argv + text = MODULE.TSJGuardianBot._confirm_pfsense_change_stage_one(bot) + + self.assertIn("--confirmation-decision", captured["argv"]) + self.assertEqual(captured["payload"]["kind"], "pfsense") + self.assertEqual(captured["payload"]["action"], "first_confirm") + self.assertEqual(bot.state.pending_pfsense_change.stage, "awaiting_second_confirm") + self.assertEqual(bot.state.pending_pfsense_change.first_confirmed_ts, 1200) + self.assertIn("/pfsense_apply 123456", text) + + def test_pfsense_apply_uses_rust_confirmation_guard_before_side_effect(self): + bot = object.__new__(MODULE.TSJGuardianBot) + bot.state = DummyState() + bot.state.pending_pfsense_change = MODULE.PendingPfSenseChange( + request_id="pf-1", + created_ts=1000, + operator_request="add firewall rule", + stage="awaiting_second_confirm", + confirm_code="123456", + first_confirmed_ts=1100, + ) + bot.pfsense_change_confirm_ttl_sec = 900 + bot._logs = [] + bot._log = lambda level, msg: bot._logs.append((level, msg)) + bot._run_pfsense_change_codex_exec = lambda pending: (_ for _ in ()).throw( + AssertionError("blocked confirmation must not execute pfSense side effect") + ) + with MODULE.tempfile.TemporaryDirectory() as tmp: + bin_path = Path(tmp) / "tsj-guardian-status" + bin_path.write_text("#!/bin/sh\n", encoding="utf-8") + bot.tsj_guardian_status_bin = str(bin_path) + + def fake_run_argv(argv, timeout_sec=180, input_text="", env_extra=None, cwd=None): + return MODULE.subprocess.CompletedProcess( + argv, + 0, + MODULE.json.dumps( + { + "kind": "pfsense", + "action": "apply", + "present": True, + "expired": False, + "allowed": False, + "clear_pending": False, + "reason": "wrong_code", + "message": "Неверный код второго подтверждения pfSense.", + } + ), + "", + ) + + bot._run_argv = fake_run_argv + result = MODULE.TSJGuardianBot._apply_pfsense_change(bot, "000000") + + self.assertEqual(result, "Неверный код второго подтверждения pfSense.") + + def test_openvpn_apply_uses_rust_confirmation_guard_before_prepare(self): + bot = object.__new__(MODULE.TSJGuardianBot) + bot.state = DummyState() + bot.state.pending_openvpn_config = MODULE.PendingOpenVpnConfig( + request_id="ovpn-1", + created_ts=1000, + common_name="user1", + stage="awaiting_second_confirm", + confirm_code="123456", + first_confirmed_ts=1100, + ) + bot.openvpn_config_confirm_ttl_sec = 900 + bot._logs = [] + bot._log = lambda level, msg: bot._logs.append((level, msg)) + sent = [] + bot._send_text = lambda chat_id, text: sent.append((chat_id, text)) + bot._prepare_openvpn_config = lambda pending: (_ for _ in ()).throw( + AssertionError("blocked confirmation must not prepare OpenVPN config") + ) + with MODULE.tempfile.TemporaryDirectory() as tmp: + bin_path = Path(tmp) / "tsj-guardian-status" + bin_path.write_text("#!/bin/sh\n", encoding="utf-8") + bot.tsj_guardian_status_bin = str(bin_path) + + def fake_run_argv(argv, timeout_sec=180, input_text="", env_extra=None, cwd=None): + return MODULE.subprocess.CompletedProcess( + argv, + 0, + MODULE.json.dumps( + { + "kind": "openvpn", + "action": "apply", + "present": True, + "expired": False, + "allowed": False, + "clear_pending": False, + "reason": "wrong_stage", + "message": "Второе подтверждение пока недоступно. Сначала выполните первый шаг подтверждения.", + } + ), + "", + ) + + bot._run_argv = fake_run_argv + MODULE.TSJGuardianBot._apply_openvpn_config(bot, 42, "123456") + + self.assertEqual(sent, [(42, "Второе подтверждение пока недоступно. Сначала выполните первый шаг подтверждения.")]) + + def test_proxmox_restore_apply_uses_rust_confirmation_guard_before_snapshot_check(self): + bot = object.__new__(MODULE.TSJGuardianBot) + bot.state = DummyState() + bot.state.pending_proxmox_restore = MODULE.PendingProxmoxRestore( + request_id="pm-1", + created_ts=1000, + kind="lxc", + guest_id="200", + guest_name="ct", + node="pve", + snapshot="tsj-guardian-manual", + confirm_code="123456", + ) + bot.proxmox_restore_confirm_ttl_sec = 900 + bot._logs = [] + bot._log = lambda level, msg: bot._logs.append((level, msg)) + bot._snapshot_exists = lambda *args, **kwargs: (_ for _ in ()).throw( + AssertionError("blocked confirmation must not check snapshot") + ) + with MODULE.tempfile.TemporaryDirectory() as tmp: + bin_path = Path(tmp) / "tsj-guardian-status" + bin_path.write_text("#!/bin/sh\n", encoding="utf-8") + bot.tsj_guardian_status_bin = str(bin_path) + + def fake_run_argv(argv, timeout_sec=180, input_text="", env_extra=None, cwd=None): + return MODULE.subprocess.CompletedProcess( + argv, + 0, + MODULE.json.dumps( + { + "kind": "proxmox_restore", + "action": "apply", + "present": True, + "expired": False, + "allowed": False, + "clear_pending": False, + "reason": "wrong_code", + "message": "Неверный код подтверждения восстановления Proxmox.", + } + ), + "", + ) + + bot._run_argv = fake_run_argv + result = MODULE.TSJGuardianBot._apply_proxmox_restore(bot, "000000") + + self.assertEqual(result, "Неверный код подтверждения восстановления Proxmox.") + class BotUiLabelTests(unittest.TestCase): def test_dlp_toggle_button_text_reflects_monitor_mode(self): @@ -439,6 +1558,70 @@ class BotUiLabelTests(unittest.TestCase): class DlpPolicyToggleTests(unittest.TestCase): + def test_aw_dlp_policy_mode_text_prefers_rust_decision_backend(self): + bot = object.__new__(MODULE.TSJGuardianBot) + bot._aw_dlp_policy_request = lambda method, path, timeout_sec=15: { + "name": "active", + "policyId": 7, + "version": 3, + "policy": {"endpoint": {"clipboard": [{"id": "c1", "action": "alert"}]}}, + } + captured = {} + + def fake_decision(policy, target_mode=""): + captured["policy"] = policy + captured["target_mode"] = target_mode + return { + "current_mode": "monitor", + "groups": [{"name": "endpoint.clipboard", "blocked": 0, "total": 1}], + } + + bot._aw_dlp_policy_decision = fake_decision + + text = MODULE.TSJGuardianBot._aw_dlp_policy_mode_text(bot) + + self.assertEqual(captured["target_mode"], "") + self.assertIn("- mode: monitor", text) + self.assertIn("- endpoint.clipboard: block=0/1", text) + + def test_aw_dlp_policy_toggle_text_prefers_rust_decision_backend(self): + bot = object.__new__(MODULE.TSJGuardianBot) + bot.aw_dlp_policy_actor = "tester" + active_policy = { + "endpoint": {"clipboard": [{"id": "c1", "enabled": True, "action": "alert"}]}, + } + updated_policy = { + "endpoint": {"clipboard": [{"id": "c1", "enabled": True, "action": "block"}]}, + "_tsj_meta": {"dlp_mode": "enforce"}, + } + requests_seen = [] + + def fake_request(method, path, payload=None, timeout_sec=20): + requests_seen.append((method, path, payload)) + if method == "GET": + return {"name": "active", "policyId": 7, "version": 3, "policy": active_policy} + return {"item": {"current_version": 4}} + + bot._aw_dlp_policy_request = fake_request + bot._aw_dlp_policy_decision = lambda policy, target_mode="": { + "current_mode": "monitor", + "target_mode": "enforce", + "changed_count": 1, + "changed_rules": ["endpoint.clipboard:c1 alert->block"], + "updated_policy": updated_policy, + } + bot._aw_rus_windows_sync_dlp_policy = lambda policy: (True, ["- windows-policy-sync: OK"]) + bot._aw_dlp_toggle_button_text = lambda current_mode=None: "DLP сейчас: блокировка | включить наблюдение" + + text = MODULE.TSJGuardianBot._aw_dlp_policy_toggle_text(bot) + + self.assertEqual(requests_seen[1][0], "PUT") + self.assertEqual(requests_seen[1][1], "/dlp/policies/7") + self.assertEqual(requests_seen[1][2]["policy"], updated_policy) + self.assertIn("- mode: monitor -> enforce", text) + self.assertIn("- changed_rules: 1", text) + self.assertIn("endpoint.clipboard:c1 alert->block", text) + def test_aw_dlp_mode_from_policy_detects_monitor_and_enforce(self): monitor_policy = { "endpoint": { diff --git a/proxmox/tsj_guardian_bot.py b/proxmox/tsj_guardian_bot.py index 0fd2ead..d8fd442 100644 --- a/proxmox/tsj_guardian_bot.py +++ b/proxmox/tsj_guardian_bot.py @@ -115,6 +115,9 @@ class GuardianState: self.pending_update_install_confirm = False self.pending_rollback_confirm = False self.last_warning_signature = "" + self.failure_streak_signature = "" + self.failure_streak_count = 0 + self.failure_streak_first_ts = 0 self.last_openvpn_expiry_signature = "" self.ai_chat_intro_variant = -1 @@ -128,6 +131,9 @@ class GuardianState: self.pending_update_install_confirm = bool(raw.get("pending_update_install_confirm", False)) self.pending_rollback_confirm = bool(raw.get("pending_rollback_confirm", False)) self.last_warning_signature = str(raw.get("last_warning_signature", "")) + self.failure_streak_signature = str(raw.get("failure_streak_signature", "")) + self.failure_streak_count = int(raw.get("failure_streak_count", 0)) + self.failure_streak_first_ts = int(raw.get("failure_streak_first_ts", 0)) self.last_openvpn_expiry_signature = str(raw.get("last_openvpn_expiry_signature", "")) self.ai_chat_intro_variant = int(raw.get("ai_chat_intro_variant", -1)) pi = raw.get("pending_incident") @@ -159,6 +165,9 @@ class GuardianState: "pending_update_install_confirm": self.pending_update_install_confirm, "pending_rollback_confirm": self.pending_rollback_confirm, "last_warning_signature": self.last_warning_signature, + "failure_streak_signature": self.failure_streak_signature, + "failure_streak_count": self.failure_streak_count, + "failure_streak_first_ts": self.failure_streak_first_ts, "last_openvpn_expiry_signature": self.last_openvpn_expiry_signature, "ai_chat_intro_variant": self.ai_chat_intro_variant, } @@ -369,8 +378,7 @@ class TSJGuardianBot: self.aw_dlp_policy_actor = os.getenv("AW_DLP_POLICY_ACTOR", "tsj-guardian-bot").strip() or "tsj-guardian-bot" self.aw_rus_worktime_heal_cmd = os.getenv( "AW_RUS_WORKTIME_HEAL_CMD", - "sshpass -p '04091968' ssh -o PubkeyAuthentication=no -o StrictHostKeyChecking=no igor@10.10.10.13 " - "'sudo -S /usr/local/bin/aw-worktime-autoheal.sh && sudo -S systemctl reset-failed aw-worktime-ui-bridge.service && sudo -S systemctl start aw-worktime-ui-bridge.service'", + "", ).strip() self.aw_rus_dlp_heal_cmd = os.getenv( "AW_RUS_DLP_HEAL_CMD", @@ -380,11 +388,19 @@ class TSJGuardianBot: self.aw_rus_hayabusa_enabled = env_bool("AW_RUS_HAYABUSA_ENABLED", True) self.aw_rus_hayabusa_ssh_cmd = os.getenv( "AW_RUS_HAYABUSA_SSH_CMD", - "sshpass -p '04091968' ssh -o PubkeyAuthentication=no -o StrictHostKeyChecking=no igor@10.10.10.13", + "", ).strip() self.aw_rus_host = os.getenv("AW_RUS_HOST", "SHARKON2025").strip() self.aw_rus_primary_user = os.getenv("AW_RUS_PRIMARY_USER", "USER1").strip() self.aw_rus_stale_sec = max(60, env_int("AW_RUS_STALE_SEC", 900)) + self.aw_rus_slo_enabled = env_bool("AW_RUS_SLO_ENABLED", True) + self.aw_rus_slo_alert_window = os.getenv("AW_RUS_SLO_ALERT_WINDOW", "24h").strip() or "24h" + self.aw_rus_slo_min_samples = max(1, env_int("AW_RUS_SLO_MIN_SAMPLES", 4)) + self.aw_rus_slo_max_age_sec = max(15, env_int("AW_RUS_SLO_MAX_AGE_SEC", 90)) + self.aw_rus_slo_summary_cmd = os.getenv( + "AW_RUS_SLO_SUMMARY_CMD", + "", + ).strip() self.aw_rus_windows_host = os.getenv("AW_RUS_WINDOWS_HOST", "192.168.100.18").strip() or "192.168.100.18" self.aw_rus_windows_ssh_user = os.getenv("AW_RUS_WINDOWS_SSH_USER", "Администратор").strip() or "Администратор" self.aw_rus_windows_ssh_password = os.getenv("AW_RUS_WINDOWS_SSH_PASSWORD", "").strip() @@ -400,6 +416,11 @@ class TSJGuardianBot: "AW_RUS_WINDOWS_SESSION_COLLECTOR_PATH", r"C:\ProgramData\AWatch-rus\worktime-session-collector.ps1", ).strip() or r"C:\ProgramData\AWatch-rus\worktime-session-collector.ps1" + launch_tasks_raw = os.getenv( + "AW_RUS_WINDOWS_LAUNCH_TASKS", + "ActivityWatch Launch [SHARKON2025_Администратор];ActivityWatch Launch [SHARKON2025_user5]", + ) + self.aw_rus_windows_launch_tasks = [item.strip() for item in launch_tasks_raw.split(";") if item.strip()] self.aw_rus_windows_policy_path = os.getenv( "AW_RUS_WINDOWS_POLICY_PATH", r"C:\ProgramData\AWatch-rus\dlp-policy.json", @@ -425,6 +446,7 @@ class TSJGuardianBot: "HEARTBEAT_FILE", f"{self.infra_admin_root}/.state/tsj_guardian_heartbeat" ) self.check_interval = env_int("CHECK_INTERVAL_SEC", 60) + self.incident_failure_quorum_checks = max(1, env_int("INCIDENT_FAILURE_QUORUM_CHECKS", 2)) self.operator_timeout = env_int("OPERATOR_TIMEOUT_SEC", 900) # 15 min self.retry_autoheal_sec = env_int("RETRY_AUTORECOVERY_EVERY_SEC", 300) self.exit_on_autoheal_success = env_bool("EXIT_ON_AUTORECOVERY_SUCCESS", True) @@ -496,6 +518,8 @@ class TSJGuardianBot: f"{self.infra_admin_root}/.state/proxmox_lxc_pending_rollback.json", ) ) + self.detmir_state_file = Path(os.getenv("DETMIR_AI_STATE_FILE", "/var/lib/detmir-ai/latest-state.json")) + self.tsj_guardian_status_bin = os.getenv("TSJ_GUARDIAN_STATUS_BIN", "/usr/local/bin/tsj-guardian-status").strip() self.proxmox_selection_ttl_sec = env_int("PROXMOX_SELECTION_TTL_SEC", 900) self.proxmox_restore_confirm_ttl_sec = env_int("PROXMOX_RESTORE_CONFIRM_TTL_SEC", 900) self.proxmox_manual_snapshot_name = ( @@ -883,6 +907,7 @@ class TSJGuardianBot: timeout_sec: int = 180, input_text: str = "", env_extra: Optional[Dict[str, str]] = None, + cwd: Optional[str] = None, ) -> subprocess.CompletedProcess: env = os.environ.copy() if env_extra: @@ -895,6 +920,7 @@ class TSJGuardianBot: timeout=timeout_sec, check=False, env=env, + cwd=cwd, ) return subprocess.CompletedProcess( proc.args, @@ -961,24 +987,62 @@ class TSJGuardianBot: cmd = f"sudo -u {shlex.quote(self.ai_exec_user)} bash -lc {shlex.quote(cmd)}" return self._run_shell(cmd, timeout_sec=timeout_sec) + def _run_ai_user_argv( + self, + argv: List[str], + timeout_sec: int = 180, + env_extra: Optional[Dict[str, str]] = None, + ) -> subprocess.CompletedProcess: + cmd = list(argv) + if self.ai_exec_user: + preserve = [] + if env_extra and env_extra.get("PFSENSE_MCP_BEARER"): + preserve.append("PFSENSE_MCP_BEARER") + sudo_argv = ["sudo", "-u", self.ai_exec_user] + if preserve: + sudo_argv.append(f"--preserve-env={','.join(preserve)}") + cmd = [*sudo_argv, "--", *cmd] + return self._run_argv( + cmd, + timeout_sec=timeout_sec, + env_extra=env_extra, + cwd=self.ai_chat_workdir, + ) + def _run_codex_exec_prompt(self, prompt: str, timeout_sec: int, model: Optional[str] = None) -> Tuple[int, str, str]: with tempfile.NamedTemporaryFile("w+", encoding="utf-8", delete=False) as tmp: tmp_path = tmp.name os.chmod(tmp_path, 0o666) selected_model = (model or self.codex_model).strip() or self.codex_model - cmd = ( - f"cd {shlex.quote(self.ai_chat_workdir)} && " - f"PFSENSE_MCP_BEARER={shlex.quote(os.getenv('PFSENSE_MCP_BEARER', ''))} " - f"codex exec --ephemeral --skip-git-repo-check " - f"--model {shlex.quote(selected_model)} " - f"-C {shlex.quote(self.ai_chat_workdir)} " - f"-s {shlex.quote(self.ai_chat_sandbox)} " - f"--color never -o {shlex.quote(tmp_path)} " - f"{shlex.quote(prompt)}" - ) + # Do not forward PFSENSE_MCP_BEARER into sudo/codex. sudo logs + # preserved env values, so passing the bearer here leaks it to journald. + env_extra = None + argv = [ + "codex", + "exec", + "--ephemeral", + "--skip-git-repo-check", + "--model", + selected_model, + "-C", + self.ai_chat_workdir, + "-s", + self.ai_chat_sandbox, + "--color", + "never", + "-o", + tmp_path, + prompt, + ] try: - rc, out = self._run_ai_user_shell(cmd, timeout_sec=timeout_sec) + proc = self._run_ai_user_argv( + argv, + timeout_sec=timeout_sec, + env_extra=env_extra, + ) + rc = proc.returncode + out = (proc.stdout or "") + (("\n" + proc.stderr) if proc.stderr else "") try: with open(tmp_path, "r", encoding="utf-8") as f: reply = f.read().strip() @@ -1027,6 +1091,8 @@ class TSJGuardianBot: @staticmethod def _summarize_exec_error(output: str, rc: int) -> str: lowered = (output or "").lower() + if TSJGuardianBot._is_codex_auth_error(output): + return "Канал расширенной диагностики недоступен: требуется повторная авторизация Codex на сервере." if "403 forbidden" in lowered or "unable to load site" in lowered: return "Сервис ответов временно недоступен. Повторите запрос чуть позже." if "selected model is at capacity" in lowered: @@ -1035,6 +1101,20 @@ class TSJGuardianBot: return "Сервис ответов временно недоступен из-за сетевой ошибки. Повторите запрос чуть позже." return f"Обработка запроса завершилась с ошибкой.\nrc={rc}" + @staticmethod + def _is_codex_auth_error(output: str) -> bool: + lowered = (output or "").lower() + return any( + marker in lowered + for marker in ( + "401 unauthorized", + "token_invalidated", + "refresh_token_reused", + "access token could not be refreshed", + "authentication token has been invalidated", + ) + ) + def _run_ai_chat_codex_exec(self, operator_text: str) -> str: clean_text = operator_text.strip() if not clean_text: @@ -1093,6 +1173,14 @@ class TSJGuardianBot: ppc = self.state.pending_pfsense_change if not ppc: return False + decision = self._confirmation_decision( + "pfsense", + "expire", + ppc, + self.pfsense_change_confirm_ttl_sec, + ) + if isinstance(decision, dict): + return bool(decision.get("expired")) return int(time.time()) - ppc.created_ts > self.pfsense_change_confirm_ttl_sec def _expire_pending_pfsense_change_if_needed(self) -> None: @@ -1140,6 +1228,30 @@ class TSJGuardianBot: ppc = self.state.pending_pfsense_change if not ppc: return "Нет ожидающего изменения pfSense." + decision = self._confirmation_decision( + "pfsense", + "first_confirm", + ppc, + self.pfsense_change_confirm_ttl_sec, + ) + if isinstance(decision, dict): + if decision.get("clear_pending"): + self.state.pending_pfsense_change = None + self.state.save() + return str(decision.get("message") or "Нет ожидающего изменения pfSense.") + if not decision.get("allowed"): + return str(decision.get("message") or "Первое подтверждение уже принято.") + ppc.stage = str(decision.get("next_stage") or "awaiting_second_confirm") + ppc.first_confirmed_ts = int(decision.get("first_confirmed_ts") or time.time()) + self.state.save() + return ( + "Первое подтверждение принято.\n" + f"- request_id: {ppc.request_id}\n" + f"- запрос: {ppc.operator_request}\n" + "- Второе подтверждение должно быть отдельным действием.\n" + f"- Для выполнения отправьте: `/pfsense_apply {ppc.confirm_code}`\n" + f"- Код подтверждения действует {self.pfsense_change_confirm_ttl_sec} секунд с момента создания запроса." + ) if ppc.stage != "awaiting_first_confirm": return ( "Первое подтверждение уже принято.\n" @@ -1159,14 +1271,30 @@ class TSJGuardianBot: ) def _cancel_pfsense_change(self) -> str: + decision = self._confirmation_decision( + "pfsense", + "cancel", + self.state.pending_pfsense_change, + self.pfsense_change_confirm_ttl_sec, + ) self.state.pending_pfsense_change = None self.state.save() + if isinstance(decision, dict): + return str(decision.get("message") or "Ожидающее изменение pfSense отменено.") return "Ожидающее изменение pfSense отменено." def _pending_openvpn_config_expired(self) -> bool: povpn = self.state.pending_openvpn_config if not povpn: return False + decision = self._confirmation_decision( + "openvpn", + "expire", + povpn, + self.openvpn_config_confirm_ttl_sec, + ) + if isinstance(decision, dict): + return bool(decision.get("expired")) return int(time.time()) - povpn.created_ts > self.openvpn_config_confirm_ttl_sec def _expire_pending_openvpn_config_if_needed(self) -> None: @@ -1409,6 +1537,29 @@ class TSJGuardianBot: povpn = self.state.pending_openvpn_config if not povpn: return "Нет ожидающего запроса на OpenVPN конфиг." + decision = self._confirmation_decision( + "openvpn", + "first_confirm", + povpn, + self.openvpn_config_confirm_ttl_sec, + ) + if isinstance(decision, dict): + if decision.get("clear_pending"): + self.state.pending_openvpn_config = None + self.state.save() + return str(decision.get("message") or "Нет ожидающего запроса на OpenVPN конфиг.") + if not decision.get("allowed"): + return str(decision.get("message") or "Первое подтверждение уже принято.") + povpn.stage = str(decision.get("next_stage") or "awaiting_second_confirm") + povpn.first_confirmed_ts = int(decision.get("first_confirmed_ts") or time.time()) + self.state.save() + return ( + "Первое подтверждение OpenVPN-конфига принято.\n" + f"- request_id: {povpn.request_id}\n" + f"- common_name: {povpn.common_name}\n" + f"- Для второго подтверждения отправьте: `/openvpn_config_apply {povpn.confirm_code}`\n" + f"- Код действует {self.openvpn_config_confirm_ttl_sec} секунд с момента создания запроса." + ) if povpn.stage != "awaiting_first_confirm": return ( "Первое подтверждение уже принято.\n" @@ -1426,14 +1577,30 @@ class TSJGuardianBot: ) def _cancel_openvpn_config(self) -> str: + decision = self._confirmation_decision( + "openvpn", + "cancel", + self.state.pending_openvpn_config, + self.openvpn_config_confirm_ttl_sec, + ) self.state.pending_openvpn_config = None self.state.save() + if isinstance(decision, dict): + return str(decision.get("message") or "Ожидающий запрос на OpenVPN конфиг отменён.") return "Ожидающий запрос на OpenVPN конфиг отменён." def _pending_proxmox_selection_expired(self) -> bool: pending = self.state.pending_proxmox_selection if not pending: return False + decision = self._confirmation_decision( + "proxmox_selection", + "expire", + pending, + self.proxmox_selection_ttl_sec, + ) + if isinstance(decision, dict): + return bool(decision.get("expired")) return int(time.time()) - pending.created_ts > self.proxmox_selection_ttl_sec def _expire_pending_proxmox_selection_if_needed(self) -> None: @@ -1445,6 +1612,14 @@ class TSJGuardianBot: pending = self.state.pending_proxmox_restore if not pending: return False + decision = self._confirmation_decision( + "proxmox_restore", + "expire", + pending, + self.proxmox_restore_confirm_ttl_sec, + ) + if isinstance(decision, dict): + return bool(decision.get("expired")) return int(time.time()) - pending.created_ts > self.proxmox_restore_confirm_ttl_sec def _expire_pending_proxmox_restore_if_needed(self) -> None: @@ -1668,13 +1843,29 @@ class TSJGuardianBot: ) def _cancel_proxmox_selection(self) -> str: + decision = self._confirmation_decision( + "proxmox_selection", + "cancel", + self.state.pending_proxmox_selection, + self.proxmox_selection_ttl_sec, + ) self.state.pending_proxmox_selection = None self.state.save() + if isinstance(decision, dict): + return str(decision.get("message") or "Выбор узла Proxmox отменён.") return "Выбор узла Proxmox отменён." def _cancel_proxmox_restore(self) -> str: + decision = self._confirmation_decision( + "proxmox_restore", + "cancel", + self.state.pending_proxmox_restore, + self.proxmox_restore_confirm_ttl_sec, + ) self.state.pending_proxmox_restore = None self.state.save() + if isinstance(decision, dict): + return str(decision.get("message") or "Ожидающее восстановление Proxmox отменено.") return "Ожидающее восстановление Proxmox отменено." def _apply_proxmox_restore(self, code: str) -> str: @@ -1682,7 +1873,21 @@ class TSJGuardianBot: pending = self.state.pending_proxmox_restore if not pending: return "Нет ожидающего восстановления Proxmox." - if code.strip() != pending.confirm_code: + decision = self._confirmation_decision( + "proxmox_restore", + "apply", + pending, + self.proxmox_restore_confirm_ttl_sec, + code=code, + ) + if isinstance(decision, dict): + if decision.get("clear_pending"): + self.state.pending_proxmox_restore = None + self.state.save() + return str(decision.get("message") or "Нет ожидающего восстановления Proxmox.") + if not decision.get("allowed"): + return str(decision.get("message") or "Неверный код подтверждения восстановления Proxmox.") + elif code.strip() != pending.confirm_code: return "Неверный код подтверждения восстановления Proxmox." if not self._snapshot_exists(pending.kind, pending.guest_id, pending.snapshot): self.state.pending_proxmox_restore = None @@ -1949,12 +2154,32 @@ class TSJGuardianBot: if not povpn: self._send_text(chat_id, "Нет ожидающего запроса на OpenVPN конфиг.") return - if povpn.stage != "awaiting_second_confirm": - self._send_text(chat_id, "Второе подтверждение пока недоступно. Сначала выполните первый шаг подтверждения.") - return - if code.strip() != povpn.confirm_code: - self._send_text(chat_id, "Неверный код второго подтверждения OpenVPN-конфига.") - return + decision = self._confirmation_decision( + "openvpn", + "apply", + povpn, + self.openvpn_config_confirm_ttl_sec, + code=code, + ) + if isinstance(decision, dict): + if decision.get("clear_pending"): + self.state.pending_openvpn_config = None + self.state.save() + self._send_text(chat_id, str(decision.get("message") or "Нет ожидающего запроса на OpenVPN конфиг.")) + return + if not decision.get("allowed"): + self._send_text( + chat_id, + str(decision.get("message") or "Неверный код второго подтверждения OpenVPN-конфига."), + ) + return + else: + if povpn.stage != "awaiting_second_confirm": + self._send_text(chat_id, "Второе подтверждение пока недоступно. Сначала выполните первый шаг подтверждения.") + return + if code.strip() != povpn.confirm_code: + self._send_text(chat_id, "Неверный код второго подтверждения OpenVPN-конфига.") + return try: filename, summary, config_bytes = self._prepare_openvpn_config(povpn) @@ -1999,10 +2224,25 @@ class TSJGuardianBot: ppc = self.state.pending_pfsense_change if not ppc: return "Нет ожидающего изменения pfSense." - if ppc.stage != "awaiting_second_confirm": - return "Второе подтверждение пока недоступно. Сначала выполните первый шаг подтверждения." - if code.strip() != ppc.confirm_code: - return "Неверный код второго подтверждения pfSense." + decision = self._confirmation_decision( + "pfsense", + "apply", + ppc, + self.pfsense_change_confirm_ttl_sec, + code=code, + ) + if isinstance(decision, dict): + if decision.get("clear_pending"): + self.state.pending_pfsense_change = None + self.state.save() + return str(decision.get("message") or "Нет ожидающего изменения pfSense.") + if not decision.get("allowed"): + return str(decision.get("message") or "Неверный код второго подтверждения pfSense.") + else: + if ppc.stage != "awaiting_second_confirm": + return "Второе подтверждение пока недоступно. Сначала выполните первый шаг подтверждения." + if code.strip() != ppc.confirm_code: + return "Неверный код второго подтверждения pfSense." result = self._run_pfsense_change_codex_exec(ppc) self.state.pending_pfsense_change = None @@ -2055,6 +2295,167 @@ class TSJGuardianBot: def _warning_signature(self, warnings: List[str]) -> str: return "\n".join(sorted(set(warnings))) + def _failure_signature(self, failures: List[str]) -> str: + return "\n".join(sorted(set(failures))) + + def _state_decision_payload(self) -> Dict: + return { + "pending_incident": asdict(self.state.pending_incident) if self.state.pending_incident else None, + "failure_streak_signature": self.state.failure_streak_signature, + "failure_streak_count": self.state.failure_streak_count, + "failure_streak_first_ts": self.state.failure_streak_first_ts, + } + + def _operator_action_state_payload(self) -> Dict: + return { + "pending_update_install_confirm": bool( + getattr(self.state, "pending_update_install_confirm", False) + ), + "pending_rollback_confirm": bool( + getattr(self.state, "pending_rollback_confirm", False) + ), + "pending_incident": asdict(self.state.pending_incident) + if getattr(self.state, "pending_incident", None) + else None, + } + + def _run_status_helper_json( + self, + args: List[str], + payload: Dict, + timeout_sec: int = 10, + ) -> Optional[Dict]: + status_bin_raw = (getattr(self, "tsj_guardian_status_bin", "") or "").strip() + if not status_bin_raw: + return None + status_bin = Path(status_bin_raw) + if not status_bin.exists(): + return None + try: + proc = self._run_argv( + [str(status_bin), *args], + timeout_sec=timeout_sec, + input_text=json.dumps(payload, ensure_ascii=False), + ) + if proc.returncode != 0: + self._log( + "WARN", + f"tsj-guardian-status decision failed rc={proc.returncode}: {((proc.stderr or '') + (proc.stdout or ''))[-500:]}", + ) + return None + return json.loads(proc.stdout or "{}") + except Exception as exc: + self._log("WARN", f"tsj-guardian-status decision unavailable: {exc}") + return None + + def _operator_action_decision(self, action: str) -> Optional[Dict]: + return self._run_status_helper_json( + ["--operator-action-decision"], + { + "action": action, + "state": self._operator_action_state_payload(), + }, + ) + + def _confirmation_decision( + self, + kind: str, + action: str, + pending: object, + ttl_seconds: int, + code: str = "", + ) -> Optional[Dict]: + state = asdict(pending) if pending else {} + return self._run_status_helper_json( + [ + "--confirmation-decision", + "--confirmation-ttl-seconds", + str(ttl_seconds), + "--now-epoch", + str(int(time.time())), + ], + { + "kind": kind, + "action": action, + "code": code, + "state": state, + }, + ) + + def _autoheal_plan_decision(self, failures: List[str], slo_stale: bool = False) -> Optional[Dict]: + return self._run_status_helper_json( + ["--autoheal-plan-decision"], + { + "failures": failures, + "slo_stale": bool(slo_stale), + }, + ) + + def _reset_failure_streak(self) -> None: + changed = bool( + self.state.failure_streak_signature + or self.state.failure_streak_count + or self.state.failure_streak_first_ts + ) + self.state.failure_streak_signature = "" + self.state.failure_streak_count = 0 + self.state.failure_streak_first_ts = 0 + if changed: + self.state.save() + + def _defer_transient_new_incident(self, failures: List[str]) -> bool: + threshold = getattr(self, "incident_failure_quorum_checks", 1) + decision = self._run_status_helper_json( + [ + "--incident-defer-decision", + "--incident-failure-quorum-checks", + str(threshold), + "--now-epoch", + str(int(time.time())), + ], + { + "failures": failures, + "state": self._state_decision_payload(), + }, + ) + if isinstance(decision, dict): + self.state.failure_streak_signature = str(decision.get("failure_streak_signature") or "") + self.state.failure_streak_count = int(decision.get("failure_streak_count") or 0) + self.state.failure_streak_first_ts = int(decision.get("failure_streak_first_ts") or 0) + if decision.get("reset_failure_streak"): + self.state.save() + self._reset_failure_streak() + else: + self.state.save() + if decision.get("defer"): + log_line = str(decision.get("log_line") or "") + self._log("WARN", log_line or f"Suppressing transient incident failures={len(failures)}") + return True + return False + + if threshold <= 1 or self._has_filesystem_critical(failures): + return False + + now = int(time.time()) + signature = self._failure_signature(failures) + if signature == self.state.failure_streak_signature: + self.state.failure_streak_count += 1 + else: + self.state.failure_streak_signature = signature + self.state.failure_streak_count = 1 + self.state.failure_streak_first_ts = now + self.state.save() + + if self.state.failure_streak_count < threshold: + self._log( + "WARN", + "Suppressing transient incident " + f"streak={self.state.failure_streak_count}/{threshold} " + f"failures={len(failures)}", + ) + return True + return False + def _filesystem_failures(self, failures: List[str]) -> List[str]: return [line for line in failures if "filesystem_usage" in line.lower()] @@ -2084,6 +2485,8 @@ class TSJGuardianBot: return False if key == "worktime": return text.startswith("- worktime(") or text.startswith("- worktime:") + if key == "slo": + return text.startswith("- slo-alert:") return text.startswith(f"- {key}:") def _aw_rus_failure_log_lines(self, lines: List[str], failures: List[str]) -> List[str]: @@ -2094,13 +2497,141 @@ class TSJGuardianBot: rendered.append(f"[FAIL] aw-rus:{key}: {summary}") return rendered + def _load_aw_rus_slo_summary(self) -> Optional[Dict]: + cmd = (getattr(self, "aw_rus_slo_summary_cmd", "") or "").strip() + if not cmd: + return None + rc, out = self._run_shell(cmd, timeout_sec=30) + if rc != 0: + self._log("WARN", f"AW-Rus SLO summary command failed rc={rc}: {out[-500:]}") + return None + try: + payload = json.loads(out) + except json.JSONDecodeError as exc: + self._log("WARN", f"AW-Rus SLO summary JSON parse failed: {exc}") + return None + return payload if isinstance(payload, dict) else None + + def _aw_rus_slo_lines_and_failures(self) -> Tuple[List[str], List[str]]: + if not getattr(self, "aw_rus_slo_enabled", True): + return [], [] + summary = self._load_aw_rus_slo_summary() + if not summary: + return [], [] + + lines = ["Проверка AW-Rus SLO:"] + failures: List[str] = [] + generated_raw = str(summary.get("generated_at_utc") or "") + age = None + try: + generated = datetime.fromisoformat(generated_raw.replace("Z", "+00:00")).astimezone(timezone.utc) + age = int((datetime.now(timezone.utc) - generated).total_seconds()) + except Exception: + generated = None + + if generated is None or age is None or age > self.aw_rus_slo_max_age_sec: + lines.append(f"- slo-summary: STALE age={age}s generated={generated_raw or 'unknown'}") + failures.append("slo") + return lines, failures + + windows = summary.get("windows") if isinstance(summary.get("windows"), dict) else {} + window_name = self.aw_rus_slo_alert_window if self.aw_rus_slo_alert_window in windows else "24h" + window = windows.get(window_name) if isinstance(windows.get(window_name), dict) else {} + samples = int(window.get("samples") or 0) + availability = window.get("availability_percent") + remaining = int(window.get("budget_remaining_seconds") or 0) + bad = int(window.get("bad_samples") or 0) + current = summary.get("current_sample") if isinstance(summary.get("current_sample"), dict) else {} + current_ok = bool(current.get("ok")) + lines.append( + f"- {window_name}: availability={availability if availability is not None else 'n/a'}% " + f"samples={samples} bad={bad} budget_remaining_seconds={remaining}" + ) + lines.append(f"- current_sample: {'OK' if current_ok else 'FAIL'} age={age}s") + + if samples < self.aw_rus_slo_min_samples: + lines.append(f"- slo-alert: WARMUP samples={samples}/{self.aw_rus_slo_min_samples}") + return lines, [] + if remaining < 0 and not current_ok: + lines.append(f"- slo-alert: FAIL error budget exhausted ({remaining}s)") + failures.append("slo") + elif remaining < 0: + lines.append(f"- slo-alert: RECOVERED error budget exhausted ({remaining}s), current sample OK") + return lines, failures + def _background_aw_rus_failures(self, failures: List[str]) -> Tuple[List[str], List[str]]: if not self._aw_rus_probe_should_run(failures): return [], [] + slo_lines, slo_failures = self._aw_rus_slo_lines_and_failures() + if slo_lines: + return self._aw_rus_failure_log_lines(slo_lines, slo_failures), slo_failures lines, aw_failures = self._aw_rus_dlp_probe() return self._aw_rus_failure_log_lines(lines, aw_failures), aw_failures + def _aw_rus_slo_status_line(self) -> str: + if getattr(self, "tsj_guardian_status_bin", ""): + status_bin = Path(self.tsj_guardian_status_bin) + if status_bin.exists(): + try: + proc = self._run_argv( + [ + str(status_bin), + "--aw-slo-status-line", + "--aw-slo-summary-command", + self.aw_rus_slo_summary_cmd, + "--aw-slo-alert-window", + self.aw_rus_slo_alert_window, + ], + timeout_sec=35, + ) + line = (proc.stdout or "").strip().splitlines() + if proc.returncode == 0 and line: + return line[-1] + if proc.returncode != 0: + self._log( + "WARN", + f"tsj-guardian-status aw-slo failed rc={proc.returncode}: {((proc.stderr or '') + (proc.stdout or ''))[-500:]}", + ) + except Exception as exc: + self._log("WARN", f"tsj-guardian-status aw-slo unavailable: {exc}") + + summary = self._load_aw_rus_slo_summary() + if not summary: + return "- aw_rus_slo: unavailable" + windows = summary.get("windows") if isinstance(summary.get("windows"), dict) else {} + window_name = self.aw_rus_slo_alert_window if self.aw_rus_slo_alert_window in windows else "24h" + window = windows.get(window_name) if isinstance(windows.get(window_name), dict) else {} + availability = window.get("availability_percent") + remaining = window.get("budget_remaining_seconds") + samples = window.get("samples") + status = window.get("status", "unknown") + current = summary.get("current_sample") if isinstance(summary.get("current_sample"), dict) else {} + current_ok = bool(current.get("ok")) + display_status = status + try: + remaining_value = int(remaining) + except (TypeError, ValueError): + remaining_value = 0 + if remaining_value < 0 and current_ok: + display_status = "recovered" + elif remaining_value < 0 and not current_ok: + display_status = "fail" + availability_text = "n/a" if availability is None else f"{float(availability):.5f}%" + return ( + f"- aw_rus_slo: {display_status} {window_name} current_sample={'OK' if current_ok else 'FAIL'} " + f"availability={availability_text} samples={samples} budget_remaining_seconds={remaining}" + ) + def _suggestions_from_failures(self, failures: List[str]) -> List[str]: + decision = self._run_status_helper_json( + ["--incident-suggestions"], + {"failures": failures}, + ) + if isinstance(decision, dict) and isinstance(decision.get("suggestions"), list): + suggestions = [str(item) for item in decision["suggestions"] if str(item)] + if suggestions: + return suggestions + suggestions = [] text = "\n".join(failures).lower() if "proxmox_api" in text: @@ -2201,6 +2732,7 @@ class TSJGuardianBot: if self.state.pending_incident: self._notify("Инцидент закрыт: система снова в норме.") self.state.pending_incident = None + self._reset_failure_streak() self._sync_warning_state(warnings) self.state.save() self._log("INFO", "Check OK") @@ -2221,6 +2753,8 @@ class TSJGuardianBot: now = int(time.time()) new_incident_created = False if not self.state.pending_incident: + if self._defer_transient_new_incident(failures): + return incident_id = time.strftime("%Y%m%d-%H%M%S") suggestions = self._suggestions_from_failures(failures) self.state.pending_incident = PendingIncident( @@ -2235,6 +2769,7 @@ class TSJGuardianBot: fallback_executed=False, ) new_incident_created = True + self._reset_failure_streak() else: self.state.pending_incident.failures = failures or self.state.pending_incident.failures self.state.pending_incident.suggestions = self._suggestions_from_failures( @@ -2380,6 +2915,10 @@ class TSJGuardianBot: if rc != 0: self._log("WARN", f"Incident codex exec returned rc={rc} but produced final message") return True + if self._is_codex_auth_error(out): + self._log("WARN", "Incident codex exec skipped after authentication failure; server fallback remains active") + self._notify(self._summarize_exec_error(out, rc)) + return True self._log("ERROR", f"Incident codex exec failed rc={rc}: {out[-2000:]}") return False @@ -2410,13 +2949,31 @@ class TSJGuardianBot: pi = self.state.pending_incident if not pi: return - if pi.operator_acked: - return now = int(time.time()) - if now - pi.created_ts < self.operator_timeout: - return + decision = self._run_status_helper_json( + [ + "--escalation-decision", + "--operator-timeout-seconds", + str(self.operator_timeout), + "--now-epoch", + str(now), + ], + {"state": self._state_decision_payload()}, + ) + if isinstance(decision, dict): + if not decision.get("timed_out"): + return + should_escalate = bool(decision.get("should_escalate")) + should_fallback = bool(decision.get("should_fallback")) + else: + if pi.operator_acked: + return + if now - pi.created_ts < self.operator_timeout: + return + should_escalate = not pi.escalated_to_ai + should_fallback = not pi.fallback_executed - if not pi.escalated_to_ai: + if should_escalate: ai_ok = self._escalate_to_ai() pi.escalated_to_ai = ai_ok self.state.save() @@ -2425,7 +2982,7 @@ class TSJGuardianBot: else: self._notify("Оператор не ответил. Эскалация не удалась.") - if not pi.fallback_executed: + if should_fallback: fallback_ok = self._run_server_fallback() pi.fallback_executed = True self.state.save() @@ -2564,8 +3121,105 @@ class TSJGuardianBot: "Резервные slash-команды: /status /check /aw_dlp_check /dlp_mode /dlp_mode_toggle /aw_dfir PACKAGE HOST [CASE_ID] [MODE] /heal /ack /resolve /run ... /openvpn_certs [filter] /openvpn_expiring /openvpn_config USER /openvpn_config_confirm /openvpn_config_cancel /openvpn_config_apply CODE /pfsense_confirm /pfsense_cancel /pfsense_apply CODE /proxmox_snapshot TARGET /proxmox_restore TARGET /proxmox_restore_apply CODE /proxmox_restore_cancel /proxmox_selection_cancel" ) + def _detmir_auto_status_line(self) -> str: + if self.tsj_guardian_status_bin: + status_bin = Path(self.tsj_guardian_status_bin) + if status_bin.exists(): + try: + proc = self._run_argv( + [ + str(status_bin), + "--state", + str(self.detmir_state_file), + ], + timeout_sec=5, + ) + line = (proc.stdout or "").strip().splitlines() + if proc.returncode == 0 and line: + return line[-1] + if proc.returncode != 0: + self._log( + "WARN", + f"tsj-guardian-status failed rc={proc.returncode}: {((proc.stderr or '') + (proc.stdout or ''))[-500:]}", + ) + except Exception as exc: + self._log("WARN", f"tsj-guardian-status unavailable: {exc}") + + try: + state = json.loads(self.detmir_state_file.read_text(encoding="utf-8")) + except FileNotFoundError: + return f"- detmir_auto: unavailable state_file={self.detmir_state_file}" + except Exception as exc: + return f"- detmir_auto: unavailable ({exc})" + + summary = state.get("detmir_summary") + if not isinstance(summary, dict): + check = state.get("check") if isinstance(state.get("check"), dict) else {} + summary = check.get("summary") if isinstance(check.get("summary"), dict) else {} + dlp_counts = state.get("dlp_counts") if isinstance(state.get("dlp_counts"), dict) else {} + severity = str(state.get("severity", "UNKNOWN")) + check_ok = state.get("check_ok") + if check_ok is None and isinstance(state.get("check"), dict): + check_ok = state["check"].get("ok") + dlp_ok = state.get("dlp_ok") + + return ( + f"- detmir_auto: {severity} check_ok={check_ok} dlp_ok={dlp_ok} " + f"bucket_stale={summary.get('bucket_stale', 'n/a')} " + f"bucket_dead={summary.get('bucket_dead', 'n/a')} " + f"service_fail={summary.get('service_failures', 'n/a')} " + f"service_warn={summary.get('service_warnings', 'n/a')} " + f"dlp_warn={dlp_counts.get('warn', 'n/a')} " + f"dlp_fail={dlp_counts.get('fail', 'n/a')}" + ) + + def _cmd_status_from_rust(self) -> Optional[str]: + if not getattr(self, "tsj_guardian_status_bin", ""): + return None + status_bin = Path(self.tsj_guardian_status_bin) + if not status_bin.exists(): + return None + pfsense_cmd = f"/usr/bin/python3 {shlex.quote(self.infra_admin_root)}/scripts/pfsense_security_status.py" + try: + proc = self._run_argv( + [ + str(status_bin), + "--status-text", + "--state", + str(self.detmir_state_file), + "--bot-state", + str(self.state_file), + "--rollback-file", + str(self.updates_rollback_file), + "--pfsense-status-command", + pfsense_cmd, + "--aw-slo-summary-command", + self.aw_rus_slo_summary_cmd, + "--aw-slo-alert-window", + self.aw_rus_slo_alert_window, + ], + timeout_sec=90, + ) + text = (proc.stdout or "").strip() + if proc.returncode == 0 and text: + return text + if proc.returncode != 0: + self._log( + "WARN", + f"tsj-guardian-status status-text failed rc={proc.returncode}: {((proc.stderr or '') + (proc.stdout or ''))[-500:]}", + ) + except Exception as exc: + self._log("WARN", f"tsj-guardian-status status-text unavailable: {exc}") + return None + def _cmd_status(self) -> str: + rust_status = self._cmd_status_from_rust() + if rust_status: + return rust_status + pfsense_status = self._pfsense_security_status_lines() + aw_slo_status = self._aw_rus_slo_status_line() + detmir_status = self._detmir_auto_status_line() pi = self.state.pending_incident if not pi: ppc = self.state.pending_pfsense_change @@ -2600,6 +3254,8 @@ class TSJGuardianBot: return ( "Статус: инцидентов нет.\n" f"{pfsense_status}\n" + f"{aw_slo_status}\n" + f"{detmir_status}\n" f"{ppc_line}\n" f"{ovpn_warn_line}\n" f"{pps_line}\n" @@ -2642,6 +3298,8 @@ class TSJGuardianBot: return ( f"Статус: активный инцидент {pi.incident_id}\n" f"{pfsense_status}\n" + f"{aw_slo_status}\n" + f"{detmir_status}\n" f"- возраст: {age}s\n" f"- autoheal attempts: {pi.autoheal_attempts}\n" f"- operator_acked: {pi.operator_acked}\n" @@ -2664,7 +3322,44 @@ class TSJGuardianBot: host = self.aw_rus_host now = datetime.now(timezone.utc) + def latest_bucket_event(bucket_id: str) -> Tuple[Optional[Dict], str]: + try: + r = requests.get(f"{base}/buckets/{bucket_id}/events?limit=20", timeout=20) + r.raise_for_status() + events = r.json() + if not isinstance(events, list) or not events: + return None, "no-events" + + latest_event = None + latest_ts = None + for event in events: + raw_ts = event.get("timestamp") if isinstance(event, dict) else None + if not raw_ts: + continue + try: + event_ts = datetime.fromisoformat(raw_ts.replace("Z", "+00:00")).astimezone(timezone.utc) + except Exception: + continue + if latest_ts is None or event_ts > latest_ts: + latest_ts = event_ts + latest_event = event + if latest_event is None: + return None, "no-valid-events" + return latest_event, str(latest_event.get("timestamp") or "unknown") + except Exception as exc: + return None, f"events-error:{exc}" + def bucket_age(bucket_id: str) -> Tuple[Optional[int], str]: + event, event_tail = latest_bucket_event(bucket_id) + if event: + end = str(event.get("timestamp") or "").strip() + try: + end_dt = datetime.fromisoformat(end.replace("Z", "+00:00")).astimezone(timezone.utc) + age = int((now - end_dt).total_seconds()) + return age, end + except Exception as exc: + return None, f"event-ts-error:{exc}" + try: r = requests.get(f"{base}/buckets/{bucket_id}", timeout=20) r.raise_for_status() @@ -2676,7 +3371,7 @@ class TSJGuardianBot: age = int((now - end_dt).total_seconds()) return age, end except Exception as exc: - return None, f"error:{exc}" + return None, f"{event_tail}; metadata-error:{exc}" def load_worktime_activity() -> Tuple[Optional[Dict[str, Dict[str, Optional[int]]]], Optional[str]]: try: @@ -2723,6 +3418,7 @@ class TSJGuardianBot: activity[bucket_host] = { "active": bool(latest_ts and latest_active and (age_seconds or 0) <= self.aw_rus_stale_sec), "age_seconds": age_seconds, + "fresh": bool(latest_ts and age_seconds is not None and age_seconds <= self.aw_rus_stale_sec), } return activity, None @@ -2733,6 +3429,33 @@ class TSJGuardianBot: current_host_activity = worktime_activity.get(host) if worktime_activity else None interactive_required = bool(current_host_activity and current_host_activity.get("active")) + guard_event, guard_tail = latest_bucket_event(f"aw-rus-collector-guard_{host}") + guard_data = (guard_event or {}).get("data") if guard_event else {} + guard_problems = guard_data.get("problems") if isinstance(guard_data, dict) else [] + guard_actions = guard_data.get("actions") if isinstance(guard_data, dict) else [] + guard_age = None + if guard_event: + try: + guard_ts = datetime.fromisoformat(str(guard_event["timestamp"]).replace("Z", "+00:00")).astimezone(timezone.utc) + guard_age = int((now - guard_ts).total_seconds()) + except Exception: + guard_age = None + guard_healthy = bool( + guard_age is not None + and guard_age <= max(300, self.aw_rus_stale_sec) + and isinstance(guard_data, dict) + and guard_data.get("status") == "ok" + and not guard_problems + ) + if guard_event: + mode = guard_data.get("mode", "unknown") if isinstance(guard_data, dict) else "unknown" + if guard_healthy: + lines.append(f"- collector-guard: OK age={guard_age}s mode={mode} actions={len(guard_actions or [])}") + else: + lines.append(f"- collector-guard: WARN age={guard_age}s mode={mode} tail={guard_tail}") + else: + lines.append(f"- collector-guard: WARN ({guard_tail})") + checks = [ (f"aw-watcher-window_{host}", "watcher-window"), (f"aw-watcher-afk_{host}", "watcher-afk"), @@ -2748,6 +3471,8 @@ class TSJGuardianBot: if age is None: if label.startswith("watcher-") and not interactive_required: lines.append(f"- {label}: WARN ({tail}; host inactive)") + elif label == "dlp-endpoint" and not interactive_required and guard_healthy: + lines.append(f"- {label}: WARN ({tail}; host inactive; guard healthy)") else: lines.append(f"- {label}: FAIL ({tail})") failures.append(label) @@ -2755,6 +3480,8 @@ class TSJGuardianBot: if age > self.aw_rus_stale_sec: if label.startswith("watcher-") and not interactive_required: lines.append(f"- {label}: WARN age={age}s end={tail} (host inactive)") + elif label == "dlp-endpoint" and not interactive_required and guard_healthy: + lines.append(f"- {label}: WARN age={age}s end={tail} (host inactive; guard healthy)") else: lines.append(f"- {label}: STALE age={age}s end={tail}") failures.append(label) @@ -2852,8 +3579,13 @@ class TSJGuardianBot: else: lines.append(f"- worktime({target}): OK active_seconds=0 (no active sessions)") except Exception as exc: - lines.append(f"- worktime: FAIL ({exc})") - failures.append("worktime") + if current_host_activity and current_host_activity.get("fresh"): + age_seconds = current_host_activity.get("age_seconds") + age_tail = f"; session bucket age={age_seconds}s" if age_seconds is not None else "" + lines.append(f"- worktime: WARN report unavailable ({exc}{age_tail})") + else: + lines.append(f"- worktime: FAIL ({exc})") + failures.append("worktime") return lines, failures @@ -2977,38 +3709,56 @@ class TSJGuardianBot: out = (proc.stdout or "") + (("\n" + proc.stderr) if proc.stderr else "") return proc.returncode, out.strip() - def _aw_rus_windows_collectors_heal(self, include_watchers: bool, include_worktime: bool) -> Tuple[bool, List[str]]: + def _aw_rus_windows_collectors_heal( + self, + include_watchers: bool, + include_worktime: bool, + include_dlp: bool = False, + ) -> Tuple[bool, List[str]]: report: List[str] = [] - if not (include_watchers or include_worktime): - report.append("- windows-heal: skipped (no watcher/worktime targets)") + if not (include_watchers or include_worktime or include_dlp): + report.append("- windows-heal: skipped (no Windows collector targets)") return True, report if not self.aw_rus_windows_ssh_password: report.append("- windows-heal: FAIL (AW_RUS_WINDOWS_SSH_PASSWORD not configured)") return False, report + task_items = ", ".join("'" + task.replace("'", "''") + "'" for task in self.aw_rus_windows_launch_tasks) + launch_tasks_expr = f"@({task_items})" if task_items else "@()" script = ( "$ErrorActionPreference = 'Stop'\n" "$ProgressPreference = 'SilentlyContinue'\n" f"$sessionCollectorScript = '{self.aw_rus_windows_session_collector_path}'\n" + f"$launchTasks = {launch_tasks_expr}\n" + "$stopSessionCollector = " + ("$true" if include_worktime else "$false") + "\n" + "$runRecovery = " + ("$true" if (include_watchers or include_worktime) else "$false") + "\n" + "if ($stopSessionCollector) {\n" "Get-CimInstance Win32_Process -ErrorAction SilentlyContinue |\n" " Where-Object { ($_.Name -ieq 'powershell.exe' -or $_.Name -ieq 'pwsh.exe') -and $_.CommandLine -and $_.CommandLine -match [Regex]::Escape($sessionCollectorScript) } |\n" " ForEach-Object { Stop-Process -Id ([int]$_.ProcessId) -Force -ErrorAction SilentlyContinue }\n" - f"if (Test-Path '{self.aw_rus_windows_hardening_recovery_path}') {{ try {{ & '{self.aw_rus_windows_hardening_recovery_path}' -ConfigPath '{self.aw_rus_windows_config_path}' }} catch {{ Write-Output ('HARDENING_RECOVERY_WARN: ' + $_.Exception.Message) }} }}\n" + "}\n" + "if ($runRecovery) {\n" + f" if (Test-Path '{self.aw_rus_windows_hardening_recovery_path}') {{ try {{ & '{self.aw_rus_windows_hardening_recovery_path}' -ConfigPath '{self.aw_rus_windows_config_path}' }} catch {{ Write-Output ('HARDENING_RECOVERY_WARN: ' + $_.Exception.Message) }} }}\n" + " Start-Sleep -Seconds 2\n" + " schtasks /Run /TN 'ActivityWatch Recovery' | Out-Null\n" + "}\n" "Start-Sleep -Seconds 2\n" - "schtasks /Run /TN 'ActivityWatch Recovery' | Out-Null\n" - "Start-Sleep -Seconds 2\n" - "Get-ScheduledTask -ErrorAction SilentlyContinue |\n" - " Where-Object { $_.TaskName -like 'ActivityWatch Launch *' } |\n" - " ForEach-Object { try { Start-ScheduledTask -TaskName $_.TaskName -ErrorAction Stop } catch {} }\n" - "Start-Sleep -Seconds 8\n" + "$allTasks = Get-ScheduledTask -ErrorAction SilentlyContinue\n" + "foreach ($taskName in $launchTasks) {\n" + " $task = $allTasks | Where-Object { $_.TaskName -eq $taskName } | Select-Object -First 1\n" + " if (-not $task) { Write-Output ('MISSING ' + $taskName); continue }\n" + " if ($task.State -eq 'Running') { Write-Output ('SKIP running ' + $taskName); continue }\n" + " try { schtasks /Run /TN $taskName | Out-String | Write-Output } catch { Write-Output ('START_WARN ' + $taskName + ': ' + $_.Exception.Message) }\n" + "}\n" + "Start-Sleep -Seconds 20\n" "Write-Output 'WATCHERS'\n" "Get-Process aw-watcher-afk,aw-watcher-window -ErrorAction SilentlyContinue |\n" " Select-Object Name, Id, SessionId, StartTime |\n" " Sort-Object SessionId, Name |\n" " Format-Table -AutoSize\n" - "Write-Output 'WORKTIME'\n" + "Write-Output 'AW_POWERSHELL'\n" "Get-CimInstance Win32_Process -ErrorAction SilentlyContinue |\n" - " Where-Object { ($_.Name -ieq 'powershell.exe' -or $_.Name -ieq 'pwsh.exe') -and $_.CommandLine -and $_.CommandLine -match [Regex]::Escape($sessionCollectorScript) } |\n" + " Where-Object { ($_.Name -ieq 'powershell.exe' -or $_.Name -ieq 'pwsh.exe') -and $_.CommandLine -and $_.CommandLine -match 'AWatch-rus' } |\n" " Select-Object Name, ProcessId, SessionId, CommandLine |\n" " Format-List\n" ) @@ -3026,34 +3776,81 @@ class TSJGuardianBot: return True, report def _perform_aw_rus_autoheal(self, failures: List[str]) -> Tuple[bool, List[str], List[str], List[str]]: - watcher_failures = [item for item in failures if item.startswith("watcher-")] - dlp_failures = [item for item in failures if item.startswith("dlp-")] - worktime_failed = "worktime" in failures + if "slo" in failures: + slo_lines, slo_failures = self._aw_rus_slo_lines_and_failures() + stale = any(line.strip().startswith("- slo-summary: STALE") for line in slo_lines) + plan = self._autoheal_plan_decision(failures, slo_stale=stale) + if isinstance(plan, dict) and plan.get("slo_only"): + triggers = [str(item) for item in (plan.get("report_triggers") or []) if str(item)] + return False, triggers, slo_lines, slo_failures + trigger = ( + "- heal trigger: SLO summary stale, check aw-slo-monitor.timer/service" + if stale + else "- heal trigger: SLO error budget exhausted, no direct autoheal target" + ) + return False, [trigger], slo_lines, slo_failures + + plan = self._autoheal_plan_decision(failures) + if isinstance(plan, dict): + watcher_failed = bool(plan.get("include_watchers")) + worktime_failed = bool(plan.get("include_worktime")) + windows_dlp_failed = bool(plan.get("include_windows_dlp")) + server_dlp_failures = [str(item) for item in (plan.get("server_dlp_failures") or []) if str(item)] + run_windows_heal = bool(plan.get("run_windows_heal")) + run_server_dlp_heal = bool(plan.get("run_server_dlp_heal")) + run_worktime_heal = bool(plan.get("run_worktime_heal")) + report_triggers = [str(item) for item in (plan.get("report_triggers") or []) if str(item)] + sleep_after_seconds = int(plan.get("sleep_after_seconds") or 5) + else: + watcher_failures = [item for item in failures if item.startswith("watcher-")] + dlp_failures = [item for item in failures if item.startswith("dlp-")] + windows_dlp_failures = [item for item in dlp_failures if item in {"dlp-endpoint", "dlp-fileops-host"}] + server_dlp_failures = [item for item in dlp_failures if item not in set(windows_dlp_failures)] + worktime_failed = "worktime" in failures + watcher_failed = bool(watcher_failures) + windows_dlp_failed = bool(windows_dlp_failures) + run_windows_heal = watcher_failed or worktime_failed or windows_dlp_failed + run_server_dlp_heal = bool(server_dlp_failures) + run_worktime_heal = watcher_failed or worktime_failed + report_triggers = [] + if run_windows_heal: + report_triggers.append("- heal trigger: Windows session collectors degraded, starting remediation") + if run_server_dlp_heal: + report_triggers.append("- heal trigger: server-side DLP degraded, starting remediation") + if run_worktime_heal: + report_triggers.append("- heal trigger: worktime/watchers degraded, rebuilding server-side worktime views") + sleep_after_seconds = 30 if (watcher_failed or windows_dlp_failed) else 5 report: List[str] = [] heal_ok = True - if watcher_failures or worktime_failed: + if run_windows_heal: windows_ok, windows_lines = self._aw_rus_windows_collectors_heal( - include_watchers=bool(watcher_failures), + include_watchers=watcher_failed, include_worktime=worktime_failed, + include_dlp=windows_dlp_failed, ) heal_ok = heal_ok and windows_ok - report.append("- heal trigger: Windows session collectors degraded, starting remediation") + if report_triggers: + report.append(report_triggers.pop(0)) + else: + report.append("- heal trigger: Windows session collectors degraded, starting remediation") report.extend(windows_lines) - if dlp_failures: - dlp_ok, dlp_lines = self._aw_rus_dlp_heal(dlp_failures) + if run_server_dlp_heal: + dlp_ok, dlp_lines = self._aw_rus_dlp_heal(server_dlp_failures) heal_ok = heal_ok and dlp_ok - report.append("- heal trigger: DLP degraded, starting remediation") + trigger = next((item for item in report_triggers if "server-side DLP" in item), None) + report.append(trigger or "- heal trigger: server-side DLP degraded, starting remediation") report.extend(dlp_lines) - if watcher_failures or worktime_failed: + if run_worktime_heal: wt_ok, wt_lines = self._aw_rus_worktime_heal() heal_ok = heal_ok and wt_ok - report.append("- heal trigger: worktime/watchers degraded, rebuilding server-side worktime views") + trigger = next((item for item in report_triggers if "worktime/watchers" in item), None) + report.append(trigger or "- heal trigger: worktime/watchers degraded, rebuilding server-side worktime views") report.extend(wt_lines) - time.sleep(5) + time.sleep(sleep_after_seconds) after_lines, after_failures = self._aw_rus_dlp_probe() return heal_ok and not after_failures, report, after_lines, after_failures @@ -3187,7 +3984,7 @@ class TSJGuardianBot: return "\n".join(lines) def _pfsense_security_status_lines(self) -> str: - cmd = "/usr/bin/python3 /home/codex/infra-admin/scripts/pfsense_security_status.py" + cmd = f"/usr/bin/python3 {shlex.quote(self.infra_admin_root)}/scripts/pfsense_security_status.py" try: rc, out = self._run_shell(cmd, timeout_sec=40) except Exception as exc: @@ -3290,6 +4087,22 @@ class TSJGuardianBot: updated["_tsj_meta"] = meta return updated, changed, changed_rules + def _aw_dlp_policy_decision(self, policy: Dict, target_mode: str = "") -> Optional[Dict]: + decision = self._run_status_helper_json( + [ + "--dlp-policy-decision", + "--now-epoch", + str(int(time.time())), + ], + { + "policy": policy if isinstance(policy, dict) else {}, + "target_mode": target_mode, + }, + ) + if isinstance(decision, dict): + return decision + return None + def _aw_rus_windows_sync_dlp_policy(self, policy: Dict) -> Tuple[bool, List[str]]: report: List[str] = [] if not self.aw_rus_windows_ssh_password: @@ -3345,18 +4158,27 @@ class TSJGuardianBot: def _aw_dlp_policy_mode_text(self) -> str: active = self._aw_dlp_policy_request("GET", "/dlp/policies/active", timeout_sec=15) policy = active.get("policy") or {} - mode = self._aw_dlp_mode_from_policy(policy if isinstance(policy, dict) else {}) - details = [] - for group_name, rules in self._aw_dlp_toggle_rule_groups(policy if isinstance(policy, dict) else {}): - block_count = 0 - total_count = 0 - for rule in rules: - if not isinstance(rule, dict) or rule.get("enabled") is False: - continue - total_count += 1 - if str(rule.get("action") or "").strip().lower() == "block": - block_count += 1 - details.append(f"- {group_name}: block={block_count}/{total_count}") + decision = self._aw_dlp_policy_decision(policy if isinstance(policy, dict) else {}) + if isinstance(decision, dict): + mode = str(decision.get("current_mode") or "unknown") + details = [ + f"- {item.get('name')}: block={item.get('blocked', 0)}/{item.get('total', 0)}" + for item in (decision.get("groups") or []) + if isinstance(item, dict) + ] + else: + mode = self._aw_dlp_mode_from_policy(policy if isinstance(policy, dict) else {}) + details = [] + for group_name, rules in self._aw_dlp_toggle_rule_groups(policy if isinstance(policy, dict) else {}): + block_count = 0 + total_count = 0 + for rule in rules: + if not isinstance(rule, dict) or rule.get("enabled") is False: + continue + total_count += 1 + if str(rule.get("action") or "").strip().lower() == "block": + block_count += 1 + details.append(f"- {group_name}: block={block_count}/{total_count}") lines = [ "DLP режим:", f"- policy: {active.get('name') or '-'} (id={active.get('policyId') or '-'})", @@ -3376,9 +4198,23 @@ class TSJGuardianBot: if not isinstance(policy, dict): raise RuntimeError("Active DLP policy payload is invalid") - current_mode = self._aw_dlp_mode_from_policy(policy) - target_mode = "monitor" if current_mode in {"enforce", "mixed"} else "enforce" - updated_policy, changed_count, changed_rules = self._aw_dlp_policy_for_mode(policy, target_mode) + decision = self._aw_dlp_policy_decision(policy, target_mode="toggle") + if isinstance(decision, dict): + current_mode = str(decision.get("current_mode") or "unknown") + target_mode = str(decision.get("target_mode") or "") + updated_policy = decision.get("updated_policy") + changed_count = int(decision.get("changed_count") or 0) + changed_rules = [ + str(item) + for item in (decision.get("changed_rules") or []) + if str(item) + ] + if target_mode not in {"monitor", "enforce"} or not isinstance(updated_policy, dict): + raise RuntimeError("Rust DLP policy decision returned invalid toggle plan") + else: + current_mode = self._aw_dlp_mode_from_policy(policy) + target_mode = "monitor" if current_mode in {"enforce", "mixed"} else "enforce" + updated_policy, changed_count, changed_rules = self._aw_dlp_policy_for_mode(policy, target_mode) if changed_count == 0: return ( "DLP режим не изменён.\n" @@ -3416,7 +4252,19 @@ class TSJGuardianBot: return "\n".join(lines) def _run_operator_action(self, action: str) -> str: - action = action.strip().lower() + requested_action = action + decision = self._operator_action_decision(requested_action) + if isinstance(decision, dict): + if not bool(decision.get("allowed")): + return str(decision.get("message") or "Неизвестное действие.") + action = str(decision.get("canonical_action") or requested_action).strip().lower() + if action and action != requested_action.strip().lower(): + self._log( + "INFO", + f"Operator action routed by Rust: {requested_action!r} -> {action!r}", + ) + else: + action = requested_action.strip().lower() if action == "check": rc, out, started = self._run_check_script_once(timeout_sec=240) if not started: diff --git a/proxmox/tsj_guardian_watchdog.sh b/proxmox/tsj_guardian_watchdog.sh new file mode 100644 index 0000000..c0ea80b --- /dev/null +++ b/proxmox/tsj_guardian_watchdog.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +HEARTBEAT_FILE="${HEARTBEAT_FILE:-/opt/infra-admin/.state/tsj_guardian_heartbeat}" +MAX_AGE_SEC="${MAX_AGE_SEC:-180}" +SERVICE_NAME="${SERVICE_NAME:-tsj-guardian-bot.service}" +GOST_SERVICE_NAME="${GOST_SERVICE_NAME:-gost-tg.service}" +GOST_DUP_PATTERN="${GOST_DUP_PATTERN:-/usr/local/bin/gost -L http+socks5://127.0.0.1:11090 -F socks5+wss://gw.iri1968.dpdns.org:4443}" + +dedupe_gost_instances() { + local main_pid + main_pid="$(systemctl show -p MainPID --value "${GOST_SERVICE_NAME}" 2>/dev/null || true)" + mapfile -t pids < <(pgrep -f -- "${GOST_DUP_PATTERN}" 2>/dev/null || true) + if [[ "${#pids[@]}" -le 1 ]]; then + return 0 + fi + + local keep_pid="" + if [[ "${main_pid}" =~ ^[0-9]+$ ]] && [[ "${main_pid}" -gt 1 ]]; then + keep_pid="${main_pid}" + else + keep_pid="${pids[0]}" + fi + + for pid in "${pids[@]}"; do + [[ "${pid}" == "${keep_pid}" ]] && continue + kill -TERM "${pid}" 2>/dev/null || true + done + + sleep 2 + + for pid in "${pids[@]}"; do + [[ "${pid}" == "${keep_pid}" ]] && continue + kill -0 "${pid}" 2>/dev/null || continue + kill -KILL "${pid}" 2>/dev/null || true + done +} + +dedupe_gost_instances + +if [[ ! -f "${HEARTBEAT_FILE}" ]]; then + systemctl restart "${SERVICE_NAME}" + exit 0 +fi + +now="$(date +%s)" +hb="$(cat "${HEARTBEAT_FILE}" 2>/dev/null || printf '0')" +if [[ ! "${hb}" =~ ^[0-9]+$ ]]; then + systemctl restart "${SERVICE_NAME}" + exit 0 +fi + +age=$((now - hb)) +if [[ "${age}" -gt "${MAX_AGE_SEC}" ]]; then + systemctl restart "${SERVICE_NAME}" +fi diff --git a/scripts/aw-contour-smoke-10.10.10.2.sh b/scripts/aw-contour-smoke-10.10.10.2.sh index 798c507..64cd38c 100644 --- a/scripts/aw-contour-smoke-10.10.10.2.sh +++ b/scripts/aw-contour-smoke-10.10.10.2.sh @@ -3,6 +3,28 @@ set -uo pipefail +TARGET_ROOT="${CARGO_TARGET_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." 2>/dev/null && pwd)/adk-rust/target}" +RUST_BIN="${AW_CONTOUR_SMOKE_RUST:-}" +rust_candidates=() +if [ -n "$RUST_BIN" ]; then + rust_candidates+=("$RUST_BIN") +fi +rust_candidates+=( + "$TARGET_ROOT/release/aw-contour-smoke" + "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." 2>/dev/null && pwd)/adk-rust/target/release/aw-contour-smoke" + "/usr/local/sbin/aw-contour-smoke" + "/usr/local/bin/aw-contour-smoke" +) + +for candidate in "${rust_candidates[@]}"; do + if [ -x "$candidate" ]; then + if [ "$#" -gt 0 ]; then + exec "$candidate" "$@" + fi + exec "$candidate" --mode proxmox-remote + fi +done + OK_COUNT=0 WARN_COUNT=0 FAIL_COUNT=0 diff --git a/scripts/aw-contour-smoke-local.sh b/scripts/aw-contour-smoke-local.sh index 1226443..be9e08e 100644 --- a/scripts/aw-contour-smoke-local.sh +++ b/scripts/aw-contour-smoke-local.sh @@ -19,6 +19,8 @@ ANSIBLE_DIR="$REPO_ROOT/ansible" INVENTORY="${AW_SMOKE_INVENTORY:-$ANSIBLE_DIR/inventory.ini}" REMOTE_SCRIPT_SRC="$REPO_ROOT/scripts/aw-contour-smoke-10.10.10.2.sh" REMOTE_SCRIPT_DST="${AW_SMOKE_REMOTE_SCRIPT:-/usr/local/sbin/aw-contour-smoke.sh}" +REMOTE_RUST_SRC="${AW_SMOKE_REMOTE_RUST_SRC:-${CARGO_TARGET_DIR:-$REPO_ROOT/adk-rust/target}/release/aw-contour-smoke}" +REMOTE_RUST_DST="${AW_SMOKE_REMOTE_RUST_BIN:-/usr/local/sbin/aw-contour-smoke}" AW_SERVER="${AW_SMOKE_AW_SERVER:-http://10.10.10.13:5600}" WORKTIME_API="${AW_SMOKE_WORKTIME_API:-http://10.10.10.13:5610}" GRAFANA_URL="${AW_SMOKE_GRAFANA_URL:-http://10.10.10.11:3000}" @@ -447,6 +449,18 @@ run_remote_proxmox_script() { fi section "Deploy Remote Script To 10.10.10.2" + if [ -x "$REMOTE_RUST_SRC" ]; then + if ANSIBLE_NOCOLOR=1 ansible proxmox -i "$INVENTORY" -m copy -a "src=$REMOTE_RUST_SRC dest=$REMOTE_RUST_DST owner=root group=root mode=0755" >/tmp/aw-smoke-copy-rust.$$ 2>&1; then + pass "remote Rust smoke deployed to $REMOTE_RUST_DST" + else + warn "remote Rust smoke deploy failed; shell fallback remains available" + sed 's/^/ /' /tmp/aw-smoke-copy-rust.$$ | head -120 + fi + rm -f /tmp/aw-smoke-copy-rust.$$ + else + skip "remote Rust smoke binary not found: $REMOTE_RUST_SRC" + fi + if ANSIBLE_NOCOLOR=1 ansible proxmox -i "$INVENTORY" -m copy -a "src=$REMOTE_SCRIPT_SRC dest=$REMOTE_SCRIPT_DST owner=root group=root mode=0755" >/tmp/aw-smoke-copy.$$ 2>&1; then pass "remote script deployed to $REMOTE_SCRIPT_DST" else diff --git a/scripts/aw-webui-browser-smoke.mjs b/scripts/aw-webui-browser-smoke.mjs new file mode 100644 index 0000000..08fe5bd --- /dev/null +++ b/scripts/aw-webui-browser-smoke.mjs @@ -0,0 +1,498 @@ +#!/usr/bin/env node +import { createRequire } from "node:module"; +import { execFile } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { promisify } from "node:util"; + +const require = createRequire(import.meta.url); +const execFileAsync = promisify(execFile); + +function loadPlaywright() { + const candidates = [ + "playwright", + "playwright-core", + process.env.PLAYWRIGHT_CORE_MODULE, + "/home/igor/.agents/skills/playwright/node_modules/playwright-core", + ].filter(Boolean); + const errors = []; + for (const candidate of candidates) { + try { + return require(candidate); + } catch (error) { + errors.push(`${candidate}: ${error.message}`); + } + } + return null; +} + +function firstExisting(candidates) { + return candidates.find((item) => item && fs.existsSync(item)) || ""; +} + +function env(name, fallback) { + const value = process.env[name]; + return value && value.trim() ? value.trim() : fallback; +} + +function normalizeBase(url) { + return url.replace(/\/+$/, ""); +} + +function safeName(value) { + return value.replace(/[^a-zA-Z0-9_.-]+/g, "-").replace(/^-|-$/g, ""); +} + +function envInt(name, fallback) { + const value = Number(env(name, String(fallback))); + return Number.isFinite(value) ? value : fallback; +} + +function isRunDirectoryName(name) { + return /^20\d{2}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z$/.test(name); +} + +function pruneOutputRuns(outRoot, keepRuns) { + if (keepRuns <= 0 || !fs.existsSync(outRoot)) { + return []; + } + const entries = fs + .readdirSync(outRoot, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && isRunDirectoryName(entry.name)) + .map((entry) => entry.name) + .sort(); + const stale = entries.slice(0, Math.max(0, entries.length - keepRuns)); + for (const name of stale) { + fs.rmSync(path.join(outRoot, name), { recursive: true, force: true }); + } + return stale; +} + +function decodeHtmlEntities(value) { + return value + .replace(/ /g, " ") + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'/g, "'"); +} + +function htmlToText(html) { + return decodeHtmlEntities( + html + .replace(//gi, " ") + .replace(//gi, " ") + .replace(/<[^>]+>/g, " ") + .replace(/\s+/g, " ") + .trim(), + ); +} + +function commandInPath(name) { + const dirs = (process.env.PATH || "").split(path.delimiter); + for (const dir of dirs) { + const candidate = path.join(dir, name); + if (fs.existsSync(candidate)) { + return candidate; + } + } + return ""; +} + +function findChromiumExecutable(explicitPath) { + return firstExisting([ + explicitPath, + "/home/igor/.cache/ms-playwright/chromium-1217/chrome-linux64/chrome", + "/home/igor/.cache/ms-playwright/chromium-1208/chrome-linux64/chrome", + "/home/igor/.cache/rod/browser/chromium-1321438/chrome", + commandInPath("chromium"), + commandInPath("chromium-browser"), + commandInPath("google-chrome"), + "/usr/bin/chromium", + "/usr/bin/chromium-browser", + "/usr/bin/google-chrome", + ]); +} + +function isBenignConsoleError(text) { + return text.includes("Failed to load resource: the server responded with a status of 404 (Not Found)"); +} + +function isBenignRequestFailure(failure) { + return failure.error === "net::ERR_ABORTED"; +} + +async function waitForTextMarkers(page, markers, timeoutMs) { + if (!markers.length) { + return; + } + try { + await page.waitForFunction( + (expected) => { + const text = `${document.title}\n${document.body ? document.body.innerText : ""}`; + return expected.every((marker) => text.includes(marker)); + }, + markers, + { timeout: timeoutMs }, + ); + } catch { + // The final assertion below reports the exact missing markers. + } +} + +async function runPageCheck(browser, spec, runDir) { + const page = await browser.newPage({ + viewport: { width: 1366, height: 768 }, + locale: "ru-RU", + timezoneId: "Europe/Moscow", + }); + const started = Date.now(); + const consoleErrors = []; + const pageErrors = []; + const requestFailures = []; + const badResponses = []; + const responses = []; + + page.on("console", (message) => { + if (message.type() === "error") { + const text = message.text(); + if (!isBenignConsoleError(text)) { + consoleErrors.push(text); + } + } + }); + page.on("pageerror", (error) => { + pageErrors.push(error.message); + }); + page.on("requestfailed", (request) => { + const url = request.url(); + if (spec.relevantHosts.some((host) => url.startsWith(host))) { + const failure = { url, error: request.failure()?.errorText || "unknown" }; + if (!isBenignRequestFailure(failure)) { + requestFailures.push(failure); + } + } + }); + page.on("response", (response) => { + const url = response.url(); + if (!spec.relevantHosts.some((host) => url.startsWith(host))) { + return; + } + const item = { url, status: response.status() }; + responses.push(item); + if (response.status() >= 400) { + badResponses.push(item); + } + }); + + let status = null; + let title = ""; + let bodyText = ""; + let screenshot = ""; + let error = ""; + try { + const response = await page.goto(spec.url, { waitUntil: "commit", timeout: spec.timeoutMs }); + status = response ? response.status() : null; + await waitForTextMarkers(page, spec.requiredText, spec.renderTimeoutMs); + await page.waitForTimeout(spec.settleMs); + title = await page.title(); + bodyText = await page.locator("body").innerText({ timeout: 5000 }); + screenshot = path.join(runDir, `${safeName(spec.name)}.png`); + await page.screenshot({ path: screenshot, fullPage: true }); + } catch (caught) { + error = caught && caught.message ? caught.message : String(caught); + try { + screenshot = path.join(runDir, `${safeName(spec.name)}-failed.png`); + await page.screenshot({ path: screenshot, fullPage: true }); + } catch { + screenshot = ""; + } + } finally { + await page.close(); + } + + const visibleText = `${title}\n${bodyText}`; + const missingMarkers = spec.requiredText.filter((marker) => !visibleText.includes(marker)); + const ok = + !error && + status !== null && + status >= 200 && + status < 300 && + bodyText.length >= spec.minBodyText && + missingMarkers.length === 0 && + consoleErrors.length === 0 && + pageErrors.length === 0 && + requestFailures.length === 0 && + badResponses.length === 0; + + return { + engine: "playwright", + name: spec.name, + ok, + url: spec.url, + status, + title, + body_text_length: bodyText.length, + missing_markers: missingMarkers, + console_errors: consoleErrors, + page_errors: pageErrors, + request_failures: requestFailures, + bad_responses: badResponses, + response_count: responses.length, + latency_ms: Date.now() - started, + screenshot, + error, + }; +} + +async function runChromiumCliPageCheck(chromiumPath, spec, runDir) { + const started = Date.now(); + const screenshot = path.join(runDir, `${safeName(spec.name)}.png`); + const profileDir = path.join(runDir, `${safeName(spec.name)}-profile`); + const screenshotRequired = spec.cliScreenshotRequired !== false; + fs.mkdirSync(profileDir, { recursive: true }); + const browserArgs = [ + "--headless", + "--no-sandbox", + "--noerrdialogs", + "--disable-gpu", + "--disable-crash-reporter", + "--disable-crashpad", + "--disable-dev-shm-usage", + `--user-data-dir=${profileDir}`, + `--virtual-time-budget=${spec.renderTimeoutMs + spec.settleMs}`, + ]; + + let status = null; + let error = ""; + let fetchedText = ""; + try { + const controller = AbortSignal.timeout(spec.timeoutMs); + const response = await fetch(spec.url, { signal: controller }); + status = response.status; + fetchedText = await response.text(); + } catch (caught) { + error = caught && caught.message ? caught.message : String(caught); + } + + let html = ""; + try { + const { stdout } = await execFileAsync(chromiumPath, [...browserArgs, "--dump-dom", spec.url], { + timeout: spec.timeoutMs + spec.renderTimeoutMs + spec.settleMs + 10000, + maxBuffer: 10 * 1024 * 1024, + }); + html = stdout || ""; + } catch (caught) { + const message = caught && caught.message ? caught.message : String(caught); + error = error ? `${error}; ${message}` : message; + } + + if (screenshotRequired) { + try { + await execFileAsync( + chromiumPath, + [...browserArgs, "--window-size=1366,768", `--screenshot=${screenshot}`, spec.url], + { + timeout: spec.timeoutMs + spec.renderTimeoutMs + spec.settleMs + 10000, + maxBuffer: 1024 * 1024, + }, + ); + } catch (caught) { + const message = caught && caught.message ? caught.message : String(caught); + error = error ? `${error}; screenshot: ${message}` : `screenshot: ${message}`; + } + } + + const rawHtml = html || fetchedText; + const titleMatch = rawHtml.match(/]*>([\s\S]*?)<\/title>/i); + const title = titleMatch ? decodeHtmlEntities(titleMatch[1].trim()) : ""; + const bodyText = htmlToText(rawHtml); + const visibleText = `${title}\n${bodyText}\n${rawHtml}`; + const requiredText = spec.cliRequiredText || spec.requiredText; + const minBodyText = spec.cliMinBodyText || spec.minBodyText; + const missingMarkers = requiredText.filter((marker) => !visibleText.includes(marker)); + const ok = + !error && + status !== null && + status >= 200 && + status < 300 && + bodyText.length >= minBodyText && + missingMarkers.length === 0 && + (!screenshotRequired || fs.existsSync(screenshot)); + + return { + engine: "chromium-cli", + name: spec.name, + ok, + url: spec.url, + status, + title, + body_text_length: bodyText.length, + missing_markers: missingMarkers, + console_errors: [], + page_errors: [], + request_failures: [], + bad_responses: [], + response_count: 0, + latency_ms: Date.now() - started, + screenshot_required: screenshotRequired, + screenshot: fs.existsSync(screenshot) ? screenshot : "", + error, + }; +} + +async function runPageCheckWithRetries(runOnce, spec, maxRetries) { + let result = await runOnce(spec); + for (let attempt = 1; !result.ok && attempt <= maxRetries; attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, 1000 * attempt)); + const retry = await runOnce({ ...spec, name: `${spec.name}_retry${attempt}` }); + retry.retry_of = spec.name; + retry.retry_attempt = attempt; + if (retry.ok) { + retry.name = spec.name; + retry.recovered_after_retry = attempt; + return retry; + } + result = retry; + result.retry_of = spec.name; + result.retry_attempt = attempt; + } + return result; +} + +async function main() { + const requestedEngine = env("AW_BROWSER_SMOKE_ENGINE", "auto"); + const playwright = requestedEngine === "chromium-cli" ? null : loadPlaywright(); + const awBase = normalizeBase(env("AW_BROWSER_SMOKE_AW_BASE", env("AW_SMOKE_AW_SERVER", "http://10.10.10.13:5600"))); + const worktimeBase = normalizeBase(env("AW_BROWSER_SMOKE_WORKTIME_BASE", env("AW_SMOKE_WORKTIME_API", "http://10.10.10.13:5610"))); + const host = env("AW_BROWSER_SMOKE_HOST", env("AW_SMOKE_SOURCE_HOSTNAME", "SHARKON2025")); + const timeoutMs = Number(env("AW_BROWSER_SMOKE_TIMEOUT_MS", "20000")); + const settleMs = Number(env("AW_BROWSER_SMOKE_SETTLE_MS", "6000")); + const renderTimeoutMs = Number(env("AW_BROWSER_SMOKE_RENDER_TIMEOUT_MS", "15000")); + const pageRetries = Number(env("AW_BROWSER_SMOKE_PAGE_RETRIES", "1")); + const keepRuns = envInt("AW_BROWSER_SMOKE_KEEP_RUNS", 24); + const outRoot = env("AW_BROWSER_SMOKE_OUTPUT_DIR", path.resolve("output", "browser-smoke")); + const runId = new Date().toISOString().replace(/[:.]/g, "-"); + const runDir = path.join(outRoot, runId); + fs.mkdirSync(outRoot, { recursive: true }); + const prunedRuns = pruneOutputRuns(outRoot, keepRuns); + fs.mkdirSync(runDir, { recursive: true }); + + const executablePath = findChromiumExecutable(env( + "PLAYWRIGHT_CHROMIUM_EXECUTABLE", + "", + )); + + const launchOptions = { + headless: true, + args: ["--no-sandbox", "--disable-dev-shm-usage"], + }; + if (executablePath) { + launchOptions.executablePath = executablePath; + } + + const relevantHosts = [awBase, worktimeBase]; + const specs = [ + { + name: "aw_webui_home", + url: `${awBase}/`, + relevantHosts, + requiredText: ["Активность", "Windows RDP", host, "DLP"], + cliRequiredText: ["ActivityWatch", "ru-patch-v5.js", "aw-report-links"], + cliMinBodyText: 100, + cliScreenshotRequired: false, + minBodyText: 500, + timeoutMs, + settleMs, + renderTimeoutMs, + }, + { + name: "worktime_today_html", + url: `${worktimeBase}/reports/worktime/today?format=html&day=today&host=${encodeURIComponent(host)}&allow_stale=1`, + relevantHosts, + requiredText: ["AW-rus", "Отчёт", "RDP"], + minBodyText: 500, + timeoutMs, + settleMs: 1000, + renderTimeoutMs, + }, + { + name: "worktime_management_html", + url: `${worktimeBase}/reports/worktime/management?format=html&day=today&host=${encodeURIComponent(host)}&allow_stale=1`, + relevantHosts, + requiredText: ["AW-rus", "Управленческий", "RDP"], + minBodyText: 500, + timeoutMs, + settleMs: 1000, + renderTimeoutMs, + }, + ]; + + const pages = []; + if (playwright) { + const { chromium } = playwright; + const browser = await chromium.launch(launchOptions); + try { + for (const spec of specs) { + pages.push(await runPageCheckWithRetries((item) => runPageCheck(browser, item, runDir), spec, pageRetries)); + } + } finally { + await browser.close(); + } + } else { + if (requestedEngine === "playwright") { + throw new Error("AW_BROWSER_SMOKE_ENGINE=playwright requested, but Playwright could not be loaded"); + } + if (!executablePath) { + throw new Error("Unable to load Playwright and no Chromium executable found"); + } + for (const spec of specs) { + pages.push(await runPageCheckWithRetries((item) => runChromiumCliPageCheck(executablePath, item, runDir), spec, pageRetries)); + } + } + + const ok = pages.every((page) => page.ok); + const result = { + ok, + engine: pages[0]?.engine || "unknown", + generated_at_utc: new Date().toISOString(), + aw_base: awBase, + worktime_base: worktimeBase, + host, + output_dir: runDir, + retention: { + keep_runs: keepRuns, + pruned_runs: prunedRuns, + }, + pages, + }; + const jsonPath = path.join(runDir, "result.json"); + const latestPath = path.join(outRoot, "latest-result.json"); + result.result_json = jsonPath; + result.latest_result_json = latestPath; + fs.writeFileSync(jsonPath, `${JSON.stringify(result, null, 2)}\n`, "utf8"); + fs.writeFileSync(latestPath, `${JSON.stringify(result, null, 2)}\n`, "utf8"); + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + return ok ? 0 : 2; +} + +main() + .then((code) => { + process.exitCode = code; + }) + .catch((error) => { + process.stdout.write( + `${JSON.stringify( + { + ok: false, + generated_at_utc: new Date().toISOString(), + error: error && error.message ? error.message : String(error), + }, + null, + 2, + )}\n`, + ); + process.exitCode = 2; + }); diff --git a/scripts/aw-webui-browser-smoke.sh b/scripts/aw-webui-browser-smoke.sh new file mode 100644 index 0000000..66d2670 --- /dev/null +++ b/scripts/aw-webui-browser-smoke.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +rust_candidates=( + "${AW_BROWSER_SMOKE_RUST:-}" + "${CARGO_TARGET_DIR:-}/release/aw-browser-smoke" + "$ROOT_DIR/adk-rust/target/release/aw-browser-smoke" + "/usr/local/bin/aw-browser-smoke" +) + +for rust_bin in "${rust_candidates[@]}"; do + if [[ -n "$rust_bin" && -x "$rust_bin" ]]; then + exec "$rust_bin" --root "$ROOT_DIR" -- "$@" + fi +done + +if [[ -z "${NODE_PATH:-}" && -d /home/igor/.agents/skills/playwright/node_modules ]]; then + export NODE_PATH=/home/igor/.agents/skills/playwright/node_modules +fi + +exec node "$ROOT_DIR/scripts/aw-webui-browser-smoke.mjs" "$@" diff --git a/scripts/build_dlp_ioc_from_hayabusa.sh b/scripts/build_dlp_ioc_from_hayabusa.sh index 7dcf42b..21e120e 100644 --- a/scripts/build_dlp_ioc_from_hayabusa.sh +++ b/scripts/build_dlp_ioc_from_hayabusa.sh @@ -13,6 +13,8 @@ set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" RULES_ROOT="${1:-/mnt/usb_hdd1/Projects/hayabusa/rules}" OUT_DIR="${2:-$REPO_ROOT/data/dlp-ioc}" +TARGET_ROOT="${CARGO_TARGET_DIR:-$REPO_ROOT/adk-rust/target}" +RUST_BIN="${EXTRACT_IOC_FROM_SIGMA_RUST:-}" if [[ ! -d "$RULES_ROOT" ]]; then echo "ERROR: rules root not found: $RULES_ROOT" >&2 @@ -21,9 +23,28 @@ fi mkdir -p "$OUT_DIR" +rust_candidates=() +if [[ -n "$RUST_BIN" ]]; then + rust_candidates+=("$RUST_BIN") +fi +rust_candidates+=( + "$TARGET_ROOT/release/extract-ioc-from-sigma" + "$REPO_ROOT/adk-rust/target/release/extract-ioc-from-sigma" + "/usr/local/bin/extract-ioc-from-sigma" +) + +for candidate in "${rust_candidates[@]}"; do + if [[ -x "$candidate" ]]; then + "$candidate" \ + --rules-root "$RULES_ROOT" \ + --out-dir "$OUT_DIR" + echo "IOC artifacts generated in: $OUT_DIR" + exit 0 + fi +done + python3 "$REPO_ROOT/scripts/extract_ioc_from_sigma.py" \ --rules-root "$RULES_ROOT" \ --out-dir "$OUT_DIR" echo "IOC artifacts generated in: $OUT_DIR" - diff --git a/scripts/check_detmir_rust_release_artifacts.sh b/scripts/check_detmir_rust_release_artifacts.sh new file mode 100644 index 0000000..1d7880c --- /dev/null +++ b/scripts/check_detmir_rust_release_artifacts.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TARGET_ROOT="${CARGO_TARGET_DIR:-$ROOT_DIR/adk-rust/target}" +RELEASE_DIR="$TARGET_ROOT/release" + +required_bins=( + detmir-status + detmir-adk-status + detmir-check + detmir-dlp + detmir-auto + detmir-heal-safe + tsj-guardian-watchdog + tsj-guardian-status + aw-rus-healthd + aw-db-health + aw-db-maintenance + aw-health-check + aw-prune-local-state + check-aw-data + check-aw-full + check-install-kit-vs-repo + validate-install-kit + verify-innosetup-installer + rebuild-install-kit + quality-gate + extract-ioc-from-sigma + merge-aw-server-dbs + prod-backup-restore + prod-rollout + rdp-worktime-report + aw-contour-smoke + aw-browser-smoke + diag-and-manual-restart + dlp-health-check + dlp-content-analyzer + dlp-admin-cli + dlp-policy-engine + dlp-case-management + dlp-compliance + dlp-aggregator + dlp-syslog-forwarder + dlp-webhook-sender + dlp-cef-exporter + dlp-influx-exporter + worktime-influx-exporter + worktime-prewarm + worktime-ui-bridge + worktime-autoheal + worktime-api + aw-slo-monitor + aw-hayabusa-case-alert-rust + aw-hayabusa-link-case-rust + aw-hayabusa-from-windows-rust + aw-hayabusa-autoprocess-rust +) + +missing=0 +for bin in "${required_bins[@]}"; do + if [[ -x "$RELEASE_DIR/$bin" ]]; then + printf 'OK %s\n' "$bin" + else + printf 'MISSING %s (%s)\n' "$bin" "$RELEASE_DIR/$bin" >&2 + missing=1 + fi +done + +if (( missing != 0 )); then + cat >&2 < str: return value if value not in (None, "") else default -def _http_json(url: str, timeout: int = 10) -> Any: - with request.urlopen(url, timeout=timeout) as resp: - return json.loads(resp.read().decode("utf-8")) +def _http_json(url: str, timeout: int = 15, attempts: int = 2, backoff_seconds: float = 0.5) -> Any: + last_exc: Exception | None = None + for attempt in range(max(1, attempts)): + try: + with request.urlopen(url, timeout=timeout) as resp: + return json.loads(resp.read().decode("utf-8")) + except Exception as exc: + last_exc = exc + if attempt + 1 >= max(1, attempts): + break + time.sleep(backoff_seconds * (2**attempt)) + assert last_exc is not None + raise last_exc def _parse_ts(value: str | None) -> datetime | None: @@ -42,6 +53,76 @@ def _age_seconds(ts: datetime | None, now: datetime) -> int | None: return max(0, int((now - ts).total_seconds())) +def _int_or_zero(value: Any) -> int: + try: + return int(value) + except (TypeError, ValueError): + return 0 + + +def _path_tail(value: Any, parts: int = 2) -> str: + text = str(value or "").replace("\\", "/").strip() + if not text: + return "" + tokens = [item for item in text.split("/") if item] + return "/".join(tokens[-parts:]) if tokens else text + + +def _text_excerpt(value: Any, limit: int = 120) -> str: + text = " ".join(str(value or "").split()) + if len(text) <= limit: + return text + return text[: max(0, limit - 1)].rstrip() + "…" + + +def _load_counter_state(path: Path) -> dict[str, Any]: + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError: + return {"counters": {}} + except Exception: + return {"counters": {}} + if not isinstance(payload, dict): + return {"counters": {}} + counters = payload.get("counters") + if not isinstance(counters, dict): + payload["counters"] = {} + return payload + + +def _save_counter_state(path: Path, state: dict[str, Any]) -> str | None: + try: + path.parent.mkdir(parents=True, exist_ok=True) + tmp_path = path.parent / f".{path.name}.{os.getpid()}.tmp" + tmp_path.write_text(json.dumps(state, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8") + os.replace(tmp_path, path) + return None + except Exception as exc: + try: + path.write_text(json.dumps(state, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8") + return None + except Exception: + return str(exc) + + +def _counter_delta(counter_state: dict[str, Any] | None, key: str, current_value: int) -> tuple[int | None, int]: + if counter_state is None: + return None, current_value + counters = counter_state.setdefault("counters", {}) + if not isinstance(counters, dict): + counters = {} + counter_state["counters"] = counters + previous: int | None = None + try: + previous = int(counters[key]) + except (KeyError, TypeError, ValueError): + previous = None + counters[key] = current_value + if previous is None or current_value < previous: + return previous, 0 + return previous, current_value - previous + + def _run_systemctl(*args: str) -> tuple[int, str]: proc = subprocess.run( ["systemctl", *args], @@ -268,6 +349,134 @@ def check_incident_buckets( ) +def check_incident_runtime( + report: HealthReport, + api_base: str, + buckets: dict[str, Any], + sample_limit: int = 20, +) -> None: + now = _now_utc() + prefix = "aw-dlp-incidents_" + matched = sorted(bucket_id for bucket_id in buckets if bucket_id.startswith(prefix)) + if not matched: + report.add("incident-runtime", "ok", "no incident buckets to sample", bucket_count=0) + return + + if sample_limit <= 0: + metadata = [] + for bucket_id in matched: + ts = _latest_bucket_ts(api_base, bucket_id, buckets.get(bucket_id, {})) + metadata.append( + { + "bucket": bucket_id, + "end": ts.isoformat().replace("+00:00", "Z") if ts else None, + "age_seconds": _age_seconds(ts, now), + } + ) + report.add( + "incident-runtime", + "ok", + "incident event sampling disabled", + bucket_count=len(matched), + sample_limit=sample_limit, + metadata=metadata, + ) + return + + sampled: list[dict[str, Any]] = [] + latest_incidents: list[dict[str, Any]] = [] + read_failed: list[dict[str, str]] = [] + totals = { + "sampled_events": 0, + "real_incidents": 0, + "self_tests": 0, + } + severity_counts: dict[str, int] = {} + action_counts: dict[str, int] = {} + rule_counts: dict[str, int] = {} + + for bucket_id in matched: + try: + events = _http_json(f"{api_base}/buckets/{bucket_id}/events?limit={sample_limit}", timeout=5, attempts=1) + except Exception as exc: + read_failed.append({"bucket": bucket_id, "error": str(exc)}) + continue + if not isinstance(events, list): + read_failed.append({"bucket": bucket_id, "error": "events response is not a list"}) + continue + + bucket_summary = { + "bucket": bucket_id, + "sampled_events": len(events), + "real_incidents": 0, + "self_tests": 0, + } + sampled.append(bucket_summary) + totals["sampled_events"] += len(events) + + for event in events: + data = event.get("data") or {} + if not isinstance(data, dict): + continue + + signal_type = str(data.get("signalType") or "").strip() + source = str(data.get("source") or "").strip() + rule_id = str(data.get("ruleId") or data.get("rule_id") or "").strip() + is_self_test = signal_type == "self_test" or source == "self-test" or rule_id.startswith("selftest-") + if is_self_test: + bucket_summary["self_tests"] += 1 + totals["self_tests"] += 1 + continue + + ts = _parse_ts(event.get("timestamp")) + severity = str(data.get("severity") or "unknown").strip().lower() or "unknown" + action = str(data.get("action") or "unknown").strip().lower() or "unknown" + rule_key = rule_id or "unknown" + severity_counts[severity] = severity_counts.get(severity, 0) + 1 + action_counts[action] = action_counts.get(action, 0) + 1 + rule_counts[rule_key] = rule_counts.get(rule_key, 0) + 1 + bucket_summary["real_incidents"] += 1 + totals["real_incidents"] += 1 + latest_incidents.append( + { + "bucket": bucket_id, + "timestamp": event.get("timestamp"), + "age_seconds": _age_seconds(ts, now), + "ruleId": rule_id, + "severity": severity, + "action": action, + "username": str(data.get("username") or ""), + "hostname": str(data.get("hostname") or ""), + "source": source, + "message_excerpt": _text_excerpt(data.get("message")), + } + ) + + latest_incidents.sort(key=lambda item: item.get("timestamp") or "", reverse=True) + status = "ok" + summary = f"{totals['real_incidents']} real incidents in sampled events" + if read_failed: + status = "warn" + summary = f"{len(read_failed)} incident buckets failed to sample" + elif totals["real_incidents"] == 0: + summary = "no real incidents in sampled events" + + report.add( + "incident-runtime", + status, + summary, + bucket_count=len(matched), + sample_limit=sample_limit, + totals=totals, + sampled=sampled, + severity_counts=severity_counts, + action_counts=action_counts, + rule_counts=dict(sorted(rule_counts.items(), key=lambda item: (-item[1], item[0]))[:10]), + latest_incidents=latest_incidents[:5], + read_failed=read_failed, + ) + + def _worktime_activity_map(api_base: str, buckets: dict[str, Any], max_age_seconds: int) -> dict[str, dict[str, Any]]: now = _now_utc() activity: dict[str, dict[str, Any]] = {} @@ -377,8 +586,239 @@ def check_file_operations_buckets( ) -def check_endpoint_self_test_metrics(report: HealthReport, api_base: str, buckets: dict[str, Any]) -> None: +def check_file_operations_runtime( + report: HealthReport, + api_base: str, + buckets: dict[str, Any], + sample_limit: int = 20, + queue_warn_depth: int = 100, + send_failure_warn_count: int = 1, + counter_state: dict[str, Any] | None = None, +) -> None: + now = _now_utc() + prefix = "aw-file-operations_" + matched = sorted(bucket_id for bucket_id in buckets if bucket_id.startswith(prefix)) + if not matched: + report.add("file-operations-runtime", "warn", "no file-operations buckets to sample", bucket_count=0) + return + + sampled: list[dict[str, Any]] = [] + latest_operations: list[dict[str, Any]] = [] + latest_health: list[dict[str, Any]] = [] + warnings: list[dict[str, Any]] = [] + read_failed: list[dict[str, str]] = [] + + for bucket_id in matched: + try: + events = _http_json(f"{api_base}/buckets/{bucket_id}/events?limit={sample_limit}") + except Exception as exc: + read_failed.append({"bucket": bucket_id, "error": str(exc)}) + continue + if not isinstance(events, list): + read_failed.append({"bucket": bucket_id, "error": "events response is not a list"}) + continue + + operation_counts: dict[str, int] = {} + latest_health_event: dict[str, Any] | None = None + latest_health_ts: datetime | None = None + + for event in events: + data = event.get("data") or {} + if not isinstance(data, dict): + continue + ts = _parse_ts(event.get("timestamp")) + signal_type = str(data.get("signalType") or "") + operation = str(data.get("operation") or "") + + if signal_type == "collector_health": + if latest_health_event is None or (ts is not None and (latest_health_ts is None or ts > latest_health_ts)): + latest_health_event = event + latest_health_ts = ts + continue + + if operation: + operation_counts[operation] = operation_counts.get(operation, 0) + 1 + latest_operations.append( + { + "bucket": bucket_id, + "timestamp": event.get("timestamp"), + "age_seconds": _age_seconds(ts, now), + "operation": operation, + "username": str(data.get("username") or ""), + "hostname": str(data.get("hostname") or ""), + "extension": str(data.get("extension") or ""), + "archiveHint": bool(data.get("archiveHint")), + "path_tail": _path_tail(data.get("path")), + "size": _int_or_zero(data.get("size")), + } + ) + + bucket_sample = { + "bucket": bucket_id, + "sampled_events": len(events), + "operation_counts": operation_counts, + } + sampled.append(bucket_sample) + + if latest_health_event is None: + warnings.append({"bucket": bucket_id, "metric": "collector_health", "value": "missing_in_sample"}) + continue + + health_data = latest_health_event.get("data") or {} + send_failures = _int_or_zero(health_data.get("sendFailures")) + previous_send_failures, send_failures_delta = _counter_delta( + counter_state, + f"file-operations:{bucket_id}:sendFailures", + send_failures, + ) + health_item = { + "bucket": bucket_id, + "timestamp": latest_health_event.get("timestamp"), + "age_seconds": _age_seconds(latest_health_ts, now), + "queueDepth": _int_or_zero(health_data.get("queueDepth")), + "eventsEnqueued": _int_or_zero(health_data.get("eventsEnqueued")), + "eventsFlushed": _int_or_zero(health_data.get("eventsFlushed")), + "sendFailures": send_failures, + "sendFailuresPrevious": previous_send_failures, + "sendFailuresDelta": send_failures_delta, + "username": str(health_data.get("username") or ""), + "hostname": str(health_data.get("hostname") or ""), + "sessionId": _int_or_zero(health_data.get("sessionId")), + } + latest_health.append(health_item) + if health_item["queueDepth"] > queue_warn_depth: + warnings.append({"bucket": bucket_id, "metric": "queueDepth", "value": health_item["queueDepth"], "threshold": queue_warn_depth}) + if send_failure_warn_count > 0 and health_item["sendFailuresDelta"] >= send_failure_warn_count: + warnings.append( + { + "bucket": bucket_id, + "metric": "sendFailuresDelta", + "value": health_item["sendFailuresDelta"], + "current": health_item["sendFailures"], + "previous": health_item["sendFailuresPrevious"], + "threshold": send_failure_warn_count, + } + ) + + latest_operations.sort(key=lambda item: item.get("timestamp") or "", reverse=True) + status = "ok" + summary = f"{len(matched)} file-operations buckets sampled" + if read_failed: + status = "warn" + summary = f"{len(read_failed)} file-operations buckets failed to sample" + elif warnings: + status = "warn" + summary = "file-operations runtime counters outside expectations" + + report.add( + "file-operations-runtime", + status, + summary, + bucket_count=len(matched), + sample_limit=sample_limit, + sampled=sampled, + latest_health=latest_health, + latest_operations=latest_operations[:5], + warnings=warnings, + read_failed=read_failed, + thresholds={ + "queueDepth": queue_warn_depth, + "sendFailures": send_failure_warn_count, + }, + ) + + +def check_endpoint_signal_buckets( + report: HealthReport, + api_base: str, + buckets: dict[str, Any], + max_age_seconds: int, +) -> None: + now = _now_utc() + prefix = "aw-dlp-endpoint-signals_" + matched = sorted(bucket_id for bucket_id in buckets if bucket_id.startswith(prefix)) + worktime = _worktime_activity_map(api_base, buckets, max_age_seconds) + active_hosts = sorted(host for host, meta in worktime.items() if meta.get("active")) + matched_by_host = {_bucket_suffix(bucket_id, prefix): bucket_id for bucket_id in matched} + + ignored_unmanaged: list[str] = [] + ignored_inactive: list[str] = [] + missing_active: list[str] = [] + stale: list[dict[str, Any]] = [] + unknown: list[str] = [] + fresh: list[str] = [] + + for host, bucket_id in matched_by_host.items(): + if host not in worktime: + ignored_unmanaged.append(bucket_id) + continue + if host not in active_hosts: + ignored_inactive.append(bucket_id) + continue + ts = _latest_bucket_ts(api_base, bucket_id, buckets.get(bucket_id, {})) + age = _age_seconds(ts, now) + if age is None: + unknown.append(bucket_id) + continue + if age > max_age_seconds: + stale.append({"bucket": bucket_id, "age_seconds": age}) + else: + fresh.append(bucket_id) + + for host in active_hosts: + if host not in matched_by_host: + missing_active.append(host) + + if not active_hosts: + report.add( + "buckets:endpoint-signals", + "ok", + "no active managed hosts require endpoint-signals freshness", + active_hosts=[], + ignored_unmanaged=ignored_unmanaged, + ignored_inactive=ignored_inactive, + worktime_hosts=sorted(worktime), + ) + return + + status = "ok" + summary = f"{len(fresh)} active host buckets fresh" + if missing_active: + status = "fail" + summary = f"{len(missing_active)} active hosts missing endpoint-signals buckets" + elif stale: + status = "fail" + summary = f"{len(stale)} active host buckets stale" + elif unknown: + status = "warn" + summary = f"{len(unknown)} active host buckets without timestamp" + + report.add( + "buckets:endpoint-signals", + status, + summary, + active_hosts=active_hosts, + fresh=fresh, + stale=stale, + missing_active=missing_active, + unknown=unknown, + ignored_unmanaged=ignored_unmanaged, + ignored_inactive=ignored_inactive, + ) + + +def check_endpoint_self_test_metrics( + report: HealthReport, + api_base: str, + buckets: dict[str, Any], + queue_warn_depth: int = 100, + send_failure_warn_count: int = 1, + counter_state: dict[str, Any] | None = None, +) -> None: + now = _now_utc() missing: list[str] = [] + latest_self_tests: list[dict[str, Any]] = [] + warnings: list[dict[str, Any]] = [] expected = ("queueDepth", "eventsEnqueued", "eventsFlushed", "sendFailures") for bucket_id in sorted(k for k in buckets if k.startswith("aw-dlp-endpoint-signals_")): try: @@ -386,20 +826,89 @@ def check_endpoint_self_test_metrics(report: HealthReport, api_base: str, bucket except Exception as exc: report.add(f"endpoint-self-test:{bucket_id}", "warn", f"failed to read events: {exc}", bucket=bucket_id) continue - found = False + latest_event: dict[str, Any] | None = None + latest_ts: datetime | None = None if isinstance(events, list): for event in events: data = event.get("data") or {} - if data.get("signalType") == "self_test" and all(key in data for key in expected): - found = True - break - if not found: + if data.get("signalType") != "self_test" or not all(key in data for key in expected): + continue + ts = _parse_ts(event.get("timestamp")) + if latest_event is None or (ts is not None and (latest_ts is None or ts > latest_ts)): + latest_event = event + latest_ts = ts + if latest_event is None: missing.append(bucket_id) + continue + + data = latest_event.get("data") or {} + send_failures = _int_or_zero(data.get("sendFailures")) + previous_send_failures, send_failures_delta = _counter_delta( + counter_state, + f"endpoint-self-test:{bucket_id}:sendFailures", + send_failures, + ) + item = { + "bucket": bucket_id, + "timestamp": latest_event.get("timestamp"), + "age_seconds": _age_seconds(latest_ts, now), + "queueDepth": _int_or_zero(data.get("queueDepth")), + "eventsEnqueued": _int_or_zero(data.get("eventsEnqueued")), + "eventsFlushed": _int_or_zero(data.get("eventsFlushed")), + "sendFailures": send_failures, + "sendFailuresPrevious": previous_send_failures, + "sendFailuresDelta": send_failures_delta, + } + latest_self_tests.append(item) + if item["queueDepth"] > queue_warn_depth: + warnings.append({"bucket": bucket_id, "metric": "queueDepth", "value": item["queueDepth"], "threshold": queue_warn_depth}) + if send_failure_warn_count > 0 and item["sendFailuresDelta"] >= send_failure_warn_count: + warnings.append( + { + "bucket": bucket_id, + "metric": "sendFailuresDelta", + "value": item["sendFailuresDelta"], + "current": item["sendFailures"], + "previous": item["sendFailuresPrevious"], + "threshold": send_failure_warn_count, + } + ) if missing: - report.add("endpoint-self-test-metrics", "warn", "missing transport metrics in recent self_test events", buckets=missing) + report.add( + "endpoint-self-test-metrics", + "warn", + "missing transport metrics in sampled self_test events", + buckets=missing, + latest_self_tests=latest_self_tests, + thresholds={ + "queueDepth": queue_warn_depth, + "sendFailures": send_failure_warn_count, + }, + ) + elif warnings: + report.add( + "endpoint-self-test-metrics", + "warn", + "endpoint transport counters outside thresholds", + latest_self_tests=latest_self_tests, + warnings=warnings, + thresholds={ + "queueDepth": queue_warn_depth, + "sendFailures": send_failure_warn_count, + }, + ) else: - report.add("endpoint-self-test-metrics", "ok", "recent self_test metrics present") + report.add( + "endpoint-self-test-metrics", + "ok", + "self_test transport metrics present", + latest_self_tests=latest_self_tests, + thresholds={ + "queueDepth": queue_warn_depth, + "sendFailures": send_failure_warn_count, + }, + ) def check_compliance_reports(report: HealthReport, report_dir: Path, profiles: list[str], month: str) -> None: @@ -425,6 +934,13 @@ def main() -> int: parser.add_argument("--case-server", default=_env("AW_HEALTH_CASE_SERVER", "http://127.0.0.1:5602")) parser.add_argument("--max-age-seconds", type=int, default=int(_env("AW_HEALTH_MAX_AGE_SECONDS", "900"))) parser.add_argument("--strict-fileops", action="store_true", default=_env("AW_HEALTH_STRICT_FILEOPS", "0").lower() in {"1", "true", "yes", "on"}) + parser.add_argument("--endpoint-queue-warn-depth", type=int, default=int(_env("AW_DLP_HEALTH_ENDPOINT_QUEUE_WARN_DEPTH", "100"))) + parser.add_argument("--endpoint-send-failure-warn-count", type=int, default=int(_env("AW_DLP_HEALTH_ENDPOINT_SEND_FAILURE_WARN_COUNT", "1"))) + parser.add_argument("--fileops-sample-limit", type=int, default=int(_env("AW_DLP_HEALTH_FILEOPS_SAMPLE_LIMIT", "20"))) + parser.add_argument("--fileops-queue-warn-depth", type=int, default=int(_env("AW_DLP_HEALTH_FILEOPS_QUEUE_WARN_DEPTH", "100"))) + parser.add_argument("--fileops-send-failure-warn-count", type=int, default=int(_env("AW_DLP_HEALTH_FILEOPS_SEND_FAILURE_WARN_COUNT", "1"))) + parser.add_argument("--incident-sample-limit", type=int, default=int(_env("AW_DLP_HEALTH_INCIDENT_SAMPLE_LIMIT", "0"))) + parser.add_argument("--state-dir", default=_env("AW_DLP_HEALTH_STATE_DIR", "/var/lib/activitywatch/health")) parser.add_argument("--report-dir", default=_env("AW_DLP_COMPLIANCE_REPORT_DIR", "/opt/activitywatch/dlp-compliance/reports")) parser.add_argument("--profiles", default=_env("AW_DLP_COMPLIANCE_PROFILES", "152-fz,pci-dss")) parser.add_argument("--json", action="store_true") @@ -432,6 +948,8 @@ def main() -> int: report = HealthReport() aw_api_base = args.aw_server.rstrip("/") + "/api/0" + counter_state_path = Path(args.state_dir) / "dlp-health-check-counters.json" + counter_state = _load_counter_state(counter_state_path) check_http_endpoint(report, "http:aw", f"{aw_api_base}/info") check_http_endpoint(report, "http:policy", args.policy_server.rstrip("/") + "/healthz") @@ -461,13 +979,34 @@ def main() -> int: if not isinstance(buckets, dict): raise RuntimeError("bucket list is not a dict") report.add("aw:buckets-index", "ok", "bucket index loaded", total=len(buckets)) - check_bucket_group(report, aw_api_base, buckets, "endpoint-signals", "aw-dlp-endpoint-signals_", args.max_age_seconds) + check_endpoint_signal_buckets(report, aw_api_base, buckets, args.max_age_seconds) check_file_operations_buckets(report, aw_api_base, buckets, args.max_age_seconds, args.strict_fileops) + check_file_operations_runtime( + report, + aw_api_base, + buckets, + args.fileops_sample_limit, + args.fileops_queue_warn_depth, + args.fileops_send_failure_warn_count, + counter_state, + ) check_incident_buckets(report, aw_api_base, buckets, args.max_age_seconds * 24) - check_endpoint_self_test_metrics(report, aw_api_base, buckets) + check_incident_runtime(report, aw_api_base, buckets, args.incident_sample_limit) + check_endpoint_self_test_metrics( + report, + aw_api_base, + buckets, + args.endpoint_queue_warn_depth, + args.endpoint_send_failure_warn_count, + counter_state, + ) except Exception as exc: report.add("aw:buckets-index", "fail", f"failed to inspect bucket index: {exc}") + state_error = _save_counter_state(counter_state_path, counter_state) + if state_error: + report.add("state:counters", "warn", f"failed to save counter baseline: {state_error}", path=str(counter_state_path)) + month = _now_utc().strftime("%Y-%m") profiles = [x.strip() for x in args.profiles.split(",") if x.strip()] check_compliance_reports(report, Path(args.report_dir), profiles, month) diff --git a/scripts/legacy/aw-prune-local-state.sh b/scripts/legacy/aw-prune-local-state.sh new file mode 100644 index 0000000..c2c6afd --- /dev/null +++ b/scripts/legacy/aw-prune-local-state.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +set -euo pipefail + +AW_DATA_DIR="${AW_DATA_DIR:-/var/lib/activitywatch}" +BACKUP_DIR="${AW_BACKUP_DIR:-${AW_DATA_DIR}/backups}" +KEEP_DAYS="${AW_BACKUP_RETENTION_DAYS:-7}" +KEEP_LAST_DB="${AW_BACKUP_KEEP_LAST_DB:-2}" +KEEP_LAST_JSON="${AW_BACKUP_KEEP_LAST_JSON:-2}" + +prune_group() { + local keep_last="$1" + local keep_days="$2" + shift 2 + local files=() + local idx=0 + local cutoff + cutoff="$(date -d "-${keep_days} days" +%s)" + mapfile -t files < <(find "$@" -maxdepth 1 -type f -printf '%T@ %p\n' 2>/dev/null | sort -nr | awk '{ $1=""; sub(/^ /,""); print }') + for path in "${files[@]}"; do + idx=$((idx + 1)) + if [ "$idx" -le "$keep_last" ]; then + continue + fi + [ -f "$path" ] || continue + if [ "$(stat -c %Y "$path")" -lt "$cutoff" ]; then + rm -f -- "$path" + fi + done +} + +mkdir -p "$BACKUP_DIR" + +prune_group "$KEEP_LAST_DB" "$KEEP_DAYS" "${BACKUP_DIR}/db" +prune_group "$KEEP_LAST_JSON" "$KEEP_DAYS" "$BACKUP_DIR" + +find /tmp -maxdepth 1 -type f \ + \( -name 'activitywatch-*.zip' -o -name 'hayabusa-*.zip' -o -name 'aw-hayabusa-profiles.txt' \) \ + -mtime +0 -delete 2>/dev/null || true + +find /tmp -maxdepth 1 -type f \ + \( -name 'aw-worktime-ui-bridge.py' -o -name 'views-default.json' -o -name 'apply_webui_ru_patch.out' \) \ + -mtime +1 -delete 2>/dev/null || true diff --git a/scripts/legacy/check-aw-data.sh b/scripts/legacy/check-aw-data.sh new file mode 100644 index 0000000..b028e4f --- /dev/null +++ b/scripts/legacy/check-aw-data.sh @@ -0,0 +1,188 @@ +#!/bin/bash +# check-aw-data.sh - Проверка сбора данных ActivityWatch с RDP-сервера SHARKON2025 +# Сервер: 10.10.10.13:5600 +# Хост-источник: 192.168.100.18 (SHARKON2025) + +SERVER="http://10.10.10.13:5600" +HOSTNAME_FILTER="SHARKON2025" +NOW=$(date -u +%s) +HOST_INACTIVE=false +GUARD_HEALTHY=false + +classify_bucket_age() { + local bucket="$1" + local age_sec="$2" + + case "$bucket" in + aw-watcher-window) + if [ "$HOST_INACTIVE" = "true" ]; then + printf '%s' "${CYAN}INACTIVE${NC}" + return + fi + ;; + aw-dlp-endpoint-signals) + if [ "$HOST_INACTIVE" = "true" ] && [ "$GUARD_HEALTHY" = "true" ]; then + printf '%s' "${CYAN}INACTIVE${NC}" + return + fi + ;; + esac + + case "$bucket" in + aw-dlp-incidents|aw-dlp-review|aw-dlp-rules|aw-session-events) + if [ "$age_sec" -lt 86400 ]; then + printf '%s' "${GREEN}FRESH${NC}" + else + printf '%s' "${CYAN}EVENT-DRIVEN${NC}" + fi + ;; + *) + if [ "$age_sec" -lt 3600 ]; then + printf '%s' "${GREEN}FRESH${NC}" + elif [ "$age_sec" -lt 86400 ]; then + printf '%s' "${YELLOW}STALE${NC}" + else + printf '%s' "${RED}DEAD${NC}" + fi + ;; + esac +} + +classify_bucket_no_events() { + local bucket="$1" + + case "$bucket" in + aw-watcher-window) + if [ "$HOST_INACTIVE" = "true" ]; then + printf '%s' "${CYAN}INACTIVE${NC}" + return + fi + ;; + aw-dlp-endpoint-signals) + if [ "$HOST_INACTIVE" = "true" ] && [ "$GUARD_HEALTHY" = "true" ]; then + printf '%s' "${CYAN}INACTIVE${NC}" + return + fi + ;; + aw-dlp-incidents|aw-dlp-review|aw-dlp-rules|aw-session-events) + printf '%s' "${CYAN}EVENT-DRIVEN${NC}" + return + ;; + esac + + printf '%s' "${RED}EMPTY${NC}" +} + +# Цвета +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +NC='\033[0m' + +# Проверка доступности сервера +echo "=== ActivityWatch Data Check: $HOSTNAME_FILTER ===" +echo "" +echo -n "Server connectivity... " +RESP=$(no_proxy=10.10.10.13 curl -s --connect-timeout 10 --max-time 15 "$SERVER/api/0/info" 2>&1) +if [ $? -eq 0 ] && echo "$RESP" | jq -e '.version' > /dev/null 2>&1; then + VERSION=$(echo "$RESP" | jq -r '.version') + echo -e "${GREEN}OK${NC} (aw-server $VERSION)" +else + echo -e "${RED}FAILED${NC} (cannot reach $SERVER)" + exit 1 +fi +echo "" + +# Context for inactive/event-driven classification. +WORKTIME_EVENT_DATA=$(no_proxy=10.10.10.13 curl -s --connect-timeout 10 --max-time 15 "$SERVER/api/0/buckets/aw-worktime-sessions_$HOSTNAME_FILTER/events?limit=1" 2>&1) +WORKTIME_TS=$(echo "$WORKTIME_EVENT_DATA" | jq -r '.[0].timestamp // ""' 2>/dev/null) +WORKTIME_ACTIVE=$(echo "$WORKTIME_EVENT_DATA" | jq -r '.[0].data.active // false' 2>/dev/null) +if [ -n "$WORKTIME_TS" ]; then + WORKTIME_EPOCH=$(date -d "$WORKTIME_TS" +%s 2>/dev/null || echo 0) + if [ "$WORKTIME_EPOCH" -gt 0 ]; then + WORKTIME_AGE=$((NOW - WORKTIME_EPOCH)) + if [ "$WORKTIME_AGE" -lt 900 ] && [ "$WORKTIME_ACTIVE" != "true" ]; then + HOST_INACTIVE=true + fi + fi +fi + +GUARD_EVENT_DATA=$(no_proxy=10.10.10.13 curl -s --connect-timeout 10 --max-time 15 "$SERVER/api/0/buckets/aw-rus-collector-guard_$HOSTNAME_FILTER/events?limit=1" 2>&1) +GUARD_TS=$(echo "$GUARD_EVENT_DATA" | jq -r '.[0].timestamp // ""' 2>/dev/null) +GUARD_STATUS=$(echo "$GUARD_EVENT_DATA" | jq -r '.[0].data.status // ""' 2>/dev/null) +GUARD_PROBLEMS=$(echo "$GUARD_EVENT_DATA" | jq -r '([.[0].data.problems[]?] | length) // 0' 2>/dev/null) +if [ -n "$GUARD_TS" ]; then + GUARD_EPOCH=$(date -d "$GUARD_TS" +%s 2>/dev/null || echo 0) + if [ "$GUARD_EPOCH" -gt 0 ]; then + GUARD_AGE=$((NOW - GUARD_EPOCH)) + if [ "$GUARD_AGE" -lt 300 ] && [ "$GUARD_STATUS" = "ok" ] && [ "$GUARD_PROBLEMS" = "0" ]; then + GUARD_HEALTHY=true + fi + fi +fi + +# Проверка бакетов +echo "--- Buckets ---" +printf "%-45s %-8s %-22s %s\n" "BUCKET" "EVENTS" "LAST EVENT" "STATUS" +printf "%-45s %-8s %-22s %s\n" "---------------------------------------------" "--------" "----------------------" "------" + +BUCKETS=( + "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" +) + +for bucket in "${BUCKETS[@]}"; do + bucket_full="${bucket}_${HOSTNAME_FILTER}" + + # Получаем последний event + EVENT_DATA=$(no_proxy=10.10.10.13 curl -s --connect-timeout 10 --max-time 15 "$SERVER/api/0/buckets/$bucket_full/events?limit=1" 2>&1) + LAST_ID=$(echo "$EVENT_DATA" | jq '.[0].id // 0') + LAST_TS=$(echo "$EVENT_DATA" | jq -r '.[0].timestamp // "no events"') + + # Вычисляем возраст + if [ "$LAST_TS" != "no events" ] && [ -n "$LAST_TS" ]; then + EVENT_EPOCH=$(date -d "$LAST_TS" +%s 2>/dev/null || echo 0) + if [ "$EVENT_EPOCH" -gt 0 ]; then + AGE_SEC=$((NOW - EVENT_EPOCH)) + if [ $AGE_SEC -lt 3600 ]; then + AGE="$((AGE_SEC / 60))m ago" + elif [ $AGE_SEC -lt 86400 ]; then + AGE="$((AGE_SEC / 3600))h ago" + else + AGE="$((AGE_SEC / 86400))d ago" + fi + STATUS="$(classify_bucket_age "$bucket" "$AGE_SEC")" + else + AGE="unknown" + STATUS="${RED}?${NC}" + fi + else + AGE="none" + LAST_ID="0" + STATUS="$(classify_bucket_no_events "$bucket")" + fi + + printf "%-45s %-8s %-22s %b\n" "$bucket_full" "$LAST_ID" "$LAST_TS ($AGE)" "$STATUS" +done + +echo "" + +# Проверка CORS +echo "--- CORS Check ---" +CORS_RESP=$(no_proxy=10.10.10.13 curl -s --connect-timeout 10 --max-time 15 -o /dev/null -w '%{http_code}' -H "Origin: http://10.10.10.13:5600" "$SERVER/api/0/settings/" 2>&1) +if [ "$CORS_RESP" = "200" ]; then + echo -e "${GREEN}CORS: OK${NC} (HTTP 200)" +else + echo -e "${RED}CORS: FAIL${NC} (HTTP $CORS_RESP)" +fi + +echo "" +echo "=== Check Complete ===" +echo "Timestamp: $(date -u '+%Y-%m-%d %H:%M:%S UTC')" diff --git a/scripts/merge_aw_server_dbs.py b/scripts/merge_aw_server_dbs.py index 0426f23..9f7408f 100644 --- a/scripts/merge_aw_server_dbs.py +++ b/scripts/merge_aw_server_dbs.py @@ -2,11 +2,34 @@ import argparse import json import os -import shutil import sqlite3 +import sys from pathlib import Path +def maybe_exec_rust() -> None: + if os.environ.get("MERGE_AW_SERVER_DBS_FORCE_LEGACY") == "1": + return + script_path = Path(__file__).resolve() + repo_root = script_path.parent.parent if script_path.parent.name == "scripts" else None + candidates = [ + os.environ.get("MERGE_AW_SERVER_DBS_RUST"), + str(Path(os.environ.get("CARGO_TARGET_DIR", "")) / "release" / "merge-aw-server-dbs") + if os.environ.get("CARGO_TARGET_DIR") + else None, + str(repo_root / "adk-rust" / "target" / "release" / "merge-aw-server-dbs") + if repo_root + else None, + "/usr/local/bin/merge-aw-server-dbs", + ] + for candidate in candidates: + if candidate and os.path.isfile(candidate) and os.access(candidate, os.X_OK): + os.execv(candidate, [candidate, *sys.argv[1:]]) + + +maybe_exec_rust() + + def connect(path: Path) -> sqlite3.Connection: connection = sqlite3.connect(str(path)) connection.execute("PRAGMA journal_mode=WAL") @@ -130,43 +153,48 @@ def main() -> int: if dest_rowid is None: dest_rowid = dest_bucket_map.get(key) if dest_rowid is None: - # Use UPSERT to handle UNIQUE(name) constraint gracefully - cursor = dest.execute( - """ - INSERT INTO buckets (name, type, client, hostname, created, data_deprecated, data) - VALUES (?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(name) DO UPDATE SET - type=excluded.type, - client=excluded.client, - hostname=excluded.hostname, - created=excluded.created, - data_deprecated=excluded.data_deprecated, - data=excluded.data - WHERE rowid = (SELECT rowid FROM buckets WHERE name = ? LIMIT 1) - """, - ( - src_bucket["name"], - src_bucket["type"], - src_bucket["client"], - src_bucket["hostname"], - src_bucket["created"], - src_bucket["data_deprecated"], - src_bucket["data"], - str(src_bucket["name"]), - ), - ) - # Get the rowid of the affected bucket (either inserted or updated) - cursor.execute("SELECT last_insert_rowid(), (SELECT rowid FROM buckets WHERE name = ? LIMIT 1)", - (str(src_bucket["name"]),)) - result = cursor.fetchone() - dest_rowid = result[0] if result[0] != 0 else result[1] - - if dest_rowid: - dest_bucket_map[key] = dest_rowid - # Only count as inserted if it was a true insert (not update) - cursor.execute("SELECT changes() FROM buckets WHERE rowid = ?", (dest_rowid,)) - if cursor.fetchone()[0] > 0: - inserted_buckets += 1 + dest_rowid = find_bucket_by_name(dest, str(src_bucket["name"])) + if dest_rowid is not None: + dest.execute( + """ + UPDATE buckets + SET type = ?, client = ?, hostname = ?, created = ?, + data_deprecated = ?, data = ? + WHERE rowid = ? + """, + ( + src_bucket["type"], + src_bucket["client"], + src_bucket["hostname"], + src_bucket["created"], + src_bucket["data_deprecated"], + src_bucket["data"], + dest_rowid, + ), + ) + else: + cursor = dest.execute( + """ + INSERT INTO buckets (id, name, type, client, hostname, created, data_deprecated, data) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + src_bucket["id"], + src_bucket["name"], + src_bucket["type"], + src_bucket["client"], + src_bucket["hostname"], + src_bucket["created"], + src_bucket["data_deprecated"], + src_bucket["data"], + ), + ) + dest_rowid = int(cursor.lastrowid) + inserted_buckets += 1 + + dest_bucket_map[key] = dest_rowid + if src_id: + dest_id_map[str(src_id)] = dest_rowid existing_events = load_existing_events(dest, dest_rowid) for starttime, endtime, data in source.execute( diff --git a/scripts/metagpt-aw-scout.sh b/scripts/metagpt-aw-scout.sh new file mode 100644 index 0000000..0a764dd --- /dev/null +++ b/scripts/metagpt-aw-scout.sh @@ -0,0 +1,172 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +OUT_DIR="${METAGPT_AW_OUT_DIR:-$ROOT/.ai/metagpt}" +METAGPT_BIN="${METAGPT_BIN:-/home/igor/bin/metagpt-lab}" +METAGPT_AW_INVESTMENT="${METAGPT_AW_INVESTMENT:-0.1}" +METAGPT_AW_N_ROUND="${METAGPT_AW_N_ROUND:-2}" +METAGPT_AW_TIMEOUT="${METAGPT_AW_TIMEOUT:-90}" +METAGPT_AW_ENGINE="${METAGPT_AW_ENGINE:-direct}" +METAGPT_CONFIG="${METAGPT_CONFIG:-/home/igor/.metagpt/config2.yaml}" + +usage() { + cat <<'USAGE' +Usage: + scripts/metagpt-aw-scout.sh + +Presets: + qa-rollback QA checklist and rollback plan for Windows collectors + smoke End-to-end smoke-test plan for AW server, RDP collectors, Grafana, Influx exporters + grafana Management-facing Grafana dashboard review plan + install-kit Install-kit rebuild and validation checklist + windows-i18n Windows localized Administrator / Cyrillic collector checks + +Examples: + scripts/metagpt-aw-scout.sh qa-rollback + scripts/metagpt-aw-scout.sh "Review risk of changing aw-worktime-ui-bridge foreground cache" +USAGE +} + +if [[ "${1:-}" == "-h" || "${1:-}" == "--help" || $# -eq 0 ]]; then + usage + exit 0 +fi + +case "$1" in + qa-rollback) + TASK="Prepare a practical QA checklist and rollback plan for ActivityWatch-Russian Windows collectors after changes in PowerShell collectors, process events, localized Administrator task names, and recovery hardening." + ;; + smoke) + TASK="Prepare a minimal but complete end-to-end smoke-test plan for ActivityWatch-Russian: AW server buckets, worktime API, RDP/WinRM collectors, Windows SSH access, Grafana dashboards, Influx worktime exporter, DLP exporter, and install-kit sanity." + ;; + grafana) + TASK="Review the management-facing Grafana dashboards for ActivityWatch-Russian. Identify confusing technical labels, panels that should be hidden or renamed, and checks needed to prove 'true user work' by application." + ;; + install-kit) + TASK="Prepare a rebuild and validation checklist for install-kit-awindows, including rebuild_install_kit.sh, check_install_kit_vs_repo.sh, validate_install_kit.sh, and optional InnoSetup exe rebuild." + ;; + windows-i18n) + TASK="Prepare a validation checklist for Windows localized account names and Cyrillic handling: CP866 query.exe decoding, SHARKON2025_Администратор scheduled task, WinRM output, SSH checks, and ActivityWatch Launch fallback rules." + ;; + *) + TASK="$*" + ;; +esac + +if [[ ! -x "$METAGPT_BIN" ]]; then + echo "MetaGPT wrapper not found or not executable: $METAGPT_BIN" >&2 + exit 2 +fi + +mkdir -p "$OUT_DIR" +STAMP="$(date +%Y%m%d-%H%M%S)" +SLUG="$(printf '%s' "$TASK" | tr '[:upper:]' '[:lower:]' | tr -cs '[:alnum:]' '-' | sed 's/^-//; s/-$//; s/--*/-/g; s/^$/task/' | cut -c1-80)" +OUT="$OUT_DIR/$STAMP-$SLUG.md" + +PROMPT="$(cat < "$OUT" + +set +e +if [[ "$METAGPT_AW_ENGINE" == "team" ]]; then + timeout "$METAGPT_AW_TIMEOUT" "$METAGPT_BIN" --investment "$METAGPT_AW_INVESTMENT" --n-round "$METAGPT_AW_N_ROUND" --no-implement --project-name "aw-scout-$STAMP" "$PROMPT" 2>&1 | tee -a "$OUT" +else + /home/igor/labs/metagpt-lab/.venv/bin/python - "$METAGPT_CONFIG" "$PROMPT" <<'PY' 2>&1 | tee -a "$OUT" +import sys +from pathlib import Path + +import yaml +from openai import OpenAI + +config_path = Path(sys.argv[1]) +prompt = sys.argv[2] +cfg = yaml.safe_load(config_path.read_text(encoding="utf-8"))["llm"] +client = OpenAI( + api_key=cfg["api_key"], + base_url=(cfg.get("base_url") or "https://api.openai.com/v1").rstrip("/"), +) +response = client.chat.completions.create( + model=cfg.get("model") or "gpt-4.1-mini", + messages=[ + { + "role": "system", + "content": ( + "Ты практичный технический ревьюер ActivityWatch-Russian. " + "Отвечай по-русски, кратко, с командами и критериями проверки. " + "Не выдумывай факты, помечай непроверенное как гипотезу." + ), + }, + {"role": "user", "content": prompt}, + ], + temperature=0.2, + max_tokens=1800, +) +print(response.choices[0].message.content.strip()) +PY +fi +status=${PIPESTATUS[0]} +set -e + +{ + echo + echo "## Exit Status" + echo + echo "$status" +} >> "$OUT" + +echo +echo "Saved: $OUT" +exit "$status" diff --git a/scripts/prod_backup_restore.sh b/scripts/prod_backup_restore.sh index a731ae6..9780df2 100644 --- a/scripts/prod_backup_restore.sh +++ b/scripts/prod_backup_restore.sh @@ -11,6 +11,29 @@ if [[ -f "${ROOT_DIR}/secrets/runtime.env" ]]; then set +a fi +if [[ "${1:-}" == "--apply-legacy" ]]; then + shift +else + for candidate in \ + "${PROD_BACKUP_RESTORE_RUST:-}" \ + "${CARGO_TARGET_DIR:-}/release/prod-backup-restore" \ + "$ROOT_DIR/adk-rust/target/release/prod-backup-restore" \ + "/usr/local/bin/prod-backup-restore"; do + if [[ -n "$candidate" && -x "$candidate" ]]; then + exec "$candidate" --root "$ROOT_DIR" "$@" + fi + done + cat >&2 <<'EOF' +prod_backup_restore.sh is destructive and now requires the Rust planner. +Build it first: + cd adk-rust && cargo build --release -p prod-backup-restore + +To run the old destructive restore explicitly: + scripts/prod_backup_restore.sh --apply-legacy +EOF + exit 2 +fi + : "${AW_SSH_PASSWORD:?AW_SSH_PASSWORD is required}" : "${AW_WINRM_PASSWORD:?AW_WINRM_PASSWORD is required}" diff --git a/scripts/prod_rollout.sh b/scripts/prod_rollout.sh index 61583f7..9c0f636 100644 --- a/scripts/prod_rollout.sh +++ b/scripts/prod_rollout.sh @@ -4,6 +4,43 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$ROOT_DIR" +if [[ -f "${ROOT_DIR}/secrets/runtime.env" ]]; then + set -a + # shellcheck disable=SC1091 + source "${ROOT_DIR}/secrets/runtime.env" + set +a +fi + +if [[ "${1:-}" == "--apply-legacy" ]]; then + shift +else + TARGET_ROOT="${CARGO_TARGET_DIR:-$ROOT_DIR/adk-rust/target}" + for candidate in \ + "${PROD_ROLLOUT_RUST:-}" \ + "$TARGET_ROOT/release/prod-rollout" \ + "$ROOT_DIR/adk-rust/target/release/prod-rollout" \ + "/usr/local/bin/prod-rollout"; do + if [[ -n "$candidate" && -x "$candidate" ]]; then + exec "$candidate" --root "$ROOT_DIR" "$@" + fi + done + cat >&2 <<'EOF' +prod_rollout.sh now requires the Rust planner/orchestrator for safe default runs. +Build it first: + cd adk-rust && cargo build --release -p prod-rollout + +Safe checks: + scripts/prod_rollout.sh --check-inputs --json + +Explicit Rust rollout: + scripts/prod_rollout.sh --apply + +Old Bash rollout: + scripts/prod_rollout.sh --apply-legacy +EOF + exit 2 +fi + timestamp() { date +"%Y%m%d-%H%M%S"; } LOG_DIR="${ROOT_DIR}/.rollout-logs/$(timestamp)" diff --git a/scripts/quality-gate.sh b/scripts/quality-gate.sh index 3b88862..7a4bcf4 100755 --- a/scripts/quality-gate.sh +++ b/scripts/quality-gate.sh @@ -4,17 +4,46 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$ROOT_DIR" -echo "[1/4] Bash syntax check" -find aw-server proxmox -type f -name "*.sh" -print0 | xargs -0 -r -n1 bash -n +TARGET_ROOT="${CARGO_TARGET_DIR:-$ROOT_DIR/adk-rust/target}" +RUST_BIN="${QUALITY_GATE_RUST:-}" -echo "[2/4] Shellcheck (if available)" +rust_candidates=() +if [[ -n "$RUST_BIN" ]]; then + rust_candidates+=("$RUST_BIN") +fi +rust_candidates+=( + "$TARGET_ROOT/release/quality-gate" + "$ROOT_DIR/adk-rust/target/release/quality-gate" + "/usr/local/bin/quality-gate" +) + +for candidate in "${rust_candidates[@]}"; do + if [[ -x "$candidate" ]]; then + exec "$candidate" --root "$ROOT_DIR" "$@" + fi +done + +echo "[1/5] Bash syntax check" +find aw-server proxmox scripts -type f -name "*.sh" -print0 | xargs -0 -r -n1 bash -n + +echo "[2/5] Shellcheck (if available)" if command -v shellcheck >/dev/null 2>&1; then - find aw-server proxmox -type f -name "*.sh" -print0 | xargs -0 -r shellcheck -e SC1007,SC1090,SC2016 + { + find aw-server proxmox -type f -name "*.sh" + printf '%s\n' scripts/aw-webui-browser-smoke.sh + } | xargs -r shellcheck -e SC1007,SC1090,SC2016 else echo "shellcheck not found, skipping." fi -echo "[3/4] PowerShell parse check (if pwsh available)" +echo "[3/5] Node syntax check (if node available)" +if command -v node >/dev/null 2>&1; then + node --check scripts/aw-webui-browser-smoke.mjs >/dev/null +else + echo "node not found, skipping." +fi + +echo "[4/5] PowerShell parse check (if pwsh available)" if command -v pwsh >/dev/null 2>&1; then pwsh -NoLogo -NoProfile -Command ' $ErrorActionPreference = "Stop" @@ -24,13 +53,14 @@ if command -v pwsh >/dev/null 2>&1; then [void][System.Management.Automation.Language.Parser]::ParseFile((Resolve-Path "windows/ActivityWatch.Windows.Common.psm1"),[ref]$null,[ref]$null) [void][System.Management.Automation.Language.Parser]::ParseFile((Resolve-Path "windows/ActivityWatch.Windows.Common.psd1"),[ref]$null,[ref]$null) ' + pwsh -NoLogo -NoProfile -File windows/aw-collector-guard.ps1 -SelfTest >/dev/null else echo "pwsh not found, skipping." fi -echo "[4/4] Ansible syntax check (if ansible-playbook available)" +echo "[5/5] Ansible syntax check (if ansible-playbook available)" if command -v ansible-playbook >/dev/null 2>&1; then for playbook in ansible/*.yml; do ansible-playbook --syntax-check "$playbook" -i ansible/inventory.example.ini >/dev/null diff --git a/scripts/rdp-worktime-report.sh b/scripts/rdp-worktime-report.sh index b56b55c..371ba0e 100644 --- a/scripts/rdp-worktime-report.sh +++ b/scripts/rdp-worktime-report.sh @@ -9,6 +9,8 @@ AW_WORKTIME_HOST="${AW_WORKTIME_HOST:-SHARKON2025}" AW_WORKTIME_DEFAULT_SAMPLE_SECONDS="${AW_WORKTIME_DEFAULT_SAMPLE_SECONDS:-30}" AW_WORKTIME_MAX_SAMPLE_SECONDS="${AW_WORKTIME_MAX_SAMPLE_SECONDS:-300}" OUT_DIR="${OUT_DIR:-reports}" +TARGET_ROOT="${CARGO_TARGET_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/adk-rust/target}" +RUST_BIN="${RDP_WORKTIME_REPORT_RUST:-}" usage() { cat </dev/null; then @@ -104,6 +147,7 @@ Includes: - windows/* (deploy scripts, collectors, common module, configs/examples) - ansible/* (Windows and AW server playbooks, examples, inventory, tasks) - aw-server/* (server installer, health orchestrator, RU patch loader, host groups, default settings) +- scripts/* (install-kit rebuild/validation, quality gates and browser/web smoke checks) - server-configs-192.168.100.18/* (working Windows/RDP config snapshots) Source: diff --git a/scripts/test_dlp_health_check.py b/scripts/test_dlp_health_check.py new file mode 100644 index 0000000..13ad931 --- /dev/null +++ b/scripts/test_dlp_health_check.py @@ -0,0 +1,421 @@ +#!/usr/bin/env python3 +import importlib.util +import sys +from pathlib import Path + + +MODULE_PATH = Path(__file__).with_name("dlp-health-check.py") +SPEC = importlib.util.spec_from_file_location("dlp_health_check", MODULE_PATH) +MODULE = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = MODULE +SPEC.loader.exec_module(MODULE) + + +def test_endpoint_signals_ok_when_no_active_managed_hosts(monkeypatch): + buckets = { + "aw-worktime-sessions_SHARKON2025": {"metadata": {"end": "2026-05-30T12:00:00Z"}}, + "aw-dlp-endpoint-signals_SHARKON2025": {"metadata": {"end": "2026-05-30T07:00:00Z"}}, + } + monkeypatch.setattr(MODULE, "_now_utc", lambda: MODULE._parse_ts("2026-05-30T12:00:10Z")) + monkeypatch.setattr( + MODULE, + "_http_json", + lambda url: [{"timestamp": "2026-05-30T12:00:00Z", "data": {"active": False}}] + if "aw-worktime-sessions_SHARKON2025/events" in url + else [], + ) + report = MODULE.HealthReport() + + MODULE.check_endpoint_signal_buckets(report, "http://127.0.0.1:5600/api/0", buckets, 900) + + result = report.results[0] + assert result.name == "buckets:endpoint-signals" + assert result.status == "ok" + assert result.summary == "no active managed hosts require endpoint-signals freshness" + assert result.details["ignored_inactive"] == ["aw-dlp-endpoint-signals_SHARKON2025"] + + +def test_endpoint_self_test_metrics_reports_transport_counters(monkeypatch): + buckets = {"aw-dlp-endpoint-signals_SHARKON2025": {"metadata": {"end": "2026-05-30T12:00:00Z"}}} + monkeypatch.setattr(MODULE, "_now_utc", lambda: MODULE._parse_ts("2026-05-30T12:01:00Z")) + monkeypatch.setattr( + MODULE, + "_http_json", + lambda url, **kwargs: [ + { + "timestamp": "2026-05-30T11:59:00Z", + "data": { + "signalType": "self_test", + "queueDepth": 8, + "eventsEnqueued": 100, + "eventsFlushed": 92, + "sendFailures": 0, + }, + }, + { + "timestamp": "2026-05-30T12:00:30Z", + "data": { + "signalType": "self_test", + "queueDepth": "3", + "eventsEnqueued": "120", + "eventsFlushed": "117", + "sendFailures": "0", + }, + }, + ], + ) + report = MODULE.HealthReport() + + MODULE.check_endpoint_self_test_metrics(report, "http://127.0.0.1:5600/api/0", buckets) + + result = report.results[0] + assert result.name == "endpoint-self-test-metrics" + assert result.status == "ok" + latest = result.details["latest_self_tests"][0] + assert latest["bucket"] == "aw-dlp-endpoint-signals_SHARKON2025" + assert latest["timestamp"] == "2026-05-30T12:00:30Z" + assert latest["age_seconds"] == 30 + assert latest["queueDepth"] == 3 + assert latest["eventsEnqueued"] == 120 + assert latest["eventsFlushed"] == 117 + assert latest["sendFailures"] == 0 + assert latest["sendFailuresDelta"] == 0 + + +def test_endpoint_self_test_metrics_warns_on_transport_counters(monkeypatch): + buckets = {"aw-dlp-endpoint-signals_SHARKON2025": {"metadata": {"end": "2026-05-30T12:00:00Z"}}} + monkeypatch.setattr(MODULE, "_now_utc", lambda: MODULE._parse_ts("2026-05-30T12:01:00Z")) + monkeypatch.setattr( + MODULE, + "_http_json", + lambda url, **kwargs: [ + { + "timestamp": "2026-05-30T12:00:30Z", + "data": { + "signalType": "self_test", + "queueDepth": 101, + "eventsEnqueued": 120, + "eventsFlushed": 19, + "sendFailures": 1, + }, + } + ], + ) + report = MODULE.HealthReport() + + MODULE.check_endpoint_self_test_metrics( + report, + "http://127.0.0.1:5600/api/0", + buckets, + queue_warn_depth=100, + send_failure_warn_count=1, + ) + + result = report.results[0] + assert result.status == "warn" + assert result.summary == "endpoint transport counters outside thresholds" + assert result.details["warnings"] == [ + {"bucket": "aw-dlp-endpoint-signals_SHARKON2025", "metric": "queueDepth", "value": 101, "threshold": 100}, + { + "bucket": "aw-dlp-endpoint-signals_SHARKON2025", + "metric": "sendFailuresDelta", + "value": 1, + "current": 1, + "previous": None, + "threshold": 1, + }, + ] + + +def test_file_operations_runtime_reports_health_and_latest_operations(monkeypatch): + buckets = {"aw-file-operations_SHARKON2025": {"metadata": {"end": "2026-05-30T12:00:00Z"}}} + monkeypatch.setattr(MODULE, "_now_utc", lambda: MODULE._parse_ts("2026-05-30T12:01:00Z")) + monkeypatch.setattr( + MODULE, + "_http_json", + lambda url: [ + { + "timestamp": "2026-05-30T12:00:30Z", + "data": { + "signalType": "collector_health", + "queueDepth": "0", + "eventsEnqueued": "5", + "eventsFlushed": "5", + "sendFailures": "0", + "username": "USER1", + "hostname": "SHARKON2025", + "sessionId": "3", + }, + }, + { + "timestamp": "2026-05-30T12:00:20Z", + "data": { + "operation": "Created", + "path": "C:\\Users\\USER1\\Downloads\\report.zip", + "extension": ".zip", + "archiveHint": True, + "username": "USER1", + "hostname": "SHARKON2025", + "size": "42", + }, + }, + ], + ) + report = MODULE.HealthReport() + + MODULE.check_file_operations_runtime(report, "http://127.0.0.1:5600/api/0", buckets) + + result = report.results[0] + assert result.name == "file-operations-runtime" + assert result.status == "ok" + assert result.details["latest_health"][0]["queueDepth"] == 0 + assert result.details["latest_health"][0]["eventsEnqueued"] == 5 + assert result.details["latest_health"][0]["sendFailuresDelta"] == 0 + latest_op = result.details["latest_operations"][0] + assert latest_op["operation"] == "Created" + assert latest_op["extension"] == ".zip" + assert latest_op["archiveHint"] is True + assert latest_op["path_tail"] == "Downloads/report.zip" + assert latest_op["size"] == 42 + assert result.details["sampled"][0]["operation_counts"] == {"Created": 1} + + +def test_file_operations_runtime_warns_on_transport_counters(monkeypatch): + buckets = {"aw-file-operations_SHARKON2025": {"metadata": {"end": "2026-05-30T12:00:00Z"}}} + monkeypatch.setattr(MODULE, "_now_utc", lambda: MODULE._parse_ts("2026-05-30T12:01:00Z")) + monkeypatch.setattr( + MODULE, + "_http_json", + lambda url: [ + { + "timestamp": "2026-05-30T12:00:30Z", + "data": { + "signalType": "collector_health", + "queueDepth": 101, + "eventsEnqueued": 5, + "eventsFlushed": 2, + "sendFailures": 1, + "username": "USER1", + "hostname": "SHARKON2025", + "sessionId": 3, + }, + } + ], + ) + report = MODULE.HealthReport() + + MODULE.check_file_operations_runtime( + report, + "http://127.0.0.1:5600/api/0", + buckets, + queue_warn_depth=100, + send_failure_warn_count=1, + ) + + result = report.results[0] + assert result.status == "warn" + assert result.summary == "file-operations runtime counters outside expectations" + assert result.details["warnings"] == [ + {"bucket": "aw-file-operations_SHARKON2025", "metric": "queueDepth", "value": 101, "threshold": 100}, + { + "bucket": "aw-file-operations_SHARKON2025", + "metric": "sendFailuresDelta", + "value": 1, + "current": 1, + "previous": None, + "threshold": 1, + }, + ] + + +def test_endpoint_send_failures_uses_delta_baseline(monkeypatch): + buckets = {"aw-dlp-endpoint-signals_SHARKON2025": {"metadata": {"end": "2026-05-30T12:00:00Z"}}} + state = {"counters": {}} + monkeypatch.setattr(MODULE, "_now_utc", lambda: MODULE._parse_ts("2026-05-30T12:01:00Z")) + values = [12, 12, 13] + + def fake_http(url, **kwargs): + value = values.pop(0) + return [ + { + "timestamp": "2026-05-30T12:00:30Z", + "data": { + "signalType": "self_test", + "queueDepth": 0, + "eventsEnqueued": 120, + "eventsFlushed": 117, + "sendFailures": value, + }, + } + ] + + monkeypatch.setattr(MODULE, "_http_json", fake_http) + + first = MODULE.HealthReport() + MODULE.check_endpoint_self_test_metrics(first, "http://127.0.0.1:5600/api/0", buckets, counter_state=state) + assert first.results[0].status == "ok" + assert first.results[0].details["latest_self_tests"][0]["sendFailuresPrevious"] is None + assert first.results[0].details["latest_self_tests"][0]["sendFailuresDelta"] == 0 + + second = MODULE.HealthReport() + MODULE.check_endpoint_self_test_metrics(second, "http://127.0.0.1:5600/api/0", buckets, counter_state=state) + assert second.results[0].status == "ok" + assert second.results[0].details["latest_self_tests"][0]["sendFailuresPrevious"] == 12 + assert second.results[0].details["latest_self_tests"][0]["sendFailuresDelta"] == 0 + + third = MODULE.HealthReport() + MODULE.check_endpoint_self_test_metrics(third, "http://127.0.0.1:5600/api/0", buckets, counter_state=state) + assert third.results[0].status == "warn" + assert third.results[0].details["warnings"] == [ + { + "bucket": "aw-dlp-endpoint-signals_SHARKON2025", + "metric": "sendFailuresDelta", + "value": 1, + "current": 13, + "previous": 12, + "threshold": 1, + } + ] + + +def test_file_operations_send_failures_uses_delta_baseline(monkeypatch): + buckets = {"aw-file-operations_SHARKON2025": {"metadata": {"end": "2026-05-30T12:00:00Z"}}} + state = {"counters": {}} + monkeypatch.setattr(MODULE, "_now_utc", lambda: MODULE._parse_ts("2026-05-30T12:01:00Z")) + values = [28, 28, 29] + + def fake_http(url, **kwargs): + value = values.pop(0) + return [ + { + "timestamp": "2026-05-30T12:00:30Z", + "data": { + "signalType": "collector_health", + "queueDepth": 0, + "eventsEnqueued": 120, + "eventsFlushed": 117, + "sendFailures": value, + }, + } + ] + + monkeypatch.setattr(MODULE, "_http_json", fake_http) + + first = MODULE.HealthReport() + MODULE.check_file_operations_runtime(first, "http://127.0.0.1:5600/api/0", buckets, counter_state=state) + assert first.results[0].status == "ok" + assert first.results[0].details["latest_health"][0]["sendFailuresPrevious"] is None + assert first.results[0].details["latest_health"][0]["sendFailuresDelta"] == 0 + + second = MODULE.HealthReport() + MODULE.check_file_operations_runtime(second, "http://127.0.0.1:5600/api/0", buckets, counter_state=state) + assert second.results[0].status == "ok" + assert second.results[0].details["latest_health"][0]["sendFailuresPrevious"] == 28 + assert second.results[0].details["latest_health"][0]["sendFailuresDelta"] == 0 + + third = MODULE.HealthReport() + MODULE.check_file_operations_runtime(third, "http://127.0.0.1:5600/api/0", buckets, counter_state=state) + assert third.results[0].status == "warn" + assert third.results[0].details["warnings"] == [ + { + "bucket": "aw-file-operations_SHARKON2025", + "metric": "sendFailuresDelta", + "value": 1, + "current": 29, + "previous": 28, + "threshold": 1, + } + ] + + +def test_incident_runtime_reports_counts_and_latest_real_incidents(monkeypatch): + buckets = {"aw-dlp-incidents_SHARKON2025": {"metadata": {"end": "2026-05-30T12:00:00Z"}}} + monkeypatch.setattr(MODULE, "_now_utc", lambda: MODULE._parse_ts("2026-05-30T12:01:00Z")) + monkeypatch.setattr( + MODULE, + "_http_json", + lambda url, **kwargs: [ + { + "timestamp": "2026-05-30T12:00:30Z", + "data": { + "ruleId": "usb-archive-copy", + "severity": "high", + "action": "alert", + "message": "Archive copied to removable device with a long message that should not leak full raw content.", + "username": "USER1", + "hostname": "SHARKON2025", + "source": "endpoint", + }, + }, + { + "timestamp": "2026-05-30T12:00:00Z", + "data": { + "ruleId": "selftest-dlp-incident", + "severity": "low", + "action": "alert", + "message": "Self-test DLP incident from validation", + "signalType": "self_test", + "source": "self-test", + }, + }, + ], + ) + report = MODULE.HealthReport() + + MODULE.check_incident_runtime(report, "http://127.0.0.1:5600/api/0", buckets, sample_limit=20) + + result = report.results[0] + assert result.name == "incident-runtime" + assert result.status == "ok" + assert result.summary == "1 real incidents in sampled events" + assert result.details["totals"] == {"sampled_events": 2, "real_incidents": 1, "self_tests": 1} + assert result.details["severity_counts"] == {"high": 1} + assert result.details["action_counts"] == {"alert": 1} + assert result.details["rule_counts"] == {"usb-archive-copy": 1} + latest = result.details["latest_incidents"][0] + assert latest["age_seconds"] == 30 + assert latest["ruleId"] == "usb-archive-copy" + assert latest["message_excerpt"].endswith(".") + + +def test_incident_runtime_defaults_to_metadata_without_event_sampling(monkeypatch): + buckets = {"aw-dlp-incidents_SHARKON2025": {"metadata": {"end": "2026-05-30T12:00:00Z"}}} + monkeypatch.setattr(MODULE, "_now_utc", lambda: MODULE._parse_ts("2026-05-30T12:01:00Z")) + + def fail_if_called(url, **kwargs): + raise AssertionError("event sampling should stay disabled") + + monkeypatch.setattr(MODULE, "_http_json", fail_if_called) + report = MODULE.HealthReport() + + MODULE.check_incident_runtime(report, "http://127.0.0.1:5600/api/0", buckets, sample_limit=0) + + result = report.results[0] + assert result.name == "incident-runtime" + assert result.status == "ok" + assert result.summary == "incident event sampling disabled" + assert result.details["metadata"] == [ + { + "bucket": "aw-dlp-incidents_SHARKON2025", + "end": "2026-05-30T12:00:00Z", + "age_seconds": 60, + } + ] + + +def test_incident_runtime_warns_on_read_failure(monkeypatch): + buckets = {"aw-dlp-incidents_SHARKON2025": {"metadata": {"end": "2026-05-30T12:00:00Z"}}} + + def fail_http(url, **kwargs): + raise RuntimeError("timeout") + + monkeypatch.setattr(MODULE, "_http_json", fail_http) + report = MODULE.HealthReport() + + MODULE.check_incident_runtime(report, "http://127.0.0.1:5600/api/0", buckets, sample_limit=20) + + result = report.results[0] + assert result.name == "incident-runtime" + assert result.status == "warn" + assert result.summary == "1 incident buckets failed to sample" + assert result.details["read_failed"] == [{"bucket": "aw-dlp-incidents_SHARKON2025", "error": "timeout"}] diff --git a/scripts/validate_install_kit.sh b/scripts/validate_install_kit.sh index 0c0e037..94b924a 100755 --- a/scripts/validate_install_kit.sh +++ b/scripts/validate_install_kit.sh @@ -8,6 +8,29 @@ KIT_DIR="install-kit-awindows-20260427-211240" MANIFEST="$KIT_DIR/MANIFEST.txt" ZIP_ARCHIVE="install-kit-awindows-20260427-211240.zip" TAR_ARCHIVE="install-kit-awindows-20260427-211240.tar.gz" +TARGET_ROOT="${CARGO_TARGET_DIR:-$ROOT_DIR/adk-rust/target}" +RUST_BIN="${VALIDATE_INSTALL_KIT_RUST:-}" + +rust_candidates=() +if [[ -n "$RUST_BIN" ]]; then + rust_candidates+=("$RUST_BIN") +fi +rust_candidates+=( + "$TARGET_ROOT/release/validate-install-kit" + "$ROOT_DIR/adk-rust/target/release/validate-install-kit" + "/usr/local/bin/validate-install-kit" +) + +for candidate in "${rust_candidates[@]}"; do + if [[ -x "$candidate" ]]; then + exec "$candidate" \ + --root "$ROOT_DIR" \ + --kit-dir "$KIT_DIR" \ + --zip-archive "$ZIP_ARCHIVE" \ + --tar-archive "$TAR_ARCHIVE" \ + "$@" + fi +done required_files=( "$MANIFEST" @@ -16,6 +39,10 @@ required_files=( "$KIT_DIR/windows/validate-deployment.ps1" "$KIT_DIR/ansible/deploy_aw_windows.yml" "$KIT_DIR/aw-server/install_aw_server.sh" + "$KIT_DIR/scripts/rebuild_install_kit.sh" + "$KIT_DIR/scripts/validate_install_kit.sh" + "$KIT_DIR/scripts/check_install_kit_vs_repo.sh" + "$KIT_DIR/scripts/quality-gate.sh" ) echo "[1/4] Required files presence" diff --git a/scripts/verify_innosetup_installer.sh b/scripts/verify_innosetup_installer.sh new file mode 100644 index 0000000..fb89657 --- /dev/null +++ b/scripts/verify_innosetup_installer.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +INSTALLER="${1:-windows/installkit/innosetup/AWatch-rus-InstallKit.exe}" +WINEPREFIX_VERIFY="${WINEPREFIX_VERIFY:-/tmp/aw-inno-verify-wineprefix}" +TARGET_ROOT="${CARGO_TARGET_DIR:-$ROOT_DIR/adk-rust/target}" +RUST_BIN="${VERIFY_INNOSETUP_INSTALLER_RUST:-}" +INSTALL_DIR_WIN='C:\AWatchRusExtract' +INSTALL_DIR_UNIX="${WINEPREFIX_VERIFY}/drive_c/AWatchRusExtract" + +rust_candidates=() +if [[ -n "$RUST_BIN" ]]; then + rust_candidates+=("$RUST_BIN") +fi +rust_candidates+=( + "$TARGET_ROOT/release/verify-innosetup-installer" + "$ROOT_DIR/adk-rust/target/release/verify-innosetup-installer" + "/usr/local/bin/verify-innosetup-installer" +) + +for candidate in "${rust_candidates[@]}"; do + if [[ -x "$candidate" ]]; then + exec "$candidate" "$INSTALLER" --root "$ROOT_DIR" --wineprefix "$WINEPREFIX_VERIFY" "${@:2}" + fi +done + +if [[ ! -f "$INSTALLER" ]]; then + echo "Installer not found: $INSTALLER" >&2 + exit 1 +fi + +if ! command -v wine >/dev/null 2>&1; then + echo "wine not found" >&2 + exit 1 +fi + +rm -rf "$WINEPREFIX_VERIFY" +mkdir -p "$WINEPREFIX_VERIFY" +export WINEPREFIX="$WINEPREFIX_VERIFY" +export WINEDEBUG="${WINEDEBUG:--all}" + +wineboot -u >/dev/null 2>&1 +wine "$INSTALLER" /VERYSILENT /SUPPRESSMSGBOXES /NORESTART /SP- /TASKS="" /DIR="$INSTALL_DIR_WIN" >/dev/null 2>&1 +wineserver -w >/dev/null 2>&1 + +required_files=( + windows/AWatchRusCollectorGuardService.cs + windows/aw-collector-guard.ps1 + windows/install-collector-guard-service.ps1 + windows/dlp-policy.native-cross-os.example.json +) + +for rel in "${required_files[@]}"; do + extracted="${INSTALL_DIR_UNIX}/${rel}" + if [[ ! -f "$extracted" ]]; then + echo "Missing extracted file: $rel" >&2 + exit 1 + fi + if ! cmp -s "$rel" "$extracted"; then + echo "Extracted file differs from repo: $rel" >&2 + exit 1 + fi +done + +if ! grep -q 'collector guard self-test OK' "${INSTALL_DIR_UNIX}/windows/aw-collector-guard.ps1"; then + echo "Guard self-test marker missing in extracted installer payload" >&2 + exit 1 +fi + +echo "verify_innosetup_installer: OK" diff --git a/windows/AWatchRusCollectorGuardService.cs b/windows/AWatchRusCollectorGuardService.cs new file mode 100644 index 0000000..efcbdd1 --- /dev/null +++ b/windows/AWatchRusCollectorGuardService.cs @@ -0,0 +1,123 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.ServiceProcess; + +namespace AWatchRus +{ + public sealed class CollectorGuardService : ServiceBase + { + private Process child; + private readonly ServiceOptions options; + + public CollectorGuardService(ServiceOptions options) + { + this.options = options; + ServiceName = options.ServiceName; + CanStop = true; + CanShutdown = true; + } + + protected override void OnStart(string[] args) + { + Directory.CreateDirectory(Path.GetDirectoryName(options.LogPath)); + File.AppendAllText(options.LogPath, DateTime.Now.ToString("s") + " service starting" + Environment.NewLine); + + var psi = new ProcessStartInfo + { + FileName = options.PowerShellPath, + Arguments = string.Format( + "-NoProfile -ExecutionPolicy Bypass -File \"{0}\" -ConfigPath \"{1}\" -Mode {2} -LoopSeconds {3}", + options.ScriptPath, + options.ConfigPath, + options.Mode, + options.LoopSeconds), + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = false, + RedirectStandardError = false, + }; + child = Process.Start(psi); + File.AppendAllText(options.LogPath, DateTime.Now.ToString("s") + " child pid=" + child.Id + Environment.NewLine); + } + + protected override void OnStop() + { + StopChild("service stopping"); + } + + protected override void OnShutdown() + { + StopChild("system shutdown"); + } + + private void StopChild(string reason) + { + try + { + File.AppendAllText(options.LogPath, DateTime.Now.ToString("s") + " " + reason + Environment.NewLine); + if (child != null && !child.HasExited) + { + child.Kill(); + child.WaitForExit(10000); + } + } + catch (Exception ex) + { + try + { + File.AppendAllText(options.LogPath, DateTime.Now.ToString("s") + " stop error: " + ex.Message + Environment.NewLine); + } + catch + { + } + } + } + } + + public sealed class ServiceOptions + { + public string ServiceName = "AWatchRusCollectorGuard"; + public string PowerShellPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Windows), "System32\\WindowsPowerShell\\v1.0\\powershell.exe"); + public string ScriptPath = @"C:\Program Files\AWatch-rus\windows\aw-collector-guard.ps1"; + public string ConfigPath = @"C:\ProgramData\AWatch-rus\deployment-config.json"; + public string Mode = "shadow"; + public int LoopSeconds = 60; + public string LogPath = @"C:\ProgramData\AWatch-rus\logs\collector-guard-service.log"; + } + + internal static class Program + { + private static void Main(string[] args) + { + var options = Parse(args); + ServiceBase.Run(new CollectorGuardService(options)); + } + + private static ServiceOptions Parse(string[] args) + { + var options = new ServiceOptions(); + for (var i = 0; i < args.Length; i++) + { + var key = args[i].ToLowerInvariant(); + var value = i + 1 < args.Length ? args[i + 1] : null; + if (value == null || value.StartsWith("--", StringComparison.Ordinal)) + { + continue; + } + if (key == "--service-name") options.ServiceName = value; + else if (key == "--script") options.ScriptPath = value; + else if (key == "--config") options.ConfigPath = value; + else if (key == "--mode") options.Mode = value; + else if (key == "--loop") + { + int parsed; + if (int.TryParse(value, out parsed)) options.LoopSeconds = parsed; + } + else if (key == "--log") options.LogPath = value; + i++; + } + return options; + } + } +} diff --git a/windows/ActivityWatch.Windows.Common.psm1 b/windows/ActivityWatch.Windows.Common.psm1 index 3c68781..053a497 100755 --- a/windows/ActivityWatch.Windows.Common.psm1 +++ b/windows/ActivityWatch.Windows.Common.psm1 @@ -1,4 +1,4 @@ -Set-StrictMode -Version Latest +Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $script:ActivityWatchBuiltInAdministratorName = $null @@ -431,6 +431,22 @@ function Get-ActivityWatchTaskNameToken { return $buffer.ToString().Trim('_') } +function Test-ActivityWatchScheduledTaskExistsExact { + param([string]$TaskName) + + if ([string]::IsNullOrWhiteSpace($TaskName)) { + return $false + } + + try { + & schtasks.exe /Query /TN $TaskName *> $null + return ($LASTEXITCODE -eq 0) + } + catch { + return $false + } +} + function New-ActivityWatchUserTaskDefinitions { param( [Parameter(Mandatory = $true)] @@ -633,6 +649,119 @@ function Test-ActivityWatchUserHasLiveSession { return $false } +function Test-ActivityWatchSessionMatchesUserId { + param( + [Parameter(Mandatory = $true)] + [object]$SessionRecord, + [Parameter(Mandatory = $true)] + [string]$UserId + ) + + if ([string]::IsNullOrWhiteSpace($UserId) -or $null -eq $SessionRecord) { + return $false + } + + $sessionUser = [string]$SessionRecord.UserName + if ([string]::IsNullOrWhiteSpace($sessionUser)) { + return $false + } + + foreach ($candidate in @(Resolve-ActivityWatchUserCandidates -UserId $UserId)) { + if ($sessionUser -ieq $candidate -or + ('{0}\{1}' -f $env:COMPUTERNAME, $sessionUser) -ieq $candidate -or + ((-not [string]::IsNullOrWhiteSpace($env:USERDOMAIN)) -and ('{0}\{1}' -f $env:USERDOMAIN, $sessionUser) -ieq $candidate)) { + return $true + } + } + + return $false +} + +function Test-ActivityWatchUserHasManagedSession { + param( + [Parameter(Mandatory = $true)] + [string]$UserId, + [object[]]$SessionRecords, + [switch]$IncludeLive, + [switch]$IncludeDisconnected + ) + + foreach ($session in @($SessionRecords)) { + if ([int]$session.SessionId -le 0) { + continue + } + if ([string]::IsNullOrWhiteSpace([string]$session.UserName)) { + continue + } + if ([bool]$session.IsLive -and -not $IncludeLive.IsPresent) { + continue + } + if (-not [bool]$session.IsLive -and -not $IncludeDisconnected.IsPresent) { + continue + } + if (Test-ActivityWatchSessionMatchesUserId -SessionRecord $session -UserId $UserId) { + return $true + } + } + + return $false +} + +function Get-ActivityWatchManagedInteractiveSessions { + param( + [pscustomobject[]]$TaskDefinitions, + [object[]]$SessionRecords, + [switch]$IncludeLive, + [switch]$IncludeDisconnected + ) + + $result = New-Object System.Collections.Generic.List[object] + $seen = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) + + foreach ($taskDef in @($TaskDefinitions)) { + $userId = [string]$taskDef.userId + $taskName = [string]$taskDef.taskName + if ([string]::IsNullOrWhiteSpace($userId) -or [string]::IsNullOrWhiteSpace($taskName)) { + continue + } + + foreach ($session in @($SessionRecords)) { + if ([int]$session.SessionId -le 0) { + continue + } + if ([string]::IsNullOrWhiteSpace([string]$session.UserName)) { + continue + } + if ([bool]$session.IsLive -and -not $IncludeLive.IsPresent) { + continue + } + if (-not [bool]$session.IsLive -and -not $IncludeDisconnected.IsPresent) { + continue + } + if (-not (Test-ActivityWatchSessionMatchesUserId -SessionRecord $session -UserId $userId)) { + continue + } + + $key = '{0}|{1}|{2}' -f $taskName, [int]$session.SessionId, $userId + if (-not $seen.Add($key)) { + continue + } + + $result.Add([pscustomobject]@{ + TaskName = $taskName + UserId = $userId + SessionName = [string]$session.SessionName + SessionId = [int]$session.SessionId + State = [string]$session.State + UserName = [string]$session.UserName + IsLive = [bool]$session.IsLive + }) | Out-Null + } + } + + return @($result.ToArray()) +} + function Copy-ActivityWatchCollectorAssets { param( [Parameter(Mandatory = $true)] @@ -777,7 +906,7 @@ function New-ActivityWatchDeploymentConfig { [int]$EvtxRetentionDays = 14, [string[]]$EvtxChannels = @(), [bool]$LogonMarkerEnabled = $true, - [bool]$ProcessEventsEnabled = $true, + [bool]$ProcessEventsEnabled = $false, [Parameter(Mandatory = $true)] [string]$LaunchScriptPath, [Parameter(Mandatory = $true)] @@ -1410,6 +1539,10 @@ function Get-ActivityWatchRecoveryTaskDefinitions { foreach ($task in @($config.userTasks)) { $taskName = [string]$task.launchTaskName $userId = Normalize-ActivityWatchUserId -UserId ([string]$task.userId) + $canonicalTaskName = "ActivityWatch Launch [$((Get-ActivityWatchTaskNameToken -UserId $userId))]" + if ($canonicalTaskName -ne $taskName -and (Test-ActivityWatchScheduledTaskExistsExact -TaskName $canonicalTaskName)) { + $taskName = $canonicalTaskName + } if (-not [string]::IsNullOrWhiteSpace($taskName) -and -not $taskMap.Contains($taskName)) { $taskMap[$taskName] = [pscustomobject]@{ taskName = $taskName @@ -1679,13 +1812,24 @@ function Stop-ActivityWatchProcessesInNonLiveSessions { [Parameter(Mandatory = $true)] [object[]]$SessionRecords, [Parameter(Mandatory = $true)] - [pscustomobject]$Config + [pscustomobject]$Config, + [pscustomobject[]]$TaskDefinitions = @(), + [switch]$PreserveManagedSessions ) $stateRoot = if ($Config.paths.PSObject.Properties.Name -contains 'stateRoot') { [string]$Config.paths.stateRoot } else { Join-Path $env:ProgramData 'AWatch-rus' } + $preservedSessionIds = @() + if ($PreserveManagedSessions.IsPresent) { + $preservedSessionIds = @( + Get-ActivityWatchManagedInteractiveSessions -TaskDefinitions $TaskDefinitions -SessionRecords $SessionRecords -IncludeDisconnected | + ForEach-Object { [int]$_.SessionId } | + Sort-Object -Unique + ) + } + $sessionIds = @( $SessionRecords | - Where-Object { -not $_.IsLive -and $_.SessionId -gt 0 } | + Where-Object { -not $_.IsLive -and $_.SessionId -gt 0 -and ($preservedSessionIds -notcontains [int]$_.SessionId) } | ForEach-Object { [int]$_.SessionId } | Sort-Object -Unique ) @@ -1704,7 +1848,7 @@ function Stop-ActivityWatchProcessesInNonLiveSessions { } } - foreach ($session in @($SessionRecords | Where-Object { -not $_.IsLive -and $_.SessionId -gt 0 })) { + foreach ($session in @($SessionRecords | Where-Object { -not $_.IsLive -and $_.SessionId -gt 0 -and ($preservedSessionIds -notcontains [int]$_.SessionId) })) { Remove-ActivityWatchLogonMarkersForSession -StateRoot $stateRoot -SessionId ([int]$session.SessionId) -UserName ([string]$session.UserName) } @@ -1885,7 +2029,7 @@ function Invoke-ActivityWatchRecoveryLoop { $config = Read-ActivityWatchDeploymentConfig -Path $ConfigPath $taskDefs = Get-ActivityWatchRecoveryTaskDefinitions -ConfigPaths $configPaths $sessionRecords = Get-ActivityWatchSessionRecords - Stop-ActivityWatchProcessesInNonLiveSessions -SessionRecords $sessionRecords -Config $config + Stop-ActivityWatchProcessesInNonLiveSessions -SessionRecords $sessionRecords -Config $config -TaskDefinitions $taskDefs -PreserveManagedSessions $sessionRecords = Get-ActivityWatchSessionRecords $stateRoot = [string]$config.paths.stateRoot $sessionCollectorScript = if ($config.paths.PSObject.Properties.Name -contains 'sessionCollectorScript') { [string]$config.paths.sessionCollectorScript } else { Join-Path $stateRoot 'worktime-session-collector.ps1' } diff --git a/windows/aw-collector-guard.ps1 b/windows/aw-collector-guard.ps1 new file mode 100644 index 0000000..559995e --- /dev/null +++ b/windows/aw-collector-guard.ps1 @@ -0,0 +1,681 @@ +[CmdletBinding()] +param( + [string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json', + [ValidateSet('shadow', 'enforce')] + [string]$Mode = 'shadow', + [int]$LoopSeconds = 60, + [int]$InteractiveMaxAgeSeconds = 900, + [int]$HeadlessMaxAgeSeconds = 900, + [int]$RestartWindowSeconds = 600, + [int]$MaxRestarts = 3, + [int]$ActionCooldownSeconds = 300, + [int]$InteractiveActionCooldownSeconds = 60, + [switch]$Once, + [switch]$HeadlessEndpointEnabled, + [switch]$HeadlessFileOpsEnabled, + [switch]$SelfTest +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$modulePath = Join-Path $PSScriptRoot 'ActivityWatch.Windows.Common.psm1' +Import-Module $modulePath -Force + +function New-GuardLock { + param([string]$StateRoot) + + if (-not (Test-Path -LiteralPath $StateRoot)) { + New-Item -Path $StateRoot -ItemType Directory -Force | Out-Null + } + + $lockPath = Join-Path $StateRoot 'collector-guard.lock' + if (Test-Path -LiteralPath $lockPath) { + try { + $lockData = Get-Content -LiteralPath $lockPath -Raw | ConvertFrom-Json + $existingPid = [int]$lockData.pid + if ($existingPid -gt 0 -and (Get-Process -Id $existingPid -ErrorAction SilentlyContinue)) { + return $null + } + } + catch { + } + } + + $payload = @{ + pid = $PID + createdAt = (Get-Date).ToUniversalTime().ToString('o') + } | ConvertTo-Json -Compress + Set-Content -LiteralPath $lockPath -Value $payload -Encoding UTF8 + return $lockPath +} + +function Write-GuardLog { + param( + [string]$LogPath, + [string]$Message + ) + + try { + $directory = Split-Path -Path $LogPath -Parent + if ($directory -and -not (Test-Path -LiteralPath $directory)) { + New-Item -Path $directory -ItemType Directory -Force | Out-Null + } + Add-Content -LiteralPath $LogPath -Value ('{0} {1}' -f (Get-Date -Format s), $Message) + } + catch { + } +} + +function Get-AwApiBase { + param([pscustomobject]$Config) + + $scheme = if ($Config.server.PSObject.Properties.Name -contains 'scheme') { [string]$Config.server.scheme } else { 'http' } + $hostName = [string]$Config.server.host + $port = [int]$Config.server.port + return ('{0}://{1}:{2}/api/0' -f $scheme, $hostName, $port) +} + +function Invoke-AwJson { + param( + [Parameter(Mandatory = $true)] + [string]$Method, + [Parameter(Mandatory = $true)] + [string]$Uri, + [object]$Body + ) + + $params = @{ + Method = $Method + Uri = $Uri + TimeoutSec = 15 + ErrorAction = 'Stop' + } + if ($null -ne $Body) { + $params.Body = ($Body | ConvertTo-Json -Depth 16 -Compress) + $params.ContentType = 'application/json' + } + return Invoke-RestMethod @params +} + +function Ensure-AwBucket { + param( + [string]$ApiBase, + [string]$BucketId, + [string]$ClientName, + [string]$BucketType, + [string]$Hostname + ) + + try { + Invoke-AwJson -Method 'GET' -Uri "$ApiBase/buckets/$BucketId" | Out-Null + return $true + } + catch { + } + + try { + $body = @{ + client = $ClientName + type = $BucketType + hostname = $Hostname + } + Invoke-AwJson -Method 'POST' -Uri "$ApiBase/buckets/$BucketId" -Body $body | Out-Null + return $true + } + catch { + return $false + } +} + +function Get-LatestBucketAge { + param( + [string]$ApiBase, + [string]$BucketId + ) + + try { + $events = Invoke-AwJson -Method 'GET' -Uri "$ApiBase/buckets/$BucketId/events?limit=20" + $latest = @($events | Where-Object { $null -ne $_.timestamp } | Sort-Object timestamp -Descending | Select-Object -First 1) + if (-not $latest) { + return [pscustomobject]@{ bucket = $BucketId; found = $false; timestamp = $null; ageSeconds = $null } + } + $ts = [DateTimeOffset]::Parse([string]$latest.timestamp).UtcDateTime + $age = [Math]::Max(0, [int]((Get-Date).ToUniversalTime() - $ts).TotalSeconds) + return [pscustomobject]@{ bucket = $BucketId; found = $true; timestamp = [string]$latest.timestamp; ageSeconds = $age } + } + catch { + return [pscustomobject]@{ bucket = $BucketId; found = $false; timestamp = $null; ageSeconds = $null; error = $_.Exception.Message } + } +} + +function Send-GuardHeartbeat { + param( + [string]$ApiBase, + [string]$Hostname, + [object]$State, + [int]$PulseSeconds + ) + + $bucketId = "aw-rus-collector-guard_$Hostname" + if (-not (Ensure-AwBucket -ApiBase $ApiBase -BucketId $bucketId -ClientName 'aw-rus-collector-guard' -BucketType 'aw.rus.collector.guard' -Hostname $Hostname)) { + return $false + } + + $event = @{ + timestamp = (Get-Date).ToUniversalTime().ToString('o') + duration = 0 + data = $State + } + + try { + Invoke-AwJson -Method 'POST' -Uri "$ApiBase/buckets/$bucketId/heartbeat?pulsetime=$PulseSeconds" -Body $event | Out-Null + return $true + } + catch { + return $false + } +} + +function Read-GuardRuntime { + param([string]$Path) + + if (-not (Test-Path -LiteralPath $Path)) { + return [pscustomobject]@{ restartHistory = @{}; lastAction = @{}; quarantine = @{} } + } + try { + $state = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json + if ($null -eq $state.restartHistory) { $state | Add-Member -NotePropertyName restartHistory -NotePropertyValue @{} } + if ($null -eq $state.lastAction) { $state | Add-Member -NotePropertyName lastAction -NotePropertyValue @{} } + if ($null -eq $state.quarantine) { $state | Add-Member -NotePropertyName quarantine -NotePropertyValue @{} } + return $state + } + catch { + return [pscustomobject]@{ restartHistory = @{}; lastAction = @{}; quarantine = @{} } + } +} + +function Write-GuardRuntime { + param( + [string]$Path, + [object]$Runtime + ) + + $directory = Split-Path -Path $Path -Parent + if ($directory -and -not (Test-Path -LiteralPath $directory)) { + New-Item -Path $directory -ItemType Directory -Force | Out-Null + } + $Runtime | ConvertTo-Json -Depth 16 | Set-Content -LiteralPath $Path -Encoding UTF8 +} + +function Get-RuntimeMapValue { + param( + [object]$Map, + [string]$Key + ) + + if ($null -eq $Map) { + return $null + } + if ($Map -is [hashtable] -and $Map.ContainsKey($Key)) { + return $Map[$Key] + } + $propertyNames = @($Map.PSObject.Properties | ForEach-Object { $_.Name }) + if ($propertyNames -contains $Key) { + return $Map.$Key + } + return $null +} + +function Set-RuntimeMapValue { + param( + [object]$Map, + [string]$Key, + [object]$Value + ) + + if ($Map -is [hashtable]) { + $Map[$Key] = $Value + return + } + $propertyNames = @($Map.PSObject.Properties | ForEach-Object { $_.Name }) + if ($propertyNames -contains $Key) { + $Map.$Key = $Value + } + else { + $Map | Add-Member -NotePropertyName $Key -NotePropertyValue $Value -Force + } +} + +function Remove-RuntimeMapValue { + param( + [object]$Map, + [string]$Key + ) + + if ($null -eq $Map) { + return + } + if ($Map -is [hashtable]) { + if ($Map.ContainsKey($Key)) { + $Map.Remove($Key) + } + return + } + $property = $Map.PSObject.Properties[$Key] + if ($null -ne $property) { + $Map.PSObject.Properties.Remove($Key) + } +} + +function Reset-GuardActionBudget { + param( + [object]$Runtime, + [string]$Key + ) + + Remove-RuntimeMapValue -Map $Runtime.restartHistory -Key $Key + Remove-RuntimeMapValue -Map $Runtime.lastAction -Key $Key + Remove-RuntimeMapValue -Map $Runtime.quarantine -Key $Key +} + +function Invoke-GuardSelfTest { + $emptyObject = [pscustomobject]@{} + if ($null -ne (Get-RuntimeMapValue -Map $emptyObject -Key 'missing')) { + throw 'empty PSCustomObject should not return a missing runtime-map value' + } + Set-RuntimeMapValue -Map $emptyObject -Key 'headless:worktime-session' -Value 123 + if ((Get-RuntimeMapValue -Map $emptyObject -Key 'headless:worktime-session') -ne 123) { + throw 'failed to set runtime-map value on empty PSCustomObject' + } + + $hash = @{} + Set-RuntimeMapValue -Map $hash -Key 'headless:worktime-session' -Value @(1, 2) + $hashValue = @(Get-RuntimeMapValue -Map $hash -Key 'headless:worktime-session') + if ($hashValue.Count -ne 2) { + throw 'failed to round-trip runtime-map value on hashtable' + } + + $runtime = [pscustomobject]@{ restartHistory = [pscustomobject]@{}; lastAction = [pscustomobject]@{}; quarantine = [pscustomobject]@{} } + $allowed = Test-ActionAllowed -Runtime $runtime -Key 'headless:worktime-session' -CooldownSeconds 1 -WindowSeconds 60 -MaxCount 3 + if (-not $allowed.allowed) { + throw "expected action to be allowed, got $($allowed.reason)" + } + Register-GuardAction -Runtime $runtime -Key 'headless:worktime-session' + $blocked = Test-ActionAllowed -Runtime $runtime -Key 'headless:worktime-session' -CooldownSeconds 300 -WindowSeconds 60 -MaxCount 3 + if ($blocked.allowed -or $blocked.reason -ne 'cooldown') { + throw 'expected cooldown after registering guard action' + } + $budgetRuntime = [pscustomobject]@{ restartHistory = [pscustomobject]@{}; lastAction = [pscustomobject]@{}; quarantine = [pscustomobject]@{} } + foreach ($i in 1..3) { + Register-GuardAction -Runtime $budgetRuntime -Key 'task:test' + } + $budgetBlocked = Test-ActionAllowed -Runtime $budgetRuntime -Key 'task:test' -CooldownSeconds 0 -WindowSeconds 600 -MaxCount 3 + if ($budgetBlocked.allowed -or $budgetBlocked.reason -ne 'quarantine') { + throw 'expected quarantine when restart budget is exhausted' + } + Reset-GuardActionBudget -Runtime $budgetRuntime -Key 'task:test' + $budgetAllowed = Test-ActionAllowed -Runtime $budgetRuntime -Key 'task:test' -CooldownSeconds 0 -WindowSeconds 600 -MaxCount 3 + if (-not $budgetAllowed.allowed) { + throw 'expected reset action budget to clear quarantine' + } + + $oldComputerName = $env:COMPUTERNAME + try { + $env:COMPUTERNAME = 'SHARKON2025' + $sessionRecords = @( + [pscustomobject]@{ SessionName = 'USER5'; UserName = 'USER5'; SessionId = 2; State = 'Disc'; IsLive = $false }, + [pscustomobject]@{ SessionName = 'console'; UserName = ''; SessionId = 1; State = 'Conn'; IsLive = $true } + ) + $taskDefs = @( + [pscustomobject]@{ taskName = 'ActivityWatch Launch [SHARKON2025_user5]'; userId = 'SHARKON2025\user5' } + ) + if (-not (Test-ActivityWatchUserHasManagedSession -UserId 'SHARKON2025\user5' -SessionRecords $sessionRecords -IncludeDisconnected)) { + throw 'expected disconnected managed session to match task user' + } + if (Test-ActivityWatchUserHasManagedSession -UserId 'SHARKON2025\user5' -SessionRecords $sessionRecords -IncludeLive) { + throw 'disconnected managed session should not match live-only filter' + } + $managed = @(Get-ActivityWatchManagedInteractiveSessions -TaskDefinitions $taskDefs -SessionRecords $sessionRecords -IncludeDisconnected) + if ($managed.Count -ne 1 -or [int]$managed[0].SessionId -ne 2) { + throw 'failed to enumerate disconnected managed session' + } + } + finally { + if ($null -eq $oldComputerName) { + Remove-Item Env:COMPUTERNAME -ErrorAction SilentlyContinue + } + else { + $env:COMPUTERNAME = $oldComputerName + } + } + + Write-Output 'collector guard self-test OK' +} + +function Test-ActionAllowed { + param( + [object]$Runtime, + [string]$Key, + [int]$CooldownSeconds, + [int]$WindowSeconds, + [int]$MaxCount + ) + + $now = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() + $last = Get-RuntimeMapValue -Map $Runtime.lastAction -Key $Key + if ($null -ne $last -and ($now - [int64]$last) -lt $CooldownSeconds) { + return [pscustomobject]@{ allowed = $false; reason = 'cooldown' } + } + + $history = @(Get-RuntimeMapValue -Map $Runtime.restartHistory -Key $Key) + $history = @($history | Where-Object { ($now - [int64]$_) -le $WindowSeconds }) + Set-RuntimeMapValue -Map $Runtime.restartHistory -Key $Key -Value @($history) + if ($history.Count -ge $MaxCount) { + Set-RuntimeMapValue -Map $Runtime.quarantine -Key $Key -Value @{ + since = (Get-Date).ToUniversalTime().ToString('o') + reason = 'restart-budget-exhausted' + count = $history.Count + } + return [pscustomobject]@{ allowed = $false; reason = 'quarantine' } + } + + Remove-RuntimeMapValue -Map $Runtime.quarantine -Key $Key + return [pscustomobject]@{ allowed = $true; reason = 'ok' } +} + +function Register-GuardAction { + param( + [object]$Runtime, + [string]$Key + ) + + $now = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() + $history = @(Get-RuntimeMapValue -Map $Runtime.restartHistory -Key $Key) + $history += $now + Set-RuntimeMapValue -Map $Runtime.restartHistory -Key $Key -Value @($history) + Set-RuntimeMapValue -Map $Runtime.lastAction -Key $Key -Value $now +} + +function Get-CollectorProcessSnapshot { + param([pscustomobject]$Config) + + $scriptPaths = [ordered]@{} + foreach ($name in @('collectorScript', 'endpointCollectorScript', 'fileCollectorScript', 'emailCollectorScript', 'sessionCollectorScript')) { + if ($Config.paths.PSObject.Properties.Name -contains $name) { + $value = [string]$Config.paths.$name + if (-not [string]::IsNullOrWhiteSpace($value)) { + $scriptPaths[$name] = $value + } + } + } + + $powershellCollectors = @() + try { + $processes = @(Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | + Where-Object { $_.Name -ieq 'powershell.exe' -or $_.Name -ieq 'pwsh.exe' }) + foreach ($proc in $processes) { + $commandLine = [string]$proc.CommandLine + foreach ($entry in $scriptPaths.GetEnumerator()) { + if ($commandLine -match [Regex]::Escape([string]$entry.Value)) { + $powershellCollectors += [pscustomobject]@{ + name = [string]$entry.Key + processId = [int]$proc.ProcessId + sessionId = [int]$proc.SessionId + scriptPath = [string]$entry.Value + } + } + } + } + } + catch { + } + + $watchers = @() + try { + $watchers = @(Get-Process -Name 'aw-watcher-afk','aw-watcher-window' -ErrorAction SilentlyContinue | + Select-Object @{Name='name'; Expression={$_.Name}}, @{Name='processId'; Expression={$_.Id}}, @{Name='sessionId'; Expression={$_.SessionId}}) + } + catch { + $watchers = @() + } + + return [pscustomobject]@{ + watchers = @($watchers) + collectors = @($powershellCollectors) + } +} + +function Invoke-ExactTaskRun { + param([string]$TaskName) + + & schtasks.exe /Run /TN $TaskName | Out-Null + return ($LASTEXITCODE -eq 0) +} + +function Invoke-GuardCycle { + param( + [object]$Runtime, + [string]$RuntimePath, + [string]$LogPath + ) + + $config = Read-ActivityWatchDeploymentConfig -Path $ConfigPath + $stateRoot = if ($config.paths.PSObject.Properties.Name -contains 'stateRoot') { [string]$config.paths.stateRoot } else { Split-Path -Path $ConfigPath -Parent } + $hostname = if ($config.PSObject.Properties.Name -contains 'awHostname' -and -not [string]::IsNullOrWhiteSpace([string]$config.awHostname)) { [string]$config.awHostname } else { [string]$env:COMPUTERNAME } + $apiBase = Get-AwApiBase -Config $config + $configPaths = Get-ActivityWatchRecoveryConfigPaths -PrimaryConfigPath $ConfigPath + $taskDefs = @(Get-ActivityWatchRecoveryTaskDefinitions -ConfigPaths $configPaths) + $sessionRecords = @(Get-ActivityWatchSessionRecords) + $liveSessions = @(Get-ActivityWatchLiveInteractiveSessions -SessionRecords $sessionRecords) + $managedInteractiveSessions = @(Get-ActivityWatchManagedInteractiveSessions -TaskDefinitions $taskDefs -SessionRecords $sessionRecords -IncludeLive -IncludeDisconnected) + $processSnapshot = Get-CollectorProcessSnapshot -Config $config + $liveSessionIds = @($liveSessions | ForEach-Object { [int]$_.SessionId }) + $managedSessionIds = @($managedInteractiveSessions | ForEach-Object { [int]$_.SessionId } | Sort-Object -Unique) + + $bucketChecks = [ordered]@{} + foreach ($bucket in @( + "aw-worktime-sessions_$hostname", + "aw-watcher-afk_$hostname", + "aw-watcher-window_$hostname", + "aw-dlp-endpoint-signals_$hostname" + )) { + $bucketChecks[$bucket] = Get-LatestBucketAge -ApiBase $apiBase -BucketId $bucket + } + + $actions = New-Object System.Collections.Generic.List[object] + $problems = New-Object System.Collections.Generic.List[string] + + $worktimeAge = $bucketChecks["aw-worktime-sessions_$hostname"].ageSeconds + $sessionCollectorScript = if ($config.paths.PSObject.Properties.Name -contains 'sessionCollectorScript') { [string]$config.paths.sessionCollectorScript } else { Join-Path $stateRoot 'worktime-session-collector.ps1' } + $sessionCollectorRunning = Test-ActivityWatchCollectorRunningGlobal -ScriptPath $sessionCollectorScript + $headlessKey = 'headless:worktime-session' + $needsHeadlessAction = (-not $sessionCollectorRunning -or $null -eq $worktimeAge -or [int]$worktimeAge -gt $HeadlessMaxAgeSeconds) + if ($needsHeadlessAction) { + $key = $headlessKey + $allowed = Test-ActionAllowed -Runtime $Runtime -Key $key -CooldownSeconds $ActionCooldownSeconds -WindowSeconds $RestartWindowSeconds -MaxCount $MaxRestarts + if ($allowed.allowed) { + if ($Mode -eq 'enforce') { + Start-ActivityWatchCollectorScriptGlobalIfNeeded -ScriptPath $sessionCollectorScript -ConfigPath $ConfigPath + Register-GuardAction -Runtime $Runtime -Key $key + Write-GuardLog -LogPath $LogPath -Message "started $key" + $actions.Add([pscustomobject]@{ action = 'start'; target = $key; applied = $true }) | Out-Null + } + else { + $actions.Add([pscustomobject]@{ action = 'start'; target = $key; applied = $false; mode = 'shadow' }) | Out-Null + } + } + else { + $problems.Add("$key action blocked: $($allowed.reason)") | Out-Null + } + } + else { + Reset-GuardActionBudget -Runtime $Runtime -Key $headlessKey + } + + if ($Mode -eq 'enforce') { + Stop-ActivityWatchProcessesInNonLiveSessions -SessionRecords $sessionRecords -Config $config -TaskDefinitions $taskDefs -PreserveManagedSessions + } + + $interactiveStale = $false + foreach ($bucket in @("aw-watcher-afk_$hostname", "aw-watcher-window_$hostname", "aw-dlp-endpoint-signals_$hostname")) { + $age = $bucketChecks[$bucket].ageSeconds + if ($null -eq $age -or [int]$age -gt $InteractiveMaxAgeSeconds) { + $interactiveStale = $true + } + } + + $watchersInLiveSessions = @($processSnapshot.watchers | Where-Object { $liveSessionIds -contains [int]$_.sessionId }) + $watchersInManagedSessions = @($processSnapshot.watchers | Where-Object { $managedSessionIds -contains [int]$_.sessionId }) + $liveWatcherMissing = $false + if ($liveSessions.Count -gt 0) { + $hasAfk = @($watchersInLiveSessions | Where-Object { [string]$_.name -ieq 'aw-watcher-afk' }).Count -gt 0 + $hasWindow = @($watchersInLiveSessions | Where-Object { [string]$_.name -ieq 'aw-watcher-window' }).Count -gt 0 + $liveWatcherMissing = (-not $hasAfk) -or (-not $hasWindow) + } + $managedWatcherMissing = $false + if ($managedInteractiveSessions.Count -gt 0) { + $afkEnabled = if ($config.PSObject.Properties.Name -contains 'collectors' -and $config.collectors.PSObject.Properties.Name -contains 'afkEnabled') { [bool]$config.collectors.afkEnabled } else { $true } + $windowEnabled = if ($config.PSObject.Properties.Name -contains 'collectors' -and $config.collectors.PSObject.Properties.Name -contains 'windowEnabled') { [bool]$config.collectors.windowEnabled } else { $true } + foreach ($managedSession in @($managedInteractiveSessions)) { + $sessionId = [int]$managedSession.SessionId + $sessionWatchers = @($watchersInManagedSessions | Where-Object { [int]$_.sessionId -eq $sessionId }) + $hasManagedAfk = @($sessionWatchers | Where-Object { [string]$_.name -ieq 'aw-watcher-afk' }).Count -gt 0 + $hasManagedWindow = @($sessionWatchers | Where-Object { [string]$_.name -ieq 'aw-watcher-window' }).Count -gt 0 + if (($afkEnabled -and -not $hasManagedAfk) -or ($windowEnabled -and -not $hasManagedWindow)) { + $managedWatcherMissing = $true + break + } + } + } + + if ($managedInteractiveSessions.Count -eq 0 -and $liveSessions.Count -gt 0 -and $interactiveStale) { + $problems.Add('interactive buckets stale but no managed interactive sessions found') | Out-Null + } + + $needsInteractiveTaskAction = $managedInteractiveSessions.Count -gt 0 -and ( + $liveWatcherMissing -or + $managedWatcherMissing -or + ($interactiveStale -and $liveSessions.Count -gt 0) + ) + + if ($needsInteractiveTaskAction) { + foreach ($taskDef in $taskDefs) { + if (-not (Test-ActivityWatchUserHasManagedSession -UserId ([string]$taskDef.userId) -SessionRecords $sessionRecords -IncludeLive -IncludeDisconnected)) { + continue + } + $key = "task:$($taskDef.taskName)" + $allowed = Test-ActionAllowed -Runtime $Runtime -Key $key -CooldownSeconds $InteractiveActionCooldownSeconds -WindowSeconds $RestartWindowSeconds -MaxCount $MaxRestarts + if (-not $allowed.allowed) { + $problems.Add("$key action blocked: $($allowed.reason)") | Out-Null + continue + } + + if ($Mode -eq 'enforce') { + $ok = Invoke-ExactTaskRun -TaskName ([string]$taskDef.taskName) + if ($ok) { + Register-GuardAction -Runtime $Runtime -Key $key + } + Write-GuardLog -LogPath $LogPath -Message ("run {0} ok={1}" -f $key, $ok) + $actions.Add([pscustomobject]@{ action = 'run-task'; target = [string]$taskDef.taskName; applied = $true; ok = $ok }) | Out-Null + } + else { + $actions.Add([pscustomobject]@{ action = 'run-task'; target = [string]$taskDef.taskName; applied = $false; mode = 'shadow' }) | Out-Null + } + } + } + else { + foreach ($taskDef in $taskDefs) { + if (Test-ActivityWatchUserHasManagedSession -UserId ([string]$taskDef.userId) -SessionRecords $sessionRecords -IncludeLive -IncludeDisconnected) { + Reset-GuardActionBudget -Runtime $Runtime -Key "task:$($taskDef.taskName)" + } + } + } + + $status = 'ok' + if ($problems.Count -gt 0) { + $status = 'warn' + } + if ($managedInteractiveSessions.Count -gt 0 -and $interactiveStale -and $Mode -eq 'shadow') { + $status = 'warn' + } + + $sessionState = @( + foreach ($session in @($sessionRecords)) { + [pscustomobject]@{ + SessionName = [string]$session.SessionName + UserName = [string]$session.UserName + SessionId = [int]$session.SessionId + State = [string]$session.State + IsLive = [bool]$session.IsLive + } + } + ) + + $state = @{} + $state['status'] = $status + $state['mode'] = $Mode + $state['host'] = $hostname + $state['generatedAtUtc'] = (Get-Date).ToUniversalTime().ToString('o') + $state['pid'] = $PID + $state['sessions'] = @($sessionState) + $state['liveSessionCount'] = $liveSessions.Count + $state['managedSessionCount'] = $managedInteractiveSessions.Count + $state['managedSessions'] = @($managedInteractiveSessions) + $bucketState = @{} + foreach ($key in $bucketChecks.Keys) { + $bucketState[$key] = $bucketChecks[$key] + } + + $state['processes'] = $processSnapshot + $state['buckets'] = $bucketState + $state['actions'] = @($actions.ToArray()) + $state['problems'] = @($problems.ToArray()) + $state['quarantine'] = $Runtime.quarantine + + $statePath = Join-Path $stateRoot 'collector-guard-state.json' + $state | ConvertTo-Json -Depth 16 | Set-Content -LiteralPath $statePath -Encoding UTF8 + Write-GuardRuntime -Path $RuntimePath -Runtime $Runtime + [void](Send-GuardHeartbeat -ApiBase $apiBase -Hostname $hostname -State $state -PulseSeconds ([Math]::Max($LoopSeconds * 2, 60))) + return $state +} + +if ($SelfTest) { + Invoke-GuardSelfTest + exit 0 +} + +$initialConfig = Read-ActivityWatchDeploymentConfig -Path $ConfigPath +$initialStateRoot = if ($initialConfig.paths.PSObject.Properties.Name -contains 'stateRoot') { [string]$initialConfig.paths.stateRoot } else { Split-Path -Path $ConfigPath -Parent } +$initialLogsRoot = if ($initialConfig.paths.PSObject.Properties.Name -contains 'logsRoot') { [string]$initialConfig.paths.logsRoot } else { Join-Path $initialStateRoot 'logs' } +$logPath = Join-Path $initialLogsRoot 'collector-guard.log' +$runtimePath = Join-Path $initialStateRoot 'collector-guard-runtime.json' +$lockPath = New-GuardLock -StateRoot $initialStateRoot +if (-not $lockPath) { + Write-GuardLog -LogPath $logPath -Message 'another collector guard instance is already running' + exit 0 +} + +try { + $runtime = Read-GuardRuntime -Path $runtimePath + Write-GuardLog -LogPath $logPath -Message "collector guard started mode=$Mode loop=$LoopSeconds once=$($Once.IsPresent)" + while ($true) { + try { + Invoke-GuardCycle -Runtime $runtime -RuntimePath $runtimePath -LogPath $logPath | Out-Null + } + catch { + Write-GuardLog -LogPath $logPath -Message ("cycle error: {0}; at {1}" -f $_.Exception.Message, $_.ScriptStackTrace) + } + + if ($Once) { + break + } + Start-Sleep -Seconds ([Math]::Max($LoopSeconds, 15)) + } +} +finally { + if ($lockPath -and (Test-Path -LiteralPath $lockPath)) { + Remove-Item -LiteralPath $lockPath -Force -ErrorAction SilentlyContinue + } + Write-GuardLog -LogPath $logPath -Message 'collector guard stopped' +} diff --git a/windows/deploy-domain-users.ps1 b/windows/deploy-domain-users.ps1 index c8b66f7..4370b43 100755 --- a/windows/deploy-domain-users.ps1 +++ b/windows/deploy-domain-users.ps1 @@ -27,7 +27,7 @@ param( [int]$EvtxRetentionDays = 14, [string[]]$EvtxChannels = @(), [bool]$LogonMarkerEnabled = $true, - [bool]$ProcessEventsEnabled = $true, + [bool]$ProcessEventsEnabled = $false, [string]$AwHostname, [string]$CustomRulesPath, [string]$CustomPolicyPath, diff --git a/windows/deploy-ensemble.ps1 b/windows/deploy-ensemble.ps1 index cbcf689..e21413a 100644 --- a/windows/deploy-ensemble.ps1 +++ b/windows/deploy-ensemble.ps1 @@ -27,7 +27,7 @@ param( [int]$EvtxRetentionDays = 14, [string[]]$EvtxChannels = @(), [bool]$LogonMarkerEnabled = $true, - [bool]$ProcessEventsEnabled = $true, + [bool]$ProcessEventsEnabled = $false, [string]$AwHostname, [string]$CustomRulesPath, [string]$CustomPolicyPath, diff --git a/windows/deploy-single-user.ps1 b/windows/deploy-single-user.ps1 index 25b7221..eb6c739 100755 --- a/windows/deploy-single-user.ps1 +++ b/windows/deploy-single-user.ps1 @@ -25,7 +25,7 @@ param( [int]$EvtxRetentionDays = 14, [string[]]$EvtxChannels = @(), [bool]$LogonMarkerEnabled = $true, - [bool]$ProcessEventsEnabled = $true, + [bool]$ProcessEventsEnabled = $false, [string]$AwHostname, [string]$CustomRulesPath, [string]$CustomPolicyPath diff --git a/windows/dlp-endpoint-signals-collector.ps1 b/windows/dlp-endpoint-signals-collector.ps1 index 1350713..8fe1831 100644 --- a/windows/dlp-endpoint-signals-collector.ps1 +++ b/windows/dlp-endpoint-signals-collector.ps1 @@ -15,7 +15,8 @@ param( [int]$PolicyRefreshSeconds, [string]$PolicyCachePath, [string]$LogPath, - [int]$PollSeconds + [int]$PollSeconds, + [switch]$SelfTestSuppressedBlock ) Set-StrictMode -Version Latest @@ -30,6 +31,7 @@ catch { $script:TransportQueuePath = $null $script:TransportQueueLockPath = $null +$script:SessionId = [System.Diagnostics.Process]::GetCurrentProcess().SessionId $script:TransportMetrics = @{ eventsEnqueued = 0 eventsFlushed = 0 @@ -71,6 +73,21 @@ function Write-EndpointLog { } } +function Get-QueueNameToken { + param( + [string]$UserName, + [int]$SessionId + ) + $token = ('{0}-s{1}' -f $UserName, $SessionId) + foreach ($ch in [System.IO.Path]::GetInvalidFileNameChars()) { + $token = $token.Replace([string]$ch, '_') + } + if ([string]::IsNullOrWhiteSpace($token)) { + return ('session-{0}' -f $SessionId) + } + return $token +} + function Invoke-AwJsonPost { param( [Parameter(Mandatory = $true)][string]$Uri, @@ -129,11 +146,20 @@ function Invoke-AwJsonPost { function Initialize-TransportQueue { param([Parameter(Mandatory = $true)][string]$StateRoot) - $script:TransportQueuePath = Join-Path $StateRoot 'dlp-endpoint-signals-queue.jsonl' - $script:TransportQueueLockPath = Join-Path $StateRoot 'dlp-endpoint-signals-queue.lock' + $queueToken = Get-QueueNameToken -UserName $env:USERNAME -SessionId $script:SessionId + $script:TransportQueuePath = Join-Path $StateRoot ("dlp-endpoint-signals-queue-{0}.jsonl" -f $queueToken) + $script:TransportQueueLockPath = Join-Path $StateRoot ("dlp-endpoint-signals-queue-{0}.lock" -f $queueToken) if (-not (Test-Path -LiteralPath $script:TransportQueuePath)) { New-Item -Path $script:TransportQueuePath -ItemType File -Force | Out-Null } + $legacyQueuePath = Join-Path $StateRoot 'dlp-endpoint-signals-queue.jsonl' + if (Test-Path -LiteralPath $legacyQueuePath) { + $legacyItems = @(Get-Content -LiteralPath $legacyQueuePath -ErrorAction SilentlyContinue | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + if ($legacyItems.Count -gt 0) { + Add-Content -LiteralPath $script:TransportQueuePath -Value $legacyItems -Encoding UTF8 + Clear-Content -LiteralPath $legacyQueuePath -ErrorAction SilentlyContinue + } + } } function Get-TransportQueueLock { @@ -617,6 +643,17 @@ function Load-DlpPolicy { regexPack = $null ocrEnabled = $false } + nativeControls = [ordered]@{ + mode = 'monitor' + rollout = [ordered]@{ + allowGlobalBlock = $false + } + channels = [ordered]@{ + clipboard = [ordered]@{ action = 'audit' } + usb = [ordered]@{ action = 'audit' } + print = [ordered]@{ action = 'audit' } + } + } } $script:PolicySource = 'defaults' @@ -656,6 +693,28 @@ function Load-DlpPolicy { $script:Policy.contentAnalysis.ocrEnabled = [bool]$raw.contentAnalysis.ocrEnabled } } + if ($raw.nativeControls) { + $nativeProps = @($raw.nativeControls.PSObject.Properties.Name) + if ($nativeProps -contains 'mode' -and $raw.nativeControls.mode) { + $script:Policy.nativeControls.mode = ([string]$raw.nativeControls.mode).ToLowerInvariant() + } + if ($nativeProps -contains 'rollout' -and $raw.nativeControls.rollout) { + $rolloutProps = @($raw.nativeControls.rollout.PSObject.Properties.Name) + if ($rolloutProps -contains 'allowGlobalBlock') { + $script:Policy.nativeControls.rollout.allowGlobalBlock = [bool]$raw.nativeControls.rollout.allowGlobalBlock + } + } + if ($nativeProps -contains 'channels' -and $raw.nativeControls.channels) { + foreach ($channelName in @('clipboard', 'usb', 'print')) { + if (@($raw.nativeControls.channels.PSObject.Properties.Name) -contains $channelName) { + $channel = $raw.nativeControls.channels.$channelName + if ($channel -and (@($channel.PSObject.Properties.Name) -contains 'action') -and $channel.action) { + $script:Policy.nativeControls.channels[$channelName].action = ([string]$channel.action).ToLowerInvariant() + } + } + } + } + } $script:PolicySource = 'local' } catch { @@ -663,6 +722,68 @@ function Load-DlpPolicy { } } +function Resolve-DlpEffectiveAction { + param( + [Parameter(Mandatory = $true)][string]$RequestedAction, + [Parameter(Mandatory = $true)][ValidateSet('clipboard', 'usb', 'print')][string]$Channel + ) + + $requested = $RequestedAction.ToLowerInvariant() + $mode = ([string]$script:Policy.nativeControls.mode).ToLowerInvariant() + $allowGlobalBlock = [bool]$script:Policy.nativeControls.rollout.allowGlobalBlock + $channelAction = 'audit' + try { + $channelAction = ([string]$script:Policy.nativeControls.channels[$Channel].action).ToLowerInvariant() + } + catch { + $channelAction = 'audit' + } + + $suppressed = $false + $effective = $requested + if ($requested -eq 'block') { + $channelAllowsBlock = $channelAction -in @('block', 'blockwithoverride') + if ($mode -ne 'enforce' -or -not $allowGlobalBlock -or -not $channelAllowsBlock) { + $effective = 'alert' + $suppressed = $true + } + } + + return [pscustomobject]@{ + requestedAction = $requested + action = $effective + enforcementMode = $mode + nativeChannelAction = $channelAction + enforcementSuppressed = $suppressed + } +} + +function Invoke-SuppressedBlockSelfTest { + $decisions = @() + foreach ($channel in @('clipboard', 'usb', 'print')) { + $decisions += (Resolve-DlpEffectiveAction -RequestedAction 'block' -Channel $channel) + } + + $failed = @( + $decisions | + Where-Object { $_.action -eq 'block' -or -not [bool]$_.enforcementSuppressed } + ) + + $result = [ordered]@{ + ok = (@($failed).Count -eq 0) + test = 'suppressed-block-in-monitor' + policySource = $script:PolicySource + policyMode = $script:PolicyMode + decisions = @($decisions) + } + + $result | ConvertTo-Json -Depth 6 + if (-not $result.ok) { + exit 2 + } + exit 0 +} + function Test-ValidInn { param([string]$Value) $digits = ($Value -replace '\D', '') @@ -925,7 +1046,9 @@ function Evaluate-ClipboardRules { $fingerprint = "clipboard|$ruleId|$ClipboardHash|$env:USERNAME" if (-not (Should-EmitByCooldown -Fingerprint $fingerprint -CooldownSeconds ([Math]::Max($cooldown, 30)))) { continue } - $action = if ($rule.action) { [string]$rule.action } else { [string]$script:Policy.defaults.action } + $requestedAction = if ($rule.action) { [string]$rule.action } else { [string]$script:Policy.defaults.action } + $actionDecision = Resolve-DlpEffectiveAction -RequestedAction $requestedAction -Channel 'clipboard' + $action = [string]$actionDecision.action $severity = if ($rule.severity) { [string]$rule.severity } else { [string]$script:Policy.defaults.severity } $message = if ($rule.message) { [string]$rule.message } else { "Clipboard rule matched: $ruleId" } @@ -939,13 +1062,17 @@ function Evaluate-ClipboardRules { clipboardHash = $ClipboardHash clipboardLength = $ClipboardText.Length enforced = $enforced + requestedAction = [string]$actionDecision.requestedAction + enforcementMode = [string]$actionDecision.enforcementMode + nativeChannelAction = [string]$actionDecision.nativeChannelAction + enforcementSuppressed = [bool]$actionDecision.enforcementSuppressed dictionaryPack = $dictionaryPack regexPack = $regexPack dictionaryMatches = @($advanced.dictionaryMatches) regexMatches = @($advanced.regexMatches) ocrRequested = $ocrEnabled } - Write-EndpointLog ("incident clipboard rule={0} action={1} severity={2} enforced={3}" -f $ruleId, $action, $severity, $enforced) + Write-EndpointLog ("incident clipboard rule={0} requested={1} action={2} severity={3} enforced={4} suppressed={5}" -f $ruleId, $requestedAction, $action, $severity, $enforced, [bool]$actionDecision.enforcementSuppressed) } } @@ -965,7 +1092,9 @@ function Evaluate-UsbRules { $fingerprint = "usb|$ruleId|$DriveLetter|$env:USERNAME" if (-not (Should-EmitByCooldown -Fingerprint $fingerprint -CooldownSeconds ([Math]::Max($cooldown, 30)))) { continue } - $action = if ($rule.action) { [string]$rule.action } else { [string]$script:Policy.defaults.action } + $requestedAction = if ($rule.action) { [string]$rule.action } else { [string]$script:Policy.defaults.action } + $actionDecision = Resolve-DlpEffectiveAction -RequestedAction $requestedAction -Channel 'usb' + $action = [string]$actionDecision.action $severity = if ($rule.severity) { [string]$rule.severity } else { [string]$script:Policy.defaults.severity } $message = if ($rule.message) { [string]$rule.message } else { "USB rule matched: $ruleId" } @@ -979,8 +1108,12 @@ function Evaluate-UsbRules { driveLetter = $DriveLetter volumeName = $VolumeName enforced = $enforced + requestedAction = [string]$actionDecision.requestedAction + enforcementMode = [string]$actionDecision.enforcementMode + nativeChannelAction = [string]$actionDecision.nativeChannelAction + enforcementSuppressed = [bool]$actionDecision.enforcementSuppressed } - Write-EndpointLog ("incident usb rule={0} action={1} severity={2} drive={3} enforced={4}" -f $ruleId, $action, $severity, $DriveLetter, $enforced) + Write-EndpointLog ("incident usb rule={0} requested={1} action={2} severity={3} drive={4} enforced={5} suppressed={6}" -f $ruleId, $requestedAction, $action, $severity, $DriveLetter, $enforced, [bool]$actionDecision.enforcementSuppressed) } } @@ -1016,7 +1149,9 @@ function Evaluate-PrintRules { $fingerprint = "print|$ruleId|$PrinterName|$Owner|$env:USERNAME" if (-not (Should-EmitByCooldown -Fingerprint $fingerprint -CooldownSeconds ([Math]::Max($cooldown, 30)))) { continue } - $action = if ($rule.action) { [string]$rule.action } else { [string]$script:Policy.defaults.action } + $requestedAction = if ($rule.action) { [string]$rule.action } else { [string]$script:Policy.defaults.action } + $actionDecision = Resolve-DlpEffectiveAction -RequestedAction $requestedAction -Channel 'print' + $action = [string]$actionDecision.action $severity = if ($rule.severity) { [string]$rule.severity } else { [string]$script:Policy.defaults.severity } $message = if ($rule.message) { [string]$rule.message } else { "Print rule matched: $ruleId" } @@ -1031,13 +1166,17 @@ function Evaluate-PrintRules { documentName = $DocumentName owner = $Owner enforced = $enforced + requestedAction = [string]$actionDecision.requestedAction + enforcementMode = [string]$actionDecision.enforcementMode + nativeChannelAction = [string]$actionDecision.nativeChannelAction + enforcementSuppressed = [bool]$actionDecision.enforcementSuppressed dictionaryPack = $dictionaryPack regexPack = $regexPack dictionaryMatches = @($advanced.dictionaryMatches) regexMatches = @($advanced.regexMatches) ocrRequested = $ocrEnabled } - Write-EndpointLog ("incident print rule={0} action={1} severity={2} printer={3} enforced={4}" -f $ruleId, $action, $severity, $PrinterName, $enforced) + Write-EndpointLog ("incident print rule={0} requested={1} action={2} severity={3} printer={4} enforced={5} suppressed={6}" -f $ruleId, $requestedAction, $action, $severity, $PrinterName, $enforced, [bool]$actionDecision.enforcementSuppressed) } } @@ -1320,6 +1459,9 @@ $script:LastEventTime = $null Initialize-TransportQueue -StateRoot $resolvedStateRoot Initialize-DlpPolicy +if ($SelfTestSuppressedBlock) { + Invoke-SuppressedBlockSelfTest +} Write-EndpointLog ("endpoint collector started against {0}" -f $script:ApiBase) while ($true) { diff --git a/windows/dlp-policy-client.ps1 b/windows/dlp-policy-client.ps1 index 541df91..fd23786 100644 --- a/windows/dlp-policy-client.ps1 +++ b/windows/dlp-policy-client.ps1 @@ -154,4 +154,6 @@ function Save-CachedDlpPolicyBundle { Set-Content -LiteralPath $CachePath -Value $json -Encoding UTF8 } -Export-ModuleMember -Function Invoke-DlpPolicyGetJson, Get-RemoteDlpPolicyBundle, Get-RemoteDlpPolicyDesired, Send-DlpPolicyAgentHeartbeat, Read-CachedDlpPolicyBundle, Save-CachedDlpPolicyBundle +if ($ExecutionContext.SessionState.Module) { + Export-ModuleMember -Function Invoke-DlpPolicyGetJson, Get-RemoteDlpPolicyBundle, Get-RemoteDlpPolicyDesired, Send-DlpPolicyAgentHeartbeat, Read-CachedDlpPolicyBundle, Save-CachedDlpPolicyBundle +} diff --git a/windows/dlp-policy.example.json b/windows/dlp-policy.example.json index 7c8c40f..828f234 100644 --- a/windows/dlp-policy.example.json +++ b/windows/dlp-policy.example.json @@ -6,6 +6,19 @@ "action": "log", "severity": "low" }, + "nativeControls": { + "mode": "monitor", + "rollout": { + "baselineDays": 7, + "requireGuardHeartbeat": true, + "allowGlobalBlock": false + }, + "channels": { + "clipboard": {"action": "audit"}, + "usb": {"action": "audit"}, + "print": {"action": "audit"} + } + }, "rules": [ { "id": "personal-web-during-workhours", diff --git a/windows/dlp-policy.native-cross-os.example.json b/windows/dlp-policy.native-cross-os.example.json new file mode 100644 index 0000000..b8710b0 --- /dev/null +++ b/windows/dlp-policy.native-cross-os.example.json @@ -0,0 +1,145 @@ +{ + "version": 1, + "defaults": { + "enabled": true, + "cooldownSeconds": 300, + "action": "alert", + "severity": "medium" + }, + "nativeControls": { + "mode": "monitor", + "rollout": { + "baselineDays": 7, + "requireGuardHeartbeat": true, + "allowGlobalBlock": false + }, + "channels": { + "removableStorage": { + "action": "audit", + "windows": { + "mechanisms": ["gpo-device-restrictions", "set-disk-readonly"], + "target": "write" + }, + "linux": { + "mechanisms": ["fanotify", "auditd"], + "target": "mountpoints" + }, + "macos": { + "mechanisms": ["mdm-restrictions", "endpoint-security"], + "target": "managed-devices" + }, + "chromeos": { + "mechanisms": ["data-controls"], + "target": "removable-storage" + } + }, + "print": { + "action": "audit", + "windows": { + "mechanisms": ["printservice-operational-log", "spooler-cancel-job"] + }, + "linux": { + "mechanisms": ["cups-logs"], + "enforcement": "monitor-only" + }, + "macos": { + "mechanisms": ["mdm-printing-restrictions"], + "enforcement": "managed-only" + }, + "chromeos": { + "mechanisms": ["data-controls"] + } + }, + "clipboard": { + "action": "audit", + "windows": { + "mechanisms": ["clipboard-monitor", "clear-clipboard"], + "blockScope": "high-confidence-only" + }, + "linux": { + "mechanisms": ["desktop-clipboard-monitor"], + "enforcement": "monitor-only" + }, + "macos": { + "mechanisms": ["endpoint-monitor", "mdm-restrictions"], + "enforcement": "monitor-first" + }, + "chromeos": { + "mechanisms": ["data-controls"] + } + }, + "browserUpload": { + "action": "audit", + "windows": { + "mechanisms": ["managed-browser-policy", "browser-extension"] + }, + "linux": { + "mechanisms": ["managed-browser-policy", "proxy-logs"] + }, + "macos": { + "mechanisms": ["managed-browser-policy", "network-extension"] + }, + "chromeos": { + "mechanisms": ["data-controls"] + } + }, + "appExecution": { + "action": "audit", + "windows": { + "mechanisms": ["applocker", "wdac"] + }, + "linux": { + "mechanisms": ["auditd", "fanotify", "bpf-lsm"], + "enforcement": "fanotify-or-lsm-only" + }, + "macos": { + "mechanisms": ["mdm-restrictions", "endpoint-security"] + }, + "chromeos": { + "mechanisms": ["admin-console-app-policy"] + } + } + } + }, + "endpoint": { + "clipboard": [ + { + "id": "clipboard-sensitive-keywords", + "enabled": true, + "cooldownSeconds": 300, + "action": "alert", + "severity": "high", + "message": "В буфере обнаружены чувствительные ключевые слова", + "minLength": 20, + "regexPatterns": [ + "(?i)парол(ь|и)", + "(?i)password", + "(?i)secret", + "(?i)cvv", + "(?i)паспорт" + ] + } + ], + "usb": [ + { + "id": "usb-media-connected", + "enabled": true, + "cooldownSeconds": 300, + "action": "alert", + "severity": "medium", + "message": "Подключен съемный носитель" + } + ], + "print": [ + { + "id": "print-sensitive-docs", + "enabled": true, + "cooldownSeconds": 300, + "action": "alert", + "severity": "high", + "message": "Печать документа с признаками чувствительных данных", + "documentRegex": "(?i)(salary|зарплат|passport|паспорт|договор|contract)" + } + ] + } +} diff --git a/windows/file-operations-collector.ps1 b/windows/file-operations-collector.ps1 index 09ddb59..dd6f1e9 100644 --- a/windows/file-operations-collector.ps1 +++ b/windows/file-operations-collector.ps1 @@ -66,6 +66,21 @@ function Write-StartupTrace { } } +function Get-QueueNameToken { + param( + [string]$UserName, + [int]$SessionId + ) + $token = ('{0}-s{1}' -f $UserName, $SessionId) + foreach ($ch in [System.IO.Path]::GetInvalidFileNameChars()) { + $token = $token.Replace([string]$ch, '_') + } + if ([string]::IsNullOrWhiteSpace($token)) { + return ('session-{0}' -f $SessionId) + } + return $token +} + function Invoke-AwJsonPost { param( [Parameter(Mandatory = $true)][string]$Uri, @@ -97,11 +112,20 @@ function Initialize-TransportQueue { param( [Parameter(Mandatory = $true)][string]$StateRoot ) - $script:TransportQueuePath = Join-Path $StateRoot 'file-operations-queue.jsonl' - $script:TransportQueueLockPath = Join-Path $StateRoot 'file-operations-queue.lock' + $queueToken = Get-QueueNameToken -UserName $env:USERNAME -SessionId $script:SessionId + $script:TransportQueuePath = Join-Path $StateRoot ("file-operations-queue-{0}.jsonl" -f $queueToken) + $script:TransportQueueLockPath = Join-Path $StateRoot ("file-operations-queue-{0}.lock" -f $queueToken) if (-not (Test-Path -LiteralPath $script:TransportQueuePath)) { New-Item -Path $script:TransportQueuePath -ItemType File -Force | Out-Null } + $legacyQueuePath = Join-Path $StateRoot 'file-operations-queue.jsonl' + if (Test-Path -LiteralPath $legacyQueuePath) { + $legacyItems = @(Get-Content -LiteralPath $legacyQueuePath -ErrorAction SilentlyContinue | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + if ($legacyItems.Count -gt 0) { + Add-Content -LiteralPath $script:TransportQueuePath -Value $legacyItems -Encoding UTF8 + Clear-Content -LiteralPath $legacyQueuePath -ErrorAction SilentlyContinue + } + } } function Get-TransportQueueLock { diff --git a/windows/hardening-recovery.ps1 b/windows/hardening-recovery.ps1 index b28b76c..fb4b6ef 100755 --- a/windows/hardening-recovery.ps1 +++ b/windows/hardening-recovery.ps1 @@ -94,7 +94,7 @@ $effectiveEvtxExportRoot = if ($PSBoundParameters.ContainsKey('EvtxExportRoot') $effectiveEvtxRetentionDays = if ($PSBoundParameters.ContainsKey('EvtxRetentionDays')) { [int]$EvtxRetentionDays } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'forensics' -and $existingConfig.forensics.PSObject.Properties.Name -contains 'retentionDays') { [int]$existingConfig.forensics.retentionDays } else { 14 } $effectiveEvtxChannels = if ($PSBoundParameters.ContainsKey('EvtxChannels')) { @($EvtxChannels) } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'forensics' -and $existingConfig.forensics.PSObject.Properties.Name -contains 'evtxChannels') { @($existingConfig.forensics.evtxChannels) } else { @() } $effectiveLogonMarkerEnabled = if ($PSBoundParameters.ContainsKey('LogonMarkerEnabled')) { [bool]$LogonMarkerEnabled } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'sessionEvents' -and $existingConfig.sessionEvents.PSObject.Properties.Name -contains 'logonEnabled') { [bool]$existingConfig.sessionEvents.logonEnabled } else { $true } -$effectiveProcessEventsEnabled = if ($PSBoundParameters.ContainsKey('ProcessEventsEnabled')) { [bool]$ProcessEventsEnabled } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'sessionEvents' -and $existingConfig.sessionEvents.PSObject.Properties.Name -contains 'processEventsEnabled') { [bool]$existingConfig.sessionEvents.processEventsEnabled } else { $true } +$effectiveProcessEventsEnabled = if ($PSBoundParameters.ContainsKey('ProcessEventsEnabled')) { [bool]$ProcessEventsEnabled } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'sessionEvents' -and $existingConfig.sessionEvents.PSObject.Properties.Name -contains 'processEventsEnabled') { [bool]$existingConfig.sessionEvents.processEventsEnabled } else { $false } $effectiveAwHostname = if ($PSBoundParameters.ContainsKey('AwHostname') -and -not [string]::IsNullOrWhiteSpace($AwHostname)) { [string]$AwHostname } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'awHostname' -and -not [string]::IsNullOrWhiteSpace([string]$existingConfig.awHostname)) { [string]$existingConfig.awHostname } else { [string]$env:COMPUTERNAME } $effectiveVersion = if ($Version) { $Version } elseif ($existingConfig) { [string]$existingConfig.package.version } else { 'v0.13.2' } $effectivePolicyMode = if ($PSBoundParameters.ContainsKey('PolicyMode') -and $PolicyMode) { [string]$PolicyMode } elseif ($existingConfig -and $existingConfig.PSObject.Properties.Name -contains 'policyEngine' -and $existingConfig.policyEngine.PSObject.Properties.Name -contains 'mode') { [string]$existingConfig.policyEngine.mode } else { 'local' } diff --git a/windows/install-collector-guard-service.ps1 b/windows/install-collector-guard-service.ps1 new file mode 100644 index 0000000..a6e270c --- /dev/null +++ b/windows/install-collector-guard-service.ps1 @@ -0,0 +1,88 @@ +[CmdletBinding()] +param( + [string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json', + [ValidateSet('shadow', 'enforce')] + [string]$Mode = 'shadow', + [string]$ServiceName = 'AWatchRusCollectorGuard', + [int]$LoopSeconds = 60, + [switch]$DisableRecoveryTask +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Assert-Admin { + $id = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = [Security.Principal.WindowsPrincipal]::new($id) + if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + throw 'Run as Administrator.' + } +} + +Assert-Admin + +$guardScriptPath = Join-Path $PSScriptRoot 'aw-collector-guard.ps1' +$serviceSourcePath = Join-Path $PSScriptRoot 'AWatchRusCollectorGuardService.cs' +$serviceExePath = Join-Path $PSScriptRoot 'AWatchRusCollectorGuardService.exe' +if (-not (Test-Path -LiteralPath $guardScriptPath)) { + throw "Collector guard script not found: $guardScriptPath" +} +if (-not (Test-Path -LiteralPath $serviceSourcePath)) { + throw "Collector guard service source not found: $serviceSourcePath" +} +if (-not (Test-Path -LiteralPath $ConfigPath)) { + throw "Config not found: $ConfigPath" +} + +$cscCandidates = @( + (Join-Path $env:WINDIR 'Microsoft.NET\Framework64\v4.0.30319\csc.exe'), + (Join-Path $env:WINDIR 'Microsoft.NET\Framework\v4.0.30319\csc.exe') +) +$csc = @($cscCandidates | Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1) +if (-not $csc) { + throw 'C# compiler not found. Install .NET Framework build tools or provide AWatchRusCollectorGuardService.exe.' +} + +$existing = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue +if ($existing) { + if ($existing.Status -ne 'Stopped') { + Stop-Service -Name $ServiceName -Force -ErrorAction SilentlyContinue + try { + $existing.WaitForStatus('Stopped', [TimeSpan]::FromSeconds(20)) + } + catch { + } + } + sc.exe delete $ServiceName | Out-Null + Start-Sleep -Seconds 2 +} + +& $csc /nologo /target:exe /optimize+ /out:$serviceExePath /reference:System.ServiceProcess.dll $serviceSourcePath | Out-Null +if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $serviceExePath)) { + throw "Failed to compile $serviceExePath" +} + +$logsRoot = Join-Path (Split-Path -Path $ConfigPath -Parent) 'logs' +$serviceLogPath = Join-Path $logsRoot 'collector-guard-service.log' +$binPath = "`"$serviceExePath`" --service-name `"$ServiceName`" --script `"$guardScriptPath`" --config `"$ConfigPath`" --mode $Mode --loop $LoopSeconds --log `"$serviceLogPath`"" + +New-Service -Name $ServiceName -BinaryPathName $binPath -DisplayName 'AWatch-rus Collector Guard' -StartupType Automatic | Out-Null +sc.exe description $ServiceName "Session-aware ActivityWatch collector guard for AWatch-rus" | Out-Null +sc.exe failure $ServiceName reset= 300 actions= restart/5000/restart/15000/restart/60000 | Out-Null + +if ($DisableRecoveryTask) { + Write-Warning 'DisableRecoveryTask is deprecated and ignored: ActivityWatch Recovery must remain enabled as collector guard fallback.' +} +else { + try { + Enable-ScheduledTask -TaskName 'ActivityWatch Recovery' -ErrorAction SilentlyContinue | Out-Null + } + catch { + } +} + +sc.exe start $ServiceName | Out-Null + +Write-Output "Collector guard service installed: $ServiceName" +Write-Output "Mode: $Mode" +Write-Output "Config: $ConfigPath" diff --git a/windows/installkit/innosetup/AWatch-rus-InnoSetup.iss b/windows/installkit/innosetup/AWatch-rus-InnoSetup.iss index 7103626..b510572 100644 --- a/windows/installkit/innosetup/AWatch-rus-InnoSetup.iss +++ b/windows/installkit/innosetup/AWatch-rus-InnoSetup.iss @@ -44,6 +44,9 @@ Source: "..\..\aw-standalone-service.ps1"; DestDir: "{app}\windows"; Flags: igno Source: "..\..\deploy-single-user.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion Source: "..\..\deploy-domain-users.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion Source: "..\..\deploy-ensemble.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion +Source: "..\..\AWatchRusCollectorGuardService.cs"; DestDir: "{app}\windows"; Flags: ignoreversion +Source: "..\..\aw-collector-guard.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion +Source: "..\..\install-collector-guard-service.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion Source: "..\..\hardening-recovery.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion Source: "..\..\validate-deployment.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion Source: "..\..\migrate-awatch-rus-paths.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion @@ -54,6 +57,7 @@ Source: "..\..\file-operations-collector.ps1"; DestDir: "{app}\windows"; Flags: Source: "..\..\email-outbound-collector.ps1"; DestDir: "{app}\windows"; Flags: ignoreversion Source: "..\..\web-category-rules.example.json"; DestDir: "{app}\windows"; Flags: ignoreversion Source: "..\..\dlp-policy.example.json"; DestDir: "{app}\windows"; Flags: ignoreversion +Source: "..\..\dlp-policy.native-cross-os.example.json"; DestDir: "{app}\windows"; Flags: ignoreversion ; Offline payload (optional): place ZIP into windows/installkit/innosetup/payload/ before compiling. Source: "payload\{#AwDefaultZipName}"; DestDir: "{app}\payload"; Flags: ignoreversion skipifsourcedoesntexist Source: "innosetup-rdp-package-filelist.md"; DestDir: "{app}\windows\installkit\innosetup"; Flags: ignoreversion diff --git a/windows/installkit/innosetup/AWatch-rus-InstallKit.exe b/windows/installkit/innosetup/AWatch-rus-InstallKit.exe index 122cdaf..fda871d 100644 --- a/windows/installkit/innosetup/AWatch-rus-InstallKit.exe +++ b/windows/installkit/innosetup/AWatch-rus-InstallKit.exe @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:736323b56689b86ecbd3c3758a7ced394a9e9613f9de411057c80a0578e8f790 -size 115989031 +oid sha256:2736b7cf6cad3259c6b4c30e311e701d3617d2f013ceaed9b8b6702b593c442e +size 115998720 diff --git a/windows/installkit/innosetup/innosetup-rdp-package-filelist.md b/windows/installkit/innosetup/innosetup-rdp-package-filelist.md index fba1cc1..450b516 100644 --- a/windows/installkit/innosetup/innosetup-rdp-package-filelist.md +++ b/windows/installkit/innosetup/innosetup-rdp-package-filelist.md @@ -29,6 +29,9 @@ - `windows/deploy-single-user.ps1` - `windows/deploy-domain-users.ps1` - `windows/deploy-ensemble.ps1` +- `windows/AWatchRusCollectorGuardService.cs` +- `windows/aw-collector-guard.ps1` +- `windows/install-collector-guard-service.ps1` - `windows/hardening-recovery.ps1` - `windows/validate-deployment.ps1` - `windows/migrate-awatch-rus-paths.ps1` @@ -41,6 +44,7 @@ ### 1.4 Шаблоны конфигурации - `windows/web-category-rules.example.json` - `windows/dlp-policy.example.json` +- `windows/dlp-policy.native-cross-os.example.json` ## 2) Бинарный payload ActivityWatch @@ -83,6 +87,7 @@ - `windows\dlp-endpoint-signals-collector.ps1` - `windows\web-category-rules.example.json` - `windows\dlp-policy.example.json` +- `windows\dlp-policy.native-cross-os.example.json` - `payload\activitywatch-v0.13.2-windows-x86_64.zip` (только для offline-режима) ## 6) Контроль перед сборкой .iss diff --git a/windows/validate-deployment.ps1 b/windows/validate-deployment.ps1 index 237bf69..bdfcb61 100644 --- a/windows/validate-deployment.ps1 +++ b/windows/validate-deployment.ps1 @@ -1,4 +1,4 @@ -[CmdletBinding()] +[CmdletBinding()] param( [string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json' ) @@ -38,7 +38,7 @@ $windowExpected = if ($config.PSObject.Properties.Name -contains 'collectors' -a $fileOpsExpected = if ($config.PSObject.Properties.Name -contains 'collectors' -and $config.collectors.PSObject.Properties.Name -contains 'fileOpsEnabled') { [bool]$config.collectors.fileOpsEnabled } else { $true } $sessionEventsConfig = if ($config.PSObject.Properties.Name -contains 'sessionEvents') { $config.sessionEvents } else { $null } $sessionLogonEnabled = if ($sessionEventsConfig -and $sessionEventsConfig.PSObject.Properties.Name -contains 'logonEnabled') { [bool]$sessionEventsConfig.logonEnabled } else { $false } -$sessionProcessEventsEnabled = if ($sessionEventsConfig -and $sessionEventsConfig.PSObject.Properties.Name -contains 'processEventsEnabled') { [bool]$sessionEventsConfig.processEventsEnabled } else { $true } +$sessionProcessEventsEnabled = if ($sessionEventsConfig -and $sessionEventsConfig.PSObject.Properties.Name -contains 'processEventsEnabled') { [bool]$sessionEventsConfig.processEventsEnabled } else { $false } $sessionEventsBucketId = if ($sessionEventsConfig -and $sessionEventsConfig.PSObject.Properties.Name -contains 'bucketPrefix' -and -not [string]::IsNullOrWhiteSpace([string]$sessionEventsConfig.bucketPrefix)) { ('{0}_{1}' -f [string]$sessionEventsConfig.bucketPrefix, $awHostname) } @@ -301,6 +301,94 @@ function Get-TransportQueueHealth { } } +function Get-TransportQueueGroupHealth { + param( + [Parameter(Mandatory = $true)] + [string]$Name, + [Parameter(Mandatory = $true)] + [string]$StateRoot, + [Parameter(Mandatory = $true)] + [string]$QueuePattern, + [Parameter(Mandatory = $true)] + [int]$StaleAfterSeconds, + [Parameter(Mandatory = $true)] + [int]$MaxDepth, + [int]$ActiveProcessCount = 0, + [bool]$Required = $true + ) + + $queues = @(Get-ChildItem -LiteralPath $StateRoot -Filter $QueuePattern -ErrorAction SilentlyContinue | Sort-Object Name) + if ($queues.Count -eq 0) { + return [pscustomobject]@{ + name = $Name + required = [bool]$Required + queuePattern = $QueuePattern + queueCount = 0 + queues = @() + depth = 0 + sizeBytes = 0 + activeProcessCount = [int]$ActiveProcessCount + staleAfterSeconds = [int]$StaleAfterSeconds + maxDepth = [int]$MaxDepth + ok = [bool](-not $Required) + } + } + + $items = @() + foreach ($queue in $queues) { + $lockPath = [System.IO.Path]::ChangeExtension($queue.FullName, '.lock') + $items += Get-TransportQueueHealth -Name $queue.BaseName -QueuePath $queue.FullName -LockPath $lockPath -StaleAfterSeconds $StaleAfterSeconds -MaxDepth $MaxDepth -ActiveProcessCount $ActiveProcessCount -Required $Required + } + + return [pscustomobject]@{ + name = $Name + required = [bool]$Required + queuePattern = $QueuePattern + queueCount = [int]$items.Count + queues = @($items) + depth = [int](($items | Measure-Object -Property depth -Sum).Sum) + sizeBytes = [int64](($items | Measure-Object -Property sizeBytes -Sum).Sum) + activeProcessCount = [int]$ActiveProcessCount + staleAfterSeconds = [int]$StaleAfterSeconds + maxDepth = [int]$MaxDepth + ok = [bool](-not ($items | Where-Object { -not $_.ok })) + } +} + +function Resolve-ActivityWatchLaunchTaskName { + param( + [Parameter(Mandatory = $true)] + [string]$TaskName + ) + + if ($TaskName -notmatch '\[[^\]]+_Administrator\]') { + return $TaskName + } + + $localizedCandidate = 'ActivityWatch Launch [{0}_Администратор]' -f $awHostname + $localizedTask = Get-ScheduledTask -ErrorAction SilentlyContinue | Where-Object { $_.TaskName -eq $localizedCandidate } | Select-Object -First 1 + if ($localizedTask) { + return $localizedCandidate + } + + try { + $builtinAdmin = Get-LocalUser -ErrorAction Stop | + Where-Object { [string]$_.SID -match '-500$' } | + Select-Object -First 1 + if ($builtinAdmin -and -not [string]::IsNullOrWhiteSpace([string]$builtinAdmin.Name)) { + $candidate = 'ActivityWatch Launch [{0}_{1}]' -f $awHostname, [string]$builtinAdmin.Name + $existing = Get-ScheduledTask -ErrorAction SilentlyContinue | Where-Object { $_.TaskName -eq $candidate } | Select-Object -First 1 + if ($existing) { + return $candidate + } + } + } + catch { + } + + return $TaskName +} + function Get-TaskSnapshot { param( [Parameter(Mandatory = $true)] @@ -337,14 +425,13 @@ function Get-TaskSnapshot { } catch { } - [pscustomobject]@{ taskName = [string]$task.TaskName present = $true enabled = [bool]$enabled state = [string]$task.State lastResult = if ($taskInfo) { [int64]$taskInfo.LastTaskResult } else { $null } - ok = [bool]($enabled) + ok = [bool]$enabled } } ) @@ -413,10 +500,14 @@ else { 0 } $sessionScopedCollectorsRequired = ($sessionScopedExpectedCount -gt 0) +$liveSessionScopedCollectorsRequired = ($liveSessionBoundUsers.Count -gt 0) + +$collectorGuardService = Get-Service -Name 'AWatchRusCollectorGuard' -ErrorAction SilentlyContinue +$collectorGuardActive = [bool]($collectorGuardService -and $collectorGuardService.Status -eq 'Running') $taskNames = @() if ($config.userTasks) { - $taskNames += @($config.userTasks | ForEach-Object { [string]$_.launchTaskName }) + $taskNames += @($config.userTasks | ForEach-Object { Resolve-ActivityWatchLaunchTaskName -TaskName ([string]$_.launchTaskName) }) } $taskNames += [string]$config.recovery.taskName $tasks = @(Get-TaskSnapshot -TaskNames $taskNames) @@ -438,24 +529,24 @@ foreach ($watcher in $runningWatchers) { $bucketChecks = @( Get-BucketHealth -BucketId ('aw-worktime-sessions_' + $awHostname) -MaxAgeSeconds $sessionFreshnessSeconds -Required $true -RequireFreshEvent $true ) -if ($sessionScopedCollectorsRequired -and $afkExpected) { +if ($liveSessionScopedCollectorsRequired -and $afkExpected) { $bucketChecks += Get-BucketHealth -BucketId ('aw-watcher-afk_' + $awHostname) -MaxAgeSeconds $freshnessSeconds -Required $true -RequireFreshEvent $false } -if ($sessionScopedCollectorsRequired -and $windowExpected) { +if ($liveSessionScopedCollectorsRequired -and $windowExpected) { $bucketChecks += Get-BucketHealth -BucketId ('aw-watcher-window_' + $awHostname) -MaxAgeSeconds $freshnessSeconds -Required $true -RequireFreshEvent $false } -if ($sessionScopedCollectorsRequired) { +if ($liveSessionScopedCollectorsRequired) { $bucketChecks += Get-BucketHealth -BucketId ('aw-dlp-endpoint-signals_' + $awHostname) -MaxAgeSeconds $endpointFreshnessSeconds -Required $true -RequireFreshEvent $true } -if ($sessionScopedCollectorsRequired -and $fileOpsExpected) { +if ($liveSessionScopedCollectorsRequired -and $fileOpsExpected) { $bucketChecks += Get-BucketHealth -BucketId ('aw-file-operations_' + $awHostname) -MaxAgeSeconds $transportStaleSeconds -Required $false -RequireFreshEvent $true } $queueChecks = @( - Get-TransportQueueHealth -Name 'endpoint' -QueuePath (Join-Path $stateRoot 'dlp-endpoint-signals-queue.jsonl') -LockPath (Join-Path $stateRoot 'dlp-endpoint-signals-queue.lock') -StaleAfterSeconds $transportStaleSeconds -MaxDepth $queueMaxDepth -ActiveProcessCount @($endpointCollectorProcesses).Count -Required $sessionScopedCollectorsRequired + Get-TransportQueueGroupHealth -Name 'endpoint' -StateRoot $stateRoot -QueuePattern 'dlp-endpoint-signals-queue*.jsonl' -StaleAfterSeconds $transportStaleSeconds -MaxDepth $queueMaxDepth -ActiveProcessCount @($endpointCollectorProcesses).Count -Required $liveSessionScopedCollectorsRequired ) if ($fileOpsExpected) { - $queueChecks += Get-TransportQueueHealth -Name 'fileops' -QueuePath (Join-Path $stateRoot 'file-operations-queue.jsonl') -LockPath (Join-Path $stateRoot 'file-operations-queue.lock') -StaleAfterSeconds $transportStaleSeconds -MaxDepth $queueMaxDepth -ActiveProcessCount @($fileCollectorProcesses).Count -Required $sessionScopedCollectorsRequired + $queueChecks += Get-TransportQueueGroupHealth -Name 'fileops' -StateRoot $stateRoot -QueuePattern 'file-operations-queue*.jsonl' -StaleAfterSeconds $transportStaleSeconds -MaxDepth $queueMaxDepth -ActiveProcessCount @($fileCollectorProcesses).Count -Required $liveSessionScopedCollectorsRequired } $printServiceOperationalEnabled = $false @@ -502,12 +593,14 @@ $result = [ordered]@{ } tasks = [ordered]@{ list = $tasks - ok = [bool]($tasks.Count -gt 0 -and -not ($tasks | Where-Object { -not $_.present -or -not $_.enabled })) + ok = [bool]($tasks.Count -gt 0 -and -not ($tasks | Where-Object { -not $_.ok })) } processes = [ordered]@{ liveSessionBoundUsers = $liveSessionBoundUsers sessionBoundUsers = $interactiveSessionBoundUsers sessionScopedExpectedCount = [int]$sessionScopedExpectedCount + liveSessionScopedCollectorsRequired = [bool]$liveSessionScopedCollectorsRequired + collectorGuardServiceActive = [bool]$collectorGuardActive watchers = @($runningWatchers) watcherDuplicates = @($watcherDuplicates) sessionCollectors = @($sessionCollectorProcesses)