From dbef90a09e71a328d67f39b8dd3ec598bef759aa Mon Sep 17 00:00:00 2001
From: igor04091968
Date: Wed, 3 Jun 2026 03:27:52 +0300
Subject: [PATCH] refactor(detmir): retire python runtime paths
---
AGENTS.md | 4 +-
adk-rust/RUNBOOK.md | 43 +-
.../crates/aw-ensure-reliability/src/main.rs | 85 +-
adk-rust/crates/aw-slo-monitor/src/main.rs | 2 +-
.../crates/prod-backup-restore/src/main.rs | 51 +-
.../crates/rebuild-install-kit/src/main.rs | 5 -
ansible/deploy_aw_server.yml | 886 ++---
ansible/deploy_tsj_guardian_bot_proxmox.yml | 4 +-
ansible/group_vars/proxmox-bot.example.yml | 2 +-
.../provision_proxmox_ct_and_deploy_aw.yml | 50 +-
...vision_proxmox_ct_matrix_and_deploy_aw.yml | 60 +-
.../roles/dlp-case-management/tasks/main.yml | 60 +-
ansible/roles/dlp-compliance/tasks/main.yml | 59 +-
ansible/roles/dlp-integrations/tasks/main.yml | 199 +-
.../roles/dlp-policy-engine/tasks/main.yml | 51 +-
aw-server/aw-dlp-influx-exporter.py | 417 ---
aw-server/aw-dlp-influx-exporter.service | 2 +-
aw-server/aw-health-check-wrapper.sh | 3 +-
aw-server/aw-rus-healthd.py | 512 ---
aw-server/aw-rus-healthd.service | 2 +-
aw-server/aw-slo-monitor.py | 465 ---
aw-server/aw-slo-monitor.service | 2 +-
aw-server/aw-worktime-api.py | 3039 -----------------
aw-server/aw-worktime-api.service | 2 +-
aw-server/aw-worktime-autoheal.service | 3 +-
aw-server/aw-worktime-autoheal.sh | 243 --
aw-server/aw-worktime-influx-exporter.py | 181 -
aw-server/aw-worktime-influx-exporter.service | 3 +-
aw-server/aw-worktime-prewarm.service | 3 +-
aw-server/aw-worktime-ui-bridge.py | 424 ---
aw-server/aw-worktime-ui-bridge.service | 2 +-
.../dlp-case-management/case-service.service | 4 +-
aw-server/dlp-case-management/case_rules.py | 8 -
aw-server/dlp-case-management/case_schema.py | 72 -
aw-server/dlp-case-management/case_service.py | 91 -
aw-server/dlp-case-management/case_storage.py | 351 --
.../dlp-case-management/evidence_chain.py | 52 -
.../dlp-case-management/test_case_rules.py | 21 -
.../dlp-case-management/test_case_storage.py | 93 -
.../dlp-compliance/compliance_scheduler.py | 24 -
.../dlp-compliance/report-scheduler.service | 4 +-
aw-server/dlp-compliance/report_generator.py | 201 --
.../dlp-integrations/cef-exporter.service | 4 +-
aw-server/dlp-integrations/cef_exporter.py | 165 -
.../dlp-integrations/syslog-forwarder.service | 4 +-
.../dlp-integrations/syslog_forwarder.py | 146 -
.../dlp-integrations/test_syslog_forwarder.py | 59 -
.../dlp-integrations/webhook-sender.service | 4 +-
aw-server/dlp-integrations/webhook_sender.py | 155 -
aw-server/dlp-monitoring/metrics_exporter.py | 19 -
.../dlp-policy-engine.service | 4 +-
.../dlp-policy-engine/policy_distributor.py | 26 -
aw-server/dlp-policy-engine/policy_schema.py | 71 -
aw-server/dlp-policy-engine/policy_service.py | 214 --
aw-server/dlp-policy-engine/policy_storage.py | 367 --
aw-server/ensure-reliability.sh | 12 +-
aw-server/hayabusa/aw-hayabusa-autoprocess.py | 136 -
aw-server/hayabusa/aw-hayabusa-case-alert.py | 361 --
.../hayabusa/aw-hayabusa-from-windows.py | 111 -
aw-server/hayabusa/aw-hayabusa-link-case.py | 76 -
aw-server/hayabusa/aw-hayabusa.sh | 56 +-
aw-server/health-check.sh | 173 -
aw-server/install_aw_server.sh | 28 +-
aw-server/test_aw_dlp_influx_exporter.py | 96 -
aw-server/test_aw_dlp_report_generator.py | 39 -
aw-server/test_aw_rus_healthd.py | 211 --
aw-server/test_aw_slo_monitor.py | 268 --
aw-server/test_aw_worktime_api.py | 1078 ------
aw-server/test_aw_worktime_influx_exporter.py | 67 -
aw-server/test_aw_worktime_ui_bridge.py | 244 --
docs/DETMIR_PYTHON_RETIREMENT_RU.md | 54 +
install-kit-awindows-20260427-211240.tar.gz | Bin 185718 -> 146391 bytes
install-kit-awindows-20260427-211240.zip | Bin 220469 -> 176728 bytes
.../MANIFEST.txt | 31 +-
.../ansible/deploy_aw_server.yml | 950 ++----
.../ansible/deploy_aw_windows.yml | 1 +
.../provision_proxmox_ct_and_deploy_aw.yml | 50 +-
...vision_proxmox_ct_matrix_and_deploy_aw.yml | 60 +-
.../aw-server/aw-rus-healthd.py | 512 ---
.../aw-server/aw-rus-healthd.service | 2 +-
.../aw-server/aw-slo-monitor.py | 465 ---
.../aw-server/aw-slo-monitor.service | 2 +-
.../aw-server/aw-worktime-api.py | 3039 -----------------
.../aw-server/aw-worktime-api.service | 2 +-
.../aw-server/aw-worktime-prewarm.service | 3 +-
.../aw-server/aw-worktime-prewarm.sh | 98 -
.../aw-server/health-check.sh | 173 -
.../aw-server/install_aw_server.sh | 28 +-
.../scripts/aw-webui-browser-smoke.sh | 13 +
.../scripts/check_install_kit_vs_repo.sh | 58 +-
.../scripts/rebuild_install_kit.sh | 149 +-
.../scripts/validate_install_kit.sh | 74 +-
scripts/aggregate_dlp_events.py | 517 ---
scripts/build_dlp_ioc_from_hayabusa.sh | 7 +-
scripts/check_install_kit_vs_repo.sh | 58 +-
scripts/dlp-admin-cli.py | 158 -
scripts/dlp-health-check.py | 1023 ------
scripts/extract_ioc_from_sigma.py | 235 --
scripts/merge_aw_server_dbs.py | 236 --
scripts/prod_backup_restore.sh | 93 +-
scripts/rebuild_install_kit.sh | 149 +-
scripts/test_dlp_health_check.py | 421 ---
scripts/validate_install_kit.sh | 74 +-
103 files changed, 914 insertions(+), 19800 deletions(-)
delete mode 100644 aw-server/aw-dlp-influx-exporter.py
delete mode 100644 aw-server/aw-rus-healthd.py
delete mode 100644 aw-server/aw-slo-monitor.py
delete mode 100644 aw-server/aw-worktime-api.py
delete mode 100644 aw-server/aw-worktime-autoheal.sh
delete mode 100644 aw-server/aw-worktime-influx-exporter.py
delete mode 100644 aw-server/aw-worktime-ui-bridge.py
delete mode 100644 aw-server/dlp-case-management/case_rules.py
delete mode 100644 aw-server/dlp-case-management/case_schema.py
delete mode 100644 aw-server/dlp-case-management/case_service.py
delete mode 100644 aw-server/dlp-case-management/case_storage.py
delete mode 100644 aw-server/dlp-case-management/evidence_chain.py
delete mode 100644 aw-server/dlp-case-management/test_case_rules.py
delete mode 100644 aw-server/dlp-case-management/test_case_storage.py
delete mode 100644 aw-server/dlp-compliance/compliance_scheduler.py
delete mode 100644 aw-server/dlp-compliance/report_generator.py
delete mode 100644 aw-server/dlp-integrations/cef_exporter.py
delete mode 100644 aw-server/dlp-integrations/syslog_forwarder.py
delete mode 100644 aw-server/dlp-integrations/test_syslog_forwarder.py
delete mode 100644 aw-server/dlp-integrations/webhook_sender.py
delete mode 100644 aw-server/dlp-monitoring/metrics_exporter.py
delete mode 100644 aw-server/dlp-policy-engine/policy_distributor.py
delete mode 100644 aw-server/dlp-policy-engine/policy_schema.py
delete mode 100644 aw-server/dlp-policy-engine/policy_service.py
delete mode 100644 aw-server/dlp-policy-engine/policy_storage.py
delete mode 100644 aw-server/hayabusa/aw-hayabusa-autoprocess.py
delete mode 100644 aw-server/hayabusa/aw-hayabusa-case-alert.py
delete mode 100644 aw-server/hayabusa/aw-hayabusa-from-windows.py
delete mode 100644 aw-server/hayabusa/aw-hayabusa-link-case.py
delete mode 100644 aw-server/health-check.sh
delete mode 100644 aw-server/test_aw_dlp_influx_exporter.py
delete mode 100644 aw-server/test_aw_dlp_report_generator.py
delete mode 100644 aw-server/test_aw_rus_healthd.py
delete mode 100644 aw-server/test_aw_slo_monitor.py
delete mode 100644 aw-server/test_aw_worktime_api.py
delete mode 100644 aw-server/test_aw_worktime_influx_exporter.py
delete mode 100644 aw-server/test_aw_worktime_ui_bridge.py
create mode 100644 docs/DETMIR_PYTHON_RETIREMENT_RU.md
delete mode 100644 install-kit-awindows-20260427-211240/aw-server/aw-rus-healthd.py
delete mode 100644 install-kit-awindows-20260427-211240/aw-server/aw-slo-monitor.py
delete mode 100644 install-kit-awindows-20260427-211240/aw-server/aw-worktime-api.py
delete mode 100644 install-kit-awindows-20260427-211240/aw-server/aw-worktime-prewarm.sh
delete mode 100644 install-kit-awindows-20260427-211240/aw-server/health-check.sh
delete mode 100755 scripts/aggregate_dlp_events.py
delete mode 100644 scripts/dlp-admin-cli.py
delete mode 100644 scripts/dlp-health-check.py
delete mode 100644 scripts/extract_ioc_from_sigma.py
delete mode 100644 scripts/merge_aw_server_dbs.py
delete mode 100644 scripts/test_dlp_health_check.py
diff --git a/AGENTS.md b/AGENTS.md
index 84b4279..8b35631 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -3,7 +3,7 @@ AGENTS for OpenCode
Keep this file minimal and high-signal: only include facts an agent would otherwise miss.
1) Repo purpose (one line)
-- This repository bundles an ActivityWatch Server deployment, RU WebUI patch, Windows collectors (PowerShell), and small Python utilities for aggregation and monitoring.
+- This repository bundles an ActivityWatch Server deployment, RU WebUI patch, Windows collectors (PowerShell), and Rust operational utilities for aggregation, monitoring, health checks, SLO, DLP helpers, and DetMir automation. Python is retained only for explicit exceptions: Telegram bot runtime, pfSense tooling, OCR/content-analysis, 1C AI/ETL, and detmir-mcp.
2) Highest-value entrypoints & commands
- Read README.md and docs/preparation.md first (they are the authoritative onboarding flow).
@@ -12,7 +12,7 @@ Keep this file minimal and high-signal: only include facts an agent would otherw
- Install AW server on the CT (runs inside CT): `aw-server/install_aw_server.sh` (requires `/etc/activitywatch/aw-server.env`).
- Apply RU WebUI patch (must run on the CT and after webui is present): `aw-server/apply_webui_ru_patch.sh`.
- Run the Windows ensemble deploy from a Windows admin host: `windows/deploy-ensemble.ps1` (see its parameters; it calls `deploy-domain-users.ps1`).
-- Quick DLP aggregation (local): `python3 scripts/aggregate_dlp_events.py`.
+- Quick DLP aggregation (local): `adk-rust/target/release/dlp-aggregator`.
3) Exact env/secrets behavior agents often miss
- Secrets live in `secrets/deploy.secrets.env` (actual file is intentionally local-only). Many scripts default to that path if no arg provided. Never add real secrets to commits. Use `.example` files as templates.
diff --git a/adk-rust/RUNBOOK.md b/adk-rust/RUNBOOK.md
index 658eaab..0ad7fa4 100644
--- a/adk-rust/RUNBOOK.md
+++ b/adk-rust/RUNBOOK.md
@@ -52,8 +52,8 @@ Ansible, PowerShell или Playwright, он остается там до поя
| `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 |
+| `aw-rus-healthd` | Rust production unit, Python entrypoint removed from repo | done |
+| `dlp-health-check` | Rust production binary, Python entrypoint removed from repo | done |
| 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 |
@@ -763,7 +763,8 @@ systemctl is-active tsj-guardian-bot tsj-guardian-watchdog gost-tg
`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;
+ legacy `/usr/local/bin/dlp-admin-cli.py` больше не является repo/runtime
+ path; рабочий CLI: `/usr/local/bin/dlp-admin-cli`;
- parity на AW server совпал с Python для `health`, `policies active`,
`incidents list --since-hours 24 --limit 5`, `cases list --limit 5`;
- production verification зеленый: AW failed units 0,
@@ -885,9 +886,9 @@ systemctl is-active tsj-guardian-bot tsj-guardian-watchdog gost-tg
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`;
+ - production `/usr/local/bin/aw-health-check` теперь Rust-required wrapper:
+ вызывает `/usr/local/bin/aw-health-check-rust` и падает с ошибкой, если
+ Rust binary отсутствует;
- shadow parity на AW server совпал с shell по exit code и ключевым
health-строкам;
- production verification зеленый: `aw-health-check` OK,
@@ -1380,32 +1381,24 @@ systemctl is-active tsj-guardian-bot tsj-guardian-watchdog gost-tg
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:
+50. `[done]` Перенести 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` или
+ - рабочий runtime path: `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;
+ - legacy `scripts/merge_aw_server_dbs.py` удален из repo/runtime path;
+ - `ansible/deploy_aw_server.yml` устанавливает
+ `/usr/local/bin/merge-aw-server-dbs` и использует его без Python;
- 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
+ - offline parity fixture: Rust replacement дал ожидаемый 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
+ 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:
@@ -1419,13 +1412,13 @@ systemctl is-active tsj-guardian-bot tsj-guardian-watchdog gost-tg
он печатает plan-only flow и помечает destructive steps;
- если Rust artifact отсутствует, wrapper отказывается запускать
destructive legacy случайно и просит собрать Rust planner;
- - старый destructive flow доступен только явно:
- `scripts/prod_backup_restore.sh --apply-legacy`;
+ - старый Python destructive flow удален; `--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,
+ `ansible-playbook`, inventory и `merge-aw-server-dbs`;
+ - план сохраняет порядок restore flow: scp Rust merge binary, 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`),
diff --git a/adk-rust/crates/aw-ensure-reliability/src/main.rs b/adk-rust/crates/aw-ensure-reliability/src/main.rs
index 6af41c4..55ad4eb 100644
--- a/adk-rust/crates/aw-ensure-reliability/src/main.rs
+++ b/adk-rust/crates/aw-ensure-reliability/src/main.rs
@@ -56,9 +56,6 @@ struct Cli {
#[arg(long, default_value = "/etc/systemd/system/aw-health-check.service")]
health_service_target: PathBuf,
- #[arg(long, default_value = "/opt/activitywatch/aw-rus-ops/health-check.sh")]
- health_script_source: PathBuf,
-
#[arg(long, default_value_t = false)]
apply: bool,
@@ -72,7 +69,6 @@ enum StepKind {
Check,
Chown,
Chmod,
- Copy,
Write,
Systemd,
Sleep,
@@ -138,11 +134,10 @@ fn build_report(cli: &Cli) -> Report {
if !cli.env_file.is_file() {
missing_required.push(format!("env file missing: {}", cli.env_file.display()));
}
- if !cli.health_script_target.is_file() && !cli.health_script_source.is_file() {
+ if !cli.health_script_target.is_file() {
missing_required.push(format!(
- "health script target and source missing: {}, {}",
- cli.health_script_target.display(),
- cli.health_script_source.display()
+ "health script target missing: {}",
+ cli.health_script_target.display()
));
}
@@ -193,17 +188,13 @@ fn build_report(cli: &Cli) -> Report {
);
push_step(
&mut steps,
- "install-health-script",
- StepKind::Copy,
- format!(
- "copy {} {}",
- shell_quote(&cli.health_script_source),
- shell_quote(&cli.health_script_target)
- ),
- true,
+ "check-health-script",
+ StepKind::Check,
+ format!("test -x {}", shell_quote(&cli.health_script_target)),
+ false,
!cli.health_script_target.is_file(),
if cli.health_script_target.is_file() {
- "health script already installed".to_string()
+ "health script already installed by Ansible".to_string()
} else {
"health script target missing".to_string()
},
@@ -331,8 +322,16 @@ fn apply_steps(report: &mut Report) -> Result<()> {
1
}),
},
+ "check-health-script" => {
+ let target = Path::new("/usr/local/bin/aw-health-check");
+ ExecResult {
+ order: step.order,
+ name: step.name.clone(),
+ ok: target.is_file(),
+ exit_code: Some(if target.is_file() { 0 } else { 1 }),
+ }
+ }
"install-logrotate" => write_file_result(step, report, LOGROTATE_CONTENT, 0o644)?,
- "install-health-script" => copy_health_script(step, report)?,
"install-health-timer" => write_file_result(step, report, HEALTH_TIMER_CONTENT, 0o644)?,
"install-health-service" => {
write_file_result(step, report, HEALTH_SERVICE_CONTENT, 0o644)?
@@ -371,24 +370,6 @@ fn write_file_result(step: &Step, report: &Report, content: &str, mode: u32) ->
})
}
-fn copy_health_script(step: &Step, report: &Report) -> Result {
- let source = source_from_copy_command(&step.command)?;
- let target = copy_target_from_command(&step.command)?;
- let _ = report;
- if let Some(parent) = target.parent() {
- fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
- }
- fs::copy(&source, &target)
- .with_context(|| format!("copy {} -> {}", source.display(), target.display()))?;
- set_mode(&target, 0o755).with_context(|| format!("chmod 0755 {}", target.display()))?;
- Ok(ExecResult {
- order: step.order,
- name: step.name.clone(),
- ok: true,
- exit_code: Some(0),
- })
-}
-
fn run_shell_step(step: &Step) -> Result {
let status = Command::new("sh")
.arg("-c")
@@ -412,22 +393,6 @@ fn target_from_command(command: &str) -> Result {
Ok(PathBuf::from(raw.trim_matches('\'')))
}
-fn source_from_copy_command(command: &str) -> Result {
- let raw = command
- .split_whitespace()
- .nth(1)
- .context("parse source from copy command")?;
- Ok(PathBuf::from(raw.trim_matches('\'')))
-}
-
-fn copy_target_from_command(command: &str) -> Result {
- let raw = command
- .split_whitespace()
- .nth(2)
- .context("parse target from copy command")?;
- Ok(PathBuf::from(raw.trim_matches('\'')))
-}
-
#[cfg(unix)]
fn set_mode(path: &Path, mode: u32) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
@@ -494,19 +459,19 @@ mod tests {
fn dry_run_marks_legacy_mutations() {
let dir = tempfile::tempdir().unwrap();
let env_file = dir.path().join("aw-server.env");
- let health = dir.path().join("health-check.sh");
+ let health_target = dir.path().join("bin/aw-health-check");
fs::write(&env_file, "AW_BASE_URL=http://127.0.0.1:5600\n").unwrap();
- fs::write(&health, "#!/bin/sh\nexit 0\n").unwrap();
+ fs::create_dir_all(health_target.parent().unwrap()).unwrap();
+ fs::write(&health_target, "#!/bin/sh\nexit 0\n").unwrap();
let cli = Cli {
env_file,
data_dir: dir.path().join("data"),
log_dir: dir.path().join("log"),
opt_dir: dir.path().join("opt"),
logrotate_target: dir.path().join("logrotate/activitywatch"),
- health_script_target: dir.path().join("bin/aw-health-check"),
+ health_script_target: health_target,
health_timer_target: dir.path().join("systemd/aw-health-check.timer"),
health_service_target: dir.path().join("systemd/aw-health-check.service"),
- health_script_source: health,
apply: false,
json: true,
};
@@ -525,18 +490,18 @@ mod tests {
#[test]
fn missing_env_blocks_apply() {
let dir = tempfile::tempdir().unwrap();
- let health = dir.path().join("health-check.sh");
- fs::write(&health, "#!/bin/sh\nexit 0\n").unwrap();
+ let health_target = dir.path().join("bin/aw-health-check");
+ fs::create_dir_all(health_target.parent().unwrap()).unwrap();
+ fs::write(&health_target, "#!/bin/sh\nexit 0\n").unwrap();
let cli = Cli {
env_file: dir.path().join("missing.env"),
data_dir: dir.path().join("data"),
log_dir: dir.path().join("log"),
opt_dir: dir.path().join("opt"),
logrotate_target: dir.path().join("logrotate/activitywatch"),
- health_script_target: dir.path().join("bin/aw-health-check"),
+ health_script_target: health_target,
health_timer_target: dir.path().join("systemd/aw-health-check.timer"),
health_service_target: dir.path().join("systemd/aw-health-check.service"),
- health_script_source: health,
apply: true,
json: true,
};
diff --git a/adk-rust/crates/aw-slo-monitor/src/main.rs b/adk-rust/crates/aw-slo-monitor/src/main.rs
index bb1fe14..3a7f77e 100644
--- a/adk-rust/crates/aw-slo-monitor/src/main.rs
+++ b/adk-rust/crates/aw-slo-monitor/src/main.rs
@@ -20,7 +20,7 @@ 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_CMD: &str = "/usr/local/bin/aw-rus-healthd-rust --json";
const DEFAULT_HEALTHD_STATE_FILE: &str = "/var/lib/activitywatch/health/aw-rus-health.json";
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
diff --git a/adk-rust/crates/prod-backup-restore/src/main.rs b/adk-rust/crates/prod-backup-restore/src/main.rs
index 1d4a00f..5767bf5 100644
--- a/adk-rust/crates/prod-backup-restore/src/main.rs
+++ b/adk-rust/crates/prod-backup-restore/src/main.rs
@@ -13,7 +13,7 @@ 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";
+const DEFAULT_REMOTE_MERGE_BIN: &str = "/tmp/merge-aw-server-dbs";
#[derive(Debug, Parser)]
#[command(about = "Safe planner/checker for the destructive prod backup restore flow")]
@@ -45,8 +45,8 @@ struct Cli {
#[arg(long, default_value = DEFAULT_TARGET_DB)]
target_db: String,
- #[arg(long, default_value = DEFAULT_REMOTE_MERGE_SCRIPT)]
- remote_merge_script: String,
+ #[arg(long, default_value = DEFAULT_REMOTE_MERGE_BIN)]
+ remote_merge_bin: String,
#[arg(long)]
apply: bool,
@@ -63,7 +63,7 @@ struct Plan {
remote_backup_dir: String,
legacy_db: String,
target_db: String,
- remote_merge_script: String,
+ remote_merge_bin: String,
required_env: Vec,
required_commands: Vec,
required_files: Vec,
@@ -101,7 +101,7 @@ 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"
+ "Rust apply is intentionally disabled for this stage; run without --apply to review the safe restore plan"
);
}
let root = cli
@@ -128,7 +128,7 @@ fn run() -> Result {
} else {
root.join(&cli.inventory)
};
- let merge_script = root.join("scripts/merge_aw_server_dbs.py");
+ let merge_bin = root.join("adk-rust/target/release/merge-aw-server-dbs");
let plan = build_plan(
&root,
&env_file,
@@ -139,9 +139,9 @@ fn run() -> Result {
&remote_backup_dir,
&cli.legacy_db,
&cli.target_db,
- &cli.remote_merge_script,
+ &cli.remote_merge_bin,
&inventory,
- &merge_script,
+ &merge_bin,
);
if cli.json {
@@ -168,9 +168,9 @@ fn build_plan(
remote_backup_dir: &str,
legacy_db: &str,
target_db: &str,
- remote_merge_script: &str,
+ remote_merge_bin: &str,
inventory: &Path,
- merge_script: &Path,
+ merge_bin: &Path,
) -> Plan {
let required_env = ["AW_SSH_PASSWORD", "AW_WINRM_PASSWORD"]
.into_iter()
@@ -199,24 +199,21 @@ fn build_plan(
},
})
.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 required_files = [("inventory", inventory), ("merge-aw-server-dbs", merge_bin)]
+ .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}"
+ "sshpass scp adk-rust/target/release/merge-aw-server-dbs {server_user}@{server_host}:{remote_merge_bin}"
),
false,
);
@@ -258,7 +255,7 @@ fn build_plan(
&mut steps,
"ssh",
format!(
- "sudo python3 '{remote_merge_script}' --base '{legacy_db}' --overlay '{target_db}' --output '{remote_backup_dir}/sqlite.merged.db'"
+ "sudo chmod 0755 '{remote_merge_bin}' && sudo '{remote_merge_bin}' --base '{legacy_db}' --overlay '{target_db}' --output '{remote_backup_dir}/sqlite.merged.db'"
),
true,
);
@@ -307,7 +304,7 @@ fn build_plan(
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(),
+ remote_merge_bin: remote_merge_bin.to_string(),
required_env,
required_commands,
required_files,
@@ -443,7 +440,9 @@ mod tests {
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");
+ let merge = dir
+ .path()
+ .join("adk-rust/target/release/merge-aw-server-dbs");
fs::create_dir_all(inventory.parent().unwrap()).unwrap();
fs::create_dir_all(merge.parent().unwrap()).unwrap();
fs::write(&inventory, "").unwrap();
@@ -461,7 +460,7 @@ mod tests {
"/var/lib/activitywatch/backups/prod-restore-20260602-000000",
DEFAULT_LEGACY_DB,
DEFAULT_TARGET_DB,
- DEFAULT_REMOTE_MERGE_SCRIPT,
+ DEFAULT_REMOTE_MERGE_BIN,
&inventory,
&merge,
);
diff --git a/adk-rust/crates/rebuild-install-kit/src/main.rs b/adk-rust/crates/rebuild-install-kit/src/main.rs
index 31c34db..74c39b4 100644
--- a/adk-rust/crates/rebuild-install-kit/src/main.rs
+++ b/adk-rust/crates/rebuild-install-kit/src/main.rs
@@ -47,23 +47,18 @@ const AW_SERVER_FILES: &[&str] = &[
"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",
diff --git a/ansible/deploy_aw_server.yml b/ansible/deploy_aw_server.yml
index 66defd1..6a81578 100644
--- a/ansible/deploy_aw_server.yml
+++ b/ansible/deploy_aw_server.yml
@@ -27,6 +27,7 @@
- tesseract-ocr
- tesseract-ocr-rus
- unzip
+ - jq
state: present
update_cache: true
@@ -109,6 +110,64 @@
- "{{ aw_rus_health_validation_dir }}"
- "{{ aw_server_log_dir }}"
+ - name: Проверить существующие health state files
+ ansible.builtin.stat:
+ path: "{{ item }}"
+ loop:
+ - "{{ aw_rus_health_state_dir }}/aw-rus-health.json"
+ - "{{ aw_rus_health_state_dir }}/aw-rus-health.txt"
+ - "{{ aw_rus_health_state_dir }}/dlp-health-check-counters.json"
+ register: aw_rus_health_state_files
+
+ - name: Нормализовать владельца существующих health state files
+ ansible.builtin.file:
+ path: "{{ item.stat.path }}"
+ owner: "{{ aw_server_user }}"
+ group: "{{ aw_server_group }}"
+ mode: "0644"
+ loop: "{{ aw_rus_health_state_files.results }}"
+ when: item.stat.exists | default(false)
+
+ - name: Удалить устаревшие Python entrypoints после Rust migration
+ ansible.builtin.file:
+ path: "{{ item }}"
+ state: absent
+ loop:
+ - /opt/activitywatch/aw-rus-ops/health-check.sh
+ - /opt/activitywatch/aw-rus-ops/hayabusa/aw-hayabusa-autoprocess.py
+ - /opt/activitywatch/aw-rus-ops/hayabusa/aw-hayabusa-case-alert.py
+ - /opt/activitywatch/aw-rus-ops/hayabusa/aw-hayabusa-from-windows.py
+ - /opt/activitywatch/aw-rus-ops/hayabusa/aw-hayabusa-link-case.py
+ - /opt/activitywatch/dlp-case-management/.venv
+ - /opt/activitywatch/dlp-case-management/case_rules.py
+ - /opt/activitywatch/dlp-case-management/case_schema.py
+ - /opt/activitywatch/dlp-case-management/case_service.py
+ - /opt/activitywatch/dlp-case-management/case_storage.py
+ - /opt/activitywatch/dlp-case-management/evidence_chain.py
+ - /opt/activitywatch/dlp-compliance/compliance_scheduler.py
+ - /opt/activitywatch/dlp-compliance/report_generator.py
+ - /opt/activitywatch/dlp-compliance/.venv
+ - /opt/activitywatch/dlp-integrations/cef_exporter.py
+ - /opt/activitywatch/dlp-integrations/syslog_forwarder.py
+ - /opt/activitywatch/dlp-integrations/webhook_sender.py
+ - /opt/activitywatch/dlp-integrations/.venv
+ - /opt/activitywatch/dlp-policy-engine/.venv
+ - /opt/activitywatch/dlp-policy-engine/policy_distributor.py
+ - /opt/activitywatch/dlp-policy-engine/policy_schema.py
+ - /opt/activitywatch/dlp-policy-engine/policy_service.py
+ - /opt/activitywatch/dlp-policy-engine/policy_storage.py
+ - /opt/activitywatch/scripts/aggregate_dlp_events.py
+ - /usr/local/bin/aw-dlp-influx-exporter.py
+ - /usr/local/bin/aw-extract-ioc-from-sigma.py
+ - /usr/local/bin/aw-rus-healthd.py
+ - /usr/local/bin/aw-slo-monitor.py
+ - /usr/local/bin/aw-worktime-afk-bridge.py
+ - /usr/local/bin/aw-worktime-api.py
+ - /usr/local/bin/aw-worktime-influx-exporter.py
+ - /usr/local/bin/aw-worktime-ui-bridge.py
+ - /usr/local/bin/dlp-admin-cli.py
+ - /usr/local/bin/merge_aw_server_dbs.py
+
- name: Установить prune script для локального state
ansible.builtin.copy:
src: "{{ aw_repo_root }}/aw-server/aw-prune-local-state.sh"
@@ -431,14 +490,6 @@
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"
@@ -455,6 +506,12 @@
mode: "0755"
when: aw_health_check_rust_binary_early.stat.exists | default(false)
+ - name: Требовать Rust aw-health-check до Influx проверок
+ ansible.builtin.assert:
+ that:
+ - aw_health_check_rust_binary_early.stat.exists | default(false)
+ fail_msg: "Rust aw-health-check artifact is required at {{ aw_rust_release_dir }}/aw-health-check"
+
- name: Проверить локальный Rust aw-db-health до Influx проверок
ansible.builtin.stat:
path: "{{ aw_rust_release_dir }}/aw-db-health"
@@ -650,34 +707,6 @@
mode: "0755"
when: aw_dlp_policy_engine_enabled | default(false) | bool
- - name: Скопировать файлы DLP policy engine
- ansible.builtin.copy:
- src: "{{ aw_repo_root }}/aw-server/dlp-policy-engine/{{ item }}"
- dest: "/opt/activitywatch/dlp-policy-engine/{{ item }}"
- owner: "{{ aw_server_user }}"
- group: "{{ aw_server_group }}"
- mode: "0644"
- loop:
- - policy_service.py
- - policy_schema.py
- - policy_storage.py
- - policy_distributor.py
- - requirements.txt
- when: aw_dlp_policy_engine_enabled | default(false) | bool
-
- - name: Создать virtualenv DLP policy engine
- ansible.builtin.command:
- cmd: python3 -m venv /opt/activitywatch/dlp-policy-engine/.venv
- args:
- creates: /opt/activitywatch/dlp-policy-engine/.venv/bin/python
- when: aw_dlp_policy_engine_enabled | default(false) | bool
-
- - name: Установить зависимости DLP policy engine
- ansible.builtin.pip:
- requirements: /opt/activitywatch/dlp-policy-engine/requirements.txt
- virtualenv: /opt/activitywatch/dlp-policy-engine/.venv
- when: aw_dlp_policy_engine_enabled | default(false) | bool
-
- name: Установить systemd unit DLP policy engine
ansible.builtin.copy:
src: "{{ aw_repo_root }}/aw-server/dlp-policy-engine/dlp-policy-engine.service"
@@ -695,6 +724,13 @@
become: false
when: aw_dlp_policy_engine_enabled | default(false) | bool
+ - name: Требовать Rust DLP policy engine artifact
+ ansible.builtin.assert:
+ that:
+ - aw_dlp_policy_engine_rust_binary.stat.exists | default(false)
+ fail_msg: "Missing Rust artifact: {{ aw_rust_release_dir }}/dlp-policy-engine"
+ 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"
@@ -706,31 +742,6 @@
- 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
@@ -807,38 +818,9 @@
group: "{{ aw_server_group }}"
mode: "0644"
loop:
- - cef_exporter.py
- - syslog_forwarder.py
- - webhook_sender.py
- cef-config.yaml
- syslog-forwarder-config.yaml
- webhook-config.yaml
- - requirements.txt
- when: aw_dlp_integrations_enabled | default(true) | bool
-
- - name: Выставить executable для python scripts DLP integrations
- ansible.builtin.file:
- path: "/opt/activitywatch/dlp-integrations/{{ item }}"
- owner: "{{ aw_server_user }}"
- group: "{{ aw_server_group }}"
- mode: "0755"
- loop:
- - cef_exporter.py
- - syslog_forwarder.py
- - webhook_sender.py
- when: aw_dlp_integrations_enabled | default(true) | bool
-
- - name: Создать virtualenv DLP integrations
- ansible.builtin.command:
- cmd: python3 -m venv /opt/activitywatch/dlp-integrations/.venv
- args:
- creates: /opt/activitywatch/dlp-integrations/.venv/bin/python
- when: aw_dlp_integrations_enabled | default(true) | bool
-
- - name: Установить зависимости DLP integrations
- ansible.builtin.pip:
- requirements: /opt/activitywatch/dlp-integrations/requirements.txt
- virtualenv: /opt/activitywatch/dlp-integrations/.venv
when: aw_dlp_integrations_enabled | default(true) | bool
- name: Создать state каталог DLP integrations
@@ -876,6 +858,13 @@
become: false
when: aw_dlp_integrations_enabled | default(true) | bool
+ - name: Требовать Rust CEF exporter artifact
+ ansible.builtin.assert:
+ that:
+ - dlp_cef_exporter_rust_binary.stat.exists | default(false)
+ fail_msg: "Missing Rust artifact: {{ aw_rust_release_dir }}/dlp-cef-exporter"
+ when: aw_dlp_integrations_enabled | default(true) | bool
+
- name: Установить Rust CEF exporter
ansible.builtin.copy:
src: "{{ aw_rust_release_dir }}/dlp-cef-exporter"
@@ -887,31 +876,6 @@
- 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"
@@ -938,6 +902,13 @@
become: false
when: aw_dlp_integrations_enabled | default(true) | bool
+ - name: Требовать Rust syslog forwarder artifact
+ ansible.builtin.assert:
+ that:
+ - dlp_syslog_forwarder_rust_binary.stat.exists | default(false)
+ fail_msg: "Missing Rust artifact: {{ aw_rust_release_dir }}/dlp-syslog-forwarder"
+ when: aw_dlp_integrations_enabled | default(true) | bool
+
- name: Установить Rust syslog forwarder
ansible.builtin.copy:
src: "{{ aw_rust_release_dir }}/dlp-syslog-forwarder"
@@ -949,31 +920,6 @@
- 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"
@@ -1000,6 +946,13 @@
become: false
when: aw_dlp_integrations_enabled | default(true) | bool
+ - name: Требовать Rust webhook sender artifact
+ ansible.builtin.assert:
+ that:
+ - dlp_webhook_sender_rust_binary.stat.exists | default(false)
+ fail_msg: "Missing Rust artifact: {{ aw_rust_release_dir }}/dlp-webhook-sender"
+ when: aw_dlp_integrations_enabled | default(true) | bool
+
- name: Установить Rust webhook sender
ansible.builtin.copy:
src: "{{ aw_rust_release_dir }}/dlp-webhook-sender"
@@ -1011,31 +964,6 @@
- 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
@@ -1045,43 +973,6 @@
mode: "0755"
when: aw_dlp_case_management_enabled | default(true) | bool
- - name: Скопировать файлы DLP case management
- ansible.builtin.copy:
- src: "{{ aw_repo_root }}/aw-server/dlp-case-management/{{ item }}"
- dest: "/opt/activitywatch/dlp-case-management/{{ item }}"
- owner: "{{ aw_server_user }}"
- group: "{{ aw_server_group }}"
- mode: "0644"
- loop:
- - case_service.py
- - case_rules.py
- - case_schema.py
- - case_storage.py
- - evidence_chain.py
- - requirements.txt
- when: aw_dlp_case_management_enabled | default(true) | bool
-
- - name: Выставить executable для case_service
- ansible.builtin.file:
- path: /opt/activitywatch/dlp-case-management/case_service.py
- owner: "{{ aw_server_user }}"
- group: "{{ aw_server_group }}"
- mode: "0755"
- when: aw_dlp_case_management_enabled | default(true) | bool
-
- - name: Создать virtualenv DLP case management
- ansible.builtin.command:
- cmd: python3 -m venv /opt/activitywatch/dlp-case-management/.venv
- args:
- creates: /opt/activitywatch/dlp-case-management/.venv/bin/python
- when: aw_dlp_case_management_enabled | default(true) | bool
-
- - name: Установить зависимости DLP case management
- ansible.builtin.pip:
- requirements: /opt/activitywatch/dlp-case-management/requirements.txt
- virtualenv: /opt/activitywatch/dlp-case-management/.venv
- when: aw_dlp_case_management_enabled | default(true) | bool
-
- name: Установить systemd unit DLP case management
ansible.builtin.copy:
src: "{{ aw_repo_root }}/aw-server/dlp-case-management/case-service.service"
@@ -1099,6 +990,13 @@
become: false
when: aw_dlp_case_management_enabled | default(true) | bool
+ - name: Требовать Rust DLP case management artifact
+ ansible.builtin.assert:
+ that:
+ - aw_dlp_case_management_rust_binary.stat.exists | default(false)
+ fail_msg: "Missing Rust artifact: {{ aw_rust_release_dir }}/dlp-case-management"
+ 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"
@@ -1110,31 +1008,6 @@
- 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 }}"
@@ -1156,28 +1029,12 @@
group: "{{ aw_server_group }}"
mode: "{{ item.mode }}"
loop:
- - { src: "report_generator.py", dest: "/opt/activitywatch/dlp-compliance/report_generator.py", mode: "0755" }
- - { src: "compliance_scheduler.py", dest: "/opt/activitywatch/dlp-compliance/compliance_scheduler.py", mode: "0755" }
- - { src: "requirements.txt", dest: "/opt/activitywatch/dlp-compliance/requirements.txt", mode: "0644" }
- { src: "templates/152-fz-report.html", dest: "/opt/activitywatch/dlp-compliance/templates/152-fz-report.html", mode: "0644" }
- { src: "templates/pci-dss-report.html", dest: "/opt/activitywatch/dlp-compliance/templates/pci-dss-report.html", mode: "0644" }
- { src: "report-scheduler.service", dest: "/etc/systemd/system/aw-dlp-report-scheduler.service", mode: "0644" }
- { src: "report-scheduler.timer", dest: "/etc/systemd/system/aw-dlp-report-scheduler.timer", mode: "0644" }
when: aw_dlp_compliance_enabled | default(true) | bool
- - name: Создать virtualenv DLP compliance
- ansible.builtin.command:
- cmd: python3 -m venv /opt/activitywatch/dlp-compliance/.venv
- args:
- creates: /opt/activitywatch/dlp-compliance/.venv/bin/python
- when: aw_dlp_compliance_enabled | default(true) | bool
-
- - name: Установить зависимости DLP compliance
- ansible.builtin.pip:
- requirements: /opt/activitywatch/dlp-compliance/requirements.txt
- 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"
@@ -1186,6 +1043,13 @@
become: false
when: aw_dlp_compliance_enabled | default(true) | bool
+ - name: Требовать Rust DLP compliance artifact
+ ansible.builtin.assert:
+ that:
+ - aw_dlp_compliance_rust_binary.stat.exists | default(false)
+ fail_msg: "Missing Rust artifact: {{ aw_rust_release_dir }}/dlp-compliance"
+ when: aw_dlp_compliance_enabled | default(true) | bool
+
- name: Установить Rust DLP compliance
ansible.builtin.copy:
src: "{{ aw_rust_release_dir }}/dlp-compliance"
@@ -1197,45 +1061,18 @@
- 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"
- dest: /usr/local/bin/dlp-admin-cli.py
- owner: root
- 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 artifact
+ ansible.builtin.assert:
+ that:
+ - aw_dlp_admin_cli_rust_binary.stat.exists | default(false)
+ fail_msg: "Missing Rust artifact: {{ aw_rust_release_dir }}/dlp-admin-cli"
+
- name: Установить Rust dlp-admin-cli
ansible.builtin.copy:
src: "{{ aw_rust_release_dir }}/dlp-admin-cli"
@@ -1245,14 +1082,6 @@
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"
- dest: /usr/local/bin/aw-worktime-api.py
- owner: root
- group: root
- mode: "0755"
-
- name: Проверить локальный Rust AW worktime API
ansible.builtin.stat:
path: "{{ aw_rust_release_dir }}/worktime-api"
@@ -1260,6 +1089,12 @@
register: aw_worktime_api_rust_binary
become: false
+ - name: Требовать Rust AW worktime API artifact
+ ansible.builtin.assert:
+ that:
+ - aw_worktime_api_rust_binary.stat.exists | default(false)
+ fail_msg: "Missing Rust artifact: {{ aw_rust_release_dir }}/worktime-api"
+
- name: Установить Rust AW worktime API
ansible.builtin.copy:
src: "{{ aw_rust_release_dir }}/worktime-api"
@@ -1277,35 +1112,6 @@
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"
- dest: /usr/local/bin/aw-worktime-ui-bridge.py
- owner: root
- group: root
- mode: "0755"
-
- name: Проверить локальный Rust AW worktime UI bridge
ansible.builtin.stat:
path: "{{ aw_rust_release_dir }}/worktime-ui-bridge"
@@ -1313,6 +1119,12 @@
register: aw_worktime_ui_bridge_rust_binary
become: false
+ - name: Требовать Rust AW worktime UI bridge artifact
+ ansible.builtin.assert:
+ that:
+ - aw_worktime_ui_bridge_rust_binary.stat.exists | default(false)
+ fail_msg: "Missing Rust artifact: {{ aw_rust_release_dir }}/worktime-ui-bridge"
+
- name: Установить Rust AW worktime UI bridge
ansible.builtin.copy:
src: "{{ aw_rust_release_dir }}/worktime-ui-bridge"
@@ -1322,14 +1134,6 @@
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"
- dest: /usr/local/bin/aw-worktime-autoheal.sh
- owner: root
- group: root
- mode: "0755"
-
- name: Проверить локальный Rust AW worktime autoheal
ansible.builtin.stat:
path: "{{ aw_rust_release_dir }}/worktime-autoheal"
@@ -1337,6 +1141,12 @@
register: aw_worktime_autoheal_rust_binary
become: false
+ - name: Требовать Rust AW worktime autoheal artifact
+ ansible.builtin.assert:
+ that:
+ - aw_worktime_autoheal_rust_binary.stat.exists | default(false)
+ fail_msg: "Missing Rust artifact: {{ aw_rust_release_dir }}/worktime-autoheal"
+
- name: Установить Rust AW worktime autoheal
ansible.builtin.copy:
src: "{{ aw_rust_release_dir }}/worktime-autoheal"
@@ -1346,14 +1156,6 @@
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"
@@ -1361,6 +1163,12 @@
register: aw_worktime_prewarm_rust_binary
become: false
+ - name: Требовать Rust AW worktime prewarm artifact
+ ansible.builtin.assert:
+ that:
+ - aw_worktime_prewarm_rust_binary.stat.exists | default(false)
+ fail_msg: "Missing Rust artifact: {{ aw_rust_release_dir }}/worktime-prewarm"
+
- name: Установить Rust AW worktime prewarm
ansible.builtin.copy:
src: "{{ aw_rust_release_dir }}/worktime-prewarm"
@@ -1370,25 +1178,7 @@
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"
- dest: /usr/local/bin/aw-worktime-influx-exporter.py
- owner: root
- group: root
- mode: "0755"
- when: aw_worktime_influx_enabled | default(false) | bool
-
- - name: Установить скрипт AW DLP Influx exporter
- ansible.builtin.copy:
- src: "{{ aw_repo_root }}/aw-server/aw-dlp-influx-exporter.py"
- dest: /usr/local/bin/aw-dlp-influx-exporter.py
- owner: root
- group: root
- mode: "0755"
- when: aw_dlp_influx_enabled | default(false) | bool
-
- - name: Создать server-side ops bundle для legacy aw-health-check
+ - name: Создать server-side ops bundle для AW ops helpers
ansible.builtin.file:
path: /opt/activitywatch/aw-rus-ops
state: directory
@@ -1404,14 +1194,6 @@
group: root
mode: "0755"
- - 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"
@@ -1428,6 +1210,12 @@
mode: "0755"
when: aw_health_check_rust_binary.stat.exists | default(false)
+ - name: Требовать Rust aw-health-check artifact
+ ansible.builtin.assert:
+ that:
+ - aw_health_check_rust_binary.stat.exists | default(false)
+ fail_msg: "Rust aw-health-check artifact is required at {{ aw_rust_release_dir }}/aw-health-check"
+
- name: Проверить локальный Rust aw-db-health
ansible.builtin.stat:
path: "{{ aw_rust_release_dir }}/aw-db-health"
@@ -1492,30 +1280,12 @@
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:
- src: "{{ aw_repo_root }}/aw-server/aw-rus-healthd.py"
- dest: /usr/local/bin/aw-rus-healthd.py
- owner: root
- 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 healthd
+ ansible.builtin.stat:
+ path: "{{ aw_rust_release_dir }}/aw-rus-healthd"
+ delegate_to: localhost
+ register: aw_rus_healthd_rust_binary
+ become: false
- name: Проверить локальный Rust AW-RUS SLO monitor
ansible.builtin.stat:
@@ -1524,6 +1294,32 @@
register: aw_slo_monitor_rust_binary
become: false
+ - name: Требовать Rust dlp-health-check artifact
+ ansible.builtin.assert:
+ that:
+ - dlp_health_check_rust_binary.stat.exists | default(false)
+ fail_msg: "Missing Rust artifact: {{ aw_rust_release_dir }}/dlp-health-check"
+
+ - name: Требовать Rust AW-RUS healthd artifact
+ ansible.builtin.assert:
+ that:
+ - aw_rus_healthd_rust_binary.stat.exists | default(false)
+ fail_msg: "Missing Rust artifact: {{ aw_rust_release_dir }}/aw-rus-healthd"
+
+ - name: Требовать Rust AW-RUS SLO monitor artifact
+ ansible.builtin.assert:
+ that:
+ - aw_slo_monitor_rust_binary.stat.exists | default(false)
+ fail_msg: "Missing Rust artifact: {{ aw_rust_release_dir }}/aw-slo-monitor"
+
+ - name: Установить Rust AW-RUS healthd
+ ansible.builtin.copy:
+ src: "{{ aw_rust_release_dir }}/aw-rus-healthd"
+ dest: /usr/local/bin/aw-rus-healthd-rust
+ owner: root
+ group: root
+ mode: "0755"
+
- name: Установить Rust AW-RUS SLO monitor
ansible.builtin.copy:
src: "{{ aw_rust_release_dir }}/aw-slo-monitor"
@@ -1550,27 +1346,6 @@
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"
@@ -1587,27 +1362,6 @@
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"
@@ -1624,27 +1378,6 @@
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"
@@ -1677,24 +1410,6 @@
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"
@@ -1738,6 +1453,13 @@
become: false
when: aw_worktime_influx_enabled | default(false) | bool
+ - name: Требовать Rust AW worktime Influx exporter artifact
+ ansible.builtin.assert:
+ that:
+ - aw_worktime_influx_exporter_rust_binary.stat.exists | default(false)
+ fail_msg: "Missing Rust artifact: {{ aw_rust_release_dir }}/worktime-influx-exporter"
+ 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"
@@ -1749,31 +1471,6 @@
- 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"
@@ -1800,6 +1497,13 @@
become: false
when: aw_dlp_influx_enabled | default(false) | bool
+ - name: Требовать Rust AW DLP Influx exporter artifact
+ ansible.builtin.assert:
+ that:
+ - aw_dlp_influx_exporter_rust_binary.stat.exists | default(false)
+ fail_msg: "Missing Rust artifact: {{ aw_rust_release_dir }}/dlp-influx-exporter"
+ 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"
@@ -1811,31 +1515,6 @@
- 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"
@@ -2044,14 +1723,6 @@
regexp: '
-
-
- Отчёт по работе в RDP
- Хост: {resolve_host(host)} · Дата: {date_local} · Часовой пояс: {REPORT_TZ} · Сформировано UTC: {generated}
-
-
-
-
-
- {''.join(f"
{html.escape(label)}{html.escape(value)}
" for label, value in cards)}
-
-
-
- Доказанная работа по приложениям
-
-
-
- | Приложение |
- Доказанная работа |
- Последнее действие |
-
-
-
- {''.join(true_active_rows)}
-
-
-
-
- Таблица по пользователям
-
-
-
- | Пользователь |
- Учётная запись |
- Активно |
- Активно, сек |
- Начало активности |
- Конец активности |
- Простой, сек |
- Сессии |
- Сэмплы |
-
-
-
- {''.join(trs)}
-
-
-
-
- Детали по пользователям
-
- {''.join(detail_cards)}
-
-
-
-
-"""
-
-
-def build_management_report_url(host, report_date, selected_day=None, fmt=None, owner_filter="", department_filter=""):
- params = {"host": resolve_host(host)}
- if fmt:
- params["format"] = fmt
- if selected_day in {"today", "yesterday"}:
- params["day"] = selected_day
- else:
- params["date"] = report_date
- if normalize_management_filter(owner_filter):
- params["owner"] = normalize_management_filter(owner_filter)
- if normalize_management_filter(department_filter):
- params["department"] = normalize_management_filter(department_filter)
- return "/reports/worktime/management?" + urlencode(params)
-
-
-def render_management_html(payload, selected_day=None):
- summary = payload["summary"]
- workday = payload["workday"]
- report_date = payload["report_date"]
- host = payload["host"]
- executive = payload.get("executive") or {}
- active_filters = payload.get("filters") or {}
- owner_filter = normalize_management_filter(active_filters.get("owner"))
- department_filter = normalize_management_filter(active_filters.get("department"))
- today_url = build_management_report_url(host, report_date, selected_day="today", fmt="html", owner_filter=owner_filter, department_filter=department_filter)
- yesterday_url = build_management_report_url(host, report_date, selected_day="yesterday", fmt="html", owner_filter=owner_filter, department_filter=department_filter)
- json_url = build_management_report_url(host, report_date, selected_day=selected_day, owner_filter=owner_filter, department_filter=department_filter)
- classic_url = "/reports/worktime/today?" + urlencode({"format": "html", "host": host, **({"day": selected_day} if selected_day in {"today", "yesterday"} else {"date": report_date})})
- reset_url = build_management_report_url(host, report_date, selected_day=selected_day, fmt="html")
- actions_html = []
- for action in payload["actions"]:
- actions_html.append(
- ""
- f"| {html.escape(action['priority'])} | "
- f"{html.escape(action['owner'])} | "
- f"{html.escape(action['action_id'])} | "
- f"{html.escape(action['deadline_hint'])} | "
- f"{html.escape(action['reason'])} | "
- f"{html.escape(action['recommended_action'])} | "
- "
"
- )
- if not actions_html:
- actions_html.append("| Отклонений по текущим правилам не найдено. |
")
-
- roster_html = []
- for row in payload["rows"]:
- roster_html.append(
- ""
- f"| {html.escape(row['user'])} | "
- f"{html.escape(row.get('canonical_user_id') or row['user_id'])} | "
- f"{html.escape(row.get('manager_owner') or row['user'])} | "
- f"{html.escape(row.get('department') or '-')} | "
- f"{html.escape(row['workday_active_hhmm'])} | "
- f"{html.escape(row['calendar_active_hhmm'])} | "
- f"{row['coverage_pct']} | "
- f"{html.escape(row['status'])} | "
- f"{html.escape(row.get('workday_first_activity_local') or '-')} | "
- f"{html.escape(row.get('workday_last_activity_local') or '-')} | "
- f"{row['sessions_count']} | "
- "
"
- )
- if not roster_html:
- roster_html.append("| За выбранную дату данных нет. |
")
-
- trend_html = []
- for row in payload.get("trend", []):
- trend_html.append(
- ""
- f"| {html.escape(row['report_date'])} | "
- f"{row['users_count']} | "
- f"{row['active_users']} | "
- f"{row['inactive_users']} | "
- f"{html.escape(row['workday_total_active_hhmm'])} | "
- f"{row['portfolio_coverage_pct']} | "
- f"{row['actions_count']} | "
- f"{row['critical_actions_count']} | "
- "
"
- )
- if not trend_html:
- trend_message = "Тренд по выбранному фильтру пока отключён, чтобы current-scope отчёт отвечал быстро." if (owner_filter or department_filter) else "Тренд пока недоступен."
- trend_html.append(f"| {html.escape(trend_message)} |
")
-
- owner_rollup_html = []
- for item in payload.get("owner_rollups", []):
- owner_url = build_management_report_url(host, report_date, selected_day=selected_day, fmt="html", owner_filter=item["name"], department_filter=department_filter)
- owner_rollup_html.append(
- ""
- f"| {html.escape(item['name'])} | "
- f"{item['users_count']} | "
- f"{item['inactive_users']} | "
- f"{item['below_target_users']} | "
- f"{item['critical_actions_count']} | "
- f"{item['high_actions_count']} | "
- f"{html.escape(item['workday_total_active_hhmm'])} | "
- f"{item['portfolio_coverage_pct']} | "
- f"{html.escape(', '.join(item['users']) if item['users'] else '-')} | "
- "
"
- )
- if not owner_rollup_html:
- owner_rollup_html.append("| Нет данных по ответственным. |
")
-
- department_rollup_html = []
- for item in payload.get("department_rollups", []):
- department_url = build_management_report_url(host, report_date, selected_day=selected_day, fmt="html", owner_filter=owner_filter, department_filter=item["name"])
- department_rollup_html.append(
- ""
- f"| {html.escape(item['name'])} | "
- f"{item['users_count']} | "
- f"{item['inactive_users']} | "
- f"{item['below_target_users']} | "
- f"{item['critical_actions_count']} | "
- f"{item['high_actions_count']} | "
- f"{html.escape(item['workday_total_active_hhmm'])} | "
- f"{item['portfolio_coverage_pct']} | "
- f"{html.escape(', '.join(item['users']) if item['users'] else '-')} | "
- "
"
- )
- if not department_rollup_html:
- department_rollup_html.append("| Нет данных по подразделениям. |
")
-
- owner_profile_html = []
- for item in payload.get("owner_roster", []):
- owner_profile_html.append(
- ""
- f" 0 else ('high' if item['high_actions_count'] > 0 else 'low')}'>{html.escape(item['display_name'])}
"
- f"{html.escape(item['title'] or item['name'])}
"
- f"Подразделение: {html.escape(item['department'] or '-')}
"
- f"Пользователи: {item['users_count']} · inactive: {item['inactive_users']} · actions: {item['actions_count']}
"
- f"Контакт: {html.escape(item['contact'] or '-')}
"
- f"Эскалация: {html.escape(item['escalation_to'] or '-')}
"
- f"{html.escape(item['notes'] or 'Без дополнительных заметок.')}
"
- ""
- )
- if not owner_profile_html:
- owner_profile_html.append("Каталог ответственных пуст
Добавьте блок owners в worktime-manager-aliases.json, чтобы отчёт показывал роли, контакты и эскалацию.
")
-
- 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['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 '-')} | "
- f"{html.escape(source.get('event_summary') or source.get('summary') or '-')} | "
- "
"
- )
- if not sources_html:
- sources_html.append("| Статусы источников недоступны. |
")
-
- focus_html = []
- for item in executive.get("focus_items", []):
- focus_html.append(
- ""
- f"{html.escape(item['priority'])}
"
- f"{html.escape(item['title'])}
"
- f"Ответственный: {html.escape(item['owner'])}
"
- f"{html.escape(item['reason'])}
"
- f"{html.escape(item['recommended_action'])}"
- ""
- )
- if not focus_html:
- focus_html.append("Критичных действий нет
На текущий момент менеджерских отклонений по активным правилам не найдено.
")
-
- stale_html = []
- for item in executive.get("stale_sources", []):
- stale_html.append(
- ""
- f"{html.escape(item['label'])}: {html.escape(item['status'])} · {html.escape(item['summary'])}"
- ""
- )
- stale_block = (
- "Проблемы со свежестью источников:"
- + "".join(stale_html)
- + "
"
- ) if stale_html else ""
- filter_parts = []
- if owner_filter:
- filter_parts.append(f"ответственный: {html.escape(owner_filter)}")
- if department_filter:
- filter_parts.append(f"подразделение: {html.escape(department_filter)}")
- filter_block = (
- "Фильтр: "
- + " · ".join(filter_parts)
- + f"
Сбросить "
- ) if filter_parts else ""
-
- cards = [
- ("Пользователи", str(summary["users_count"])),
- ("Активны", str(summary["active_users"])),
- ("Без активности", str(summary["inactive_users"])),
- ("Ниже цели", str(summary["below_target_users"])),
- ("Покрытие", f"{summary['portfolio_coverage_pct']}%"),
- ("Действия", str(summary["actions_count"])),
- ("Рабочее окно", summary["workday_total_active_hhmm"]),
- ("Календарный день", summary["calendar_total_active_hhmm"]),
- ]
-
- return f"""
-
-
-
-
- AW-rus Управленческий отчёт по работе в RDP
-
-
-
-
-
- Управленческий отчёт по работе в RDP
- Хост: {html.escape(host)} · Дата: {html.escape(report_date)} · Рабочее окно: {html.escape(workday['start_local'])} -> {html.escape(workday['end_local'])} · Сформировано UTC: {html.escape(payload['generated_at_utc'])}
-
-
- {''.join(f"
{html.escape(label)}{html.escape(value)}
" for label, value in cards)}
-
-
- Целевое покрытие: {MANAGER_TARGET_COVERAGE_PCT}% от ожидаемого рабочего окна на пользователя.
- Критический провал: ниже {MANAGER_LOW_COVERAGE_PCT}% или полное отсутствие активности.
- Рабочее окно считается отдельно от календарной активности, чтобы ночная работа не маскировала дневной провал.
-
-
-
- Что делать сегодня
-
-
{html.escape(executive.get('headline') or 'Сводка недоступна')}
-
{html.escape(executive.get('message') or '')}
- {filter_block}
- {stale_block}
-
-
- {''.join(focus_html)}
-
-
-
- Тренд за {MANAGER_TREND_DAYS} дней
-
-
-
- | Дата |
- Пользователи |
- Активны |
- Без активности |
- Рабочее окно |
- Покрытие, % |
- Действия |
- Critical |
-
-
-
- {''.join(trend_html)}
-
-
-
-
- По ответственным
-
-
-
- | Ответственный |
- Сотрудники |
- Без активности |
- Ниже цели |
- Critical |
- High |
- Рабочее окно |
- Покрытие, % |
- Кого затрагивает |
-
-
-
- {''.join(owner_rollup_html)}
-
-
-
-
- Ответственные и эскалация
-
- {''.join(owner_profile_html)}
-
-
-
- По подразделениям
-
-
-
- | Подразделение |
- Сотрудники |
- Без активности |
- Ниже цели |
- Critical |
- High |
- Рабочее окно |
- Покрытие, % |
- Кого затрагивает |
-
-
-
- {''.join(department_rollup_html)}
-
-
-
-
- Очередь действий руководителя
-
-
-
- | Приоритет |
- Сотрудник |
- Тип |
- Срок |
- Почему это важно |
- Что сделать |
-
-
-
- {''.join(actions_html)}
-
-
-
-
- Покрытие по сотрудникам
-
-
-
- | Сотрудник |
- Учётная запись |
- Ответственный |
- Подразделение |
- Активно в окне |
- Активно за день |
- Покрытие, % |
- Статус |
- Первая активность в окне |
- Последняя активность в окне |
- Сессии |
-
-
-
- {''.join(roster_html)}
-
-
-
-
- Свежесть источников данных
-
-
-
- | Источник |
- Статус |
- Бакет |
- Последнее событие UTC |
- Возраст, сек |
- Контекст |
-
-
-
- {''.join(sources_html)}
-
-
-
-
-
-"""
-
-
-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)
- handler.send_header("Content-Length", str(len(data)))
- handler.end_headers()
- try:
- handler.wfile.write(data)
- except (BrokenPipeError, ConnectionResetError):
- return False
- return True
-
-
-class H(BaseHTTPRequestHandler):
- def do_GET(self):
- parsed = urlparse(self.path)
- if parsed.path in {"/health", "/api/health"}:
- data = json.dumps(worktime_health_payload(), ensure_ascii=False, indent=2).encode("utf-8")
- send_bytes(self, data, "application/json; charset=utf-8")
- return
-
- if parsed.path.startswith("/dlp-ioc/"):
- name = parsed.path.rsplit("/", 1)[-1]
- if name not in {"ioc_blacklist.json", "ioc_blacklist.csv", "ioc_blacklist.sql"}:
- self.send_response(404)
- self.end_headers()
- return
- path = os.path.join(IOC_DIR, name)
- if not os.path.isfile(path):
- self.send_response(404)
- self.end_headers()
- return
- with open(path, "rb") as f:
- data = f.read()
- if name.endswith(".json"):
- ctype = "application/json; charset=utf-8"
- elif name.endswith(".csv"):
- ctype = "text/csv; charset=utf-8"
- else:
- ctype = "text/plain; charset=utf-8"
- send_bytes(self, data, ctype)
- return
-
- if parsed.path not in {"/reports/worktime/today", "/reports/worktime/management"}:
- self.send_response(404)
- self.end_headers()
- return
-
- params = parse_qs(parsed.query, keep_blank_values=False)
- 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)
- 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,
- )
-
- send_worktime_report(self, parsed.path, params, cache_key, host=host, report_date=report_date)
-
-
-class WorktimeHTTPServer(ThreadingHTTPServer):
- daemon_threads = True
- request_queue_size = 64
-
-
-def main():
- WorktimeHTTPServer((LISTEN_HOST, LISTEN_PORT), H).serve_forever(poll_interval=0.5)
-
-
-if __name__ == "__main__":
- main()
diff --git a/aw-server/aw-worktime-api.service b/aw-server/aw-worktime-api.service
index e85d3bf..07e32d8 100644
--- a/aw-server/aw-worktime-api.service
+++ b/aw-server/aw-worktime-api.service
@@ -8,7 +8,7 @@ StartLimitIntervalSec=60
[Service]
Type=simple
EnvironmentFile=/etc/activitywatch/aw-server.env
-ExecStart=/usr/bin/python3 /usr/local/bin/aw-worktime-api.py
+ExecStart=/usr/local/bin/aw-worktime-api-rust
Restart=on-failure
RestartSec=5
User=activitywatch
diff --git a/aw-server/aw-worktime-autoheal.service b/aw-server/aw-worktime-autoheal.service
index e5bd3ec..37244ff 100644
--- a/aw-server/aw-worktime-autoheal.service
+++ b/aw-server/aw-worktime-autoheal.service
@@ -7,10 +7,9 @@ Wants=network-online.target
Type=oneshot
Environment=AW_URL=http://127.0.0.1:5600
Environment=AW_WORKTIME_HOST=SHARKON2025
-ExecStart=/usr/local/bin/aw-worktime-autoheal.sh
+ExecStart=/usr/local/bin/aw-worktime-autoheal-rust
User=root
Group=root
StandardOutput=journal
StandardError=journal
SyslogIdentifier=aw-worktime-autoheal
-
diff --git a/aw-server/aw-worktime-autoheal.sh b/aw-server/aw-worktime-autoheal.sh
deleted file mode 100644
index d027072..0000000
--- a/aw-server/aw-worktime-autoheal.sh
+++ /dev/null
@@ -1,243 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-AW_URL="${AW_URL:-http://127.0.0.1:5600}"
-HOST="${AW_WORKTIME_HOST:-SHARKON2025}"
-PYTHON_BIN="${PYTHON_BIN:-/usr/bin/python3}"
-WORKTIME_HEALTH_URL="${WORKTIME_HEALTH_URL:-http://127.0.0.1:5610/health}"
-WORKTIME_REPORT_TIMEOUT_SECONDS="${WORKTIME_REPORT_TIMEOUT_SECONDS:-20}"
-WORKTIME_MANAGEMENT_WARM_ENABLED="${WORKTIME_MANAGEMENT_WARM_ENABLED:-1}"
-WORKTIME_MANAGEMENT_WARM_URL="${WORKTIME_MANAGEMENT_WARM_URL:-http://127.0.0.1:5610/reports/worktime/management?day=today&format=json}"
-WORKTIME_MANAGEMENT_WARM_TIMEOUT_SECONDS="${WORKTIME_MANAGEMENT_WARM_TIMEOUT_SECONDS:-60}"
-WORKTIME_TODAY_PROBE_ENABLED="${WORKTIME_TODAY_PROBE_ENABLED:-1}"
-WORKTIME_TODAY_PROBE_URL="${WORKTIME_TODAY_PROBE_URL:-http://127.0.0.1:5610/reports/worktime/today?day=today&format=json}"
-WORKTIME_TODAY_PROBE_TIMEOUT_SECONDS="${WORKTIME_TODAY_PROBE_TIMEOUT_SECONDS:-20}"
-WORKTIME_SESSION_FRESHNESS_SECONDS="${WORKTIME_SESSION_FRESHNESS_SECONDS:-600}"
-LOG_TAG="aw-worktime-autoheal"
-
-log() {
- logger -t "$LOG_TAG" "$*"
- printf '%s %s\n' "$(date '+%F %T')" "$*"
-}
-
-probe_url() {
- local url="$1"
- local timeout="$2"
- curl -fsS --max-time "$timeout" "$url" >/dev/null 2>&1
-}
-
-probe_reports() {
- probe_url "$WORKTIME_HEALTH_URL" "$WORKTIME_REPORT_TIMEOUT_SECONDS" || return 1
- if [[ "$WORKTIME_TODAY_PROBE_ENABLED" == "1" ]]; then
- probe_url "$WORKTIME_TODAY_PROBE_URL" "$WORKTIME_TODAY_PROBE_TIMEOUT_SECONDS" || return 1
- fi
-}
-
-worktime_ok=1
-if ! probe_reports; then
- log "worktime API probe failed, restarting aw-worktime-api.service"
- systemctl restart aw-worktime-api.service || true
- sleep 2
- if ! probe_reports; then
- log "worktime API still degraded after restart"
- worktime_ok=0
- else
- log "worktime API recovered after restart"
- fi
-fi
-
-if [[ "$worktime_ok" != "1" ]]; then
- log "skip warm/heal because worktime API is still unavailable"
- exit 0
-fi
-
-if [[ "$WORKTIME_MANAGEMENT_WARM_ENABLED" == "1" ]]; then
- if probe_url "$WORKTIME_MANAGEMENT_WARM_URL" "$WORKTIME_MANAGEMENT_WARM_TIMEOUT_SECONDS"; then
- log "management cache warm ok"
- else
- log "management cache warm failed"
- fi
-fi
-
-need_heal="$("$PYTHON_BIN" - <<'PY'
-import json, urllib.request, datetime, os, sys
-AW=os.environ.get("AW_URL","http://127.0.0.1:5600")
-host=os.environ.get("HOST","SHARKON2025")
-window_bucket=f"aw-rdp-window_{host}"
-session_bucket=f"aw-worktime-sessions_{host}"
-freshness=int(os.environ.get("WORKTIME_SESSION_FRESHNESS_SECONDS","600"))
-msk=datetime.timezone(datetime.timedelta(hours=3))
-start=datetime.datetime.now(msk).replace(hour=0,minute=0,second=0,microsecond=0).astimezone(datetime.timezone.utc)
-now=datetime.datetime.now(datetime.timezone.utc)
-
-def p(ts):
- if ts.endswith("Z"): ts=ts[:-1] + "+00:00"
- return datetime.datetime.fromisoformat(ts).astimezone(datetime.timezone.utc)
-
-def is_active(d):
- if isinstance(d.get("active"), bool) and d.get("active"):
- return True
- st=str(d.get("state","")).strip().lower()
- if st in ("active","активно"):
- return True
- if st == "unknown":
- try:
- sid=int(d.get("sessionId"))
- except Exception:
- sid=-1
- u=str(d.get("username","")).strip().lower()
- sn=str(d.get("sessionName","")).strip().lower()
- if sid > 0 and u and (not u.endswith("$")) and (sn.startswith("rdp-") or sn == "console"):
- return True
- return False
-
-try:
- with urllib.request.urlopen(f"{AW}/api/0/buckets/{window_bucket}/events?limit=12000", timeout=25) as r:
- window_events=json.loads(r.read().decode("utf-8"))
- with urllib.request.urlopen(f"{AW}/api/0/buckets/{session_bucket}/events?limit=12000", timeout=25) as r:
- session_events=json.loads(r.read().decode("utf-8"))
-except Exception:
- print("1")
- sys.exit(0)
-
-latest_ts=None
-active_users=set()
-for e in session_events:
- ts=e.get("timestamp")
- if not ts:
- continue
- try:
- cur=p(ts)
- except Exception:
- continue
- if latest_ts is None or cur > latest_ts:
- latest_ts=cur
- active_users.clear()
- if cur == latest_ts:
- d=e.get("data") or {}
- u=str(d.get("username","")).strip()
- if u and is_active(d):
- active_users.add(u)
-
-if latest_ts is None or (now - latest_ts).total_seconds() > freshness:
- print("0")
- sys.exit(0)
-
-if not active_users:
- print("0")
- sys.exit(0)
-
-active=0.0
-for e in window_events:
- ts=e.get("timestamp")
- if not ts:
- continue
- try:
- if p(ts) < start:
- continue
- except Exception:
- continue
- d=float(e.get("duration",0) or 0)
- title=((e.get("data") or {}).get("title") or "").strip().lower()
- if d > 0 and "rdp active" in title:
- active += d
-
-print("1" if active <= 0 else "0")
-PY
-)"
-
-if [[ "$need_heal" != "1" ]]; then
- log "health ok: activity present for ${HOST}, no action"
- exit 0
-fi
-
-log "detected zero activity for ${HOST}, running heal"
-systemctl restart aw-worktime-ui-bridge.timer
-systemctl start aw-worktime-ui-bridge.service || true
-
-"$PYTHON_BIN" - <<'PY'
-import json, urllib.request, datetime, os
-AW=os.environ.get("AW_URL","http://127.0.0.1:5600")
-host=os.environ.get("HOST","SHARKON2025")
-sb=f"aw-worktime-sessions_{host}"
-afk=f"aw-rdp-afk_{host}"
-win=f"aw-rdp-window_{host}"
-msk=datetime.timezone(datetime.timedelta(hours=3))
-start=datetime.datetime.now(msk).replace(hour=0,minute=0,second=0,microsecond=0).astimezone(datetime.timezone.utc)
-
-def req(method,path,payload=None):
- data=None; headers={}
- if payload is not None:
- data=json.dumps(payload,ensure_ascii=False).encode("utf-8"); headers["Content-Type"]="application/json"
- r=urllib.request.Request(AW+path,data=data,headers=headers,method=method)
- try:
- with urllib.request.urlopen(r,timeout=30) as resp:
- body=resp.read()
- return json.loads(body.decode("utf-8")) if body else None
- except urllib.error.HTTPError as e:
- if e.code in (304, 409):
- return None
- raise
-
-def reset_bucket(bucket_id, event_type, client, hostname):
- try:
- req("DELETE", f"/api/0/buckets/{bucket_id}")
- except Exception:
- pass
- req("POST", f"/api/0/buckets/{bucket_id}", {
- "client": client,
- "type": event_type,
- "hostname": hostname,
- })
-
-def parse(ts):
- if ts.endswith("Z"): ts=ts[:-1]+"+00:00"
- return datetime.datetime.fromisoformat(ts).astimezone(datetime.timezone.utc)
-
-def is_active(d):
- if isinstance(d.get("active"), bool) and d.get("active"): return True
- st=str(d.get("state","")).strip().lower()
- if st in ("active","активно"): return True
- if st=="unknown":
- try: sid=int(d.get("sessionId"))
- except: sid=-1
- u=str(d.get("username","")).strip().lower()
- sn=str(d.get("sessionName","")).strip().lower()
- if sid>0 and u and (not u.endswith("$")) and (sn.startswith("rdp-") or sn=="console"): return True
- return False
-
-rows=req("GET",f"/api/0/buckets/{sb}/events?limit=12000") or []
-rows=[e for e in rows if e.get("timestamp") and parse(e["timestamp"])>=start]
-if not rows:
- raise SystemExit(0)
-
-# Hard normalization: drop corrupted/mixed watcher buckets and rebuild from source sessions.
-reset_bucket(afk, "afkstatus", "aw-worktime-ui-bridge", host)
-reset_bucket(win, "currentwindow", "aw-worktime-ui-bridge", host)
-
-by={}
-for e in rows:
- by.setdefault(e["timestamp"],[]).append(e)
-keys=sorted(by.keys())
-out_afk=[]; out_win=[]
-for i,ts in enumerate(keys):
- cur=parse(ts)
- nxt=parse(keys[i+1]) if i+10 else 10.0)
- act=[]
- for r in by[ts]:
- d=r.get("data") or {}
- u=str(d.get("username","")).strip()
- if u and is_active(d): act.append(u)
- act=sorted(set(act))
- active=bool(act)
- out_afk.append({"timestamp":ts,"duration":dur,"data":{"status":"not-afk" if active else "afk","source":"aw-worktime-autoheal"}})
- out_win.append({"timestamp":ts,"duration":dur,"data":{"app":"RDP","title":("RDP active (%d): %s"%(len(act),", ".join(act))) if active else "RDP idle","source":"aw-worktime-autoheal"}})
-
-req("POST",f"/api/0/buckets/{afk}/events",out_afk)
-req("POST",f"/api/0/buckets/{win}/events",out_win)
-print(f"autoheal backfill posted afk={len(out_afk)} win={len(out_win)}")
-PY
-
-log "heal completed for ${HOST}"
diff --git a/aw-server/aw-worktime-influx-exporter.py b/aw-server/aw-worktime-influx-exporter.py
deleted file mode 100644
index eb2993f..0000000
--- a/aw-server/aw-worktime-influx-exporter.py
+++ /dev/null
@@ -1,181 +0,0 @@
-#!/usr/bin/env python3
-import importlib.util
-import os
-import sys
-import urllib.request
-from datetime import datetime, timezone
-from pathlib import Path
-
-
-MODULE_PATH = Path(__file__).with_name("aw-worktime-api.py")
-SPEC = importlib.util.spec_from_file_location("aw_worktime_api", MODULE_PATH)
-WORKTIME = importlib.util.module_from_spec(SPEC)
-SPEC.loader.exec_module(WORKTIME)
-
-INFLUX_URL = os.environ.get("AW_WORKTIME_INFLUX_URL", "").strip().rstrip("/")
-INFLUX_ORG = os.environ.get("AW_WORKTIME_INFLUX_ORG", "proxmox").strip() or "proxmox"
-INFLUX_BUCKET = os.environ.get("AW_WORKTIME_INFLUX_BUCKET", "aw_metrics").strip() or "aw_metrics"
-INFLUX_TOKEN = os.environ.get("AW_WORKTIME_INFLUX_TOKEN", "").strip()
-INFLUX_ENABLED = os.environ.get("AW_WORKTIME_INFLUX_ENABLED", "").strip().lower() in {"1", "true", "yes", "on"}
-HOSTS = [item.strip() for item in os.environ.get("AW_WORKTIME_INFLUX_HOSTS", WORKTIME.DEFAULT_HOST).split(",") if item.strip()]
-DAYS = [item.strip() for item in os.environ.get("AW_WORKTIME_INFLUX_DAYS", "today,yesterday").split(",") if item.strip()]
-
-
-def _escape_tag(value):
- return (
- str(value or "")
- .replace("\\", "\\\\")
- .replace(" ", "\\ ")
- .replace(",", "\\,")
- .replace("=", "\\=")
- )
-
-
-def _line(measurement, tags, fields, timestamp_ns):
- tag_part = ",".join(f"{key}={_escape_tag(value)}" for key, value in sorted(tags.items()) if value is not None and value != "")
- field_parts = []
- for key, value in fields.items():
- if isinstance(value, bool):
- field_parts.append(f"{key}={'true' if value else 'false'}")
- elif isinstance(value, int):
- field_parts.append(f"{key}={value}i")
- elif isinstance(value, float):
- field_parts.append(f"{key}={value}")
- else:
- text = str(value or "").replace("\\", "\\\\").replace('"', '\\"')
- field_parts.append(f'{key}="{text}"')
- if not field_parts:
- return ""
- if tag_part:
- return f"{measurement},{tag_part} {','.join(field_parts)} {timestamp_ns}"
- return f"{measurement} {','.join(field_parts)} {timestamp_ns}"
-
-
-def _timestamp_ns(dt):
- return int(dt.astimezone(timezone.utc).timestamp() * 1_000_000_000)
-
-
-def build_lines_for_day(host, report_date):
- bounds, events = WORKTIME.fetch_events_for_date(host, report_date)
- rows = WORKTIME.aggregate_rows(events, bounds["start"], bounds["end"], host)
- hourly_rows = WORKTIME.aggregate_hourly_rows(events, bounds["start"], bounds["end"], host)
- summary = WORKTIME.build_report_summary(rows)
- true_active_apps = WORKTIME.build_true_active_apps(host, report_date)
-
- lines = []
- daily_ts = _timestamp_ns(bounds["start"])
-
- for row in rows:
- lines.append(
- _line(
- "aw_rdp_worktime_daily",
- {
- "host": host,
- "user": row["user"],
- "user_id": row["user_id"],
- "report_date": report_date.isoformat(),
- },
- {
- "active_seconds": int(row["active_seconds"]),
- "idle_seconds": int(row["idle_seconds"]),
- "sessions_count": int(row["sessions_count"]),
- "samples_count": int(row["samples_count"]),
- "active_samples": int(row["active_samples"]),
- },
- daily_ts,
- )
- )
-
- for row in hourly_rows:
- lines.append(
- _line(
- "aw_rdp_worktime_hourly",
- {
- "host": host,
- "user": row["user"],
- "user_id": row["user_id"],
- "report_date": row["report_date"],
- "hour_local": row["hour_local"],
- },
- {
- "active_seconds": int(row["active_seconds"]),
- },
- _timestamp_ns(WORKTIME.pts(row["bucket_start_utc"])),
- )
- )
-
- lines.append(
- _line(
- "aw_rdp_worktime_summary_daily",
- {
- "host": host,
- "report_date": report_date.isoformat(),
- },
- {
- "users_count": int(summary["users_count"]),
- "total_active_seconds": int(summary["total_active_seconds"]),
- "top_user": summary["top_user"],
- },
- daily_ts,
- )
- )
-
- for app in true_active_apps:
- lines.append(
- _line(
- "aw_true_active_app_daily",
- {
- "host": host,
- "application": app["application"],
- "report_date": report_date.isoformat(),
- },
- {
- "proved_work_seconds": int(app.get("proved_work_seconds", 0) or 0),
- "evidence_events": int(app.get("evidence_events", 0) or 0),
- "proved_work_human": app.get("proved_work_human", ""),
- "proved_work_hhmm": app.get("proved_work_hhmm", ""),
- "last_action": app.get("last_action", ""),
- "last_action_local": app.get("last_action_local", ""),
- "last_action_utc": app.get("last_action_utc", ""),
- },
- daily_ts,
- )
- )
- return [line for line in lines if line]
-
-
-def write_lines(lines):
- if not lines:
- return 0
- if not INFLUX_URL or not INFLUX_TOKEN:
- raise RuntimeError("InfluxDB destination is not configured")
- payload = ("\n".join(lines) + "\n").encode("utf-8")
- req = urllib.request.Request(
- f"{INFLUX_URL}/api/v2/write?org={INFLUX_ORG}&bucket={INFLUX_BUCKET}&precision=ns",
- data=payload,
- method="POST",
- headers={"Authorization": f"Token {INFLUX_TOKEN}", "Content-Type": "text/plain; charset=utf-8"},
- )
- with urllib.request.urlopen(req, timeout=30) as response:
- if response.status not in {204, 200}:
- raise RuntimeError(f"InfluxDB write failed with status={response.status}")
- return len(lines)
-
-
-def main():
- if not INFLUX_ENABLED:
- print("[aw-worktime-influx-exporter] disabled by AW_WORKTIME_INFLUX_ENABLED", file=sys.stderr)
- return 0
-
- lines = []
- for host in HOSTS:
- for day in DAYS:
- report_date = WORKTIME.resolve_report_date(day=day)
- lines.extend(build_lines_for_day(host, report_date))
- written = write_lines(lines)
- print(f"[aw-worktime-influx-exporter] wrote {written} points to {INFLUX_BUCKET}", file=sys.stderr)
- return 0
-
-
-if __name__ == "__main__":
- raise SystemExit(main())
diff --git a/aw-server/aw-worktime-influx-exporter.service b/aw-server/aw-worktime-influx-exporter.service
index 4cdf451..25ffc9f 100644
--- a/aw-server/aw-worktime-influx-exporter.service
+++ b/aw-server/aw-worktime-influx-exporter.service
@@ -6,10 +6,9 @@ Wants=network-online.target aw-worktime-api.service
[Service]
Type=oneshot
EnvironmentFile=/etc/activitywatch/aw-server.env
-ExecStart=/usr/bin/python3 /usr/local/bin/aw-worktime-influx-exporter.py
+ExecStart=/usr/local/bin/aw-worktime-influx-exporter-rust
User=activitywatch
Group=activitywatch
StandardOutput=journal
StandardError=journal
SyslogIdentifier=aw-worktime-influx-exporter
-
diff --git a/aw-server/aw-worktime-prewarm.service b/aw-server/aw-worktime-prewarm.service
index a013770..6f3cb29 100644
--- a/aw-server/aw-worktime-prewarm.service
+++ b/aw-server/aw-worktime-prewarm.service
@@ -6,10 +6,9 @@ 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
+ExecStart=/usr/local/bin/aw-worktime-prewarm-rust
User=activitywatch
Group=activitywatch
StandardOutput=journal
StandardError=journal
SyslogIdentifier=aw-worktime-prewarm
-
diff --git a/aw-server/aw-worktime-ui-bridge.py b/aw-server/aw-worktime-ui-bridge.py
deleted file mode 100644
index 5621fb5..0000000
--- a/aw-server/aw-worktime-ui-bridge.py
+++ /dev/null
@@ -1,424 +0,0 @@
-#!/usr/bin/env python3
-import json
-import os
-import urllib.error
-import urllib.request
-from datetime import datetime, timedelta, timezone
-
-
-AW_URL = os.environ.get("AW_SERVER_URL", "http://127.0.0.1:5600")
-HOST = os.environ.get("AW_WORKTIME_HOST", "SHARKON2025")
-STATE_PATH = os.environ.get(
- "AW_WORKTIME_UI_BRIDGE_STATE",
- "/var/lib/activitywatch/aw-worktime-ui-bridge-state.json",
-)
-TIMEOUT = float(os.environ.get("AW_WORKTIME_UI_BRIDGE_TIMEOUT", "60"))
-WATCHER_FALLBACK_ENABLED = os.environ.get("AW_WORKTIME_UI_BRIDGE_WATCHER_FALLBACK", "1").strip().lower() not in {
- "0",
- "false",
- "no",
- "off",
-}
-WATCHER_FALLBACK_STALE_SECONDS = float(os.environ.get("AW_WORKTIME_UI_BRIDGE_WATCHER_STALE_SECONDS", "600"))
-
-
-SESSIONS_BUCKET = f"aw-worktime-sessions_{HOST}"
-AFK_BUCKET = f"aw-rdp-afk_{HOST}"
-WINDOW_BUCKET = f"aw-rdp-window_{HOST}"
-WATCHER_AFK_BUCKET = f"aw-watcher-afk_{HOST}"
-WATCHER_WINDOW_BUCKET = f"aw-watcher-window_{HOST}"
-WEB_CATEGORY_BUCKET = f"aw-detmir-web-category_{HOST}"
-COLLECTOR_HEALTH_MAX_AGE_SECONDS = float(os.environ.get("AW_WORKTIME_UI_BRIDGE_COLLECTOR_HEALTH_MAX_AGE_SECONDS", "300"))
-COLLECTOR_HEALTH_QUERY_LIMIT = int(os.environ.get("AW_WORKTIME_UI_BRIDGE_COLLECTOR_HEALTH_QUERY_LIMIT", "200"))
-FOREGROUND_CONTEXT_CACHE_SECONDS = float(os.environ.get("AW_WORKTIME_UI_BRIDGE_FOREGROUND_CACHE_SECONDS", "900"))
-
-
-def _req(method: str, path: str, payload=None):
- data = None
- headers = {}
- if payload is not None:
- data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
- headers["Content-Type"] = "application/json"
- req = urllib.request.Request(AW_URL + path, data=data, headers=headers, method=method)
- with urllib.request.urlopen(req, timeout=TIMEOUT) as r:
- raw = r.read()
- if not raw:
- return None
- return json.loads(raw.decode("utf-8"))
-
-
-def ensure_bucket(bucket_id: str, event_type: str, client: str):
- payload = {"client": client, "type": event_type, "hostname": HOST}
- try:
- _req("POST", f"/api/0/buckets/{bucket_id}", payload)
- except urllib.error.HTTPError as e:
- if e.code != 304:
- raise
-
-
-def get_latest_bucket_event(bucket_id: str):
- try:
- events = _req("GET", f"/api/0/buckets/{bucket_id}/events?limit=1") or []
- except urllib.error.HTTPError as e:
- if e.code == 404:
- return None
- raise
- if not events:
- return None
- return events[0]
-
-
-def get_latest_bucket_event_ts(bucket_id: str):
- event = get_latest_bucket_event(bucket_id)
- if not event:
- return None
- ts = event.get("timestamp")
- if not ts:
- return None
- try:
- return parse_iso_utc(ts)
- except Exception:
- return None
-
-
-def bucket_needs_fallback(bucket_id: str, now_utc: datetime, stale_after_seconds: float):
- latest_dt = get_latest_bucket_event_ts(bucket_id)
- if latest_dt is None:
- return True
- return (now_utc - latest_dt).total_seconds() >= stale_after_seconds
-
-
-def watcher_window_needs_bridge_sync(now_utc: datetime):
- latest_event = get_latest_bucket_event(WATCHER_WINDOW_BUCKET)
- if latest_event is None:
- return True
- latest_dt = get_latest_bucket_event_ts(WATCHER_WINDOW_BUCKET)
- if latest_dt is None:
- return True
- if (now_utc - latest_dt).total_seconds() >= WATCHER_FALLBACK_STALE_SECONDS:
- return True
-
- data = latest_event.get("data") or {}
- source = str(data.get("source", "")).strip().lower()
- app = str(data.get("app", "")).strip()
- title = str(data.get("title", "")).strip()
- if source == "aw-worktime-ui-bridge":
- return True
- if source != "aw-worktime-ui-bridge":
- return False
- if app.upper() == "RDP":
- return True
- if title == "RDP idle" or title.startswith("RDP active"):
- return True
- return False
-
-
-def load_state():
- try:
- with open(STATE_PATH, "r", encoding="utf-8") as f:
- data = json.load(f)
- if isinstance(data, dict) and "last_ts" in data:
- return data
- except FileNotFoundError:
- pass
- except json.JSONDecodeError:
- pass
- return {"last_ts": "1970-01-01T00:00:00Z"}
-
-
-def save_state(state):
- os.makedirs(os.path.dirname(STATE_PATH), exist_ok=True)
- tmp = STATE_PATH + ".tmp"
- with open(tmp, "w", encoding="utf-8") as f:
- json.dump(state, f, ensure_ascii=False)
- os.replace(tmp, STATE_PATH)
-
-
-def to_iso_utc(ts):
- if ts.endswith("Z"):
- return ts
- return ts.replace("+00:00", "Z")
-
-
-def parse_iso_utc(ts: str):
- if ts.endswith("Z"):
- ts = ts[:-1] + "+00:00"
- return datetime.fromisoformat(ts).astimezone(timezone.utc)
-
-
-def build_window_title(users, active_count):
- if not users:
- return "RDP idle"
- return f"RDP active ({active_count}): " + ", ".join(users)
-
-
-def get_latest_active_session_ids(events):
- grouped = {}
- for event in events:
- ts = event.get("timestamp")
- if not ts:
- continue
- grouped.setdefault(ts, []).append(event)
- if not grouped:
- return set()
- latest_ts = max(grouped.keys(), key=lambda item: parse_iso_utc(item))
- active_session_ids = set()
- for event in grouped.get(latest_ts, []):
- data = event.get("data") or {}
- if not _is_session_active(data):
- continue
- try:
- active_session_ids.add(int(data.get("sessionId")))
- except Exception:
- continue
- return active_session_ids
-
-
-def _normalize_foreground_context(data):
- foreground_process = str(data.get("foregroundProcess", "")).strip()
- foreground_title = str(data.get("foregroundTitle", "")).strip()
- if not foreground_process and not foreground_title:
- return None
- return {
- "app": foreground_process if foreground_process.endswith(".exe") else f"{foreground_process}.exe",
- "title": foreground_title or foreground_process,
- }
-
-
-def get_latest_foreground_context(now_utc: datetime, active_session_ids=None, state=None):
- try:
- events = _req("GET", f"/api/0/buckets/{WEB_CATEGORY_BUCKET}/events?limit={COLLECTOR_HEALTH_QUERY_LIMIT}") or []
- except urllib.error.HTTPError as e:
- if e.code == 404:
- events = []
- else:
- raise
-
- active_session_ids = set(active_session_ids or [])
- recent_candidates = []
- for event in reversed(events):
- data = event.get("data") or {}
- if str(data.get("signalType", "")).strip().lower() != "collector_health":
- continue
- ts = event.get("timestamp")
- if not ts:
- continue
- try:
- event_dt = parse_iso_utc(ts)
- except Exception:
- continue
- if (now_utc - event_dt).total_seconds() > COLLECTOR_HEALTH_MAX_AGE_SECONDS:
- continue
- normalized = _normalize_foreground_context(data)
- if not normalized:
- continue
- session_id = data.get("sessionId")
- try:
- session_id = int(session_id)
- except Exception:
- session_id = None
- recent_candidates.append((session_id, event_dt, normalized))
-
- for session_id, event_dt, normalized in recent_candidates:
- if active_session_ids and session_id in active_session_ids:
- normalized["timestamp"] = to_iso_utc(event_dt.isoformat())
- return normalized
-
- if recent_candidates:
- session_id, event_dt, normalized = recent_candidates[0]
- normalized["timestamp"] = to_iso_utc(event_dt.isoformat())
- return normalized
-
- cached = (state or {}).get("last_foreground_context")
- if isinstance(cached, dict):
- cached_ts = str(cached.get("timestamp", "")).strip()
- if cached_ts:
- try:
- cached_dt = parse_iso_utc(cached_ts)
- except Exception:
- cached_dt = None
- if cached_dt and (now_utc - cached_dt).total_seconds() <= FOREGROUND_CONTEXT_CACHE_SECONDS:
- app = str(cached.get("app", "")).strip()
- title = str(cached.get("title", "")).strip()
- if app or title:
- return {
- "app": app or "RDP",
- "title": title or app or "RDP",
- "timestamp": cached_ts,
- }
- return None
-
-
-def _is_session_active(row_data):
- if isinstance(row_data.get("active"), bool):
- if row_data.get("active"):
- return True
- state = str(row_data.get("state", "")).strip().lower()
- if state in {"active", "активно"}:
- return True
- # query user can intermittently return Unknown on RDP hosts.
- if state == "unknown":
- try:
- sid = int(row_data.get("sessionId"))
- except Exception:
- sid = -1
- user = str(row_data.get("username", "")).strip().lower()
- session_name = str(row_data.get("sessionName", "")).strip().lower()
- if sid > 0 and user and (not user.endswith("$")) and (session_name.startswith("rdp-") or session_name == "console"):
- return True
- return False
-
-
-def transform(events, foreground_context=None):
- out_afk = []
- out_win = []
- last_ts = None
-
- grouped = {}
- for e in events:
- ts = e.get("timestamp")
- if not ts:
- continue
- try:
- normalized_ts = to_iso_utc(parse_iso_utc(ts).replace(microsecond=0).isoformat())
- except Exception:
- normalized_ts = ts
- grouped.setdefault(normalized_ts, []).append(e)
-
- ordered_ts = sorted(grouped.keys())
- parsed_ts = {}
- for ts in ordered_ts:
- try:
- parsed_ts[ts] = parse_iso_utc(ts)
- except Exception:
- parsed_ts[ts] = None
-
- for idx, ts in enumerate(ordered_ts):
- rows = grouped[ts]
- src_duration = max(float(r.get("duration", 0.0)) for r in rows)
- duration = src_duration
- cur_dt = parsed_ts.get(ts)
- next_dt = parsed_ts.get(ordered_ts[idx + 1]) if idx + 1 < len(ordered_ts) else None
- next_gap = None
- if cur_dt and next_dt:
- next_gap = max(0.0, (next_dt - cur_dt).total_seconds())
- if duration <= 0:
- if cur_dt and next_dt:
- duration = next_gap or 0.0
- if duration <= 0:
- duration = 10.0
- elif next_gap is not None and next_gap > 0:
- # Do not let a sampled session interval extend past the next sample.
- duration = min(duration, next_gap)
- duration = min(duration, 30.0)
- active_users = []
- for r in rows:
- data = r.get("data") or {}
- user = str(data.get("username", "")).strip()
- if user and _is_session_active(data):
- active_users.append(user)
- active_users = sorted(set(active_users))
- active_count = len(active_users)
- is_active = active_count > 0
-
- afk_data = {"status": "not-afk" if is_active else "afk", "source": "aw-worktime-ui-bridge"}
- out_afk.append({"timestamp": ts, "duration": duration, "data": afk_data})
-
- if is_active and foreground_context:
- title = str(foreground_context.get("title") or "").strip()
- if active_count > 1:
- title = f"{title} | {build_window_title(active_users, active_count)}" if title else build_window_title(active_users, active_count)
- win_data = {
- "app": str(foreground_context.get("app") or "RDP"),
- "title": title or build_window_title(active_users, active_count),
- "source": "aw-worktime-ui-bridge",
- }
- else:
- win_data = {
- "app": "RDP",
- "title": build_window_title(active_users, active_count),
- "source": "aw-worktime-ui-bridge",
- }
- out_win.append({"timestamp": ts, "duration": duration, "data": win_data})
- last_ts = ts
-
- return out_afk, out_win, last_ts
-
-
-def normalize_watcher_window_events(win_events):
- normalized = []
- for event in win_events:
- cloned = dict(event)
- data = dict(event.get("data") or {})
- app = str(data.get("app") or "").strip()
- title = str(data.get("title") or "").strip()
- if not app or app.upper() == "RDP":
- continue
- if app and app.upper() != "RDP" and " | RDP active (" in title:
- data["title"] = title.split(" | RDP active (", 1)[0].strip()
- cloned["data"] = data
- normalized.append(cloned)
- return normalized
-
-
-def main():
- state = load_state()
- last_ts = state.get("last_ts", "1970-01-01T00:00:00Z")
-
- ensure_bucket(AFK_BUCKET, "afkstatus", "aw-worktime-ui-bridge")
- ensure_bucket(WINDOW_BUCKET, "currentwindow", "aw-worktime-ui-bridge")
-
- now_utc = datetime.now(timezone.utc)
- recent = _req("GET", f"/api/0/buckets/{SESSIONS_BUCKET}/events?limit=5000") or []
- if not recent:
- return
-
- try:
- last_dt = parse_iso_utc(last_ts)
- except Exception:
- last_dt = datetime(1970, 1, 1, tzinfo=timezone.utc)
-
- events = []
- for e in recent:
- ts = e.get("timestamp")
- if not ts:
- continue
- try:
- if parse_iso_utc(ts) > last_dt:
- events.append(e)
- except Exception:
- continue
- if not events:
- return
-
- active_session_ids = get_latest_active_session_ids(events)
- foreground_context = get_latest_foreground_context(now_utc, active_session_ids=active_session_ids, state=state)
- afk_events, win_events, new_last_ts = transform(events, foreground_context=foreground_context)
- if not afk_events or not win_events or not new_last_ts:
- return
- watcher_win_events = normalize_watcher_window_events(win_events)
-
- _req("POST", f"/api/0/buckets/{AFK_BUCKET}/events", afk_events)
- _req("POST", f"/api/0/buckets/{WINDOW_BUCKET}/events", win_events)
- if WATCHER_FALLBACK_ENABLED:
- if bucket_needs_fallback(WATCHER_AFK_BUCKET, now_utc, WATCHER_FALLBACK_STALE_SECONDS):
- ensure_bucket(WATCHER_AFK_BUCKET, "afkstatus", "aw-watcher-afk")
- _req("POST", f"/api/0/buckets/{WATCHER_AFK_BUCKET}/events", afk_events)
- if watcher_win_events and watcher_window_needs_bridge_sync(now_utc):
- ensure_bucket(WATCHER_WINDOW_BUCKET, "currentwindow", "aw-watcher-window")
- _req("POST", f"/api/0/buckets/{WATCHER_WINDOW_BUCKET}/events", watcher_win_events)
- next_state = {"last_ts": new_last_ts}
- if foreground_context:
- next_state["last_foreground_context"] = {
- "app": str(foreground_context.get("app") or ""),
- "title": str(foreground_context.get("title") or ""),
- "timestamp": str(foreground_context.get("timestamp") or to_iso_utc(now_utc.isoformat())),
- }
- elif isinstance(state.get("last_foreground_context"), dict):
- next_state["last_foreground_context"] = state["last_foreground_context"]
- save_state(next_state)
- print(f"posted_afk={len(afk_events)} posted_win={len(win_events)} last_ts={new_last_ts}")
-
-
-if __name__ == "__main__":
- main()
diff --git a/aw-server/aw-worktime-ui-bridge.service b/aw-server/aw-worktime-ui-bridge.service
index 3ca931d..b836e4f 100644
--- a/aw-server/aw-worktime-ui-bridge.service
+++ b/aw-server/aw-worktime-ui-bridge.service
@@ -9,7 +9,7 @@ StartLimitIntervalSec=120
Type=simple
Environment=AW_SERVER_URL=http://127.0.0.1:5600
Environment=AW_WORKTIME_HOST=SHARKON2025
-ExecStart=/usr/bin/python3 /usr/local/bin/aw-worktime-ui-bridge.py
+ExecStart=/usr/local/bin/aw-worktime-ui-bridge-rust
Restart=on-failure
RestartSec=10
User=activitywatch
diff --git a/aw-server/dlp-case-management/case-service.service b/aw-server/dlp-case-management/case-service.service
index 054e7be..65fa604 100644
--- a/aw-server/dlp-case-management/case-service.service
+++ b/aw-server/dlp-case-management/case-service.service
@@ -5,8 +5,8 @@ After=network-online.target
[Service]
Type=simple
EnvironmentFile=-/etc/activitywatch/aw-server.env
-WorkingDirectory=/opt/activitywatch/dlp-case-management
-ExecStart=/opt/activitywatch/dlp-case-management/.venv/bin/uvicorn case_service:APP --host ${AW_DLP_CASE_BIND_HOST} --port ${AW_DLP_CASE_PORT}
+WorkingDirectory=/var/lib/activitywatch
+ExecStart=/usr/local/bin/aw-dlp-case-management-rust
Restart=on-failure
RestartSec=3
User=activitywatch
diff --git a/aw-server/dlp-case-management/case_rules.py b/aw-server/dlp-case-management/case_rules.py
deleted file mode 100644
index 899d0c2..0000000
--- a/aw-server/dlp-case-management/case_rules.py
+++ /dev/null
@@ -1,8 +0,0 @@
-#!/usr/bin/env python3
-from __future__ import annotations
-
-
-def is_self_test_case(incident_id: str | None, title: str | None) -> bool:
- incident = str(incident_id or "").lower()
- label = str(title or "").lower()
- return "|self_test|" in incident or label.startswith("dlp self_test")
diff --git a/aw-server/dlp-case-management/case_schema.py b/aw-server/dlp-case-management/case_schema.py
deleted file mode 100644
index ec6493d..0000000
--- a/aw-server/dlp-case-management/case_schema.py
+++ /dev/null
@@ -1,72 +0,0 @@
-#!/usr/bin/env python3
-from __future__ import annotations
-
-from datetime import datetime
-from typing import Literal
-
-from pydantic import BaseModel, Field
-
-CaseStatus = Literal["open", "investigating", "resolved", "closed"]
-
-
-class CaseHayabusaLink(BaseModel):
- tool: Literal["hayabusa"] = "hayabusa"
- host: str = Field(min_length=1, max_length=128)
- mode: str = Field(min_length=1, max_length=32)
- status: str = Field(min_length=1, max_length=64)
- intake_id: str | None = Field(default=None, max_length=256)
- package_path: str | None = Field(default=None, max_length=1024)
- sha256: str | None = Field(default=None, max_length=128)
- report_dir: str | None = Field(default=None, max_length=1024)
- summary_html: str | None = Field(default=None, max_length=1024)
- timeline_path: str | None = Field(default=None, max_length=1024)
- manifest_path: str | None = Field(default=None, max_length=1024)
- linked_at: str | None = Field(default=None, max_length=64)
- link_source: str | None = Field(default=None, max_length=64)
-
-
-class CaseCreate(BaseModel):
- incident_id: str = Field(min_length=1, max_length=256)
- host: str | None = Field(default=None, max_length=128)
- title: str = Field(min_length=1, max_length=512)
- severity: str = Field(default="medium", max_length=32)
- assignee: str | None = Field(default=None, max_length=128)
- source_bucket: str | None = Field(default=None, max_length=256)
- source_event_ts: str | None = Field(default=None, max_length=64)
- evidence: dict | None = None
-
-
-class CaseUpdate(BaseModel):
- status: CaseStatus | None = None
- assignee: str | None = Field(default=None, max_length=128)
- title: str | None = Field(default=None, max_length=512)
- severity: str | None = Field(default=None, max_length=32)
-
-
-class CaseCommentCreate(BaseModel):
- comment: str = Field(min_length=1, max_length=2000)
- author: str | None = Field(default=None, max_length=128)
-
-
-class CaseComment(BaseModel):
- id: int
- case_id: int
- comment: str
- author: str | None
- created_at: datetime
-
-
-class CaseRecord(BaseModel):
- id: int
- incident_id: str
- host: str | None
- title: str
- severity: str
- assignee: str | None
- status: CaseStatus
- source_bucket: str | None
- source_event_ts: str | None
- evidence: dict | None
- forensics: dict | None
- created_at: datetime
- updated_at: datetime
diff --git a/aw-server/dlp-case-management/case_service.py b/aw-server/dlp-case-management/case_service.py
deleted file mode 100644
index 8663a9c..0000000
--- a/aw-server/dlp-case-management/case_service.py
+++ /dev/null
@@ -1,91 +0,0 @@
-#!/usr/bin/env python3
-from __future__ import annotations
-
-import os
-from pathlib import Path
-from typing import Any
-
-from fastapi import FastAPI, HTTPException, Query
-from fastapi.middleware.cors import CORSMiddleware
-
-from case_rules import is_self_test_case
-from case_schema import CaseCommentCreate, CaseCreate, CaseHayabusaLink, CaseUpdate
-from case_storage import CaseStorage, ForensicsHostMismatchError
-
-DB = Path(os.environ.get("AW_DLP_CASE_DB_PATH", "/opt/activitywatch/dlp-case-management/cases.db"))
-APP = FastAPI(title="AWatch DLP Case Management")
-APP.add_middleware(
- CORSMiddleware,
- allow_origins=["http://127.0.0.1:5600", "http://localhost:5600", "http://10.10.10.13:5600", "*"],
- allow_credentials=True,
- allow_methods=["*"],
- allow_headers=["*"],
-)
-STORE = CaseStorage(DB)
-
-
-@APP.get("/health")
-def health() -> dict[str, Any]:
- return {"ok": True, "db": str(DB)}
-
-
-@APP.post("/api/0/dlp/cases")
-def create_case(payload: CaseCreate) -> dict[str, Any]:
- if is_self_test_case(payload.incident_id, payload.title):
- raise HTTPException(status_code=422, detail="self_test cases are not allowed")
- return STORE.create_case(payload.model_dump(exclude_none=True), actor="api")
-
-
-@APP.get("/api/0/dlp/cases")
-def list_cases(
- status: str | None = Query(default=None),
- host: str | None = Query(default=None),
- limit: int = Query(default=200, ge=1, le=2000),
-) -> list[dict[str, Any]]:
- return STORE.list_cases(status=status, host=host, limit=limit)
-
-
-@APP.get("/api/0/dlp/cases/{case_id}")
-def get_case(case_id: int) -> dict[str, Any]:
- try:
- case = STORE.get_case(case_id)
- except KeyError:
- raise HTTPException(status_code=404, detail="case not found")
- case["comments"] = STORE.list_comments(case_id, limit=200)
- case["audit"] = STORE.list_audit(case_id, limit=200)
- return case
-
-
-@APP.patch("/api/0/dlp/cases/{case_id}")
-def update_case(case_id: int, payload: CaseUpdate) -> dict[str, Any]:
- patch = payload.model_dump(exclude_none=True)
- if not patch:
- return STORE.get_case(case_id)
- try:
- return STORE.update_case(case_id, patch=patch, actor="api")
- except KeyError:
- raise HTTPException(status_code=404, detail="case not found")
-
-
-@APP.post("/api/0/dlp/cases/{case_id}/comments")
-def add_comment(case_id: int, payload: CaseCommentCreate) -> dict[str, Any]:
- try:
- STORE.get_case(case_id)
- except KeyError:
- raise HTTPException(status_code=404, detail="case not found")
- return STORE.add_comment(case_id=case_id, comment=payload.comment, author=payload.author)
-
-
-@APP.get("/api/0/dlp/cases/{case_id}/comments")
-def list_comments(case_id: int, limit: int = Query(default=200, ge=1, le=2000)) -> list[dict[str, Any]]:
- return STORE.list_comments(case_id=case_id, limit=limit)
-
-
-@APP.post("/api/0/dlp/cases/{case_id}/forensics/hayabusa")
-def link_hayabusa(case_id: int, payload: CaseHayabusaLink) -> dict[str, Any]:
- try:
- return STORE.link_hayabusa(case_id=case_id, payload=payload.model_dump(exclude_none=True), actor="api")
- except ForensicsHostMismatchError as exc:
- raise HTTPException(status_code=409, detail=str(exc))
- except KeyError:
- raise HTTPException(status_code=404, detail="case not found")
diff --git a/aw-server/dlp-case-management/case_storage.py b/aw-server/dlp-case-management/case_storage.py
deleted file mode 100644
index 3b3f8a4..0000000
--- a/aw-server/dlp-case-management/case_storage.py
+++ /dev/null
@@ -1,351 +0,0 @@
-#!/usr/bin/env python3
-from __future__ import annotations
-
-import json
-import sqlite3
-from contextlib import contextmanager
-from datetime import datetime, timezone
-from pathlib import Path
-from typing import Any, Iterator
-
-from evidence_chain import evidence_sha256, normalize_evidence_chain
-
-
-class ForensicsHostMismatchError(ValueError):
- pass
-
-
-class CaseStorage:
- def __init__(self, db_path: Path) -> None:
- self.db_path = db_path
- self.db_path.parent.mkdir(parents=True, exist_ok=True)
- self._init_db()
-
- @contextmanager
- def conn(self) -> Iterator[sqlite3.Connection]:
- c = sqlite3.connect(self.db_path)
- c.row_factory = sqlite3.Row
- c.execute("PRAGMA journal_mode=WAL")
- c.execute("PRAGMA foreign_keys=ON")
- try:
- yield c
- finally:
- c.close()
-
- def _init_db(self) -> None:
- with self.conn() as c:
- c.executescript(
- """
- 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
- );
- """
- )
- self._ensure_column(c, "cases", "forensics_json", "TEXT")
- c.commit()
-
- @staticmethod
- def _ensure_column(c: sqlite3.Connection, table: str, column: str, definition: str) -> None:
- columns = {
- str(row["name"])
- for row in c.execute(f"PRAGMA table_info({table})").fetchall()
- }
- if column not in columns:
- c.execute(f"ALTER TABLE {table} ADD COLUMN {column} {definition}")
-
- @staticmethod
- def _now() -> str:
- return datetime.now(timezone.utc).isoformat()
-
- @staticmethod
- def _normalize_host(value: Any) -> str:
- return str(value or "").strip().lower()
-
- @staticmethod
- def _load_json_field(raw: Any) -> dict[str, Any] | None:
- if not raw:
- return None
- try:
- return json.loads(raw)
- except Exception:
- return None
-
- @classmethod
- def _to_case_dict(cls, row: sqlite3.Row) -> dict[str, Any]:
- evidence = cls._load_json_field(row["evidence_json"])
- forensics = cls._load_json_field(row["forensics_json"])
- return {
- "id": int(row["id"]),
- "incident_id": row["incident_id"],
- "host": row["host"],
- "title": row["title"],
- "severity": row["severity"],
- "assignee": row["assignee"],
- "status": row["status"],
- "source_bucket": row["source_bucket"],
- "source_event_ts": row["source_event_ts"],
- "evidence": evidence,
- "forensics": forensics,
- "created_at": row["created_at"],
- "updated_at": row["updated_at"],
- }
-
- def create_case(self, payload: dict[str, Any], actor: str | None = None) -> dict[str, Any]:
- now = self._now()
- normalized_evidence = None
- evidence_digest = None
- if payload.get("evidence") is not None:
- normalized_evidence = normalize_evidence_chain(
- payload=payload.get("evidence"),
- source_bucket=payload.get("source_bucket"),
- source_event_ts=payload.get("source_event_ts"),
- )
- evidence_digest = normalized_evidence.get("latest_sha256") or evidence_sha256(payload.get("evidence"))
- with self.conn() as c:
- existing = c.execute(
- """
- SELECT * FROM cases
- WHERE incident_id = ? AND COALESCE(host, '') = COALESCE(?, '')
- ORDER BY id DESC
- LIMIT 1
- """,
- (payload["incident_id"], payload.get("host")),
- ).fetchone()
- if existing:
- return self._to_case_dict(existing)
- cur = c.execute(
- """
- 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', ?, ?, ?, ?, ?, ?)
- """,
- (
- payload["incident_id"],
- payload.get("host"),
- payload["title"],
- payload.get("severity", "medium"),
- payload.get("assignee"),
- payload.get("source_bucket"),
- payload.get("source_event_ts"),
- json.dumps(normalized_evidence, ensure_ascii=False) if normalized_evidence is not None else None,
- None,
- now,
- now,
- ),
- )
- case_id = int(cur.lastrowid)
- self._insert_audit(
- c,
- case_id=case_id,
- action="create",
- actor=actor,
- details={
- "fields": {k: v for k, v in payload.items() if k != "evidence"},
- "evidence_sha256": evidence_digest,
- },
- )
- c.commit()
- return self.get_case(case_id, c)
-
- def list_cases(self, status: str | None = None, host: str | None = None, limit: int = 200) -> list[dict[str, Any]]:
- q = "SELECT * FROM cases"
- clauses = []
- args: list[Any] = []
- if status:
- clauses.append("status = ?")
- args.append(status)
- if host:
- clauses.append("host = ?")
- args.append(host)
- if clauses:
- q += " WHERE " + " AND ".join(clauses)
- q += " ORDER BY id DESC LIMIT ?"
- args.append(int(limit))
- with self.conn() as c:
- rows = c.execute(q, args).fetchall()
- return [self._to_case_dict(r) for r in rows]
-
- def get_case(self, case_id: int, c: sqlite3.Connection | None = None) -> dict[str, Any]:
- own = False
- if c is None:
- own = True
- c = sqlite3.connect(self.db_path)
- c.row_factory = sqlite3.Row
- try:
- row = c.execute("SELECT * FROM cases WHERE id = ?", (int(case_id),)).fetchone()
- if not row:
- raise KeyError(case_id)
- return self._to_case_dict(row)
- finally:
- if own:
- c.close()
-
- def update_case(self, case_id: int, patch: dict[str, Any], actor: str | None = None) -> dict[str, Any]:
- fields = []
- args: list[Any] = []
- for key in ("status", "assignee", "title", "severity"):
- if key in patch and patch[key] is not None:
- fields.append(f"{key} = ?")
- args.append(patch[key])
- if not fields:
- return self.get_case(case_id)
- fields.append("updated_at = ?")
- args.append(self._now())
- args.append(int(case_id))
- with self.conn() as c:
- c.execute(f"UPDATE cases SET {', '.join(fields)} WHERE id = ?", args)
- self._insert_audit(c, case_id=case_id, action="update", actor=actor, details=patch)
- c.commit()
- return self.get_case(case_id, c)
-
- def link_hayabusa(self, case_id: int, payload: dict[str, Any], actor: str | None = None) -> dict[str, Any]:
- now = self._now()
- with self.conn() as c:
- existing = self.get_case(case_id, c)
- case_host = self._normalize_host(existing.get("host"))
- forensic_host = self._normalize_host(payload.get("host"))
- if case_host and forensic_host and case_host != forensic_host:
- raise ForensicsHostMismatchError(
- f"hayabusa host mismatch: case host={existing.get('host')} payload host={payload.get('host')}"
- )
- forensics = existing.get("forensics") or {}
- forensics["hayabusa"] = {
- "tool": "hayabusa",
- "host": payload["host"],
- "mode": payload["mode"],
- "status": payload["status"],
- "intake_id": payload.get("intake_id"),
- "package_path": payload.get("package_path"),
- "sha256": payload.get("sha256"),
- "report_dir": payload.get("report_dir"),
- "summary_html": payload.get("summary_html"),
- "timeline_path": payload.get("timeline_path"),
- "manifest_path": payload.get("manifest_path"),
- "linked_at": payload.get("linked_at") or now,
- "link_source": payload.get("link_source") or "api",
- }
- c.execute(
- "UPDATE cases SET forensics_json = ?, updated_at = ? WHERE id = ?",
- (json.dumps(forensics, ensure_ascii=False), now, int(case_id)),
- )
- self._insert_audit(
- c,
- case_id=case_id,
- action="link_hayabusa",
- actor=actor,
- details={
- "host": payload["host"],
- "mode": payload["mode"],
- "status": payload["status"],
- "intake_id": payload.get("intake_id"),
- "report_dir": payload.get("report_dir"),
- },
- )
- c.commit()
- return self.get_case(case_id, c)
-
- def add_comment(self, case_id: int, comment: str, author: str | None = None) -> dict[str, Any]:
- now = self._now()
- with self.conn() as c:
- cur = c.execute(
- "INSERT INTO case_comments (case_id, comment, author, created_at) VALUES (?, ?, ?, ?)",
- (int(case_id), comment, author, now),
- )
- cid = int(cur.lastrowid)
- self._insert_audit(
- c,
- case_id=case_id,
- action="comment",
- actor=author,
- details={"comment_id": cid},
- )
- c.commit()
- row = c.execute("SELECT id, case_id, comment, author, created_at FROM case_comments WHERE id = ?", (cid,)).fetchone()
- return dict(row)
-
- def list_comments(self, case_id: int, limit: int = 200) -> list[dict[str, Any]]:
- with self.conn() as c:
- rows = c.execute(
- "SELECT id, case_id, comment, author, created_at FROM case_comments WHERE case_id = ? ORDER BY id DESC LIMIT ?",
- (int(case_id), int(limit)),
- ).fetchall()
- return [dict(r) for r in rows]
-
- def list_audit(self, case_id: int, limit: int = 200) -> list[dict[str, Any]]:
- with self.conn() as c:
- rows = c.execute(
- "SELECT id, case_id, action, actor, details_json, created_at FROM case_audit WHERE case_id = ? ORDER BY id DESC LIMIT ?",
- (int(case_id), int(limit)),
- ).fetchall()
- out: list[dict[str, Any]] = []
- for r in rows:
- details = None
- if r["details_json"]:
- try:
- details = json.loads(r["details_json"])
- except Exception:
- details = None
- out.append(
- {
- "id": int(r["id"]),
- "case_id": int(r["case_id"]),
- "action": r["action"],
- "actor": r["actor"],
- "details": details,
- "created_at": r["created_at"],
- }
- )
- return out
-
- def _insert_audit(
- self,
- c: sqlite3.Connection,
- case_id: int,
- action: str,
- actor: str | None,
- details: dict[str, Any] | None = None,
- ) -> None:
- c.execute(
- "INSERT INTO case_audit (case_id, action, actor, details_json, created_at) VALUES (?, ?, ?, ?, ?)",
- (
- int(case_id),
- action,
- actor,
- json.dumps(details, ensure_ascii=False) if details is not None else None,
- self._now(),
- ),
- )
diff --git a/aw-server/dlp-case-management/evidence_chain.py b/aw-server/dlp-case-management/evidence_chain.py
deleted file mode 100644
index c8c4294..0000000
--- a/aw-server/dlp-case-management/evidence_chain.py
+++ /dev/null
@@ -1,52 +0,0 @@
-#!/usr/bin/env python3
-from __future__ import annotations
-
-import hashlib
-import json
-from datetime import datetime, timezone
-from typing import Any
-
-
-def _utc_now() -> str:
- return datetime.now(timezone.utc).isoformat()
-
-
-def _canonical_json(payload: Any) -> str:
- return json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
-
-
-def evidence_sha256(payload: Any) -> str:
- return hashlib.sha256(_canonical_json(payload).encode("utf-8")).hexdigest()
-
-
-def build_evidence_record(
- payload: Any,
- source_bucket: str | None = None,
- source_event_ts: str | None = None,
-) -> dict[str, Any]:
- return {
- "recorded_at": _utc_now(),
- "source_bucket": source_bucket,
- "source_event_ts": source_event_ts,
- "sha256": evidence_sha256(payload),
- "payload": payload,
- }
-
-
-def normalize_evidence_chain(
- payload: Any,
- source_bucket: str | None = None,
- source_event_ts: str | None = None,
-) -> dict[str, Any]:
- if isinstance(payload, dict) and isinstance(payload.get("items"), list):
- return payload
- record = build_evidence_record(
- payload=payload,
- source_bucket=source_bucket,
- source_event_ts=source_event_ts,
- )
- return {
- "items": [record],
- "latest_sha256": record["sha256"],
- "chain_length": 1,
- }
diff --git a/aw-server/dlp-case-management/test_case_rules.py b/aw-server/dlp-case-management/test_case_rules.py
deleted file mode 100644
index 7ca1eb3..0000000
--- a/aw-server/dlp-case-management/test_case_rules.py
+++ /dev/null
@@ -1,21 +0,0 @@
-#!/usr/bin/env python3
-from __future__ import annotations
-
-import unittest
-
-from case_rules import is_self_test_case
-
-
-class CaseRulesTest(unittest.TestCase):
- def test_is_self_test_case_by_incident_id(self) -> None:
- self.assertTrue(is_self_test_case("2026-05-14T17:00:52.186Z|self_test|Администратор|||", "Normal"))
-
- def test_is_self_test_case_by_title(self) -> None:
- self.assertTrue(is_self_test_case("inc-1", "DLP self_test · Администратор"))
-
- def test_is_self_test_case_false_for_normal_case(self) -> None:
- self.assertFalse(is_self_test_case("inc-1", "DLP print incident"))
-
-
-if __name__ == "__main__":
- unittest.main()
diff --git a/aw-server/dlp-case-management/test_case_storage.py b/aw-server/dlp-case-management/test_case_storage.py
deleted file mode 100644
index faf6f72..0000000
--- a/aw-server/dlp-case-management/test_case_storage.py
+++ /dev/null
@@ -1,93 +0,0 @@
-#!/usr/bin/env python3
-from __future__ import annotations
-
-import tempfile
-import unittest
-from pathlib import Path
-
-from case_storage import CaseStorage, ForensicsHostMismatchError
-
-
-class CaseStorageHayabusaLinkTest(unittest.TestCase):
- def test_link_hayabusa_metadata(self) -> None:
- with tempfile.TemporaryDirectory() as tmpdir:
- db_path = Path(tmpdir) / "cases.db"
- storage = CaseStorage(db_path)
- created = storage.create_case(
- {
- "incident_id": "inc-1",
- "host": "SHARKON2025",
- "title": "DLP print incident",
- "severity": "high",
- },
- actor="test",
- )
- linked = storage.link_hayabusa(
- case_id=int(created["id"]),
- payload={
- "host": "SHARKON2025",
- "mode": "incident",
- "status": "ok",
- "intake_id": "pkg-1",
- "report_dir": "/opt/hayabusa/reports/SHARKON2025/run-1",
- "package_path": "/opt/hayabusa/archive/packages/SHARKON2025/pkg-1.zip",
- "sha256": "abc123",
- "link_source": "unit-test",
- },
- actor="test",
- )
- hayabusa = (linked.get("forensics") or {}).get("hayabusa") or {}
- self.assertEqual(hayabusa.get("tool"), "hayabusa")
- self.assertEqual(hayabusa.get("host"), "SHARKON2025")
- self.assertEqual(hayabusa.get("mode"), "incident")
- self.assertEqual(hayabusa.get("status"), "ok")
- self.assertEqual(hayabusa.get("intake_id"), "pkg-1")
- self.assertEqual(hayabusa.get("link_source"), "unit-test")
- audit = storage.list_audit(int(created["id"]))
- self.assertTrue(any(row.get("action") == "link_hayabusa" for row in audit))
-
- def test_create_case_deduplicates_by_incident_and_host(self) -> None:
- with tempfile.TemporaryDirectory() as tmpdir:
- db_path = Path(tmpdir) / "cases.db"
- storage = CaseStorage(db_path)
- payload = {
- "incident_id": "2026-05-14T17:00:52.186Z|self_test|Администратор|||",
- "host": "SHARKON2025",
- "title": "DLP self_test · Администратор",
- "severity": "medium",
- }
- created = storage.create_case(payload, actor="test")
- duplicate = storage.create_case(payload, actor="test")
- self.assertEqual(created["id"], duplicate["id"])
- cases = storage.list_cases(host="SHARKON2025", limit=10)
- self.assertEqual(len(cases), 1)
-
- def test_link_hayabusa_rejects_host_mismatch(self) -> None:
- with tempfile.TemporaryDirectory() as tmpdir:
- db_path = Path(tmpdir) / "cases.db"
- storage = CaseStorage(db_path)
- created = storage.create_case(
- {
- "incident_id": "inc-2",
- "host": "SHARKON2025",
- "title": "DLP print incident",
- "severity": "high",
- },
- actor="test",
- )
- with self.assertRaises(ForensicsHostMismatchError):
- storage.link_hayabusa(
- case_id=int(created["id"]),
- payload={
- "host": "stability",
- "mode": "incident",
- "status": "ok",
- "intake_id": "pkg-2",
- "report_dir": "/opt/hayabusa/reports/stability/run-1",
- },
- actor="test",
- )
-
-
-if __name__ == "__main__":
- unittest.main()
diff --git a/aw-server/dlp-compliance/compliance_scheduler.py b/aw-server/dlp-compliance/compliance_scheduler.py
deleted file mode 100644
index fb55a9a..0000000
--- a/aw-server/dlp-compliance/compliance_scheduler.py
+++ /dev/null
@@ -1,24 +0,0 @@
-#!/usr/bin/env python3
-from __future__ import annotations
-
-import argparse
-import json
-
-from report_generator import generate_report
-
-
-def main() -> None:
- parser = argparse.ArgumentParser(description="Run one or more DLP compliance report profiles")
- parser.add_argument("--month", help="Month in YYYY-MM format")
- parser.add_argument("--profiles", default="152-fz,pci-dss", help="Comma-separated profiles to generate")
- parser.add_argument("--stdout-json", action="store_true")
- args = parser.parse_args()
-
- profiles = [item.strip() for item in str(args.profiles).split(",") if item.strip()]
- results = [generate_report(month=args.month, profile=profile) for profile in profiles]
- if args.stdout_json:
- print(json.dumps({"items": results}, ensure_ascii=False))
-
-
-if __name__ == "__main__":
- main()
diff --git a/aw-server/dlp-compliance/report-scheduler.service b/aw-server/dlp-compliance/report-scheduler.service
index d0ad7da..3f380f3 100644
--- a/aw-server/dlp-compliance/report-scheduler.service
+++ b/aw-server/dlp-compliance/report-scheduler.service
@@ -5,7 +5,7 @@ After=network-online.target
[Service]
Type=oneshot
EnvironmentFile=-/etc/activitywatch/aw-server.env
-WorkingDirectory=/opt/activitywatch/dlp-compliance
-ExecStart=/opt/activitywatch/dlp-compliance/.venv/bin/python /opt/activitywatch/dlp-compliance/compliance_scheduler.py
+WorkingDirectory=/var/lib/activitywatch
+ExecStart=/usr/local/bin/aw-dlp-compliance-rust
User=activitywatch
Group=activitywatch
diff --git a/aw-server/dlp-compliance/report_generator.py b/aw-server/dlp-compliance/report_generator.py
deleted file mode 100644
index c7b6905..0000000
--- a/aw-server/dlp-compliance/report_generator.py
+++ /dev/null
@@ -1,201 +0,0 @@
-#!/usr/bin/env python3
-from __future__ import annotations
-
-import argparse
-import json
-import os
-from dataclasses import dataclass
-from datetime import UTC, datetime
-from pathlib import Path
-from urllib.parse import quote
-from urllib.request import Request, urlopen
-
-
-def _env(name: str, default: str) -> str:
- value = os.environ.get(name)
- return value if value not in (None, "") else default
-
-
-def build_aw_api_base(raw_url: str | None) -> str:
- url = (raw_url or "http://127.0.0.1:5600").strip().rstrip("/")
- if url.endswith("/api/0"):
- return url
- return url + "/api/0"
-
-
-AW_API_BASE = build_aw_api_base(_env("AW_DLP_AW_API_BASE", _env("AW_SERVER_URL", "http://127.0.0.1:5600")))
-OUTPUT_DIR = Path(_env("AW_DLP_COMPLIANCE_REPORT_DIR", "/opt/activitywatch/dlp-compliance/reports"))
-BASE_DIR = Path(__file__).resolve().parent
-PROFILE_TEMPLATE_MAP = {
- "152-fz": BASE_DIR / "templates" / "152-fz-report.html",
- "pci-dss": BASE_DIR / "templates" / "pci-dss-report.html",
-}
-
-
-@dataclass
-class ReportStats:
- total_incidents: int
- high: int
- medium: int
- low: int
- by_host: dict[str, int]
- channels: dict[str, int]
-
-
-def _http_json(url: str) -> object:
- req = Request(url, headers={"Accept": "application/json"})
- with urlopen(req, timeout=30) as response:
- return json.loads(response.read().decode("utf-8"))
-
-
-def _parse_ts(value: str | None) -> datetime | None:
- if not value:
- return None
- text = value.replace("Z", "+00:00")
- try:
- return datetime.fromisoformat(text).astimezone(UTC)
- except ValueError:
- return None
-
-
-def _load_incidents(start: datetime, end: datetime) -> list[dict]:
- buckets = _http_json(f"{AW_API_BASE}/buckets")
- if not isinstance(buckets, dict):
- return []
- bucket_ids = sorted([bid for bid in buckets.keys() if str(bid).startswith("aw-dlp-incidents_")])
-
- incidents: list[dict] = []
- for bucket_id in bucket_ids:
- encoded = quote(str(bucket_id), safe="")
- events = _http_json(f"{AW_API_BASE}/buckets/{encoded}/events?limit=2000")
- if not isinstance(events, list):
- continue
- for event in events:
- if not isinstance(event, dict):
- continue
- ts = _parse_ts(event.get("timestamp"))
- if ts is None or ts < start or ts > end:
- continue
- incidents.append(event)
- return incidents
-
-
-def _build_stats(incidents: list[dict]) -> ReportStats:
- by_host: dict[str, int] = {}
- channels: dict[str, int] = {}
- high = medium = low = 0
- for event in incidents:
- data = event.get("data") or {}
- if not isinstance(data, dict):
- data = {}
- host = str(data.get("hostname") or "unknown")
- by_host[host] = by_host.get(host, 0) + 1
-
- severity = str(data.get("severity") or "low").lower()
- if severity == "high":
- high += 1
- elif severity == "medium":
- medium += 1
- else:
- low += 1
-
- channel = str(data.get("signalType") or data.get("source") or "unknown")
- channels[channel] = channels.get(channel, 0) + 1
-
- return ReportStats(
- total_incidents=len(incidents),
- high=high,
- medium=medium,
- low=low,
- by_host=dict(sorted(by_host.items(), key=lambda item: item[1], reverse=True)),
- channels=dict(sorted(channels.items(), key=lambda item: item[1], reverse=True)),
- )
-
-
-def _render_table(title: str, rows: list[tuple[str, int]]) -> str:
- if not rows:
- return f"{title}
Нет данных
"
- body = "".join([f"| {name} | {count} |
" for name, count in rows])
- return f"{title}
"
-
-
-def _resolve_template_path(profile: str) -> Path:
- if profile == "152-fz":
- explicit = _env("AW_DLP_COMPLIANCE_TEMPLATE", str(PROFILE_TEMPLATE_MAP["152-fz"]))
- return Path(explicit)
- return PROFILE_TEMPLATE_MAP.get(profile, PROFILE_TEMPLATE_MAP["152-fz"])
-
-
-def _render_html(profile: str, period_label: str, stats: ReportStats, generated_at: str) -> str:
- template = _resolve_template_path(profile).read_text(encoding="utf-8")
- return (
- template.replace("{{PERIOD}}", period_label)
- .replace("{{PROFILE}}", profile)
- .replace("{{GENERATED_AT}}", generated_at)
- .replace("{{TOTAL}}", str(stats.total_incidents))
- .replace("{{HIGH}}", str(stats.high))
- .replace("{{MEDIUM}}", str(stats.medium))
- .replace("{{LOW}}", str(stats.low))
- .replace("{{HOST_TABLE}}", _render_table("Инциденты по хостам", list(stats.by_host.items())))
- .replace("{{CHANNEL_TABLE}}", _render_table("Инциденты по каналам", list(stats.channels.items())))
- )
-
-
-def _period_bounds(month: str | None) -> tuple[datetime, datetime, str]:
- if month:
- start = datetime.fromisoformat(f"{month}-01T00:00:00+00:00").astimezone(UTC)
- else:
- now = datetime.now(UTC)
- start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
- if start.month == 12:
- end = start.replace(year=start.year + 1, month=1)
- else:
- end = start.replace(month=start.month + 1)
- end = end.replace(second=0, microsecond=0)
- return start, end, start.strftime("%Y-%m")
-
-
-def generate_report(month: str | None = None, profile: str = "152-fz") -> dict[str, object]:
- start, end, period_label = _period_bounds(month)
- incidents = _load_incidents(start, end)
- stats = _build_stats(incidents)
-
- OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
- generated_at = datetime.now(UTC).isoformat().replace("+00:00", "Z")
- html_out = OUTPUT_DIR / f"{profile}-{period_label}.html"
- html_out.write_text(_render_html(profile, period_label, stats, generated_at), encoding="utf-8")
-
- metadata = {
- "profile": profile,
- "period": period_label,
- "generated_at": generated_at,
- "aw_api_base": AW_API_BASE,
- "report_path": str(html_out),
- "stats": {
- "total_incidents": stats.total_incidents,
- "high": stats.high,
- "medium": stats.medium,
- "low": stats.low,
- },
- }
- (OUTPUT_DIR / f"{profile}-{period_label}.json").write_text(
- json.dumps(metadata, ensure_ascii=False, indent=2),
- encoding="utf-8",
- )
- return metadata
-
-
-def main() -> None:
- parser = argparse.ArgumentParser(description="Generate DLP compliance report from AW DLP incidents")
- parser.add_argument("--month", help="Month in YYYY-MM format (default: current month)")
- parser.add_argument("--profile", default="152-fz", help="Profile name: 152-fz or pci-dss")
- parser.add_argument("--stdout-json", action="store_true", help="Print report metadata as JSON")
- args = parser.parse_args()
- metadata = generate_report(month=args.month, profile=args.profile)
-
- if args.stdout_json:
- print(json.dumps(metadata, ensure_ascii=False))
-
-
-if __name__ == "__main__":
- main()
diff --git a/aw-server/dlp-integrations/cef-exporter.service b/aw-server/dlp-integrations/cef-exporter.service
index 41186a0..9c147c7 100644
--- a/aw-server/dlp-integrations/cef-exporter.service
+++ b/aw-server/dlp-integrations/cef-exporter.service
@@ -6,8 +6,8 @@ After=network-online.target
Type=oneshot
User=activitywatch
Group=activitywatch
-WorkingDirectory=/opt/activitywatch/dlp-integrations
-ExecStart=/opt/activitywatch/dlp-integrations/.venv/bin/python /opt/activitywatch/dlp-integrations/cef_exporter.py
+WorkingDirectory=/var/lib/activitywatch
+ExecStart=/usr/local/bin/dlp-cef-exporter-rust
[Install]
WantedBy=multi-user.target
diff --git a/aw-server/dlp-integrations/cef_exporter.py b/aw-server/dlp-integrations/cef_exporter.py
deleted file mode 100644
index a21e966..0000000
--- a/aw-server/dlp-integrations/cef_exporter.py
+++ /dev/null
@@ -1,165 +0,0 @@
-#!/usr/bin/env python3
-from __future__ import annotations
-
-import json
-import logging
-import socket
-from datetime import datetime, timezone
-from pathlib import Path
-from typing import Any
-from urllib import error, request
-
-import yaml
-
-LOG = logging.getLogger("aw.dlp.cef_exporter")
-
-
-def setup_logging() -> None:
- logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
-
-
-def load_yaml(path: Path) -> dict[str, Any]:
- if not path.exists():
- return {}
- data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
- if not isinstance(data, dict):
- return {}
- return data
-
-
-def load_json(path: Path) -> dict[str, Any]:
- if not path.exists():
- return {}
- try:
- data = json.loads(path.read_text(encoding="utf-8"))
- if isinstance(data, dict):
- return data
- except Exception:
- return {}
- return {}
-
-
-def save_json(path: Path, payload: dict[str, Any]) -> None:
- path.parent.mkdir(parents=True, exist_ok=True)
- path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
-
-
-def http_json(url: str, timeout: int = 15) -> Any:
- req = request.Request(url, method="GET")
- with request.urlopen(req, timeout=timeout) as resp:
- return json.loads(resp.read().decode("utf-8", errors="ignore"))
-
-
-def escape_cef(v: Any) -> str:
- s = "" if v is None else str(v)
- return s.replace("\\", "\\\\").replace("|", "\\|").replace("=", "\\=").replace("\n", "\\n").replace("\r", "")
-
-
-def map_severity(name: str, mapping: dict[str, int]) -> int:
- return int(mapping.get((name or "").lower(), 3))
-
-
-def build_cef(event: dict[str, Any], mapping: dict[str, int]) -> str:
- data = event.get("data") or {}
- sev_name = str(data.get("severity") or "low").lower()
- sev_num = map_severity(sev_name, mapping)
- rt = event.get("timestamp") or datetime.now(timezone.utc).isoformat()
- rule = data.get("ruleId") or "dlp-incident"
- msg = data.get("message") or "AWatch DLP incident"
- sig = data.get("signalType") or "unknown"
- host = data.get("hostname") or "unknown"
- user = data.get("username") or "unknown"
- action = data.get("action") or "alert"
- ext = (
- f"rt={escape_cef(rt)} "
- f"shost={escape_cef(host)} "
- f"suser={escape_cef(user)} "
- f"cs1Label=signalType cs1={escape_cef(sig)} "
- f"cs2Label=action cs2={escape_cef(action)} "
- f"cs3Label=ruleId cs3={escape_cef(rule)}"
- )
- return (
- f"CEF:0|AWatch-rus|DLP|1.0|{escape_cef(rule)}|{escape_cef(msg)}|{sev_num}|{ext}"
- )
-
-
-def send_syslog_udp(line: str, host: str, port: int) -> None:
- sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
- try:
- sock.sendto(line.encode("utf-8", errors="ignore"), (host, port))
- finally:
- sock.close()
-
-
-def send_syslog_tcp(line: str, host: str, port: int, timeout: int = 10) -> None:
- sock = socket.create_connection((host, port), timeout=timeout)
- try:
- sock.sendall((line + "\n").encode("utf-8", errors="ignore"))
- finally:
- sock.close()
-
-
-def iter_new_incidents(
- aw_base: str,
- state: dict[str, Any],
- per_bucket_limit: int,
-) -> tuple[list[dict[str, Any]], dict[str, int]]:
- buckets = http_json(f"{aw_base}/buckets/")
- bucket_ids = sorted([bid for bid in buckets.keys() if bid.startswith("aw-dlp-incidents_")])
- last_ids = state.get("last_ids", {})
- if not isinstance(last_ids, dict):
- last_ids = {}
- max_ids: dict[str, int] = {}
- out: list[dict[str, Any]] = []
- 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:
- LOG.warning("skip bucket %s: %s", bid, exc)
- continue
- prev = int(last_ids.get(bid, 0))
- bucket_max = prev
- for ev in events:
- eid = int(ev.get("id") or 0)
- if eid <= prev:
- continue
- out.append(ev)
- if eid > bucket_max:
- bucket_max = eid
- max_ids[bid] = bucket_max
- out.sort(key=lambda x: int(x.get("id") or 0))
- return out, max_ids
-
-
-def main() -> None:
- setup_logging()
- cfg_path = Path("/opt/activitywatch/dlp-integrations/cef-config.yaml")
- cfg = load_yaml(cfg_path)
- aw_base = str(cfg.get("aw_api_base", "http://127.0.0.1:5600/api/0")).rstrip("/")
- syslog_host = str(cfg.get("syslog_host", "127.0.0.1"))
- syslog_port = int(cfg.get("syslog_port", 514))
- syslog_proto = str(cfg.get("syslog_proto", "udp")).lower()
- per_bucket_limit = int(cfg.get("per_bucket_limit", 300))
- state_path = Path(str(cfg.get("state_path", "/var/lib/activitywatch/dlp-integrations/cef-state.json")))
- sev_mapping = cfg.get("severity_mapping", {"low": 3, "medium": 6, "high": 10})
- if not isinstance(sev_mapping, dict):
- sev_mapping = {"low": 3, "medium": 6, "high": 10}
-
- state = load_json(state_path)
- incidents, max_ids = iter_new_incidents(aw_base=aw_base, state=state, per_bucket_limit=per_bucket_limit)
- sent = 0
- for ev in incidents:
- line = build_cef(ev, sev_mapping)
- if syslog_proto == "tcp":
- send_syslog_tcp(line, syslog_host, syslog_port)
- else:
- send_syslog_udp(line, syslog_host, syslog_port)
- sent += 1
- state["last_ids"] = max_ids
- state["updated_at"] = datetime.now(timezone.utc).isoformat()
- save_json(state_path, state)
- LOG.info("CEF exporter done: sent=%d buckets=%d target=%s:%d/%s", sent, len(max_ids), syslog_host, syslog_port, syslog_proto)
-
-
-if __name__ == "__main__":
- main()
diff --git a/aw-server/dlp-integrations/syslog-forwarder.service b/aw-server/dlp-integrations/syslog-forwarder.service
index d354551..bbd573d 100644
--- a/aw-server/dlp-integrations/syslog-forwarder.service
+++ b/aw-server/dlp-integrations/syslog-forwarder.service
@@ -4,7 +4,7 @@ After=network-online.target
[Service]
Type=oneshot
-WorkingDirectory=/opt/activitywatch/dlp-integrations
-ExecStart=/opt/activitywatch/dlp-integrations/.venv/bin/python /opt/activitywatch/dlp-integrations/syslog_forwarder.py
+WorkingDirectory=/var/lib/activitywatch
+ExecStart=/usr/local/bin/dlp-syslog-forwarder-rust
User=activitywatch
Group=activitywatch
diff --git a/aw-server/dlp-integrations/syslog_forwarder.py b/aw-server/dlp-integrations/syslog_forwarder.py
deleted file mode 100644
index 4ecec71..0000000
--- a/aw-server/dlp-integrations/syslog_forwarder.py
+++ /dev/null
@@ -1,146 +0,0 @@
-#!/usr/bin/env python3
-from __future__ import annotations
-
-import json
-import logging
-import socket
-from datetime import datetime, timezone
-from pathlib import Path
-from typing import Any
-from urllib import error, request
-
-import yaml
-
-LOG = logging.getLogger("aw.dlp.syslog_forwarder")
-
-
-def setup_logging() -> None:
- logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
-
-
-def load_yaml(path: Path) -> dict[str, Any]:
- if not path.exists():
- return {}
- data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
- return data if isinstance(data, dict) else {}
-
-
-def load_json(path: Path) -> dict[str, Any]:
- if not path.exists():
- return {}
- try:
- data = json.loads(path.read_text(encoding="utf-8"))
- except Exception:
- return {}
- return data if isinstance(data, dict) else {}
-
-
-def save_json(path: Path, payload: dict[str, Any]) -> None:
- path.parent.mkdir(parents=True, exist_ok=True)
- path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
-
-
-def http_json(url: str, timeout: int = 15) -> Any:
- req = request.Request(url, method="GET")
- with request.urlopen(req, timeout=timeout) as resp:
- return json.loads(resp.read().decode("utf-8", errors="ignore"))
-
-
-def iter_new_incidents(aw_base: str, state: dict[str, Any], per_bucket_limit: int) -> tuple[list[dict[str, Any]], dict[str, int]]:
- buckets = http_json(f"{aw_base}/buckets/")
- bucket_ids = sorted([bid for bid in buckets.keys() if bid.startswith("aw-dlp-incidents_")])
- last_ids = state.get("last_ids", {})
- if not isinstance(last_ids, dict):
- last_ids = {}
- max_ids: dict[str, int] = {}
- out: list[dict[str, Any]] = []
- for bid in bucket_ids:
- try:
- events = http_json(f"{aw_base}/buckets/{bid}/events?limit={int(per_bucket_limit)}")
- 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))
- bucket_max = prev
- for ev in events:
- eid = int(ev.get("id") or 0)
- if eid <= prev:
- continue
- out.append(ev)
- if eid > bucket_max:
- bucket_max = eid
- max_ids[bid] = bucket_max
- out.sort(key=lambda x: int(x.get("id") or 0))
- return out, max_ids
-
-
-def build_message(event: dict[str, Any], app_name: str, facility: int) -> str:
- pri = facility * 8 + 6
- ts = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
- data = event.get("data") or {}
- host = str(data.get("hostname") or "unknown")
- payload = json.dumps(
- {
- "event_id": event.get("id"),
- "timestamp": event.get("timestamp"),
- "host": host,
- "severity": data.get("severity"),
- "signalType": data.get("signalType"),
- "username": data.get("username"),
- "action": data.get("action"),
- "message": data.get("message"),
- "data": data,
- },
- ensure_ascii=False,
- separators=(",", ":"),
- )
- return f"<{pri}>1 {ts} {host} {app_name} - - - {payload}"
-
-
-def send_syslog(line: str, host: str, port: int, proto: str, timeout: int = 10) -> None:
- if proto.lower() == "tcp":
- sock = socket.create_connection((host, port), timeout=timeout)
- try:
- sock.sendall((line + "\n").encode("utf-8", errors="ignore"))
- finally:
- sock.close()
- return
- sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
- try:
- sock.sendto(line.encode("utf-8", errors="ignore"), (host, port))
- finally:
- sock.close()
-
-
-def main() -> None:
- setup_logging()
- cfg_path = Path("/opt/activitywatch/dlp-integrations/syslog-forwarder-config.yaml")
- cfg = load_yaml(cfg_path)
- aw_base = str(cfg.get("aw_api_base", "http://127.0.0.1:5600/api/0")).rstrip("/")
- state_path = Path(str(cfg.get("state_path", "/var/lib/activitywatch/dlp-integrations/syslog-forwarder-state.json")))
- per_bucket_limit = int(cfg.get("per_bucket_limit", 300))
- syslog_host = str(cfg.get("syslog_host", "127.0.0.1"))
- syslog_port = int(cfg.get("syslog_port", 514))
- syslog_proto = str(cfg.get("syslog_proto", "udp"))
- facility = int(cfg.get("facility", 16))
- app_name = str(cfg.get("app_name", "aw-dlp"))
-
- state = load_json(state_path)
- 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:
- line = build_message(event, app_name=app_name, facility=facility)
- send_syslog(line=line, host=syslog_host, port=syslog_port, proto=syslog_proto)
- sent += 1
-
- save_json(state_path, {"last_ids": max_ids})
- LOG.info("syslog forwarder sent=%d buckets=%d", sent, len(max_ids))
-
-
-if __name__ == "__main__":
- main()
diff --git a/aw-server/dlp-integrations/test_syslog_forwarder.py b/aw-server/dlp-integrations/test_syslog_forwarder.py
deleted file mode 100644
index 446cbe3..0000000
--- a/aw-server/dlp-integrations/test_syslog_forwarder.py
+++ /dev/null
@@ -1,59 +0,0 @@
-#!/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.service b/aw-server/dlp-integrations/webhook-sender.service
index 425dd98..5e6fa2d 100644
--- a/aw-server/dlp-integrations/webhook-sender.service
+++ b/aw-server/dlp-integrations/webhook-sender.service
@@ -6,8 +6,8 @@ After=network-online.target
Type=oneshot
User=activitywatch
Group=activitywatch
-WorkingDirectory=/opt/activitywatch/dlp-integrations
-ExecStart=/opt/activitywatch/dlp-integrations/.venv/bin/python /opt/activitywatch/dlp-integrations/webhook_sender.py
+WorkingDirectory=/var/lib/activitywatch
+ExecStart=/usr/local/bin/dlp-webhook-sender-rust
[Install]
WantedBy=multi-user.target
diff --git a/aw-server/dlp-integrations/webhook_sender.py b/aw-server/dlp-integrations/webhook_sender.py
deleted file mode 100644
index 49ddd6c..0000000
--- a/aw-server/dlp-integrations/webhook_sender.py
+++ /dev/null
@@ -1,155 +0,0 @@
-#!/usr/bin/env python3
-from __future__ import annotations
-
-import json
-import logging
-import time
-from datetime import datetime, timezone
-from pathlib import Path
-from typing import Any
-from urllib import error, request
-
-import yaml
-
-LOG = logging.getLogger("aw.dlp.webhook_sender")
-
-
-def setup_logging() -> None:
- logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
-
-
-def load_yaml(path: Path) -> dict[str, Any]:
- if not path.exists():
- return {}
- data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
- if not isinstance(data, dict):
- return {}
- return data
-
-
-def load_json(path: Path) -> dict[str, Any]:
- if not path.exists():
- return {}
- try:
- data = json.loads(path.read_text(encoding="utf-8"))
- if isinstance(data, dict):
- return data
- except Exception:
- return {}
- return {}
-
-
-def save_json(path: Path, payload: dict[str, Any]) -> None:
- path.parent.mkdir(parents=True, exist_ok=True)
- path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
-
-
-def http_json(url: str, timeout: int = 15) -> Any:
- req = request.Request(url, method="GET")
- with request.urlopen(req, timeout=timeout) as resp:
- return json.loads(resp.read().decode("utf-8", errors="ignore"))
-
-
-def post_with_retry(url: str, payload: dict[str, Any], retries: int, timeout: int, backoff_base: float) -> bool:
- body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
- headers = {"Content-Type": "application/json; charset=utf-8"}
- for attempt in range(1, retries + 1):
- try:
- req = request.Request(url, data=body, headers=headers, method="POST")
- with request.urlopen(req, timeout=timeout) as resp:
- code = getattr(resp, "status", 200)
- if 200 <= code < 300:
- return True
- except error.HTTPError as exc:
- LOG.warning("webhook http error url=%s code=%s attempt=%d/%d", url, exc.code, attempt, retries)
- except Exception as exc:
- LOG.warning("webhook transport error url=%s err=%s attempt=%d/%d", url, exc, attempt, retries)
- if attempt < retries:
- time.sleep(backoff_base ** (attempt - 1))
- return False
-
-
-def iter_new_incidents(aw_base: str, state: dict[str, Any], per_bucket_limit: int) -> tuple[list[dict[str, Any]], dict[str, int]]:
- buckets = http_json(f"{aw_base}/buckets/")
- bucket_ids = sorted([bid for bid in buckets.keys() if bid.startswith("aw-dlp-incidents_")])
- last_ids = state.get("last_ids", {})
- if not isinstance(last_ids, dict):
- last_ids = {}
- max_ids: dict[str, int] = {}
- out: list[dict[str, Any]] = []
- for bid in bucket_ids:
- events = http_json(f"{aw_base}/buckets/{bid}/events?limit={int(per_bucket_limit)}")
- prev = int(last_ids.get(bid, 0))
- bucket_max = prev
- for ev in events:
- eid = int(ev.get("id") or 0)
- if eid <= prev:
- continue
- out.append(ev)
- if eid > bucket_max:
- bucket_max = eid
- max_ids[bid] = bucket_max
- out.sort(key=lambda x: int(x.get("id") or 0))
- return out, max_ids
-
-
-def should_send(severity: str, allowed: list[str]) -> bool:
- return severity.lower() in {s.lower() for s in allowed}
-
-
-def main() -> None:
- setup_logging()
- cfg_path = Path("/opt/activitywatch/dlp-integrations/webhook-config.yaml")
- cfg = load_yaml(cfg_path)
- aw_base = str(cfg.get("aw_api_base", "http://127.0.0.1:5600/api/0")).rstrip("/")
- state_path = Path(str(cfg.get("state_path", "/var/lib/activitywatch/dlp-integrations/webhook-state.json")))
- retries = int(cfg.get("retries", 4))
- timeout = int(cfg.get("timeout_sec", 15))
- backoff_base = float(cfg.get("backoff_base", 2.0))
- per_bucket_limit = int(cfg.get("per_bucket_limit", 300))
- hooks = cfg.get("critical_webhooks", [])
- if not isinstance(hooks, list):
- hooks = []
-
- state = load_json(state_path)
- incidents, max_ids = iter_new_incidents(aw_base=aw_base, state=state, per_bucket_limit=per_bucket_limit)
-
- sent = 0
- for ev in incidents:
- data = ev.get("data") or {}
- severity = str(data.get("severity") or "low")
- for hook in hooks:
- if not isinstance(hook, dict):
- continue
- url = str(hook.get("url") or "").strip()
- if not url:
- continue
- allowed = hook.get("severity", ["high"])
- if isinstance(allowed, str):
- allowed = [allowed]
- if not should_send(severity, [str(x) for x in allowed]):
- continue
- payload = {
- "source": "AWatch-rus DLP",
- "timestamp": ev.get("timestamp"),
- "event_id": ev.get("id"),
- "severity": severity,
- "message": data.get("message"),
- "ruleId": data.get("ruleId"),
- "signalType": data.get("signalType"),
- "hostname": data.get("hostname"),
- "username": data.get("username"),
- "action": data.get("action"),
- "raw": data,
- }
- if post_with_retry(url=url, payload=payload, retries=retries, timeout=timeout, backoff_base=backoff_base):
- sent += 1
-
- state["last_ids"] = max_ids
- state["updated_at"] = datetime.now(timezone.utc).isoformat()
- save_json(state_path, state)
- LOG.info("Webhook sender done: delivered=%d incidents_seen=%d", sent, len(incidents))
-
-
-if __name__ == "__main__":
- main()
diff --git a/aw-server/dlp-monitoring/metrics_exporter.py b/aw-server/dlp-monitoring/metrics_exporter.py
deleted file mode 100644
index 8a52a8a..0000000
--- a/aw-server/dlp-monitoring/metrics_exporter.py
+++ /dev/null
@@ -1,19 +0,0 @@
-#!/usr/bin/env python3
-from __future__ import annotations
-
-from fastapi import FastAPI
-from fastapi.responses import PlainTextResponse
-
-app = FastAPI(title="AWatch DLP Metrics")
-
-
-@app.get("/metrics", response_class=PlainTextResponse)
-def metrics() -> str:
- # Minimal exporter baseline for Prometheus scraping.
- return "\n".join(
- [
- "# HELP aw_dlp_exporter_up Exporter availability.",
- "# TYPE aw_dlp_exporter_up gauge",
- "aw_dlp_exporter_up 1",
- ]
- )
diff --git a/aw-server/dlp-policy-engine/dlp-policy-engine.service b/aw-server/dlp-policy-engine/dlp-policy-engine.service
index 989be92..65c2c4b 100644
--- a/aw-server/dlp-policy-engine/dlp-policy-engine.service
+++ b/aw-server/dlp-policy-engine/dlp-policy-engine.service
@@ -8,8 +8,8 @@ StartLimitIntervalSec=60
[Service]
Type=simple
EnvironmentFile=/etc/activitywatch/aw-server.env
-WorkingDirectory=/opt/activitywatch/dlp-policy-engine
-ExecStart=/opt/activitywatch/dlp-policy-engine/.venv/bin/uvicorn policy_service:app --host ${AW_DLP_POLICY_ENGINE_BIND_HOST} --port ${AW_DLP_POLICY_ENGINE_PORT}
+WorkingDirectory=/var/lib/activitywatch
+ExecStart=/usr/local/bin/aw-dlp-policy-engine-rust
Restart=on-failure
RestartSec=5
User=activitywatch
diff --git a/aw-server/dlp-policy-engine/policy_distributor.py b/aw-server/dlp-policy-engine/policy_distributor.py
deleted file mode 100644
index 915bb13..0000000
--- a/aw-server/dlp-policy-engine/policy_distributor.py
+++ /dev/null
@@ -1,26 +0,0 @@
-from __future__ import annotations
-
-from typing import Any
-
-
-def build_policy_bundle(record: dict[str, Any] | None) -> dict[str, Any]:
- if not record:
- return {
- "active": False,
- "policyId": None,
- "name": None,
- "version": None,
- "checksum": None,
- "updatedAtUtc": None,
- "policy": None,
- }
-
- return {
- "active": True,
- "policyId": record["id"],
- "name": record["name"],
- "version": record["current_version"],
- "checksum": record["checksum"],
- "updatedAtUtc": record["updated_at"],
- "policy": record["policy"],
- }
diff --git a/aw-server/dlp-policy-engine/policy_schema.py b/aw-server/dlp-policy-engine/policy_schema.py
deleted file mode 100644
index 3109f4e..0000000
--- a/aw-server/dlp-policy-engine/policy_schema.py
+++ /dev/null
@@ -1,71 +0,0 @@
-from __future__ import annotations
-
-from datetime import datetime
-from typing import Any
-
-from pydantic import BaseModel, Field, ConfigDict
-
-
-class PolicyDocument(BaseModel):
- model_config = ConfigDict(extra="allow")
-
- version: int = 1
- defaults: dict[str, Any] = Field(
- default_factory=lambda: {
- "enabled": True,
- "cooldownSeconds": 300,
- "action": "alert",
- "severity": "medium",
- }
- )
- endpoint: dict[str, list[dict[str, Any]]] = Field(
- default_factory=lambda: {
- "clipboard": [],
- "usb": [],
- "print": [],
- }
- )
-
-
-class PolicyCreateRequest(BaseModel):
- name: str = Field(min_length=1, max_length=128)
- description: str | None = Field(default=None, max_length=2048)
- policy: PolicyDocument
- activate: bool = False
- actor: str | None = Field(default="api")
-
-
-class PolicyUpdateRequest(BaseModel):
- name: str | None = Field(default=None, min_length=1, max_length=128)
- description: str | None = Field(default=None, max_length=2048)
- policy: PolicyDocument | None = None
- activate: bool = False
- actor: str | None = Field(default="api")
-
-
-class PolicyActivateRequest(BaseModel):
- actor: str | None = Field(default="api")
-
-
-class PolicyStatusRequest(BaseModel):
- actor: str | None = Field(default="api")
- comment: str | None = Field(default=None, max_length=2048)
-
-
-class PolicyRecord(BaseModel):
- id: int
- name: str
- description: str | None
- is_active: bool
- current_version: int
- checksum: str
- created_at: datetime
- updated_at: datetime
-
-
-class PolicyVersionRecord(BaseModel):
- policy_id: int
- version: int
- checksum: str
- created_at: datetime
- created_by: str | None
diff --git a/aw-server/dlp-policy-engine/policy_service.py b/aw-server/dlp-policy-engine/policy_service.py
deleted file mode 100644
index e9dac58..0000000
--- a/aw-server/dlp-policy-engine/policy_service.py
+++ /dev/null
@@ -1,214 +0,0 @@
-#!/usr/bin/env python3
-from __future__ import annotations
-
-import os
-from pathlib import Path
-from typing import Any
-
-from fastapi import FastAPI, HTTPException
-
-from policy_distributor import build_policy_bundle
-from policy_schema import PolicyActivateRequest, PolicyCreateRequest, PolicyStatusRequest, PolicyUpdateRequest
-from policy_storage import PolicyStorage
-
-
-def _env(name: str, default: str) -> str:
- value = os.environ.get(name)
- return value if value not in (None, "") else default
-
-
-APP_NAME = "aw-dlp-policy-engine"
-APP_VERSION = "0.1.0"
-DB_PATH = _env("AW_DLP_POLICY_ENGINE_DB_PATH", "/var/lib/activitywatch/dlp-policy-engine.sqlite")
-storage = PolicyStorage(DB_PATH)
-
-app = FastAPI(title=APP_NAME, version=APP_VERSION)
-AGENT_STATE: dict[str, dict[str, Any]] = {}
-
-
-@app.get("/healthz")
-def healthz() -> dict[str, str]:
- return {
- "status": "ok",
- "service": APP_NAME,
- "db_path": DB_PATH,
- "db_exists": str(Path(DB_PATH).exists()).lower(),
- }
-
-
-@app.get("/api/0/dlp/policies")
-def list_policies() -> dict[str, object]:
- return {"items": storage.list_policies()}
-
-
-@app.post("/api/0/dlp/policies", status_code=201)
-def create_policy(payload: PolicyCreateRequest) -> dict[str, object]:
- try:
- item = storage.create_policy(
- name=payload.name,
- description=payload.description,
- policy=payload.policy.model_dump(mode="json"),
- activate=payload.activate,
- actor=payload.actor,
- )
- except Exception as exc:
- raise HTTPException(status_code=400, detail=str(exc)) from exc
- return {"item": item}
-
-
-@app.get("/api/0/dlp/policies/active")
-def get_active_policy() -> dict[str, object]:
- item = storage.get_active_policy()
- if not item:
- raise HTTPException(status_code=404, detail="no active policy configured")
- return build_policy_bundle(item)
-
-
-@app.get("/api/0/dlp/policies/active/version")
-def get_active_policy_version() -> dict[str, object]:
- item = storage.get_active_policy()
- if not item:
- raise HTTPException(status_code=404, detail="no active policy configured")
- return {
- "active": True,
- "policyId": item["id"],
- "version": item["current_version"],
- "checksum": item["checksum"],
- "updatedAtUtc": item["updated_at"],
- }
-
-
-@app.post("/api/0/dlp/policies/agents/{agent_id}/heartbeat")
-def update_agent_policy_heartbeat(agent_id: str, payload: dict[str, Any]) -> dict[str, object]:
- AGENT_STATE[agent_id] = {
- "agentId": agent_id,
- "hostname": payload.get("hostname") or agent_id,
- "version": payload.get("version"),
- "checksum": payload.get("checksum"),
- "updatedAtUtc": payload.get("updatedAtUtc"),
- }
- return {"ok": True, "agent": AGENT_STATE[agent_id]}
-
-
-@app.get("/api/0/dlp/policies/agents/{agent_id}/desired")
-def get_agent_desired_policy(agent_id: str) -> dict[str, object]:
- item = storage.get_active_policy()
- if not item:
- raise HTTPException(status_code=404, detail="no active policy configured")
-
- current = AGENT_STATE.get(agent_id, {})
- current_version = current.get("version")
- current_checksum = current.get("checksum")
- desired_version = item["current_version"]
- desired_checksum = item["checksum"]
- refresh_now = (str(current_version) != str(desired_version)) or (str(current_checksum) != str(desired_checksum))
-
- return {
- "agentId": agent_id,
- "refreshNow": refresh_now,
- "reason": "mismatch" if refresh_now else "up-to-date",
- "current": {
- "version": current_version,
- "checksum": current_checksum,
- },
- "desired": {
- "policyId": item["id"],
- "version": desired_version,
- "checksum": desired_checksum,
- "updatedAtUtc": item["updated_at"],
- },
- }
-
-
-@app.post("/api/0/dlp/policies/rollback")
-def rollback_active_policy(payload: PolicyActivateRequest) -> dict[str, object]:
- item = storage.rollback_active_policy(actor=payload.actor)
- if not item:
- raise HTTPException(status_code=404, detail="no active policy configured")
- return {"item": item}
-
-
-@app.get("/api/0/dlp/policies/{policy_id}")
-def get_policy(policy_id: int) -> dict[str, object]:
- item = storage.get_policy(policy_id)
- if not item:
- raise HTTPException(status_code=404, detail="policy not found")
- return {"item": item}
-
-
-@app.put("/api/0/dlp/policies/{policy_id}")
-def update_policy(policy_id: int, payload: PolicyUpdateRequest) -> dict[str, object]:
- try:
- item = storage.update_policy(
- policy_id=policy_id,
- name=payload.name,
- description=payload.description,
- policy=payload.policy.model_dump(mode="json") if payload.policy is not None else None,
- activate=payload.activate,
- actor=payload.actor,
- )
- except Exception as exc:
- raise HTTPException(status_code=400, detail=str(exc)) from exc
-
- if not item:
- raise HTTPException(status_code=404, detail="policy not found")
- return {"item": item}
-
-
-@app.post("/api/0/dlp/policies/{policy_id}/activate")
-def activate_policy(policy_id: int, payload: PolicyActivateRequest) -> dict[str, object]:
- try:
- item = storage.activate_policy(policy_id=policy_id, actor=payload.actor)
- except Exception as exc:
- raise HTTPException(status_code=400, detail=str(exc)) from exc
- if not item:
- raise HTTPException(status_code=404, detail="policy not found")
- return {"item": item}
-
-
-@app.post("/api/0/dlp/policies/{policy_id}/submit")
-def submit_policy_for_approval(policy_id: int, payload: PolicyStatusRequest) -> dict[str, object]:
- item = storage.set_policy_status(policy_id, "pending_approval", payload.actor, payload.comment)
- if not item:
- raise HTTPException(status_code=404, detail="policy not found")
- return {"item": item}
-
-
-@app.post("/api/0/dlp/policies/{policy_id}/approve")
-def approve_policy(policy_id: int, payload: PolicyStatusRequest) -> dict[str, object]:
- item = storage.set_policy_status(policy_id, "approved", payload.actor, payload.comment)
- if not item:
- raise HTTPException(status_code=404, detail="policy not found")
- return {"item": item}
-
-
-@app.post("/api/0/dlp/policies/{policy_id}/draft")
-def return_policy_to_draft(policy_id: int, payload: PolicyStatusRequest) -> dict[str, object]:
- item = storage.set_policy_status(policy_id, "draft", payload.actor, payload.comment)
- if not item:
- raise HTTPException(status_code=404, detail="policy not found")
- return {"item": item}
-
-
-@app.delete("/api/0/dlp/policies/{policy_id}")
-def delete_policy(policy_id: int) -> dict[str, bool]:
- try:
- deleted = storage.delete_policy(policy_id)
- except ValueError as exc:
- raise HTTPException(status_code=409, detail=str(exc)) from exc
-
- if not deleted:
- raise HTTPException(status_code=404, detail="policy not found")
- return {"deleted": True}
-
-
-@app.get("/api/0/dlp/policies/audit")
-def list_policy_audit(limit: int = 200) -> dict[str, object]:
- return {"items": storage.list_audit(policy_id=None, limit=max(1, min(limit, 1000)))}
-
-
-@app.get("/api/0/dlp/policies/{policy_id}/audit")
-def list_single_policy_audit(policy_id: int, limit: int = 200) -> dict[str, object]:
- if not storage.get_policy(policy_id):
- raise HTTPException(status_code=404, detail="policy not found")
- return {"items": storage.list_audit(policy_id=policy_id, limit=max(1, min(limit, 1000)))}
diff --git a/aw-server/dlp-policy-engine/policy_storage.py b/aw-server/dlp-policy-engine/policy_storage.py
deleted file mode 100644
index 3365579..0000000
--- a/aw-server/dlp-policy-engine/policy_storage.py
+++ /dev/null
@@ -1,367 +0,0 @@
-from __future__ import annotations
-
-import hashlib
-import json
-import sqlite3
-from contextlib import contextmanager
-from datetime import datetime, timezone
-from pathlib import Path
-from typing import Any, Iterator
-
-
-def utc_now() -> str:
- return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
-
-
-def canonical_policy_json(policy: dict[str, Any]) -> str:
- return json.dumps(policy, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
-
-
-def checksum_policy(policy: dict[str, Any]) -> str:
- return hashlib.sha256(canonical_policy_json(policy).encode("utf-8")).hexdigest()
-
-
-class PolicyStorage:
- def __init__(self, db_path: str) -> None:
- self.db_path = Path(db_path)
- self.db_path.parent.mkdir(parents=True, exist_ok=True)
- self._init_schema()
-
- @contextmanager
- def connect(self) -> Iterator[sqlite3.Connection]:
- conn = sqlite3.connect(self.db_path)
- conn.row_factory = sqlite3.Row
- try:
- yield conn
- conn.commit()
- finally:
- conn.close()
-
- def _init_schema(self) -> None:
- with self.connect() as conn:
- conn.executescript(
- """
- PRAGMA journal_mode=WAL;
-
- 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);
- """
- )
- cols = [r["name"] for r in conn.execute("PRAGMA table_info(policies)").fetchall()]
- if "status" not in cols:
- conn.execute("ALTER TABLE policies ADD COLUMN status TEXT NOT NULL DEFAULT 'draft'")
-
- def _audit(
- self,
- conn: sqlite3.Connection,
- policy_id: int | None,
- action: str,
- actor: str | None,
- comment: str | None = None,
- details: dict[str, Any] | None = None,
- ) -> None:
- conn.execute(
- """
- INSERT INTO policy_audit(policy_id, action, actor, comment, details_json, created_at)
- VALUES(?, ?, ?, ?, ?, ?)
- """,
- (
- policy_id,
- action,
- actor,
- comment,
- canonical_policy_json(details) if details is not None else None,
- utc_now(),
- ),
- )
-
- def list_policies(self) -> list[dict[str, Any]]:
- with self.connect() as conn:
- rows = conn.execute(
- """
- 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
- """
- ).fetchall()
- return [dict(row) for row in rows]
-
- def get_policy(self, policy_id: int) -> dict[str, Any] | None:
- with self.connect() as conn:
- policy_row = conn.execute(
- """
- SELECT id, name, description, status, is_active, current_version, checksum, created_at, updated_at
- FROM policies
- WHERE id = ?
- """,
- (policy_id,),
- ).fetchone()
- if not policy_row:
- return None
-
- version_row = conn.execute(
- """
- SELECT version, policy_json, checksum, created_at, created_by
- FROM policy_versions
- WHERE policy_id = ? AND version = ?
- """,
- (policy_id, policy_row["current_version"]),
- ).fetchone()
- if not version_row:
- return None
-
- result = dict(policy_row)
- result["policy"] = json.loads(version_row["policy_json"])
- result["version_created_at"] = version_row["created_at"]
- result["version_created_by"] = version_row["created_by"]
- return result
-
- def get_active_policy(self) -> dict[str, Any] | None:
- with self.connect() as conn:
- row = conn.execute("SELECT id FROM policies WHERE is_active = 1 ORDER BY updated_at DESC LIMIT 1").fetchone()
- if not row:
- return None
- return self.get_policy(int(row["id"]))
-
- def create_policy(self, name: str, description: str | None, policy: dict[str, Any], activate: bool, actor: str | None) -> dict[str, Any]:
- checksum = checksum_policy(policy)
- now = utc_now()
- status = "deployed" if activate else "draft"
- policy_json = canonical_policy_json(policy)
- with self.connect() as conn:
- if activate:
- conn.execute("UPDATE policies SET is_active = 0")
- cursor = conn.execute(
- """
- INSERT INTO policies(name, description, status, is_active, current_version, checksum, created_at, updated_at)
- VALUES(?, ?, ?, ?, 1, ?, ?, ?)
- """,
- (name, description, status, 1 if activate else 0, checksum, now, now),
- )
- policy_id = int(cursor.lastrowid)
- conn.execute(
- """
- INSERT INTO policy_versions(policy_id, version, policy_json, checksum, created_at, created_by, rollback_of_version)
- VALUES(?, 1, ?, ?, ?, ?, NULL)
- """,
- (policy_id, policy_json, checksum, now, actor),
- )
- self._audit(conn, policy_id, "create", actor, details={"activate": activate, "status": status})
- return self.get_policy(policy_id) # type: ignore[return-value]
-
- def update_policy(
- self,
- policy_id: int,
- name: str | None,
- description: str | None,
- policy: dict[str, Any] | None,
- activate: bool,
- actor: str | None,
- ) -> dict[str, Any] | None:
- current = self.get_policy(policy_id)
- if not current:
- return None
-
- with self.connect() as conn:
- new_name = name if name is not None else current["name"]
- new_description = description if description is not None else current["description"]
- new_version = int(current["current_version"])
- new_checksum = current["checksum"]
- new_status = current.get("status", "draft")
-
- if policy is not None:
- new_version += 1
- new_checksum = checksum_policy(policy)
- new_status = "draft"
- policy_json = canonical_policy_json(policy)
- conn.execute(
- """
- INSERT INTO policy_versions(policy_id, version, policy_json, checksum, created_at, created_by, rollback_of_version)
- VALUES(?, ?, ?, ?, ?, ?, NULL)
- """,
- (policy_id, new_version, policy_json, new_checksum, utc_now(), actor),
- )
-
- if activate:
- conn.execute("UPDATE policies SET is_active = 0")
- new_status = "deployed"
-
- conn.execute(
- """
- UPDATE policies
- SET name = ?, description = ?, status = ?, is_active = ?, current_version = ?, checksum = ?, updated_at = ?
- WHERE id = ?
- """,
- (
- new_name,
- new_description,
- new_status,
- 1 if activate else current["is_active"],
- new_version,
- new_checksum,
- utc_now(),
- policy_id,
- ),
- )
- self._audit(conn, policy_id, "update", actor, details={"activate": activate, "status": new_status})
- return self.get_policy(policy_id)
-
- def activate_policy(self, policy_id: int, actor: str | None) -> dict[str, Any] | None:
- current = self.get_policy(policy_id)
- if not current:
- return None
- if current.get("status") != "approved":
- raise ValueError("policy must be approved before deploy")
-
- with self.connect() as conn:
- conn.execute("UPDATE policies SET is_active = 0")
- conn.execute(
- "UPDATE policies SET status = 'deployed', is_active = 1, updated_at = ? WHERE id = ?",
- (utc_now(), policy_id),
- )
- self._audit(conn, policy_id, "deploy", actor)
- return self.get_policy(policy_id)
-
- def rollback_active_policy(self, actor: str | None) -> dict[str, Any] | None:
- active = self.get_active_policy()
- if not active:
- return None
-
- with self.connect() as conn:
- rows = conn.execute(
- """
- SELECT version, policy_json
- FROM policy_versions
- WHERE policy_id = ?
- ORDER BY version DESC
- LIMIT 2
- """,
- (active["id"],),
- ).fetchall()
- if len(rows) < 2:
- return active
-
- previous_version = int(rows[1]["version"])
- previous_policy = json.loads(rows[1]["policy_json"])
- rollback_version = int(active["current_version"]) + 1
- rollback_checksum = checksum_policy(previous_policy)
- now = utc_now()
-
- conn.execute(
- """
- INSERT INTO policy_versions(policy_id, version, policy_json, checksum, created_at, created_by, rollback_of_version)
- VALUES(?, ?, ?, ?, ?, ?, ?)
- """,
- (
- active["id"],
- rollback_version,
- canonical_policy_json(previous_policy),
- rollback_checksum,
- now,
- actor,
- previous_version,
- ),
- )
- conn.execute(
- """
- UPDATE policies
- SET status = 'draft', current_version = ?, checksum = ?, updated_at = ?
- WHERE id = ?
- """,
- (rollback_version, rollback_checksum, now, active["id"]),
- )
- self._audit(conn, int(active["id"]), "rollback", actor, details={"rollback_to": previous_version})
- return self.get_policy(int(active["id"]))
-
- def delete_policy(self, policy_id: int) -> bool:
- current = self.get_policy(policy_id)
- if not current:
- return False
- if current["is_active"]:
- raise ValueError("cannot delete active policy")
-
- with self.connect() as conn:
- self._audit(conn, policy_id, "delete", None)
- conn.execute("DELETE FROM policy_versions WHERE policy_id = ?", (policy_id,))
- conn.execute("DELETE FROM policies WHERE id = ?", (policy_id,))
- return True
-
- def set_policy_status(self, policy_id: int, status: str, actor: str | None, comment: str | None = None) -> dict[str, Any] | None:
- current = self.get_policy(policy_id)
- if not current:
- return None
- allowed = {"draft", "pending_approval", "approved", "deployed"}
- if status not in allowed:
- raise ValueError(f"unsupported status: {status}")
- with self.connect() as conn:
- conn.execute(
- "UPDATE policies SET status = ?, updated_at = ? WHERE id = ?",
- (status, utc_now(), policy_id),
- )
- self._audit(conn, policy_id, "status_change", actor, comment=comment, details={"status": status})
- return self.get_policy(policy_id)
-
- def list_audit(self, policy_id: int | None = None, limit: int = 200) -> list[dict[str, Any]]:
- query = """
- SELECT id, policy_id, action, actor, comment, details_json, created_at
- FROM policy_audit
- """
- params: tuple[Any, ...]
- if policy_id is None:
- query += " ORDER BY id DESC LIMIT ?"
- params = (limit,)
- else:
- query += " WHERE policy_id = ? ORDER BY id DESC LIMIT ?"
- params = (policy_id, limit)
- with self.connect() as conn:
- rows = conn.execute(query, params).fetchall()
- items: list[dict[str, Any]] = []
- for row in rows:
- d = dict(row)
- if d.get("details_json"):
- try:
- d["details"] = json.loads(d["details_json"])
- except Exception:
- d["details"] = None
- else:
- d["details"] = None
- d.pop("details_json", None)
- items.append(d)
- return items
diff --git a/aw-server/ensure-reliability.sh b/aw-server/ensure-reliability.sh
index 8b2c6ce..54c40d7 100644
--- a/aw-server/ensure-reliability.sh
+++ b/aw-server/ensure-reliability.sh
@@ -18,7 +18,7 @@ else
"$ROOT_DIR/adk-rust/target/release/aw-ensure-reliability" \
"/usr/local/bin/aw-ensure-reliability"; do
if [[ -n "$candidate" && -x "$candidate" ]]; then
- exec "$candidate" --health-script-source "$SCRIPT_DIR/health-check.sh" "$@"
+ exec "$candidate" "$@"
fi
done
cat >&2 <<'EOF'
@@ -115,10 +115,12 @@ setup_health_check() {
local health_service="/etc/systemd/system/aw-health-check.service"
if [[ ! -f "$health_script" ]]; then
- log "Setting up health check..."
- cp "$SCRIPT_DIR/health-check.sh" "$health_script"
- chmod +x "$health_script"
-
+ log "ERROR: Rust-first health check wrapper not found: $health_script"
+ return 1
+ fi
+
+ if [[ ! -f "$health_timer" || ! -f "$health_service" ]]; then
+ log "Setting up health check timer..."
# Create systemd timer for health checks
cat > "$health_timer" << 'EOF'
[Unit]
diff --git a/aw-server/hayabusa/aw-hayabusa-autoprocess.py b/aw-server/hayabusa/aw-hayabusa-autoprocess.py
deleted file mode 100644
index d42b733..0000000
--- a/aw-server/hayabusa/aw-hayabusa-autoprocess.py
+++ /dev/null
@@ -1,136 +0,0 @@
-#!/usr/bin/env python3
-import argparse
-import fcntl
-import json
-import pathlib
-import shutil
-import subprocess
-import sys
-from typing import Optional
-
-DROP_DIR = pathlib.Path('/opt/activitywatch/aw-rus-ops/drop')
-LOCK_PATH = pathlib.Path('/opt/hayabusa/state/aw-hayabusa-autoprocess.lock')
-WRAPPER = pathlib.Path('/usr/local/bin/aw-hayabusa')
-LINKER = pathlib.Path('/usr/local/bin/aw-hayabusa-link-case')
-CASE_ALERT = pathlib.Path('/usr/local/bin/aw-hayabusa-case-alert')
-
-
-def run(cmd):
- print('RUN', ' '.join(str(x) for x in cmd), flush=True)
- subprocess.run(cmd, check=True)
-
-
-def run_capture(cmd):
- print('RUN', ' '.join(str(x) for x in cmd), flush=True)
- return subprocess.run(cmd, check=False, text=True, capture_output=True)
-
-
-def read_latest_intake():
- return json.loads(pathlib.Path('/opt/hayabusa/state/latest-intake.json').read_text(encoding='utf-8'))
-
-
-def load_sidecars(zip_path: pathlib.Path):
- base = zip_path.with_suffix('')
- caseid_path = base.with_suffix('.caseid')
- meta_path = base.with_suffix('.meta.json')
- meta = {}
- if meta_path.is_file():
- meta = json.loads(meta_path.read_text(encoding='utf-8'))
- case_id = meta.get('case_id')
- if case_id is None and caseid_path.is_file():
- raw = caseid_path.read_text(encoding='utf-8').strip()
- if raw:
- case_id = int(raw)
- return {
- 'base': base,
- 'case_id': case_id,
- 'host': meta.get('host'),
- 'mode': meta.get('mode', 'incident'),
- 'link_source': meta.get('link_source', 'aw-rus-drop-autoprocess'),
- 'caseid_path': caseid_path,
- 'meta_path': meta_path,
- }
-
-
-def archive_sidecars(report_dir: pathlib.Path, sidecars):
- target_dir = report_dir / 'input-sidecars'
- target_dir.mkdir(parents=True, exist_ok=True)
- for path in (sidecars['caseid_path'], sidecars['meta_path']):
- if path.is_file():
- shutil.move(str(path), str(target_dir / path.name))
-
-
-def archive_drop_package(report_dir: pathlib.Path, zip_path: pathlib.Path):
- target_dir = report_dir / 'input-drop'
- target_dir.mkdir(parents=True, exist_ok=True)
- target_path = target_dir / zip_path.name
- if target_path.exists():
- target_path.unlink()
- shutil.move(str(zip_path), str(target_path))
-
-
-def guess_host(zip_path: pathlib.Path, sidecars) -> Optional[str]:
- if sidecars['host']:
- return str(sidecars['host'])
- name = zip_path.stem
- if '-' in name:
- return name.split('-', 1)[0]
- return name or None
-
-
-def process_one(zip_path: pathlib.Path):
- sidecars = load_sidecars(zip_path)
- host = guess_host(zip_path, sidecars)
- mode = sidecars['mode'] or 'incident'
- accept_cmd = [str(WRAPPER), 'accept', '--package', str(zip_path)]
- if host:
- accept_cmd += ['--host', host]
- run(accept_cmd)
- run([str(WRAPPER), 'process-inbox', '--mode', mode, '--limit', '1'])
- latest = read_latest_intake()
- report_dir = pathlib.Path(latest['report_dir'])
- case_alert = None
- if CASE_ALERT.is_file():
- alert_cmd = [str(CASE_ALERT), '--mode', mode, '--link-source', sidecars['link_source']]
- if sidecars['case_id'] is not None:
- alert_cmd += ['--case-id', str(sidecars['case_id'])]
- result = run_capture(alert_cmd)
- case_alert = {
- 'returncode': result.returncode,
- 'stdout': result.stdout.strip(),
- 'stderr': result.stderr.strip(),
- }
- archive_sidecars(report_dir, sidecars)
- archive_drop_package(report_dir, zip_path)
- if sidecars['case_id'] is not None and not CASE_ALERT.is_file():
- run([str(LINKER), '--case-id', str(sidecars['case_id']), '--mode', mode, '--link-source', sidecars['link_source']])
- return {'latest_intake': latest, 'case_alert': case_alert}
-
-
-def main():
- p = argparse.ArgumentParser(description='Auto-process Hayabusa zip packages dropped onto aw-rus server')
- p.add_argument('--drop-dir', default=str(DROP_DIR))
- p.add_argument('--once', action='store_true', default=True)
- args = p.parse_args()
-
- drop_dir = pathlib.Path(args.drop_dir)
- drop_dir.mkdir(parents=True, exist_ok=True)
- LOCK_PATH.parent.mkdir(parents=True, exist_ok=True)
- with LOCK_PATH.open('w') as lock_fh:
- try:
- fcntl.flock(lock_fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
- except BlockingIOError:
- print('autoprocess already running', file=sys.stderr)
- return 0
- zips = sorted(drop_dir.glob('*.zip'))
- if not zips:
- print('no zip packages in drop dir')
- return 0
- for zip_path in zips:
- result = process_one(zip_path)
- print(json.dumps({'processed': str(zip_path), **result}, ensure_ascii=False, indent=2))
- return 0
-
-
-if __name__ == '__main__':
- raise SystemExit(main())
diff --git a/aw-server/hayabusa/aw-hayabusa-case-alert.py b/aw-server/hayabusa/aw-hayabusa-case-alert.py
deleted file mode 100644
index fe0fa01..0000000
--- a/aw-server/hayabusa/aw-hayabusa-case-alert.py
+++ /dev/null
@@ -1,361 +0,0 @@
-#!/usr/bin/env python3
-from __future__ import annotations
-
-import argparse
-import csv
-import json
-import os
-import pathlib
-import urllib.error
-import urllib.parse
-import urllib.request
-from collections import Counter
-from datetime import datetime, timezone
-from typing import Any
-
-
-SEVERITY_ORDER = {"low": 1, "medium": 2, "high": 3, "critical": 4}
-LEVEL_NORMALIZATION = {
- "informational": "info",
- "info": "info",
- "low": "low",
- "med": "med",
- "medium": "med",
- "high": "high",
- "crit": "crit",
- "critical": "crit",
-}
-LEVEL_WEIGHTS = {
- "info": 1,
- "low": 4,
- "med": 12,
- "high": 40,
- "crit": 100,
-}
-
-
-def env_bool(name: str, default: bool) -> bool:
- value = os.environ.get(name)
- if value is None:
- return default
- return value.strip().lower() in {"1", "true", "yes", "on"}
-
-
-def normalize_level(level: str | None) -> str:
- if not level:
- return "info"
- return LEVEL_NORMALIZATION.get(level.strip().lower(), level.strip().lower())
-
-
-def severity_meets(actual: str, threshold: str) -> bool:
- return SEVERITY_ORDER.get(actual, 0) >= SEVERITY_ORDER.get(threshold, 0)
-
-
-def build_hayabusa_payload(intake: dict[str, Any], mode: str, link_source: str) -> dict[str, Any]:
- report_dir = pathlib.Path(intake["report_dir"])
- return {
- "tool": "hayabusa",
- "host": intake["host"],
- "mode": mode,
- "status": intake["status"],
- "intake_id": intake["intake_id"],
- "package_path": intake["package_path"],
- "sha256": intake["sha256"],
- "report_dir": intake["report_dir"],
- "summary_html": str(report_dir / "summary.html"),
- "timeline_path": str(report_dir / "timeline.jsonl"),
- "manifest_path": str(report_dir / "manifest.json"),
- "link_source": link_source,
- }
-
-
-def post_json(url: str, payload: dict[str, Any]) -> dict[str, Any]:
- data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
- req = urllib.request.Request(url, data=data, method="POST", headers={"Content-Type": "application/json"})
- with urllib.request.urlopen(req) as resp:
- body = resp.read().decode("utf-8")
- return json.loads(body) if body else {}
-
-
-def patch_json(url: str, payload: dict[str, Any]) -> dict[str, Any]:
- data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
- req = urllib.request.Request(url, data=data, method="PATCH", headers={"Content-Type": "application/json"})
- with urllib.request.urlopen(req) as resp:
- body = resp.read().decode("utf-8")
- return json.loads(body) if body else {}
-
-
-def get_json(url: str) -> dict[str, Any]:
- with urllib.request.urlopen(url) as resp:
- return json.loads(resp.read().decode("utf-8"))
-
-
-def parse_timestamp(value: str | None) -> datetime | None:
- if not value:
- return None
- try:
- return datetime.fromisoformat(value.replace("Z", "+00:00"))
- except ValueError:
- return None
-
-
-def read_csv_rows(path: pathlib.Path) -> int:
- if not path.is_file():
- return 0
- with path.open("r", encoding="utf-8-sig", newline="") as fh:
- reader = csv.reader(fh)
- rows = list(reader)
- if not rows:
- return 0
- return max(0, len(rows) - 1)
-
-
-def analyze_report(report_dir: pathlib.Path) -> dict[str, Any]:
- timeline_path = report_dir / "timeline.jsonl"
- level_counts: Counter[str] = Counter()
- title_counts: Counter[str] = Counter()
- first_ts: datetime | None = None
- last_ts: datetime | None = None
- total_events = 0
-
- if timeline_path.is_file():
- with timeline_path.open("r", encoding="utf-8") as fh:
- for raw_line in fh:
- line = raw_line.strip()
- if not line:
- continue
- try:
- event = json.loads(line)
- except json.JSONDecodeError:
- continue
- total_events += 1
- level = normalize_level(str(event.get("Level", "")))
- level_counts[level] += 1
- title = str(event.get("RuleTitle") or "").strip() or "Unknown rule"
- title_counts[title] += 1
- ts = parse_timestamp(event.get("Timestamp"))
- if ts is not None:
- first_ts = ts if first_ts is None or ts < first_ts else first_ts
- last_ts = ts if last_ts is None or ts > last_ts else last_ts
-
- failed_logons = read_csv_rows(report_dir / "logon-summary-failed.csv")
- successful_logons = read_csv_rows(report_dir / "logon-summary-successful.csv")
-
- suspicious_pwsh = sum(
- count
- for title, count in title_counts.items()
- if "pwsh" in title.lower() or "powershell" in title.lower() or "obfuscation" in title.lower()
- )
- credential_events = sum(count for title, count in title_counts.items() if "credential" in title.lower())
- timestomp_events = sum(count for title, count in title_counts.items() if "timestomp" in title.lower())
- logon_failure_events = sum(count for title, count in title_counts.items() if "logon failure" in title.lower())
-
- score = (
- sum(LEVEL_WEIGHTS.get(level, 0) * count for level, count in level_counts.items())
- + min(failed_logons, 200) * 2
- + suspicious_pwsh * 6
- + credential_events * 8
- + timestomp_events * 12
- + logon_failure_events * 2
- )
-
- crit_count = level_counts.get("crit", 0)
- high_count = level_counts.get("high", 0)
- med_count = level_counts.get("med", 0)
-
- if crit_count >= 1 or score >= 240 or (high_count >= 4 and suspicious_pwsh >= 4):
- severity = "critical"
- elif high_count >= 1 or score >= 120 or suspicious_pwsh >= 8 or credential_events >= 5:
- severity = "high"
- elif med_count >= 1 or score >= 40 or failed_logons >= 10:
- severity = "medium"
- else:
- severity = "low"
-
- return {
- "severity": severity,
- "score": score,
- "events_total": total_events,
- "level_counts": dict(level_counts),
- "top_rules": [{"title": title, "count": count} for title, count in title_counts.most_common(5)],
- "first_timestamp": first_ts.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") if first_ts else None,
- "last_timestamp": last_ts.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") if last_ts else None,
- "failed_logon_rows": failed_logons,
- "successful_logon_rows": successful_logons,
- "suspicious_pwsh": suspicious_pwsh,
- "credential_events": credential_events,
- "timestomp_events": timestomp_events,
- "logon_failure_events": logon_failure_events,
- }
-
-
-def build_case_title(host: str, summary: dict[str, Any]) -> str:
- top_rule = summary.get("top_rules") or []
- suffix = top_rule[0]["title"] if top_rule else "No dominant rule"
- return f"Hayabusa {summary['severity'].upper()} · {host} · {suffix}"
-
-
-def build_case_payload(intake: dict[str, Any], summary: dict[str, Any]) -> dict[str, Any]:
- return {
- "incident_id": f"hayabusa:{intake['host']}:{intake['intake_id']}",
- "host": intake["host"],
- "title": build_case_title(intake["host"], summary),
- "severity": summary["severity"],
- "evidence": {
- "hayabusa": {
- "intake_id": intake["intake_id"],
- "package_path": intake["package_path"],
- "sha256": intake["sha256"],
- "report_dir": intake["report_dir"],
- "summary": summary,
- }
- },
- }
-
-
-def build_comment(summary: dict[str, Any], intake: dict[str, Any]) -> str:
- top = ", ".join(f"{item['title']} ({item['count']})" for item in summary.get("top_rules", [])[:3]) or "n/a"
- return (
- f"Hayabusa auto-summary\n"
- f"Severity: {summary['severity']} (score={summary['score']})\n"
- f"Host: {intake['host']}\n"
- f"Intake: {intake['intake_id']}\n"
- f"Events: {summary['events_total']}, failed_logons={summary['failed_logon_rows']}, "
- f"suspicious_pwsh={summary['suspicious_pwsh']}, credential_events={summary['credential_events']}\n"
- f"Top rules: {top}\n"
- f"Report: {intake['report_dir']}"
- )
-
-
-def send_telegram(bot_token: str, chat_ids: list[str], text: str) -> list[dict[str, Any]]:
- results = []
- for chat_id in chat_ids:
- payload = urllib.parse.urlencode({"chat_id": chat_id, "text": text}).encode("utf-8")
- url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
- req = urllib.request.Request(url, data=payload, method="POST")
- try:
- with urllib.request.urlopen(req) as resp:
- body = json.loads(resp.read().decode("utf-8"))
- results.append({"chat_id": chat_id, "ok": True, "response": body})
- except Exception as exc: # noqa: BLE001
- results.append({"chat_id": chat_id, "ok": False, "error": str(exc)})
- return results
-
-
-def build_telegram_text(case_id: int | None, intake: dict[str, Any], summary: dict[str, Any]) -> str:
- severity_label = {
- "critical": "критичное событие",
- "high": "опасное событие",
- "medium": "подозрительное событие",
- "low": "слабый сигнал",
- }.get(summary["severity"], summary["severity"])
- top_rules = summary.get("top_rules", [])
- top_rule = top_rules[0]["title"] if top_rules else "нет явного доминирующего правила"
- lines = [
- f"Hayabusa: {severity_label}",
- "",
- f"Хост: {intake['host']}",
- ]
- if case_id is not None:
- lines.append(f"Кейс: {case_id}")
- lines.extend(
- [
- f"Уровень: {summary['severity']}",
- "",
- "Что найдено:",
- f"- {top_rule}: {top_rules[0]['count'] if top_rules else 0}",
- f"- подозрительный PowerShell: {summary['suspicious_pwsh']}",
- f"- ошибок входа: {summary['logon_failure_events']}",
- f"- событий по учётным данным: {summary['credential_events']}",
- ]
- )
- if summary.get("timestomp_events", 0) > 0:
- lines.append(f"- timestomp-подобных событий: {summary['timestomp_events']}")
- lines.extend(
- [
- "",
- "Главный риск:",
- "возможная активность вокруг учётных данных и PowerShell",
- "",
- "Отчёт:",
- f"{intake['report_dir']}",
- ]
- )
- return "\n".join(lines)
-
-
-def main() -> int:
- p = argparse.ArgumentParser(description="Auto-create/update AW-rus case, compute Hayabusa severity, and send Telegram alerts")
- p.add_argument("--case-id", type=int)
- p.add_argument("--intake-json", default="/opt/hayabusa/state/latest-intake.json")
- p.add_argument("--case-api-base", default=os.environ.get("AW_HAYABUSA_CASE_API_BASE", "http://127.0.0.1:5602"))
- p.add_argument("--mode", default="incident")
- p.add_argument("--link-source", default="aw-rus-drop-autoprocess")
- p.add_argument("--auto-create", action="store_true", default=env_bool("AW_HAYABUSA_AUTO_CASE_ENABLED", True))
- p.add_argument("--auto-create-min-severity", default=os.environ.get("AW_HAYABUSA_AUTO_CASE_MIN_SEVERITY", "medium"))
- p.add_argument("--telegram-enabled", action="store_true", default=env_bool("AW_HAYABUSA_TELEGRAM_ENABLED", False))
- p.add_argument("--telegram-min-severity", default=os.environ.get("AW_HAYABUSA_TELEGRAM_MIN_SEVERITY", "high"))
- p.add_argument("--telegram-bot-token", default=os.environ.get("AW_HAYABUSA_TELEGRAM_BOT_TOKEN", ""))
- p.add_argument("--telegram-chat-ids", default=os.environ.get("AW_HAYABUSA_TELEGRAM_CHAT_IDS", ""))
- args = p.parse_args()
-
- intake_path = pathlib.Path(args.intake_json)
- intake = json.loads(intake_path.read_text(encoding="utf-8"))
- summary = analyze_report(pathlib.Path(intake["report_dir"]))
- case_api_base = args.case_api_base.rstrip("/")
- if case_api_base.endswith("/api/0/dlp/cases"):
- case_api_base = case_api_base[: -len("/api/0/dlp/cases")]
- case_id = args.case_id
- created_case = None
- case_error = None
- linked = False
- comment_added = False
-
- try:
- if case_id is None and args.auto_create and severity_meets(summary["severity"], args.auto_create_min_severity):
- created_case = post_json(f"{case_api_base}/api/0/dlp/cases", build_case_payload(intake, summary))
- case_id = int(created_case["id"])
-
- if case_id is not None:
- patch_json(
- f"{case_api_base}/api/0/dlp/cases/{case_id}",
- {"severity": summary["severity"]},
- )
- post_json(
- f"{case_api_base}/api/0/dlp/cases/{case_id}/forensics/hayabusa",
- build_hayabusa_payload(intake, args.mode, args.link_source),
- )
- linked = True
- post_json(
- f"{case_api_base}/api/0/dlp/cases/{case_id}/comments",
- {"comment": build_comment(summary, intake), "author": "aw-hayabusa-auto"},
- )
- comment_added = True
- except Exception as exc: # noqa: BLE001
- case_error = str(exc)
-
- telegram_results: list[dict[str, Any]] = []
- if args.telegram_enabled and args.telegram_bot_token and severity_meets(summary["severity"], args.telegram_min_severity):
- chat_ids = [item.strip() for item in args.telegram_chat_ids.split(",") if item.strip()]
- if chat_ids:
- telegram_results = send_telegram(
- bot_token=args.telegram_bot_token,
- chat_ids=chat_ids,
- text=build_telegram_text(case_id, intake, summary),
- )
-
- result = {
- "summary": summary,
- "case_id": case_id,
- "case_created": created_case,
- "case_linked": linked,
- "case_comment_added": comment_added,
- "case_error": case_error,
- "telegram_results": telegram_results,
- }
- print(json.dumps(result, ensure_ascii=False, indent=2))
- return 0 if case_error is None else 1
-
-
-if __name__ == "__main__":
- raise SystemExit(main())
diff --git a/aw-server/hayabusa/aw-hayabusa-from-windows.py b/aw-server/hayabusa/aw-hayabusa-from-windows.py
deleted file mode 100644
index 6d157ee..0000000
--- a/aw-server/hayabusa/aw-hayabusa-from-windows.py
+++ /dev/null
@@ -1,111 +0,0 @@
-#!/usr/bin/env python3
-import argparse
-import json
-import pathlib
-import re
-import subprocess
-import sys
-
-OPS_ROOT = pathlib.Path('/opt/activitywatch/aw-rus-ops')
-DEFAULT_INVENTORY = OPS_ROOT / 'ansible' / 'inventory.ini'
-DEFAULT_DROP = OPS_ROOT / 'drop'
-DEFAULT_ANSIBLE = OPS_ROOT / 'venv' / 'bin' / 'ansible'
-DEFAULT_WRAPPER = pathlib.Path('/usr/local/bin/aw-hayabusa')
-DEFAULT_LINKER = pathlib.Path('/usr/local/bin/aw-hayabusa-link-case')
-WINDOWS_EXPORT_CMD = r"powershell.exe -ExecutionPolicy Bypass -File C:\ProgramData\AWatch-rus\export-evtx-for-hayabusa.ps1 -DaysBack {days_back} | ConvertTo-Json -Depth 8 -Compress"
-WINDOWS_LATEST_ZIP_CMD = 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"
-
-
-def run(cmd):
- proc = subprocess.run(cmd, text=True, capture_output=True)
- if proc.returncode != 0:
- sys.stderr.write(proc.stdout)
- sys.stderr.write(proc.stderr)
- raise SystemExit(proc.returncode)
- return proc.stdout
-
-
-def extract_json_blob(text):
- matches = re.findall(r'(\{.*\}|\[.*\])', text, re.S)
- for candidate in reversed(matches):
- try:
- return json.loads(candidate)
- except Exception:
- continue
- raise SystemExit('cannot parse JSON from ansible output:\n' + text)
-
-
-def main():
- p = argparse.ArgumentParser(description='Run Windows EVTX export and Hayabusa intake directly from aw-server, without the laptop')
- p.add_argument('--inventory', default=str(DEFAULT_INVENTORY))
- p.add_argument('--ansible-bin', default=str(DEFAULT_ANSIBLE))
- p.add_argument('--drop-dir', default=str(DEFAULT_DROP))
- p.add_argument('--days-back', type=int, default=1)
- p.add_argument('--mode', default='incident', choices=['quick', 'incident', 'full'])
- p.add_argument('--case-id', type=int)
- p.add_argument('--link-source', default='aw-rus-ops-from-windows')
- p.add_argument('--windows-group', default='aw_windows')
- p.add_argument('--wrapper', default=str(DEFAULT_WRAPPER))
- p.add_argument('--linker', default=str(DEFAULT_LINKER))
- args = p.parse_args()
-
- inventory = pathlib.Path(args.inventory)
- ansible_bin = pathlib.Path(args.ansible_bin)
- drop_dir = pathlib.Path(args.drop_dir)
- wrapper = pathlib.Path(args.wrapper)
- linker = pathlib.Path(args.linker)
- if not inventory.is_file():
- raise SystemExit(f'inventory not found: {inventory}')
- if not ansible_bin.is_file():
- raise SystemExit(f'ansible binary not found: {ansible_bin}')
- if not wrapper.is_file():
- raise SystemExit(f'wrapper not found: {wrapper}')
- drop_dir.mkdir(parents=True, exist_ok=True)
-
- export_cmd = [str(ansible_bin), args.windows_group, '-i', str(inventory), '-m', 'win_shell', '-a', WINDOWS_EXPORT_CMD.format(days_back=args.days_back)]
- print('RUN_EXPORT', ' '.join(export_cmd))
- export_out = run(export_cmd)
- export_json = extract_json_blob(export_out)
-
- list_cmd = [str(ansible_bin), args.windows_group, '-i', str(inventory), '-m', 'win_shell', '-a', WINDOWS_LATEST_ZIP_CMD]
- print('RUN_LIST', ' '.join(list_cmd))
- latest_out = run(list_cmd)
- latest = extract_json_blob(latest_out)
- if isinstance(latest, list):
- latest = latest[0]
- remote_zip = latest['FullName']
- filename = pathlib.PureWindowsPath(remote_zip).name
- local_zip = drop_dir / filename
-
- remote_zip_posix = remote_zip.replace('\\', '/')
- fetch_cmd = [str(ansible_bin), args.windows_group, '-i', str(inventory), '-m', 'fetch', '-a', f'src={remote_zip_posix} dest={drop_dir}/ flat=yes']
- print('RUN_FETCH', ' '.join(fetch_cmd))
- run(fetch_cmd)
- if not local_zip.is_file():
- raise SystemExit(f'fetched zip not found: {local_zip}')
-
- accept_cmd = [str(wrapper), 'accept', '--package', str(local_zip)]
- host = export_json.get('hostname') or pathlib.Path(filename).stem.split('-')[0]
- if host:
- accept_cmd += ['--host', str(host)]
- print('RUN_ACCEPT', ' '.join(accept_cmd))
- subprocess.run(accept_cmd, check=True)
-
- process_cmd = [str(wrapper), 'process-inbox', '--mode', args.mode, '--limit', '1']
- print('RUN_PROCESS', ' '.join(process_cmd))
- subprocess.run(process_cmd, check=True)
-
- if args.case_id is not None:
- if not linker.is_file():
- raise SystemExit(f'linker not found: {linker}')
- link_cmd = [str(linker), '--case-id', str(args.case_id), '--mode', args.mode, '--link-source', args.link_source]
- print('RUN_LINK', ' '.join(link_cmd))
- subprocess.run(link_cmd, check=True)
-
- latest_intake = pathlib.Path('/opt/hayabusa/state/latest-intake.json')
- print('LATEST_INTAKE')
- print(latest_intake.read_text(encoding='utf-8'))
-
-
-if __name__ == '__main__':
- main()
diff --git a/aw-server/hayabusa/aw-hayabusa-link-case.py b/aw-server/hayabusa/aw-hayabusa-link-case.py
deleted file mode 100644
index 1cb8d08..0000000
--- a/aw-server/hayabusa/aw-hayabusa-link-case.py
+++ /dev/null
@@ -1,76 +0,0 @@
-#!/usr/bin/env python3
-import argparse
-import json
-import pathlib
-import sys
-import urllib.error
-import urllib.request
-
-
-def normalize_host(value):
- return str(value or '').strip().lower()
-
-
-def build_payload(intake, mode, link_source):
- report_dir = pathlib.Path(intake['report_dir'])
- return {
- 'tool': 'hayabusa',
- 'host': intake['host'],
- 'mode': mode,
- 'status': intake['status'],
- 'intake_id': intake['intake_id'],
- 'package_path': intake['package_path'],
- 'sha256': intake['sha256'],
- 'report_dir': intake['report_dir'],
- 'summary_html': str(report_dir / 'summary.html'),
- 'timeline_path': str(report_dir / 'timeline.jsonl'),
- 'manifest_path': str(report_dir / 'manifest.json'),
- 'link_source': link_source,
- }
-
-
-def post_json(url, payload):
- data = json.dumps(payload, ensure_ascii=False).encode('utf-8')
- req = urllib.request.Request(url, data=data, method='POST', headers={'Content-Type': 'application/json'})
- with urllib.request.urlopen(req) as resp:
- return json.loads(resp.read().decode('utf-8'))
-
-
-def get_json(url):
- with urllib.request.urlopen(url) as resp:
- return json.loads(resp.read().decode('utf-8'))
-
-
-def main():
- p = argparse.ArgumentParser(description='Link latest or specified Hayabusa intake metadata to AW-rus case management')
- p.add_argument('--case-id', type=int, required=True)
- p.add_argument('--intake-json', default='/opt/hayabusa/state/latest-intake.json')
- p.add_argument('--case-api-base', default='http://127.0.0.1:5602')
- p.add_argument('--mode', default='incident')
- p.add_argument('--link-source', default='aw-rus-ops')
- args = p.parse_args()
-
- intake_path = pathlib.Path(args.intake_json)
- if not intake_path.is_file():
- raise SystemExit(f'intake json not found: {intake_path}')
- intake = json.loads(intake_path.read_text(encoding='utf-8'))
- case = get_json(f"{args.case_api_base.rstrip('/')}/api/0/dlp/cases/{args.case_id}")
- case_host = normalize_host(case.get('host'))
- intake_host = normalize_host(intake.get('host'))
- if case_host and intake_host and case_host != intake_host:
- raise SystemExit(
- f"hayabusa host mismatch: case host={case.get('host')} intake host={intake.get('host')}"
- )
- payload = build_payload(intake, args.mode, args.link_source)
- case_url = f"{args.case_api_base.rstrip('/')}/api/0/dlp/cases/{args.case_id}/forensics/hayabusa"
- try:
- post_json(case_url, payload)
- except urllib.error.HTTPError as exc:
- body = exc.read().decode('utf-8', errors='replace')
- raise SystemExit(f'case API POST failed: HTTP {exc.code}: {body}')
- case = get_json(f"{args.case_api_base.rstrip('/')}/api/0/dlp/cases/{args.case_id}")
- print(json.dumps({'case_id': args.case_id, 'intake': intake, 'forensics': case.get('forensics')}, ensure_ascii=False, indent=2))
-
-
-if __name__ == '__main__':
- main()
diff --git a/aw-server/hayabusa/aw-hayabusa.sh b/aw-server/hayabusa/aw-hayabusa.sh
index b82a326..974f5d2 100644
--- a/aw-server/hayabusa/aw-hayabusa.sh
+++ b/aw-server/hayabusa/aw-hayabusa.sh
@@ -96,19 +96,8 @@ EOF
json_field() {
local json_path="$1"
local field_name="$2"
- python3 - "$json_path" "$field_name" <<'PY'
-import json, sys
-path, field = sys.argv[1], sys.argv[2]
-try:
- with open(path, 'r', encoding='utf-8') as fh:
- obj = json.load(fh)
-except Exception:
- sys.exit(0)
-value = obj.get(field)
-if value is None:
- sys.exit(0)
-print(str(value))
-PY
+ command -v jq >/dev/null 2>&1 || fail "jq is required to read ${json_path}"
+ jq -er --arg field "${field_name}" '.[$field] // empty | tostring' "${json_path}" 2>/dev/null || true
}
detect_host_from_manifest() {
@@ -124,34 +113,19 @@ detect_host_from_manifest() {
extract_zip_normalized() {
local package_path="$1"
local dest_dir="$2"
- python3 - "${package_path}" "${dest_dir}" <<'PY'
-import pathlib
-import shutil
-import sys
-import zipfile
-
-zip_path = pathlib.Path(sys.argv[1])
-dest_dir = pathlib.Path(sys.argv[2])
-dest_dir.mkdir(parents=True, exist_ok=True)
-
-with zipfile.ZipFile(zip_path) as zf:
- for info in zf.infolist():
- raw_name = info.filename.replace('\\', '/')
- normalized = pathlib.PurePosixPath(raw_name)
- parts = [part for part in normalized.parts if part not in ('', '.')]
- if any(part == '..' for part in parts):
- raise SystemExit(f'unsafe zip entry: {info.filename}')
- if not parts:
- continue
- target = dest_dir.joinpath(*parts)
- is_dir = info.is_dir() or raw_name.endswith('/')
- if is_dir:
- target.mkdir(parents=True, exist_ok=True)
- continue
- target.parent.mkdir(parents=True, exist_ok=True)
- with zf.open(info) as src, target.open('wb') as dst:
- shutil.copyfileobj(src, dst)
-PY
+ command -v zipinfo >/dev/null 2>&1 || fail "zipinfo is required to inspect ${package_path}"
+ command -v unzip >/dev/null 2>&1 || fail "unzip is required to extract ${package_path}"
+ mkdir -p "${dest_dir}"
+ local entry normalized
+ while IFS= read -r entry; do
+ normalized="${entry//\\//}"
+ case "${normalized}" in
+ ""|.|/*|*"/../"*|../*|*".."|*"/..")
+ fail "unsafe zip entry: ${entry}"
+ ;;
+ esac
+ done < <(zipinfo -1 "${package_path}")
+ unzip -q "${package_path}" -d "${dest_dir}"
}
write_package_manifest() {
diff --git a/aw-server/health-check.sh b/aw-server/health-check.sh
deleted file mode 100644
index 7df5693..0000000
--- a/aw-server/health-check.sh
+++ /dev/null
@@ -1,173 +0,0 @@
-#!/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/aw-server/install_aw_server.sh b/aw-server/install_aw_server.sh
index 34930d6..cdc1241 100755
--- a/aw-server/install_aw_server.sh
+++ b/aw-server/install_aw_server.sh
@@ -24,13 +24,13 @@ required_vars=(
BOOTSTRAP_DIR="/root/bootstrap"
VIEWS_JSON="$BOOTSTRAP_DIR/settings/views-default.json"
CLASSES_JSON="$BOOTSTRAP_DIR/settings/classes-worktime.json"
-WORKTIME_API_SRC="$BOOTSTRAP_DIR/aw-worktime-api.py"
+WORKTIME_API_RUST_SRC="$BOOTSTRAP_DIR/worktime-api"
WORKTIME_API_SERVICE_SRC="$BOOTSTRAP_DIR/aw-worktime-api.service"
WORKTIME_ALIASES_SRC="$BOOTSTRAP_DIR/worktime-manager-aliases.example.json"
-WORKTIME_UI_BRIDGE_SRC="$BOOTSTRAP_DIR/aw-worktime-ui-bridge.py"
+WORKTIME_UI_BRIDGE_RUST_SRC="$BOOTSTRAP_DIR/worktime-ui-bridge"
WORKTIME_UI_BRIDGE_SERVICE_SRC="$BOOTSTRAP_DIR/aw-worktime-ui-bridge.service"
WORKTIME_UI_BRIDGE_TIMER_SRC="$BOOTSTRAP_DIR/aw-worktime-ui-bridge.timer"
-HEALTHD_SRC="$BOOTSTRAP_DIR/aw-rus-healthd.py"
+HEALTHD_RUST_SRC="$BOOTSTRAP_DIR/aw-rus-healthd"
HEALTHD_SERVICE_SRC="$BOOTSTRAP_DIR/aw-rus-healthd.service"
HEALTHD_TIMER_SRC="$BOOTSTRAP_DIR/aw-rus-healthd.timer"
@@ -99,11 +99,11 @@ systemctl enable activitywatch-server.service
systemctl restart activitywatch-server.service
systemctl --no-pager --full status activitywatch-server.service || true
-if [[ -f "$WORKTIME_API_SRC" ]]; then
- install -m 0755 "$WORKTIME_API_SRC" /usr/local/bin/aw-worktime-api.py
+if [[ -f "$WORKTIME_API_RUST_SRC" ]]; then
+ install -m 0755 "$WORKTIME_API_RUST_SRC" /usr/local/bin/aw-worktime-api-rust
fi
-if [[ -f "$WORKTIME_API_SERVICE_SRC" ]]; then
+if [[ -f "$WORKTIME_API_SERVICE_SRC" && -f /usr/local/bin/aw-worktime-api-rust ]]; then
install -m 0644 "$WORKTIME_API_SERVICE_SRC" /etc/systemd/system/aw-worktime-api.service
systemctl daemon-reload
systemctl enable aw-worktime-api.service
@@ -119,15 +119,15 @@ if [[ -f "$WORKTIME_ALIASES_SRC" ]]; then
fi
fi
-if [[ -f "$WORKTIME_UI_BRIDGE_SRC" ]]; then
- install -m 0755 "$WORKTIME_UI_BRIDGE_SRC" /usr/local/bin/aw-worktime-ui-bridge.py
+if [[ -f "$WORKTIME_UI_BRIDGE_RUST_SRC" ]]; then
+ install -m 0755 "$WORKTIME_UI_BRIDGE_RUST_SRC" /usr/local/bin/aw-worktime-ui-bridge-rust
fi
-if [[ -f "$WORKTIME_UI_BRIDGE_SERVICE_SRC" ]]; then
+if [[ -f "$WORKTIME_UI_BRIDGE_SERVICE_SRC" && -f /usr/local/bin/aw-worktime-ui-bridge-rust ]]; then
install -m 0644 "$WORKTIME_UI_BRIDGE_SERVICE_SRC" /etc/systemd/system/aw-worktime-ui-bridge.service
fi
-if [[ -f "$WORKTIME_UI_BRIDGE_TIMER_SRC" ]]; then
+if [[ -f "$WORKTIME_UI_BRIDGE_TIMER_SRC" && -f /usr/local/bin/aw-worktime-ui-bridge-rust ]]; then
install -m 0644 "$WORKTIME_UI_BRIDGE_TIMER_SRC" /etc/systemd/system/aw-worktime-ui-bridge.timer
systemctl daemon-reload
systemctl disable --now aw-worktime-afk-bridge.timer >/dev/null 2>&1 || true
@@ -137,15 +137,15 @@ if [[ -f "$WORKTIME_UI_BRIDGE_TIMER_SRC" ]]; then
systemctl --no-pager --full status aw-worktime-ui-bridge.timer || true
fi
-if [[ -f "$HEALTHD_SRC" ]]; then
- install -m 0755 "$HEALTHD_SRC" /usr/local/bin/aw-rus-healthd.py
+if [[ -f "$HEALTHD_RUST_SRC" ]]; then
+ install -m 0755 "$HEALTHD_RUST_SRC" /usr/local/bin/aw-rus-healthd-rust
fi
-if [[ -f "$HEALTHD_SERVICE_SRC" ]]; then
+if [[ -f "$HEALTHD_SERVICE_SRC" && -f /usr/local/bin/aw-rus-healthd-rust ]]; then
install -m 0644 "$HEALTHD_SERVICE_SRC" /etc/systemd/system/aw-rus-healthd.service
fi
-if [[ -f "$HEALTHD_TIMER_SRC" ]]; then
+if [[ -f "$HEALTHD_TIMER_SRC" && -f /usr/local/bin/aw-rus-healthd-rust ]]; then
install -m 0644 "$HEALTHD_TIMER_SRC" /etc/systemd/system/aw-rus-healthd.timer
systemctl daemon-reload
systemctl enable aw-rus-healthd.timer
diff --git a/aw-server/test_aw_dlp_influx_exporter.py b/aw-server/test_aw_dlp_influx_exporter.py
deleted file mode 100644
index a41c062..0000000
--- a/aw-server/test_aw_dlp_influx_exporter.py
+++ /dev/null
@@ -1,96 +0,0 @@
-#!/usr/bin/env python3
-import importlib.util
-from datetime import UTC, datetime
-from pathlib import Path
-
-
-MODULE_PATH = Path(__file__).with_name("aw-dlp-influx-exporter.py")
-SPEC = importlib.util.spec_from_file_location("aw_dlp_influx_exporter", MODULE_PATH)
-MODULE = importlib.util.module_from_spec(SPEC)
-SPEC.loader.exec_module(MODULE)
-
-
-def test_build_endpoint_lines_emits_self_test_and_signal():
- events = [
- {
- "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,
- },
- },
- {
- "id": 11,
- "timestamp": "2026-05-15T10:01:00Z",
- "data": {
- "hostname": "SHARKON2025",
- "username": "Администратор",
- "signalType": "print_job",
- "source": "endpoint-signals-phase2",
- "documentName": "Документ.docx",
- "printerName": "HP LaserJet",
- },
- },
- ]
- lines = MODULE.build_endpoint_lines("SHARKON2025", events)
- assert any(line.startswith("aw_dlp_endpoint_self_test,") for line in lines)
- assert any(line.startswith("aw_dlp_signal,") for line in lines)
-
-
-def test_normalize_incident_handles_nested_source_event():
- item = {
- "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",
- }
- },
- },
- }
- normalized = MODULE.normalize_incident(item, "SHARKON2025")
- assert normalized["signal_type"] == "print_job"
- assert normalized["username"] == "Администратор"
- assert normalized["action"] == "incident"
- assert normalized["incident_status"] == "open"
-
-
-def test_build_case_lines_emits_case_state():
- cases = [
- {
- "id": 28,
- "host": "SHARKON2025",
- "status": "open",
- "severity": "medium",
- "assignee": None,
- "title": "DLP print_job · Администратор",
- "incident_id": "case-1",
- "evidence": {"items": [1], "chain_length": 1},
- "forensics": None,
- "updated_at": "2026-05-15T10:03:00+00:00",
- }
- ]
- lines = MODULE.build_case_lines("SHARKON2025", cases)
- assert len(lines) == 1
- assert lines[0].startswith("aw_dlp_case,")
-
-
-def test_timestamp_parser_accepts_zulu():
- assert MODULE.pts("2026-05-15T10:00:00Z") == datetime(2026, 5, 15, 10, 0, 0, tzinfo=UTC)
diff --git a/aw-server/test_aw_dlp_report_generator.py b/aw-server/test_aw_dlp_report_generator.py
deleted file mode 100644
index 214abaf..0000000
--- a/aw-server/test_aw_dlp_report_generator.py
+++ /dev/null
@@ -1,39 +0,0 @@
-#!/usr/bin/env python3
-import importlib.util
-import os
-import sys
-from pathlib import Path
-
-
-MODULE_PATH = Path(__file__).parent / "dlp-compliance" / "report_generator.py"
-
-
-def load_module(name: str):
- spec = importlib.util.spec_from_file_location(name, MODULE_PATH)
- module = importlib.util.module_from_spec(spec)
- sys.modules[spec.name] = module
- spec.loader.exec_module(module)
- return module
-
-
-MODULE = load_module("aw_dlp_report_generator")
-
-
-def test_build_aw_api_base_accepts_root_and_api_urls():
- assert MODULE.build_aw_api_base("http://127.0.0.1:5600") == "http://127.0.0.1:5600/api/0"
- assert MODULE.build_aw_api_base("http://127.0.0.1:5600/") == "http://127.0.0.1:5600/api/0"
- assert MODULE.build_aw_api_base("http://127.0.0.1:5600/api/0") == "http://127.0.0.1:5600/api/0"
-
-
-def test_aw_dlp_api_base_env_takes_precedence(monkeypatch):
- monkeypatch.setenv("AW_SERVER_URL", "http://127.0.0.1:5600")
- monkeypatch.setenv("AW_DLP_AW_API_BASE", "http://127.0.0.1:5600/api/0")
- module = load_module("aw_dlp_report_generator_env_precedence")
- assert module.AW_API_BASE == "http://127.0.0.1:5600/api/0"
-
-
-def test_aw_server_url_is_normalized_when_dlp_api_base_is_missing(monkeypatch):
- monkeypatch.setenv("AW_SERVER_URL", "http://127.0.0.1:5600")
- monkeypatch.delenv("AW_DLP_AW_API_BASE", raising=False)
- module = load_module("aw_dlp_report_generator_server_url_fallback")
- assert module.AW_API_BASE == "http://127.0.0.1:5600/api/0"
diff --git a/aw-server/test_aw_rus_healthd.py b/aw-server/test_aw_rus_healthd.py
deleted file mode 100644
index 72fad82..0000000
--- a/aw-server/test_aw_rus_healthd.py
+++ /dev/null
@@ -1,211 +0,0 @@
-#!/usr/bin/env python3
-import importlib.util
-import sys
-from pathlib import Path
-
-
-MODULE_PATH = Path(__file__).with_name("aw-rus-healthd.py")
-SPEC = importlib.util.spec_from_file_location("aw_rus_healthd", MODULE_PATH)
-MODULE = importlib.util.module_from_spec(SPEC)
-sys.modules[SPEC.name] = MODULE
-SPEC.loader.exec_module(MODULE)
-
-
-def test_host_activity_marks_recent_active_session():
- MODULE.now_utc = lambda: MODULE.parse_ts("2026-05-18T10:05:00Z")
- activity = MODULE.host_activity_from_worktime(
- {
- "timestamp": "2026-05-18T10:00:00Z",
- "data": {"active": True},
- },
- max_age_seconds=900,
- )
- assert activity["active"] is True
- assert activity["fresh"] is True
-
-
-def test_bucket_health_reports_missing_events_without_crash(monkeypatch):
- monkeypatch.setattr(MODULE, "latest_bucket_event", lambda api_base, bucket_id: None)
- status, summary, details = MODULE.bucket_health(
- "http://127.0.0.1:5600/api/0",
- "aw-watcher-window_SHARKON2025",
- 900,
- missing_status="warn",
- stale_status="fail",
- )
- assert status == "warn"
- assert summary == "no events"
- 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,
- "latest_bucket_event",
- lambda api_base, bucket_id: {"timestamp": "2026-05-22T15:53:08Z"},
- )
- monkeypatch.setattr(MODULE, "now_utc", lambda: MODULE.parse_ts("2026-05-24T01:46:25Z"))
- status, summary, details = MODULE.bucket_health(
- "http://127.0.0.1:5600/api/0",
- "aw-session-events_SHARKON2025",
- 86400,
- missing_status="fail",
- stale_status="warn",
- )
- if status == "warn" and details.get("age_seconds") is not None:
- status = "ok"
- summary = f"event-driven ({details['age_seconds']}s since last logon marker)"
- 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
deleted file mode 100644
index 00b55ee..0000000
--- a/aw-server/test_aw_slo_monitor.py
+++ /dev/null
@@ -1,268 +0,0 @@
-#!/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
deleted file mode 100644
index 02acff8..0000000
--- a/aw-server/test_aw_worktime_api.py
+++ /dev/null
@@ -1,1078 +0,0 @@
-#!/usr/bin/env python3
-import importlib.util
-import json
-import tempfile
-from datetime import datetime, timezone
-from pathlib import Path
-
-
-MODULE_PATH = Path(__file__).with_name("aw-worktime-api.py")
-SPEC = importlib.util.spec_from_file_location("aw_worktime_api", MODULE_PATH)
-MODULE = importlib.util.module_from_spec(SPEC)
-SPEC.loader.exec_module(MODULE)
-
-
-class _FakeWriter:
- def __init__(self, exc=None):
- self.exc = exc
- self.data = b""
-
- def write(self, data):
- if self.exc is not None:
- raise self.exc
- self.data += data
-
-
-class _FakeHandler:
- def __init__(self, exc=None):
- self.wfile = _FakeWriter(exc=exc)
- self.status = None
- self.headers = {}
- self.ended = False
-
- def send_response(self, status):
- self.status = status
-
- def send_header(self, key, value):
- self.headers[key] = value
-
- def end_headers(self):
- self.ended = True
-
-
-def _event(ts, username, session_id, active, **extra):
- data = {
- "username": username,
- "userId": f"WORKGROUP\\{username}",
- "sessionId": session_id,
- "state": "Активно" if active else "Диск",
- "active": active,
- }
- data.update(extra)
- return {"timestamp": ts, "duration": 0.0, "data": data}
-
-
-def test_aggregate_rows_uses_sample_seconds_and_merges_overlap():
- start = datetime(2026, 5, 14, 6, 0, 0, tzinfo=timezone.utc)
- end = datetime(2026, 5, 14, 6, 59, 59, tzinfo=timezone.utc)
- rows = MODULE.aggregate_rows(
- [
- _event("2026-05-14T06:00:00Z", "user5", 4, True, sampleSeconds=30),
- _event("2026-05-14T06:00:30Z", "user5", 4, True, sampleSeconds=30),
- _event("2026-05-14T06:00:15Z", "user5", 5, True, sampleSeconds=30),
- _event("2026-05-14T06:01:00Z", "user5", 4, False, sampleSeconds=30),
- ],
- start,
- end,
- "SHARKON2025",
- )
- assert len(rows) == 1
- row = rows[0]
- assert row["user"] == "user5"
- assert row["user_id"] == "SHARKON2025\\user5"
- assert row["active_seconds"] == 60
- assert row["active_hhmm"] == "00:01"
- assert row["sessions_count"] == 2
- assert row["samples_count"] == 4
- assert row["active_samples"] == 3
- assert row["first_activity"] == "2026-05-14T06:00:00Z"
- assert row["last_activity"] == "2026-05-14T06:01:00Z"
-
-
-def test_aggregate_rows_falls_back_to_next_sample_delta():
- start = datetime(2026, 5, 14, 7, 0, 0, tzinfo=timezone.utc)
- end = datetime(2026, 5, 14, 7, 59, 59, tzinfo=timezone.utc)
- rows = MODULE.aggregate_rows(
- [
- _event("2026-05-14T07:00:00Z", "user1", 3, True),
- _event("2026-05-14T07:00:05Z", "user1", 3, True),
- _event("2026-05-14T07:00:10Z", "user1", 3, False),
- ],
- start,
- end,
- "SHARKON2025",
- )
- row = rows[0]
- assert row["active_seconds"] == 10
- assert row["active_hhmm"] == "00:00"
-
-
-def test_build_aw_api_base_accepts_root_and_api_urls():
- assert MODULE.build_aw_api_base("http://127.0.0.1:5600") == "http://127.0.0.1:5600/api/0"
- assert MODULE.build_aw_api_base("http://127.0.0.1:5600/") == "http://127.0.0.1:5600/api/0"
- assert MODULE.build_aw_api_base("http://127.0.0.1:5600/api/0") == "http://127.0.0.1:5600/api/0"
-
-
-def test_worktime_health_payload_is_lightweight_and_ok():
- payload = MODULE.worktime_health_payload()
- assert payload["ok"] is True
- assert payload["default_host"] == MODULE.DEFAULT_HOST
- assert payload["aw_api_base"] == MODULE.AW
- assert payload["report_timezone"] == str(MODULE.REPORT_TZ)
- assert payload["generated_at_utc"].endswith("Z")
-
-
-def test_aggregate_hourly_rows_splits_interval_by_local_hour():
- start = datetime(2026, 5, 14, 6, 0, 0, tzinfo=timezone.utc)
- end = datetime(2026, 5, 14, 8, 59, 59, tzinfo=timezone.utc)
- rows = MODULE.aggregate_hourly_rows(
- [
- _event("2026-05-14T06:50:00Z", "user5", 4, True, sampleSeconds=1800),
- _event("2026-05-14T07:20:00Z", "user5", 4, True, sampleSeconds=1800),
- ],
- start,
- end,
- "SHARKON2025",
- )
- assert [row["hour_local"] for row in rows] == ["09:00", "10:00"]
- assert [row["active_seconds"] for row in rows] == [300, 300]
-
-
-def test_build_true_active_apps_requires_foreground_not_afk_and_evidence():
- start = datetime(2026, 5, 14, 6, 0, 0, tzinfo=timezone.utc)
- end = datetime(2026, 5, 14, 7, 59, 59, tzinfo=timezone.utc)
- window_events = [
- {"timestamp": "2026-05-14T06:00:00Z", "duration": 120, "data": {"app": "1cv8.exe", "title": "ИНФОВЕСТ"}},
- {"timestamp": "2026-05-14T06:02:00Z", "duration": 180, "data": {"app": "1cv8.exe", "title": "Счета учета: Материалы"}},
- {"timestamp": "2026-05-14T07:00:00Z", "duration": 600, "data": {"app": "totalcmd.exe", "title": "Total Commander"}},
- ]
- afk_events = [
- {"timestamp": "2026-05-14T06:00:00Z", "duration": 600, "data": {"status": "not-afk"}},
- {"timestamp": "2026-05-14T07:00:00Z", "duration": 600, "data": {"status": "not-afk"}},
- ]
- evidence_events_by_bucket = {
- "aw-file-operations_SHARKON2025": [
- {
- "timestamp": "2026-05-14T07:05:00Z",
- "duration": 0,
- "data": {"signalType": "file_write", "path": "C:\\data\\report.xlsx"},
- }
- ],
- "aw-dlp-endpoint-signals_SHARKON2025": [
- {
- "timestamp": "2026-05-14T07:06:00Z",
- "duration": 0,
- "data": {"signalType": "collector_health", "eventsFlushed": 100},
- }
- ],
- }
-
- rows = MODULE.build_true_active_apps_from_events(window_events, afk_events, evidence_events_by_bucket, start, end)
-
- by_app = {row["application"]: row for row in rows}
- assert "1С" in by_app
- assert "Total Commander" in by_app
- assert by_app["1С"]["proved_work_seconds"] == 300
- assert by_app["1С"]["last_action"] == "Счета учета: Материалы"
- assert by_app["Total Commander"]["proved_work_seconds"] == 480
- 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(
- [],
- "SHARKON2025",
- datetime(2026, 5, 14, tzinfo=timezone.utc).date(),
- selected_day="today",
- true_active_apps=[
- {
- "application": "1С",
- "proved_work_human": "34 мин",
- "proved_work_hhmm": "00:34",
- "last_action_local": "15:31",
- "last_action": "Счета учета: Материалы",
- }
- ],
- )
- assert "Доказанная работа по приложениям" in html
- assert "Приложение" in html
- assert "Доказанная работа" in html
- assert "Последнее действие" in html
- assert "Счета учета: Материалы" in html
-
-
-def test_build_management_payload_creates_actions_for_missing_and_late_users():
- rows = [
- {
- "user": "user1",
- "user_id": "SHARKON2025\\user1",
- "active_seconds": 0,
- "active_hhmm": "00:00",
- "first_activity": "",
- "last_activity": "",
- "idle_seconds": 86400,
- "sessions_count": 1,
- "samples_count": 10,
- "active_samples": 0,
- "_intervals": [],
- },
- {
- "user": "user5",
- "user_id": "SHARKON2025\\user5",
- "active_seconds": 5400,
- "active_hhmm": "01:30",
- "first_activity": "2026-05-14T08:30:00Z",
- "last_activity": "2026-05-14T10:00:00Z",
- "idle_seconds": 81000,
- "sessions_count": 1,
- "samples_count": 180,
- "active_samples": 180,
- "_intervals": [
- (
- datetime(2026, 5, 14, 8, 30, 0, tzinfo=timezone.utc),
- datetime(2026, 5, 14, 10, 0, 0, tzinfo=timezone.utc),
- )
- ],
- },
- ]
- payload = MODULE.build_management_payload(rows, "SHARKON2025", datetime(2026, 5, 14, tzinfo=timezone.utc).date())
- assert payload["summary"]["users_count"] == 2
- assert payload["summary"]["inactive_users"] == 1
- assert payload["summary"]["actions_count"] >= 2
- action_ids = {action["action_id"] for action in payload["actions"]}
- assert "missing_activity" in action_ids
- assert "late_start_review" in action_ids
- assert payload["actions"][0]["priority"] == "critical"
-
-
-def test_build_management_payload_uses_workday_window_not_midnight_activity():
- rows = [
- {
- "user": "администратор",
- "user_id": "SHARKON2025\\Администратор",
- "active_seconds": 39600,
- "active_hhmm": "11:00",
- "first_activity": "2026-05-14T00:00:00Z",
- "last_activity": "2026-05-14T11:00:00Z",
- "idle_seconds": 0,
- "sessions_count": 1,
- "samples_count": 100,
- "active_samples": 100,
- "_intervals": [
- (
- datetime(2026, 5, 14, 0, 0, 0, tzinfo=timezone.utc),
- datetime(2026, 5, 14, 11, 0, 0, tzinfo=timezone.utc),
- )
- ],
- }
- ]
- payload = MODULE.build_management_payload(rows, "SHARKON2025", datetime(2026, 5, 14, tzinfo=timezone.utc).date())
- roster = payload["rows"][0]
- assert roster["calendar_active_hhmm"] == "11:00"
- assert roster["workday_active_hhmm"] == "05:00"
- assert roster["coverage_pct"] == 55.56
- assert payload["summary"]["calendar_total_active_hhmm"] == "11:00"
- assert payload["summary"]["workday_total_active_hhmm"] == "05:00"
-
-
-def test_build_management_payload_applies_alias_and_executive_summary():
- original = MODULE.load_manager_aliases
- original_owners = MODULE.load_manager_owners
- original_sources = MODULE.build_source_freshness
- original_trend = MODULE.build_management_trend
- try:
- MODULE.load_manager_aliases = lambda: {
- "sharkon2025\\user1": {
- "display_name": "Иван Петров",
- "manager": "Руководитель смены",
- "department": "Бухгалтерия",
- "role": "Оператор 1С",
- }
- }
- MODULE.load_manager_owners = lambda: {
- "руководитель смены": {
- "display_name": "Сменный руководитель",
- "title": "Руководитель смены 1С",
- "department": "Операторы 1С",
- "contact": "@shift-lead",
- "escalation_to": "Финансовый директор",
- "notes": "Дневной контур",
- }
- }
- MODULE.build_source_freshness = lambda host, **kwargs: ([], [])
- MODULE.build_management_trend = lambda host, report_date, owner_filter="", department_filter="", **kwargs: []
- rows = [
- {
- "user": "user1",
- "user_id": "SHARKON2025\\user1",
- "active_seconds": 0,
- "active_hhmm": "00:00",
- "first_activity": "",
- "last_activity": "",
- "idle_seconds": 86400,
- "sessions_count": 1,
- "samples_count": 10,
- "active_samples": 0,
- "_intervals": [],
- }
- ]
- payload = MODULE.build_management_payload(rows, "SHARKON2025", datetime(2026, 5, 14, tzinfo=timezone.utc).date())
- row = payload["rows"][0]
- assert row["user"] == "Иван Петров"
- assert row["manager_owner"] == "Руководитель смены"
- assert row["department"] == "Бухгалтерия"
- assert payload["actions"][0]["owner"] == "Руководитель смены"
- assert payload["executive"]["portfolio_state"] == "critical"
- assert payload["executive"]["focus_items"][0]["owner"] == "Руководитель смены"
- assert payload["owner_rollups"][0]["name"] == "Руководитель смены"
- assert payload["owner_rollups"][0]["critical_actions_count"] >= 1
- assert payload["department_rollups"][0]["name"] == "Бухгалтерия"
- assert payload["owner_roster"][0]["display_name"] == "Сменный руководитель"
- assert payload["owner_roster"][0]["contact"] == "@shift-lead"
- finally:
- MODULE.load_manager_aliases = original
- MODULE.load_manager_owners = original_owners
- MODULE.build_source_freshness = original_sources
- MODULE.build_management_trend = original_trend
-
-
-def test_build_management_payload_filters_by_owner():
- original_aliases = MODULE.load_manager_aliases
- original_owners = MODULE.load_manager_owners
- original_trend = MODULE.build_management_trend
- original_sources = MODULE.build_source_freshness
- try:
- MODULE.load_manager_aliases = lambda: {
- "sharkon2025\\user1": {
- "display_name": "Иван Петров",
- "manager": "Руководитель смены",
- "department": "Бухгалтерия",
- "role": "Оператор 1С",
- },
- "sharkon2025\\user2": {
- "display_name": "Мария Соколова",
- "manager": "Финансовый директор",
- "department": "Финансы",
- "role": "Главбух",
- },
- }
- MODULE.load_manager_owners = lambda: {
- "руководитель смены": {"display_name": "Сменный руководитель", "title": "Руководитель смены 1С"}
- }
- MODULE.build_management_trend = lambda *args, **kwargs: []
- MODULE.build_source_freshness = lambda host, **kwargs: ([], [])
- rows = [
- {
- "user": "user1",
- "user_id": "SHARKON2025\\user1",
- "active_seconds": 0,
- "active_hhmm": "00:00",
- "first_activity": "",
- "last_activity": "",
- "idle_seconds": 86400,
- "sessions_count": 1,
- "samples_count": 10,
- "active_samples": 0,
- "_intervals": [],
- },
- {
- "user": "user2",
- "user_id": "SHARKON2025\\user2",
- "active_seconds": 3600,
- "active_hhmm": "01:00",
- "first_activity": "2026-05-14T07:00:00Z",
- "last_activity": "2026-05-14T08:00:00Z",
- "idle_seconds": 82800,
- "sessions_count": 1,
- "samples_count": 120,
- "active_samples": 120,
- "_intervals": [
- (
- datetime(2026, 5, 14, 7, 0, 0, tzinfo=timezone.utc),
- datetime(2026, 5, 14, 8, 0, 0, tzinfo=timezone.utc),
- )
- ],
- },
- ]
- payload = MODULE.build_management_payload(
- rows,
- "SHARKON2025",
- datetime(2026, 5, 14, tzinfo=timezone.utc).date(),
- owner_filter="Руководитель смены",
- )
- assert payload["filters"]["owner"] == "Руководитель смены"
- assert payload["filters"]["department"] == ""
- assert payload["summary"]["users_count"] == 1
- assert [row["user"] for row in payload["rows"]] == ["Иван Петров"]
- assert len(payload["owner_rollups"]) == 1
- assert payload["owner_rollups"][0]["name"] == "Руководитель смены"
- assert len(payload["department_rollups"]) == 1
- assert payload["department_rollups"][0]["name"] == "Бухгалтерия"
- assert payload["owner_roster"][0]["display_name"] == "Сменный руководитель"
- assert all(action["owner"] == "Руководитель смены" for action in payload["actions"])
- finally:
- MODULE.load_manager_aliases = original_aliases
- MODULE.load_manager_owners = original_owners
- MODULE.build_management_trend = original_trend
- MODULE.build_source_freshness = original_sources
-
-
-def _minimal_management_payload(report_date, users_count=1):
- return {
- "summary": {
- "users_count": users_count,
- "active_users": users_count,
- "inactive_users": 0,
- "workday_total_active_seconds": 3600 * users_count,
- "workday_total_active_hhmm": f"0{users_count}:00",
- "portfolio_coverage_pct": 100.0,
- "actions_count": 0,
- "critical_actions_count": 0,
- },
- "rows": [],
- "actions": [],
- "sources": [],
- "filters": {"owner": "", "department": ""},
- }
-
-
-def test_load_management_cache_keeps_historical_reports_after_ttl():
- original_dir = MODULE.MANAGER_CACHE_DIR
- original_ttl = MODULE.MANAGER_CACHE_TTL_SECONDS
- try:
- with tempfile.TemporaryDirectory() as tmp:
- MODULE.MANAGER_CACHE_DIR = Path(tmp)
- MODULE.MANAGER_CACHE_TTL_SECONDS = 1
- report_date = datetime(2026, 5, 14, tzinfo=timezone.utc).date()
- payload = _minimal_management_payload(report_date)
- path = MODULE.management_cache_path("SHARKON2025", report_date)
- path.parent.mkdir(parents=True, exist_ok=True)
- path.write_text(json.dumps(payload), encoding="utf-8")
- loaded = MODULE.load_management_cache("SHARKON2025", report_date)
- assert loaded["summary"]["users_count"] == 1
- finally:
- MODULE.MANAGER_CACHE_DIR = original_dir
- MODULE.MANAGER_CACHE_TTL_SECONDS = original_ttl
-
-
-def test_build_management_trend_reuses_precomputed_anchor_payload():
- original_trend_days = MODULE.MANAGER_TREND_DAYS
- original_load = MODULE.load_management_cache
- original_fetch = MODULE.fetch_events_for_date
- original_aggregate = MODULE.aggregate_rows_with_intervals
- fetch_dates = []
- try:
- MODULE.MANAGER_TREND_DAYS = 3
- anchor = datetime(2026, 5, 14, tzinfo=timezone.utc).date()
-
- def fake_load(host, report_date):
- if report_date < anchor:
- return _minimal_management_payload(report_date)
- return None
-
- def fake_fetch(host, report_date):
- fetch_dates.append(report_date)
- return MODULE.get_report_bounds(report_date), []
-
- MODULE.load_management_cache = fake_load
- MODULE.fetch_events_for_date = fake_fetch
- MODULE.aggregate_rows_with_intervals = lambda events, start, end, host: []
- trend = MODULE.build_management_trend(
- "SHARKON2025",
- anchor,
- precomputed_payloads={anchor: _minimal_management_payload(anchor, users_count=2)},
- )
- assert [item["report_date"] for item in trend] == ["2026-05-12", "2026-05-13", "2026-05-14"]
- assert trend[-1]["users_count"] == 2
- assert fetch_dates == []
- finally:
- MODULE.MANAGER_TREND_DAYS = original_trend_days
- MODULE.load_management_cache = original_load
- MODULE.fetch_events_for_date = original_fetch
- MODULE.aggregate_rows_with_intervals = original_aggregate
-
-
-def test_render_management_html_contains_action_queue():
- payload = {
- "generated_at_utc": "2026-05-14T12:00:00Z",
- "host": "SHARKON2025",
- "report_date": "2026-05-14",
- "report_timezone": "Europe/Moscow",
- "filters": {
- "owner": "Руководитель смены",
- "department": "Бухгалтерия",
- },
- "workday": {
- "start_local": "2026-05-14T09:00:00+03:00",
- "end_local": "2026-05-14T18:00:00+03:00",
- "expected_seconds_per_user": 32400,
- "expected_hhmm_per_user": "09:00",
- "target_coverage_pct": 75,
- "low_coverage_pct": 35,
- },
- "summary": {
- "users_count": 1,
- "active_users": 0,
- "inactive_users": 1,
- "on_target_users": 0,
- "below_target_users": 0,
- "portfolio_coverage_pct": 0.0,
- "actions_count": 1,
- "critical_actions_count": 1,
- "high_actions_count": 0,
- "total_active_seconds": 0,
- "total_active_hhmm": "00:00",
- "calendar_total_active_hhmm": "00:00",
- "workday_total_active_hhmm": "00:00",
- "first_activity": "",
- "last_activity": "",
- "top_user": "",
- "top_user_active_hhmm": "00:00",
- },
- "actions": [
- {
- "action_id": "missing_activity",
- "priority": "critical",
- "owner": "Руководитель смены",
- "user_id": "SHARKON2025\\user1",
- "deadline_hint": "today",
- "reason": "Нет активности",
- "recommended_action": "Проверить сотрудника",
- "evidence": {},
- }
- ],
- "rows": [
- {
- "user": "Иван Петров",
- "user_id": "SHARKON2025\\user1",
- "canonical_user_id": "SHARKON2025\\user1",
- "manager_owner": "Руководитель смены",
- "department": "Бухгалтерия",
- "active_hhmm": "00:00",
- "calendar_active_hhmm": "00:00",
- "workday_active_hhmm": "00:00",
- "coverage_pct": 0.0,
- "status": "inactive",
- "first_activity_local": "",
- "last_activity_local": "",
- "workday_first_activity_local": "",
- "workday_last_activity_local": "",
- "sessions_count": 1,
- }
- ],
- "executive": {
- "portfolio_state": "critical",
- "headline": "Есть 1 критичный вопрос, требующий решения сегодня.",
- "message": "Активны 0 из 1 сотрудников. Покрытие рабочего окна 0.0%.",
- "focus_items": [
- {
- "priority": "critical",
- "owner": "Руководитель смены",
- "title": "missing_activity",
- "reason": "Нет активности",
- "recommended_action": "Проверить сотрудника",
- }
- ],
- "stale_sources": [],
- },
- "owner_rollups": [
- {
- "name": "Руководитель смены",
- "users_count": 1,
- "active_users": 0,
- "inactive_users": 1,
- "below_target_users": 0,
- "workday_total_active_seconds": 0,
- "workday_total_active_hhmm": "00:00",
- "portfolio_coverage_pct": 0.0,
- "actions_count": 1,
- "critical_actions_count": 1,
- "high_actions_count": 0,
- "medium_actions_count": 0,
- "low_actions_count": 0,
- "users": ["Иван Петров"],
- }
- ],
- "department_rollups": [
- {
- "name": "Бухгалтерия",
- "users_count": 1,
- "active_users": 0,
- "inactive_users": 1,
- "below_target_users": 0,
- "workday_total_active_seconds": 0,
- "workday_total_active_hhmm": "00:00",
- "portfolio_coverage_pct": 0.0,
- "actions_count": 1,
- "critical_actions_count": 1,
- "high_actions_count": 0,
- "medium_actions_count": 0,
- "low_actions_count": 0,
- "users": ["Иван Петров"],
- }
- ],
- "owner_roster": [
- {
- "name": "Руководитель смены",
- "display_name": "Сменный руководитель",
- "title": "Руководитель смены 1С",
- "department": "Операторы 1С",
- "contact": "@shift-lead",
- "escalation_to": "Финансовый директор",
- "notes": "Дневной контур",
- "users_count": 1,
- "active_users": 0,
- "inactive_users": 1,
- "below_target_users": 0,
- "workday_total_active_seconds": 0,
- "workday_total_active_hhmm": "00:00",
- "portfolio_coverage_pct": 0.0,
- "actions_count": 1,
- "critical_actions_count": 1,
- "high_actions_count": 0,
- "medium_actions_count": 0,
- "low_actions_count": 0,
- "users": ["Иван Петров"]
- }
- ],
- "trend": [
- {
- "report_date": "2026-05-14",
- "users_count": 1,
- "active_users": 0,
- "inactive_users": 1,
- "workday_total_active_hhmm": "00:00",
- "portfolio_coverage_pct": 0.0,
- "actions_count": 1,
- "critical_actions_count": 1,
- }
- ],
- "sources": [
- {
- "label": "RDP worktime sessions",
- "status": "ok",
- "status_label": "fresh",
- "bucket_id": "aw-worktime-sessions_SHARKON2025",
- "timestamp": "2026-05-14T12:00:00Z",
- "age_seconds": 30,
- "required": True,
- "summary": "fresh (30s)",
- "event_summary": "queue=0 failures=0 flushed=10",
- }
- ],
- "bucket_id": "aw-worktime-sessions_SHARKON2025",
- "report_bounds": {
- "start_utc": "2026-05-13T21:00:00Z",
- "end_utc": "2026-05-14T20:59:59Z",
- },
- }
- html = MODULE.render_management_html(payload, selected_day="today")
- assert "Управленческий отчёт по работе в RDP" in html
- assert "Очередь действий руководителя" in html
- assert "missing_activity" in html
- assert "Тренд за" in html
- assert "По ответственным" in html
- assert "Ответственные и эскалация" in html
- assert "По подразделениям" in html
- assert "Свежесть источников данных" in html
- assert "Что делать сегодня" in html
- assert "Фильтр:" in html
- assert "Сбросить" in html
- assert "@shift-lead" in html
- assert "Иван Петров" in html
- assert "Руководитель смены" in html
-
-
-def test_build_source_freshness_uses_host_fileops_bucket_only():
- original = MODULE.latest_bucket_event
- original_now_utc = MODULE.now_utc
- try:
- def fake_latest(bucket_id):
- if bucket_id == "aw-file-operations_SHARKON2025":
- return {
- "timestamp": "2026-05-23T12:00:00Z",
- "data": {"signalType": "collector_health", "queueDepth": 0, "sendFailures": 0, "eventsFlushed": 50},
- }
- 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")
- file_source = next(source for source in sources if source["source_id"] == "file_operations")
- assert file_source["bucket_id"] == "aw-file-operations_SHARKON2025"
- assert file_source["status"] == "ok"
- assert not any(action["evidence"].get("source_id") == "file_operations" for action in actions)
- finally:
- MODULE.latest_bucket_event = original
- 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")
- assert ok is False
- assert handler.status == 200
- assert handler.headers["Content-Type"] == "application/json; charset=utf-8"
- assert handler.headers["Content-Length"] == "2"
- assert handler.ended is True
-
-
-def test_send_bytes_writes_payload_when_client_is_connected():
- handler = _FakeHandler()
- ok = MODULE.send_bytes(handler, b"payload", "text/plain; charset=utf-8", status=201)
- assert ok is True
- 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/aw-server/test_aw_worktime_influx_exporter.py b/aw-server/test_aw_worktime_influx_exporter.py
deleted file mode 100644
index c4ee32e..0000000
--- a/aw-server/test_aw_worktime_influx_exporter.py
+++ /dev/null
@@ -1,67 +0,0 @@
-#!/usr/bin/env python3
-import importlib.util
-from datetime import date
-from pathlib import Path
-
-
-MODULE_PATH = Path(__file__).with_name("aw-worktime-influx-exporter.py")
-SPEC = importlib.util.spec_from_file_location("aw_worktime_influx_exporter", MODULE_PATH)
-MODULE = importlib.util.module_from_spec(SPEC)
-SPEC.loader.exec_module(MODULE)
-
-
-def test_build_lines_for_day_emits_daily_hourly_and_summary(monkeypatch):
- bounds = MODULE.WORKTIME.get_report_bounds(date(2026, 5, 14))
- events = [
- {
- "timestamp": "2026-05-14T06:00:00Z",
- "duration": 0.0,
- "data": {
- "username": "user5",
- "userId": "WORKGROUP\\user5",
- "sessionId": 4,
- "state": "Активно",
- "active": True,
- "sampleSeconds": 1800,
- },
- }
- ]
-
- monkeypatch.setattr(MODULE.WORKTIME, "fetch_events_for_date", lambda host, report_date: (bounds, events))
- monkeypatch.setattr(MODULE.WORKTIME, "build_true_active_apps", lambda host, report_date: [])
- lines = MODULE.build_lines_for_day("SHARKON2025", date(2026, 5, 14))
-
- assert any(line.startswith("aw_rdp_worktime_daily,") for line in lines)
- assert any(line.startswith("aw_rdp_worktime_hourly,") for line in lines)
- assert any(line.startswith("aw_rdp_worktime_summary_daily,") for line in lines)
-
-
-def test_build_lines_for_day_emits_true_active_app_daily(monkeypatch):
- bounds = MODULE.WORKTIME.get_report_bounds(date(2026, 5, 14))
-
- monkeypatch.setattr(MODULE.WORKTIME, "fetch_events_for_date", lambda host, report_date: (bounds, []))
- monkeypatch.setattr(
- MODULE.WORKTIME,
- "build_true_active_apps",
- lambda host, report_date: [
- {
- "application": "1С",
- "proved_work_seconds": 2040,
- "proved_work_human": "34 мин",
- "proved_work_hhmm": "00:34",
- "last_action": "1С:Предприятие",
- "last_action_local": "09:42",
- "last_action_utc": "2026-05-14T06:42:00Z",
- "evidence_events": 7,
- }
- ],
- )
-
- lines = MODULE.build_lines_for_day("SHARKON2025", date(2026, 5, 14))
-
- true_active_line = next(line for line in lines if line.startswith("aw_true_active_app_daily,"))
- assert "application=1С" in true_active_line
- assert "report_date=2026-05-14" in true_active_line
- assert "proved_work_seconds=2040i" in true_active_line
- assert "evidence_events=7i" in true_active_line
- assert 'last_action="1С:Предприятие"' in true_active_line
diff --git a/aw-server/test_aw_worktime_ui_bridge.py b/aw-server/test_aw_worktime_ui_bridge.py
deleted file mode 100644
index 3becf60..0000000
--- a/aw-server/test_aw_worktime_ui_bridge.py
+++ /dev/null
@@ -1,244 +0,0 @@
-#!/usr/bin/env python3
-import importlib.util
-import unittest
-from datetime import datetime, timedelta, timezone
-from pathlib import Path
-from unittest import mock
-
-
-MODULE_PATH = Path(__file__).with_name("aw-worktime-ui-bridge.py")
-SPEC = importlib.util.spec_from_file_location("aw_worktime_ui_bridge", MODULE_PATH)
-MODULE = importlib.util.module_from_spec(SPEC)
-SPEC.loader.exec_module(MODULE)
-
-
-class WorktimeUiBridgeTests(unittest.TestCase):
- def test_bucket_needs_fallback_when_bucket_is_missing(self):
- now = datetime(2026, 5, 22, 16, 0, 0, tzinfo=timezone.utc)
- with mock.patch.object(MODULE, "get_latest_bucket_event_ts", return_value=None):
- self.assertTrue(MODULE.bucket_needs_fallback("aw-watcher-window_SHARKON2025", now, 600))
-
- def test_bucket_needs_fallback_when_bucket_is_recent(self):
- now = datetime(2026, 5, 22, 16, 0, 0, tzinfo=timezone.utc)
- recent = now - timedelta(seconds=120)
- with mock.patch.object(MODULE, "get_latest_bucket_event_ts", return_value=recent):
- self.assertFalse(MODULE.bucket_needs_fallback("aw-watcher-window_SHARKON2025", now, 600))
-
- def test_bucket_needs_fallback_when_bucket_is_stale(self):
- now = datetime(2026, 5, 22, 16, 0, 0, tzinfo=timezone.utc)
- stale = now - timedelta(seconds=601)
- with mock.patch.object(MODULE, "get_latest_bucket_event_ts", return_value=stale):
- self.assertTrue(MODULE.bucket_needs_fallback("aw-watcher-afk_SHARKON2025", now, 600))
-
- def test_get_latest_foreground_context_uses_recent_collector_health(self):
- now = datetime(2026, 5, 27, 8, 0, 0, tzinfo=timezone.utc)
- recent_events = [
- {
- "timestamp": "2026-05-27T07:59:30Z",
- "data": {
- "signalType": "collector_health",
- "foregroundProcess": "totalcmd",
- "foregroundTitle": "Total Commander 6.01 - HARVEST",
- },
- }
- ]
- with mock.patch.object(MODULE, "_req", return_value=recent_events):
- ctx = MODULE.get_latest_foreground_context(now)
- self.assertEqual(ctx["app"], "totalcmd.exe")
- self.assertEqual(ctx["title"], "Total Commander 6.01 - HARVEST")
-
- def test_get_latest_active_session_ids_uses_latest_timestamp_group(self):
- events = [
- {
- "timestamp": "2026-05-27T07:59:25Z",
- "data": {"sessionId": 2, "state": "Активно", "username": "администратор", "sessionName": "console"},
- },
- {
- "timestamp": "2026-05-27T07:59:30Z",
- "data": {"sessionId": 2, "state": "Активно", "username": "администратор", "sessionName": "console"},
- },
- {
- "timestamp": "2026-05-27T07:59:30Z",
- "data": {"sessionId": 3, "state": "Активно", "username": "user5", "sessionName": "rdp-tcp#0"},
- },
- {
- "timestamp": "2026-05-27T07:59:30Z",
- "data": {"sessionId": 4, "state": "Диск", "username": "user1", "sessionName": ""},
- },
- ]
- self.assertEqual(MODULE.get_latest_active_session_ids(events), {2, 3})
-
- def test_get_latest_foreground_context_prefers_active_session(self):
- now = datetime(2026, 5, 27, 8, 0, 0, tzinfo=timezone.utc)
- recent_events = [
- {
- "timestamp": "2026-05-27T07:59:40Z",
- "data": {
- "signalType": "collector_health",
- "foregroundProcess": "explorer",
- "foregroundTitle": "Explorer",
- "sessionId": 9,
- },
- },
- {
- "timestamp": "2026-05-27T07:59:45Z",
- "data": {
- "signalType": "collector_health",
- "foregroundProcess": "totalcmd",
- "foregroundTitle": "Total Commander 6.01 - HARVEST",
- "sessionId": 2,
- },
- },
- ]
- with mock.patch.object(MODULE, "_req", return_value=recent_events):
- ctx = MODULE.get_latest_foreground_context(now, active_session_ids={2})
- self.assertEqual(ctx["app"], "totalcmd.exe")
- self.assertEqual(ctx["title"], "Total Commander 6.01 - HARVEST")
-
- def test_get_latest_foreground_context_uses_cached_context_when_recent_events_are_blank(self):
- now = datetime(2026, 5, 27, 8, 5, 0, tzinfo=timezone.utc)
- recent_events = [
- {
- "timestamp": "2026-05-27T08:04:50Z",
- "data": {
- "signalType": "collector_health",
- "foregroundProcess": "",
- "foregroundTitle": "",
- "sessionId": 3,
- },
- }
- ]
- state = {
- "last_foreground_context": {
- "app": "totalcmd.exe",
- "title": "Total Commander 6.01 - HARVEST",
- "timestamp": "2026-05-27T08:03:30Z",
- }
- }
- with mock.patch.object(MODULE, "_req", return_value=recent_events):
- ctx = MODULE.get_latest_foreground_context(now, active_session_ids={2, 3}, state=state)
- self.assertEqual(ctx["app"], "totalcmd.exe")
- self.assertEqual(ctx["title"], "Total Commander 6.01 - HARVEST")
-
- def test_transform_uses_foreground_context_for_active_sessions(self):
- events = [
- {
- "timestamp": "2026-05-27T07:59:30Z",
- "duration": 0,
- "data": {
- "username": "user5",
- "state": "Активно",
- "sessionId": 3,
- "sessionName": "rdp-tcp#0",
- },
- }
- ]
- afk_events, win_events, last_ts = MODULE.transform(
- events,
- foreground_context={"app": "totalcmd.exe", "title": "Total Commander 6.01 - HARVEST"},
- )
- self.assertEqual(afk_events[0]["data"]["status"], "not-afk")
- self.assertEqual(win_events[0]["data"]["app"], "totalcmd.exe")
- self.assertEqual(win_events[0]["data"]["title"], "Total Commander 6.01 - HARVEST")
- self.assertEqual(last_ts, "2026-05-27T07:59:30Z")
-
- def test_transform_caps_duration_at_next_timestamp_gap(self):
- events = [
- {
- "timestamp": "2026-05-27T07:59:30Z",
- "duration": 5,
- "data": {
- "username": "user5",
- "state": "Активно",
- "sessionId": 3,
- "sessionName": "rdp-tcp#0",
- },
- },
- {
- "timestamp": "2026-05-27T07:59:31Z",
- "duration": 5,
- "data": {
- "username": "user5",
- "state": "Активно",
- "sessionId": 3,
- "sessionName": "rdp-tcp#0",
- },
- },
- ]
- _, win_events, _ = MODULE.transform(
- events,
- foreground_context={"app": "totalcmd.exe", "title": "Total Commander 6.01 - HARVEST"},
- )
- self.assertEqual(win_events[0]["duration"], 1.0)
- self.assertEqual(win_events[1]["duration"], 5)
-
- def test_normalize_watcher_window_events_strips_rdp_suffix_for_real_apps(self):
- events = [
- {
- "timestamp": "2026-05-27T08:10:00Z",
- "duration": 5,
- "data": {
- "app": "totalcmd.exe",
- "title": "Total Commander 6.01 - HARVEST | RDP active (2): user5, администратор",
- "source": "aw-worktime-ui-bridge",
- },
- },
- {
- "timestamp": "2026-05-27T08:10:05Z",
- "duration": 5,
- "data": {
- "app": "RDP",
- "title": "RDP active (2): user5, администратор",
- "source": "aw-worktime-ui-bridge",
- },
- },
- ]
- normalized = MODULE.normalize_watcher_window_events(events)
- self.assertEqual(normalized[0]["data"]["title"], "Total Commander 6.01 - HARVEST")
- self.assertEqual(len(normalized), 1)
-
- def test_watcher_window_needs_bridge_sync_for_generic_bridge_rdp(self):
- now = datetime(2026, 5, 27, 8, 5, 0, tzinfo=timezone.utc)
- latest_event = {
- "timestamp": "2026-05-27T08:04:40Z",
- "data": {
- "app": "RDP",
- "title": "RDP active (2): user5, администратор",
- "source": "aw-worktime-ui-bridge",
- },
- }
- with mock.patch.object(MODULE, "get_latest_bucket_event", return_value=latest_event), \
- mock.patch.object(MODULE, "get_latest_bucket_event_ts", return_value=datetime(2026, 5, 27, 8, 4, 40, tzinfo=timezone.utc)):
- self.assertTrue(MODULE.watcher_window_needs_bridge_sync(now))
-
- def test_watcher_window_needs_bridge_sync_for_non_generic_bridge_event(self):
- now = datetime(2026, 5, 27, 8, 5, 0, tzinfo=timezone.utc)
- latest_event = {
- "timestamp": "2026-05-27T08:04:40Z",
- "data": {
- "app": "totalcmd.exe",
- "title": "Total Commander 6.01 - HARVEST",
- "source": "aw-worktime-ui-bridge",
- },
- }
- with mock.patch.object(MODULE, "get_latest_bucket_event", return_value=latest_event), \
- mock.patch.object(MODULE, "get_latest_bucket_event_ts", return_value=datetime(2026, 5, 27, 8, 4, 40, tzinfo=timezone.utc)):
- self.assertTrue(MODULE.watcher_window_needs_bridge_sync(now))
-
- def test_watcher_window_does_not_override_real_watcher_stream(self):
- now = datetime(2026, 5, 27, 8, 5, 0, tzinfo=timezone.utc)
- latest_event = {
- "timestamp": "2026-05-27T08:04:40Z",
- "data": {
- "app": "notepad.exe",
- "title": "Безымянный — Блокнот",
- "source": "aw-watcher-window",
- },
- }
- with mock.patch.object(MODULE, "get_latest_bucket_event", return_value=latest_event), \
- mock.patch.object(MODULE, "get_latest_bucket_event_ts", return_value=datetime(2026, 5, 27, 8, 4, 40, tzinfo=timezone.utc)):
- self.assertFalse(MODULE.watcher_window_needs_bridge_sync(now))
-
-
-if __name__ == "__main__":
- unittest.main()
diff --git a/docs/DETMIR_PYTHON_RETIREMENT_RU.md b/docs/DETMIR_PYTHON_RETIREMENT_RU.md
new file mode 100644
index 0000000..1bb453a
--- /dev/null
+++ b/docs/DETMIR_PYTHON_RETIREMENT_RU.md
@@ -0,0 +1,54 @@
+# DetMir: статус вывода Python из runtime
+
+Дата фиксации: 2026-06-03.
+
+## Решение
+
+Боевой контур DetMir/AWatch-rus переводится на Rust-first runtime. Python entrypoints удаляются из серверных DLP, health, SLO, worktime, Hayabusa/offline tooling, backup/merge и deploy путей, если для них уже есть Rust-замена.
+
+## Удалено из repo/runtime path
+
+- AW health/SLO/worktime Python services.
+- DLP policy engine, case management, compliance, integrations Python services.
+- DLP aggregator, health check, admin CLI, IOC extractor, AW DB merge Python scripts.
+- Hayabusa case/offline helper Python scripts.
+- Legacy `aw-health-check.sh` server-side fallback.
+- Active `/usr/local/bin/*.py` entrypoints and old DLP service virtualenvs on AW server.
+- Python-era DLP Ansible roles now fail fast and point to `ansible/deploy_aw_server.yml`.
+- Python-era Proxmox CT bootstrap playbooks now fail fast until rebuilt as Rust bootstrap.
+
+## Оставленные исключения
+
+- Telegram bot runtime on Proxmox remains Python by product decision.
+- pfSense tooling is frozen/no-touch in this migration track.
+- OCR/content-analysis keeps Python dependencies for image/OCR path; Rust handles text/dictionary/regex path.
+- `clickhouse-1c/ai` and `clickhouse-1c/etl` remain a separate 1C/business-data track.
+- `detmir-mcp/main.py` remains a separate MCP/runtime track.
+
+## Проверка
+
+```bash
+rg -n '\.py\b|python3' ansible aw-server scripts adk-rust \
+ --glob '!adk-rust/target/**'
+
+ansible-playbook --syntax-check -i ansible/inventory.ini ansible/deploy_aw_server.yml
+
+cd adk-rust
+CARGO_TARGET_DIR=/tmp/detmir-adk-rust-target cargo test --workspace
+CARGO_TARGET_DIR=/tmp/detmir-adk-rust-target cargo clippy --workspace --all-targets -- -D warnings
+```
+
+Ожидаемые оставшиеся Python-ссылки после очистки: только перечисленные исключения, историческая документация или legacy cleanup patterns.
+
+Production AW server scan excludes the agreed OCR/content-analysis path,
+temporary external Hayabusa rules under `dlp-ioc/tmp`, and rollback backups:
+
+```bash
+find /usr/local/bin /opt/activitywatch \
+ -path "/opt/activitywatch/dlp-content-analysis" -prune -o \
+ -path "/opt/activitywatch/dlp-ioc/tmp" -prune -o \
+ -path "*/switch-backups" -prune -o \
+ \( -name "*.py" -o -name "health-check.sh" -o -path "*/.venv" \) -print
+```
+
+Ожидаемый результат для active server runtime: пустой вывод.
diff --git a/install-kit-awindows-20260427-211240.tar.gz b/install-kit-awindows-20260427-211240.tar.gz
index 72b91d6d287a5852dc64ee9e0154f1bab6f245ec..6a74debc182386d0ae58107ff02794a78da86044 100644
GIT binary patch
delta 144246
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