use std::{ fs, io::{self, Read}, path::{Path, PathBuf}, process::Command, time::{SystemTime, UNIX_EPOCH}, }; use anyhow::{Context, Result}; use chrono::{DateTime, SecondsFormat, Utc}; use clap::Parser; use detmir_state::{DEFAULT_STATE_FILE, NormalizedStatus, read_state}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; #[derive(Debug, Parser)] #[command(about = "Read-only backend helpers for TSJ Guardian Telegram status.")] struct Cli { #[arg(long, default_value = DEFAULT_STATE_FILE)] state: PathBuf, #[arg(long)] json: bool, #[arg(long)] aw_slo_status_line: bool, #[arg(long)] status_text: bool, #[arg(long)] incident_suggestions: bool, #[arg(long)] incident_defer_decision: bool, #[arg(long)] escalation_decision: bool, #[arg(long)] operator_action_decision: bool, #[arg(long)] dlp_policy_decision: bool, #[arg(long)] confirmation_decision: bool, #[arg(long)] autoheal_plan_decision: bool, #[arg(long, default_value_t = 1)] incident_failure_quorum_checks: i64, #[arg(long, default_value_t = 900)] operator_timeout_seconds: i64, #[arg(long, default_value_t = 900)] confirmation_ttl_seconds: i64, #[arg(long)] aw_slo_summary_command: Option, #[arg(long, default_value = "24h")] aw_slo_alert_window: String, #[arg(long)] bot_state: Option, #[arg(long)] rollback_file: Option, #[arg(long)] pfsense_status_command: Option, #[arg(long)] now_epoch: Option, } fn detmir_auto_line(status: &NormalizedStatus) -> String { let summary = &status.detmir_summary; let dlp = &status.dlp_counts; format!( "- detmir_auto: {severity} check_ok={check_ok} dlp_ok={dlp_ok} \ bucket_stale={bucket_stale} bucket_dead={bucket_dead} \ service_fail={service_failures} service_warn={service_warnings} \ dlp_warn={dlp_warn} dlp_fail={dlp_fail}", severity = status.severity, check_ok = status.check_ok, dlp_ok = status.dlp_ok, bucket_stale = summary.bucket_stale.unwrap_or(0), bucket_dead = summary.bucket_dead.unwrap_or(0), service_failures = summary.service_failures.unwrap_or(0), service_warnings = summary.service_warnings.unwrap_or(0), dlp_warn = dlp.warn.unwrap_or(0), dlp_fail = dlp.fail.unwrap_or(0), ) } fn run_shell_json(command: &str) -> Result { let stdout = run_shell_text(command)?; serde_json::from_str(&stdout).context("parse AW SLO summary JSON") } fn run_shell_text(command: &str) -> Result { let output = Command::new("sh") .arg("-lc") .arg(command) .output() .with_context(|| format!("run command: {command}"))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); anyhow::bail!("command failed rc={:?}: {}", output.status.code(), stderr); } String::from_utf8(output.stdout).context("decode command stdout as UTF-8") } fn value_i64(value: Option<&Value>) -> Option { match value { Some(Value::Number(n)) => n.as_i64().or_else(|| n.as_f64().map(|v| v as i64)), Some(Value::String(s)) => s.parse::().ok(), _ => None, } } fn value_f64(value: Option<&Value>) -> Option { match value { Some(Value::Number(n)) => n.as_f64(), Some(Value::String(s)) => s.parse::().ok(), _ => None, } } fn value_string(value: Option<&Value>, fallback: &str) -> String { match value { Some(Value::String(s)) => s.clone(), Some(Value::Number(n)) => n.to_string(), Some(Value::Bool(v)) => v.to_string(), Some(Value::Null) | None => fallback.to_string(), Some(v) => v.to_string(), } } fn aw_slo_status_line(summary: Option<&Value>, alert_window: &str) -> String { let Some(summary) = summary else { return "- aw_rus_slo: unavailable".to_string(); }; let windows = summary.get("windows").and_then(Value::as_object); let window_name = if windows .and_then(|w| w.get(alert_window)) .and_then(Value::as_object) .is_some() { alert_window } else { "24h" }; let window = windows .and_then(|w| w.get(window_name)) .and_then(Value::as_object); let availability = window.and_then(|w| value_f64(w.get("availability_percent"))); let remaining_raw = window.and_then(|w| w.get("budget_remaining_seconds")); let remaining_value = value_i64(remaining_raw).unwrap_or(0); let remaining_text = value_string(remaining_raw, "None"); let samples_text = value_string(window.and_then(|w| w.get("samples")), "None"); let status = value_string(window.and_then(|w| w.get("status")), "unknown"); let current = summary.get("current_sample").and_then(Value::as_object); let current_ok = current .and_then(|c| c.get("ok")) .and_then(Value::as_bool) .unwrap_or(false); let display_status = if remaining_value < 0 && current_ok { "recovered".to_string() } else if remaining_value < 0 && !current_ok { "fail".to_string() } else { status }; let availability_text = availability .map(|v| format!("{v:.5}%")) .unwrap_or_else(|| "n/a".to_string()); format!( "- aw_rus_slo: {display_status} {window_name} current_sample={} availability={availability_text} samples={samples_text} budget_remaining_seconds={remaining_text}", if current_ok { "OK" } else { "FAIL" } ) } fn parse_generated_age_seconds(summary: &Value) -> Option { let generated = summary.get("generated_at_utc")?.as_str()?; let ts = DateTime::parse_from_rfc3339(&generated.replace('Z', "+00:00")) .ok()? .with_timezone(&Utc); Some((Utc::now() - ts).num_seconds()) } #[derive(Debug, Serialize)] struct StatusTextPayload { status_text: String, pfsense_status: String, aw_rus_slo_line: String, detmir_auto_line: String, rollback_pending_items: usize, } #[derive(Debug, Deserialize)] struct IncidentInput { #[serde(default)] failures: Vec, #[serde(default)] state: Value, } #[derive(Debug, Serialize)] struct SuggestionsPayload { suggestions: Vec, } #[derive(Debug, Serialize)] struct DeferDecisionPayload { defer: bool, signature: String, failure_streak_signature: String, failure_streak_count: i64, failure_streak_first_ts: i64, reset_failure_streak: bool, log_line: Option, } #[derive(Debug, Serialize)] struct EscalationDecisionPayload { should_escalate: bool, should_fallback: bool, timed_out: bool, operator_acked: bool, age_seconds: i64, reason: String, } #[derive(Debug, Deserialize)] struct OperatorActionInput { #[serde(default)] action: String, #[serde(default)] state: Value, } #[derive(Debug, Serialize)] struct OperatorActionDecisionPayload { requested_action: String, canonical_action: String, handler: String, allowed: bool, requires_confirmation: bool, risk_level: String, reason: String, message: Option, state_update_hints: Vec, } #[derive(Debug, Deserialize)] struct DlpPolicyInput { #[serde(default)] policy: Value, #[serde(default)] target_mode: String, } #[derive(Debug, Serialize)] struct DlpRuleGroupSummary { name: String, total: usize, blocked: usize, } #[derive(Debug, Serialize)] struct DlpPolicyDecisionPayload { current_mode: String, target_mode: Option, changed_count: usize, changed_rules: Vec, groups: Vec, updated_policy: Option, reason: String, } #[derive(Debug, Deserialize)] struct ConfirmationInput { #[serde(default)] kind: String, #[serde(default)] action: String, #[serde(default)] code: String, #[serde(default)] state: Value, } #[derive(Debug, Serialize)] struct ConfirmationDecisionPayload { kind: String, action: String, present: bool, expired: bool, allowed: bool, clear_pending: bool, next_stage: Option, first_confirmed_ts: Option, reason: String, message: Option, } #[derive(Debug, Deserialize)] struct AutohealPlanInput { #[serde(default)] failures: Vec, #[serde(default)] slo_stale: bool, } #[derive(Debug, Serialize)] struct AutohealPlanPayload { failures: Vec, slo_only: bool, slo_stale: bool, include_watchers: bool, include_worktime: bool, include_windows_dlp: bool, server_dlp_failures: Vec, run_windows_heal: bool, run_server_dlp_heal: bool, run_worktime_heal: bool, sleep_after_seconds: i64, report_triggers: Vec, direct_autoheal_target: bool, } fn read_json_file(path: &Path) -> Option { let content = fs::read_to_string(path).ok()?; serde_json::from_str(&content).ok() } fn read_stdin_value() -> Result { let mut input = String::new(); io::stdin() .read_to_string(&mut input) .context("read stdin JSON")?; serde_json::from_str(&input).context("parse stdin JSON") } fn read_stdin_json() -> Result { serde_json::from_value(read_stdin_value()?).context("parse incident stdin JSON") } fn read_operator_action_input() -> Result { serde_json::from_value(read_stdin_value()?).context("parse operator action stdin JSON") } fn read_dlp_policy_input() -> Result { serde_json::from_value(read_stdin_value()?).context("parse DLP policy stdin JSON") } fn read_confirmation_input() -> Result { serde_json::from_value(read_stdin_value()?).context("parse confirmation stdin JSON") } fn read_autoheal_plan_input() -> Result { serde_json::from_value(read_stdin_value()?).context("parse autoheal plan stdin JSON") } fn failure_signature(failures: &[String]) -> String { let mut unique = failures.to_vec(); unique.sort(); unique.dedup(); unique.join("\n") } fn has_filesystem_critical(failures: &[String]) -> bool { failures .iter() .any(|line| line.to_ascii_lowercase().contains("filesystem_usage")) } fn suggestions_from_failures(failures: &[String]) -> Vec { let text = failures.join("\n").to_ascii_lowercase(); let mut suggestions = Vec::new(); if text.contains("proxmox_api") { suggestions.push("Перезапустить pveproxy/pvedaemon/pve-cluster и проверить порт 8006."); } if text.contains("pfsense_web") { suggestions .push("Проверить доступность pfSense 192.0.2.1:8443, перезапустить WebGUI/nginx."); } if text.contains("pfsense_mcp") { suggestions.push("Проверить локальный pfsense-mcp-server.service, bearer token и endpoint 127.0.0.1:3010/mcp."); } if text.contains("influxdb") { suggestions.push("Проверить контейнер InfluxDB и restart сервиса influxdb."); } if text.contains("grafana") { suggestions.push("Проверить grafana-server и NO_PROXY для 192.0.2.0/24."); } if text.contains("loki") || text.contains("alloy") { suggestions.push("Проверить LXC логов и restart сервисов loki/alloy."); } if text.contains("aw-rus:watcher-") || text.contains("aw-rus:worktime:") { suggestions.push("Проверить Windows collector recovery: worktime-session-collector, ActivityWatch Recovery и Launch tasks на 198.51.100.18."); suggestions.push("После Windows recovery проверить server-side aw-worktime-autoheal/ui-bridge для пересборки afk/window bucket'ов."); } if text.contains("aw-rus:dlp-") { suggestions.push( "Проверить DLP endpoint/fileops collectors и server-side DLP transport на 192.0.2.13.", ); } if text.contains("filesystem_usage") { suggestions.push("Проверить самые большие каталоги: du -x /var /srv /home, журналы в /var/log и apt cache."); suggestions.push("Проверить давление по снапшотам/хранилищу Proxmox и решить: очистка, ротация или расширение диска."); } if suggestions.is_empty() { suggestions.push("Запустить расширенную диагностику: /run check"); } suggestions.into_iter().map(ToOwned::to_owned).collect() } fn incident_defer_decision( failures: &[String], state: &Value, threshold: i64, now_epoch: i64, ) -> DeferDecisionPayload { let signature = failure_signature(failures); if threshold <= 1 || has_filesystem_critical(failures) { return DeferDecisionPayload { defer: false, signature, failure_streak_signature: value_string(state.get("failure_streak_signature"), ""), failure_streak_count: value_i64(state.get("failure_streak_count")).unwrap_or(0), failure_streak_first_ts: value_i64(state.get("failure_streak_first_ts")).unwrap_or(0), reset_failure_streak: false, log_line: None, }; } let current_signature = value_string(state.get("failure_streak_signature"), ""); let mut count = value_i64(state.get("failure_streak_count")).unwrap_or(0); let first_ts; if signature == current_signature { count += 1; first_ts = value_i64(state.get("failure_streak_first_ts")).unwrap_or(now_epoch); } else { count = 1; first_ts = now_epoch; } let defer = count < threshold; DeferDecisionPayload { defer, signature: signature.clone(), failure_streak_signature: signature, failure_streak_count: count, failure_streak_first_ts: first_ts, reset_failure_streak: !defer, log_line: defer.then(|| { format!( "Suppressing transient incident streak={count}/{threshold} failures={}", failures.len() ) }), } } fn escalation_decision( state: &Value, operator_timeout_seconds: i64, now_epoch: i64, ) -> EscalationDecisionPayload { let Some(pi) = state .get("pending_incident") .filter(|value| value.is_object()) else { return EscalationDecisionPayload { should_escalate: false, should_fallback: false, timed_out: false, operator_acked: false, age_seconds: 0, reason: "no_pending_incident".to_string(), }; }; let operator_acked = pi .get("operator_acked") .and_then(Value::as_bool) .unwrap_or(false); let created_ts = value_i64(pi.get("created_ts")).unwrap_or(now_epoch); let age_seconds = (now_epoch - created_ts).max(0); if operator_acked { return EscalationDecisionPayload { should_escalate: false, should_fallback: false, timed_out: false, operator_acked, age_seconds, reason: "operator_acked".to_string(), }; } if age_seconds < operator_timeout_seconds { return EscalationDecisionPayload { should_escalate: false, should_fallback: false, timed_out: false, operator_acked, age_seconds, reason: "timeout_not_reached".to_string(), }; } let escalated_to_ai = pi .get("escalated_to_ai") .and_then(Value::as_bool) .unwrap_or(false); let fallback_executed = pi .get("fallback_executed") .and_then(Value::as_bool) .unwrap_or(false); EscalationDecisionPayload { should_escalate: !escalated_to_ai, should_fallback: !fallback_executed, timed_out: true, operator_acked, age_seconds, reason: "operator_timeout_reached".to_string(), } } fn strip_run_prefix(action: &str) -> String { let trimmed = action.trim(); if let Some(rest) = trimmed.strip_prefix("/run ") { rest.trim().to_string() } else { trimmed.to_string() } } fn canonical_operator_action(action: &str) -> Option<&'static str> { match strip_run_prefix(action).to_ascii_lowercase().as_str() { "check" => Some("check"), "aw-dlp-check" | "awrus-dlp-check" => Some("aw-dlp-check"), "dlp-mode" | "dlp-policy-mode" => Some("dlp-mode"), "dlp-mode-toggle" | "dlp-policy-toggle" => Some("dlp-mode-toggle"), "heal" => Some("heal"), "ai" | "support" | "techsupport" | "техподдержка" | "тех.поддержка" => { Some("support") } "fallback" => Some("fallback"), "updates-check" => Some("updates-check"), "updates-install-request" => Some("updates-install-request"), "updates-install-confirm" => Some("updates-install-confirm"), "updates-rollback-confirm" => Some("updates-rollback-confirm"), _ => None, } } fn operator_action_risk(action: &str) -> &'static str { match action { "check" | "dlp-mode" => "low", "aw-dlp-check" | "updates-check" | "support" => "medium", "heal" | "fallback" | "dlp-mode-toggle" | "updates-install-request" | "updates-install-confirm" => "high", "updates-rollback-confirm" => "critical", _ => "unknown", } } fn operator_action_handler(action: &str) -> &'static str { match action { "check" => "check_script", "aw-dlp-check" => "aw_rus_dlp_check_and_heal", "dlp-mode" => "aw_dlp_policy_mode", "dlp-mode-toggle" => "aw_dlp_policy_toggle", "heal" => "autoheal", "support" => "ai_escalation", "fallback" => "server_fallback", "updates-check" => "updates_check", "updates-install-request" => "updates_install_request", "updates-install-confirm" => "updates_install_apply", "updates-rollback-confirm" => "updates_rollback", _ => "unknown", } } fn operator_action_requires_confirmation(action: &str) -> bool { matches!( action, "updates-install-confirm" | "updates-rollback-confirm" ) } fn state_bool(state: &Value, key: &str) -> bool { state.get(key).and_then(Value::as_bool).unwrap_or(false) } fn operator_action_decision(input: &OperatorActionInput) -> OperatorActionDecisionPayload { let requested_action = input.action.trim().to_string(); let Some(canonical) = canonical_operator_action(&requested_action) else { return OperatorActionDecisionPayload { requested_action, canonical_action: String::new(), handler: "unknown".to_string(), allowed: false, requires_confirmation: false, risk_level: "unknown".to_string(), reason: "unknown_action".to_string(), message: Some("Неизвестное действие.".to_string()), state_update_hints: Vec::new(), }; }; if canonical == "updates-install-confirm" && !state_bool(&input.state, "pending_update_install_confirm") { return OperatorActionDecisionPayload { requested_action, canonical_action: canonical.to_string(), handler: operator_action_handler(canonical).to_string(), allowed: false, requires_confirmation: true, risk_level: operator_action_risk(canonical).to_string(), reason: "missing_update_install_confirmation".to_string(), message: Some( "Нет ожидающего запроса на установку. Сначала нажмите \"Установить критичные и важные обновления\"." .to_string(), ), state_update_hints: Vec::new(), }; } if canonical == "updates-rollback-confirm" && !state_bool(&input.state, "pending_rollback_confirm") { return OperatorActionDecisionPayload { requested_action, canonical_action: canonical.to_string(), handler: operator_action_handler(canonical).to_string(), allowed: false, requires_confirmation: true, risk_level: operator_action_risk(canonical).to_string(), reason: "missing_update_rollback_confirmation".to_string(), message: Some( "Нет ожидающего отката. Откат доступен только после неуспешного ручного обновления." .to_string(), ), state_update_hints: Vec::new(), }; } let state_update_hints = match canonical { "updates-install-request" => vec!["set_pending_update_install_confirm".to_string()], "updates-install-confirm" => vec![ "clear_pending_update_install_confirm".to_string(), "set_pending_rollback_confirm_if_rollback_items_exist".to_string(), ], "updates-rollback-confirm" => vec!["clear_pending_rollback_confirm".to_string()], _ => Vec::new(), }; OperatorActionDecisionPayload { requested_action, canonical_action: canonical.to_string(), handler: operator_action_handler(canonical).to_string(), allowed: true, requires_confirmation: operator_action_requires_confirmation(canonical), risk_level: operator_action_risk(canonical).to_string(), reason: "allowed".to_string(), message: None, state_update_hints, } } fn dlp_toggle_group_names() -> [&'static str; 4] { ["clipboard", "usb", "print", "email"] } fn dlp_group_summaries(policy: &Value) -> Vec { let Some(endpoint) = policy.get("endpoint").and_then(Value::as_object) else { return Vec::new(); }; dlp_toggle_group_names() .into_iter() .filter_map(|key| { let rules = endpoint.get(key)?.as_array()?; let mut total = 0; let mut blocked = 0; for rule in rules { let Some(rule) = rule.as_object() else { continue; }; if rule.get("enabled").and_then(Value::as_bool) == Some(false) { continue; } let action = value_string(rule.get("action"), "") .trim() .to_ascii_lowercase(); if action.is_empty() { continue; } total += 1; if action == "block" { blocked += 1; } } Some(DlpRuleGroupSummary { name: format!("endpoint.{key}"), total, blocked, }) }) .collect() } fn dlp_mode_from_policy(policy: &Value) -> String { if let Some(mode) = policy .get("_tsj_meta") .and_then(Value::as_object) .and_then(|meta| meta.get("dlp_mode")) .and_then(Value::as_str) .map(|mode| mode.trim().to_ascii_lowercase()) .filter(|mode| mode == "monitor" || mode == "enforce") { return mode; } let groups = dlp_group_summaries(policy); let total: usize = groups.iter().map(|group| group.total).sum(); let blocked: usize = groups.iter().map(|group| group.blocked).sum(); if blocked == 0 { "monitor".to_string() } else if blocked == total { "enforce".to_string() } else { "mixed".to_string() } } fn dlp_updated_at_utc(now_epoch: i64) -> String { DateTime::::from_timestamp(now_epoch, 0) .unwrap_or_else(Utc::now) .to_rfc3339_opts(SecondsFormat::Secs, true) } fn dlp_policy_for_mode( policy: &Value, target_mode: &str, now_epoch: i64, ) -> Result<(Value, usize, Vec)> { if !matches!(target_mode, "monitor" | "enforce") { anyhow::bail!("unsupported DLP mode: {target_mode}"); } let mut updated = policy.clone(); let mut changed = 0; let mut changed_rules = Vec::new(); if let Some(endpoint) = updated.get_mut("endpoint").and_then(Value::as_object_mut) { for key in dlp_toggle_group_names() { let Some(rules) = endpoint.get_mut(key).and_then(Value::as_array_mut) else { continue; }; for rule in rules { let Some(rule_obj) = rule.as_object_mut() else { continue; }; if rule_obj.get("enabled").and_then(Value::as_bool) == Some(false) { continue; } let old_action = value_string(rule_obj.get("action"), "alert") .trim() .to_ascii_lowercase(); let new_action = if target_mode == "monitor" { if old_action == "block" { "alert" } else { old_action.as_str() } } else if matches!(old_action.as_str(), "log" | "alert") { "block" } else { old_action.as_str() }; if new_action == old_action { continue; } rule_obj.insert("action".to_string(), Value::String(new_action.to_string())); changed += 1; if changed_rules.len() < 10 { let rule_id = value_string(rule_obj.get("id"), "?"); changed_rules.push(format!( "endpoint.{key}:{rule_id} {old_action}->{new_action}" )); } } } } let root = updated .as_object_mut() .context("DLP policy root must be a JSON object")?; let meta = root.entry("_tsj_meta").or_insert_with(|| json!({})); if !meta.is_object() { *meta = json!({}); } let meta_obj = meta.as_object_mut().expect("meta object created above"); meta_obj.insert( "dlp_mode".to_string(), Value::String(target_mode.to_string()), ); meta_obj.insert( "updated_by".to_string(), Value::String("tsj-guardian-bot".to_string()), ); meta_obj.insert( "updated_at_utc".to_string(), Value::String(dlp_updated_at_utc(now_epoch)), ); Ok((updated, changed, changed_rules)) } fn dlp_policy_decision(input: &DlpPolicyInput, now_epoch: i64) -> Result { let current_mode = dlp_mode_from_policy(&input.policy); let requested = input.target_mode.trim().to_ascii_lowercase(); let target_mode = match requested.as_str() { "" => None, "toggle" => Some(if current_mode == "enforce" || current_mode == "mixed" { "monitor".to_string() } else { "enforce".to_string() }), "monitor" | "enforce" => Some(requested), other => anyhow::bail!("unsupported DLP target mode: {other}"), }; let groups = dlp_group_summaries(&input.policy); let Some(target_mode) = target_mode else { return Ok(DlpPolicyDecisionPayload { current_mode, target_mode: None, changed_count: 0, changed_rules: Vec::new(), groups, updated_policy: None, reason: "mode_only".to_string(), }); }; let (updated_policy, changed_count, changed_rules) = dlp_policy_for_mode(&input.policy, &target_mode, now_epoch)?; let reason = if changed_count == 0 { "no_toggleable_changes" } else { "policy_plan_ready" }; Ok(DlpPolicyDecisionPayload { current_mode, target_mode: Some(target_mode), changed_count, changed_rules, groups, updated_policy: Some(updated_policy), reason: reason.to_string(), }) } fn confirmation_no_pending_message(kind: &str) -> &'static str { match kind { "pfsense" => "Нет ожидающего изменения pfSense.", "openvpn" => "Нет ожидающего запроса на OpenVPN конфиг.", "proxmox_restore" => "Нет ожидающего восстановления Proxmox.", "proxmox_selection" => "Нет ожидающего выбора узла Proxmox.", _ => "Нет ожидающего подтверждения.", } } fn confirmation_wrong_stage_message(kind: &str) -> &'static str { match kind { "pfsense" => { "Второе подтверждение пока недоступно. Сначала выполните первый шаг подтверждения." } "openvpn" => { "Второе подтверждение пока недоступно. Сначала выполните первый шаг подтверждения." } _ => "Подтверждение пока недоступно.", } } fn confirmation_wrong_code_message(kind: &str) -> &'static str { match kind { "pfsense" => "Неверный код второго подтверждения pfSense.", "openvpn" => "Неверный код второго подтверждения OpenVPN-конфига.", "proxmox_restore" => "Неверный код подтверждения восстановления Proxmox.", _ => "Неверный код подтверждения.", } } fn confirmation_cancel_message(kind: &str) -> &'static str { match kind { "pfsense" => "Ожидающее изменение pfSense отменено.", "openvpn" => "Ожидающий запрос на OpenVPN конфиг отменён.", "proxmox_restore" => "Ожидающее восстановление Proxmox отменено.", "proxmox_selection" => "Выбор узла Proxmox отменён.", _ => "Ожидающее подтверждение отменено.", } } fn confirmation_first_already_message(kind: &str, code: &str) -> String { match kind { "pfsense" => format!( "Первое подтверждение уже принято.\nДля второго подтверждения отправьте `/pfsense_apply {code}`." ), "openvpn" => format!( "Первое подтверждение уже принято.\nДля второго подтверждения отправьте `/openvpn_config_apply {code}`." ), _ => "Первое подтверждение уже принято.".to_string(), } } fn confirmation_state_object(state: &Value) -> Option<&serde_json::Map> { state.as_object().filter(|object| !object.is_empty()) } fn confirmation_decision( input: &ConfirmationInput, ttl_seconds: i64, now_epoch: i64, ) -> ConfirmationDecisionPayload { let kind = input.kind.trim().to_ascii_lowercase(); let action = input.action.trim().to_ascii_lowercase(); let state = confirmation_state_object(&input.state); let present = state.is_some(); let created_ts = state .and_then(|object| value_i64(object.get("created_ts"))) .unwrap_or(now_epoch); let expired = present && ttl_seconds >= 0 && now_epoch - created_ts > ttl_seconds; let stage = state .and_then(|object| object.get("stage")) .map(|value| value_string(Some(value), "")) .unwrap_or_default(); let confirm_code = state .and_then(|object| object.get("confirm_code")) .map(|value| value_string(Some(value), "")) .unwrap_or_default(); let base = |allowed: bool, clear_pending: bool, next_stage: Option, first_confirmed_ts: Option, reason: &str, message: Option| ConfirmationDecisionPayload { kind: kind.clone(), action: action.clone(), present, expired, allowed, clear_pending, next_stage, first_confirmed_ts, reason: reason.to_string(), message, }; if action == "cancel" { return base( true, true, None, None, "cancelled", Some(confirmation_cancel_message(&kind).to_string()), ); } if !present { return base( false, false, None, None, "no_pending", Some(confirmation_no_pending_message(&kind).to_string()), ); } if expired { return base( false, true, None, None, "expired", Some(confirmation_no_pending_message(&kind).to_string()), ); } match action.as_str() { "expire" => base(false, false, None, None, "active", None), "first_confirm" => { if stage != "awaiting_first_confirm" { return base( false, false, None, None, "already_first_confirmed", Some(confirmation_first_already_message(&kind, &confirm_code)), ); } base( true, false, Some("awaiting_second_confirm".to_string()), Some(now_epoch), "first_confirmed", None, ) } "apply" => { if kind != "proxmox_restore" && stage != "awaiting_second_confirm" { return base( false, false, None, None, "wrong_stage", Some(confirmation_wrong_stage_message(&kind).to_string()), ); } if input.code.trim() != confirm_code { return base( false, false, None, None, "wrong_code", Some(confirmation_wrong_code_message(&kind).to_string()), ); } base(true, false, None, None, "apply_allowed", None) } _ => base( false, false, None, None, "unknown_action", Some("Неизвестное действие подтверждения.".to_string()), ), } } fn autoheal_plan_decision(input: &AutohealPlanInput) -> AutohealPlanPayload { let mut failures: Vec = input .failures .iter() .map(|item| item.trim().to_string()) .filter(|item| !item.is_empty()) .collect(); failures.sort(); failures.dedup(); let slo_only = failures.iter().any(|item| item == "slo"); if slo_only { let trigger = if input.slo_stale { "- heal trigger: SLO summary stale, check aw-slo-monitor.timer/service" } else { "- heal trigger: SLO error budget exhausted, no direct autoheal target" }; return AutohealPlanPayload { failures, slo_only: true, slo_stale: input.slo_stale, include_watchers: false, include_worktime: false, include_windows_dlp: false, server_dlp_failures: Vec::new(), run_windows_heal: false, run_server_dlp_heal: false, run_worktime_heal: false, sleep_after_seconds: 0, report_triggers: vec![trigger.to_string()], direct_autoheal_target: false, }; } let watcher_failures: Vec = failures .iter() .filter(|item| item.starts_with("watcher-")) .cloned() .collect(); let dlp_failures: Vec = failures .iter() .filter(|item| item.starts_with("dlp-")) .cloned() .collect(); let include_worktime = failures.iter().any(|item| item == "worktime"); let windows_dlp_failures: Vec = dlp_failures .iter() .filter(|item| matches!(item.as_str(), "dlp-endpoint" | "dlp-fileops-host")) .cloned() .collect(); let server_dlp_failures: Vec = dlp_failures .iter() .filter(|item| !matches!(item.as_str(), "dlp-endpoint" | "dlp-fileops-host")) .cloned() .collect(); let include_watchers = !watcher_failures.is_empty(); let include_windows_dlp = !windows_dlp_failures.is_empty(); let run_windows_heal = include_watchers || include_worktime || include_windows_dlp; let run_server_dlp_heal = !server_dlp_failures.is_empty(); let run_worktime_heal = include_watchers || include_worktime; let sleep_after_seconds = if include_watchers || include_windows_dlp { 30 } else { 5 }; let mut report_triggers = Vec::new(); if run_windows_heal { report_triggers.push( "- heal trigger: Windows session collectors degraded, starting remediation".to_string(), ); } if run_server_dlp_heal { report_triggers .push("- heal trigger: server-side DLP degraded, starting remediation".to_string()); } if run_worktime_heal { report_triggers.push( "- heal trigger: worktime/watchers degraded, rebuilding server-side worktime views" .to_string(), ); } AutohealPlanPayload { failures, slo_only: false, slo_stale: false, include_watchers, include_worktime, include_windows_dlp, server_dlp_failures, run_windows_heal, run_server_dlp_heal, run_worktime_heal, sleep_after_seconds, report_triggers, direct_autoheal_target: run_windows_heal || run_server_dlp_heal || run_worktime_heal, } } fn pfsense_status_lines(command: Option<&str>) -> String { let Some(command) = command.map(str::trim).filter(|value| !value.is_empty()) else { return "- pfsense_security: status unavailable (command not configured)".to_string(); }; match run_shell_text(command) { Ok(output) => { let lines: Vec = output .lines() .map(str::trim) .filter(|line| !line.is_empty()) .map(ToOwned::to_owned) .collect(); if lines.is_empty() { "- pfsense_security: status unavailable (empty output)".to_string() } else { lines.join("\n") } } Err(error) => { let text = error.to_string(); let tail = text .chars() .rev() .take(500) .collect::>() .into_iter() .rev() .collect::(); format!("- pfsense_security: status unavailable ({tail})") } } } fn rollback_pending_count(path: Option<&PathBuf>) -> usize { let Some(path) = path else { return 0; }; read_json_file(path) .and_then(|payload| { payload .get("pending_rollback") .and_then(Value::as_array) .map(Vec::len) }) .unwrap_or(0) } fn pending_line(state: &Value, key: &str, none: &str) -> String { let Some(value) = state.get(key).filter(|value| value.is_object()) else { return none.to_string(); }; match key { "pending_pfsense_change" => format!( "- pending_pfsense_change: {} stage={}", value_string(value.get("request_id"), "unknown"), value_string(value.get("stage"), "unknown") ), "pending_proxmox_selection" => format!( "- pending_proxmox_selection: mode={}", value_string(value.get("mode"), "unknown") ), "pending_proxmox_restore" => format!( "- pending_proxmox_restore: {}:{} snapshot={}", value_string(value.get("kind"), "unknown"), value_string(value.get("guest_id"), "unknown"), value_string(value.get("snapshot"), "unknown") ), "pending_openvpn_config" => format!( "- pending_openvpn_config: {} cn={} stage={}", value_string(value.get("request_id"), "unknown"), value_string(value.get("common_name"), "unknown"), value_string(value.get("stage"), "unknown") ), _ => none.to_string(), } } fn status_bool_line(state: &Value, key: &str) -> String { format!( "- {key}: {}", state.get(key).and_then(Value::as_bool).unwrap_or(false) ) } fn status_text_payload( detmir_state: &Path, bot_state_path: Option<&PathBuf>, rollback_path: Option<&PathBuf>, pfsense_command: Option<&str>, slo_command: Option<&str>, slo_alert_window: &str, now_epoch: i64, ) -> StatusTextPayload { let detmir_auto_line = match read_state(detmir_state) { Ok(status) => detmir_auto_line(&status), Err(error) => format!("- detmir_auto: unavailable ({error})"), }; let summary = match slo_command.map(str::trim) { Some(command) if !command.is_empty() => run_shell_json(command).ok(), _ => None, }; let aw_rus_slo_line = aw_slo_status_line(summary.as_ref(), slo_alert_window); let pfsense_status = pfsense_status_lines(pfsense_command); let bot_state = bot_state_path .and_then(|path| read_json_file(path)) .unwrap_or_else(|| json!({})); let rollback_pending_items = rollback_pending_count(rollback_path); let pending_incident = bot_state .get("pending_incident") .filter(|value| value.is_object()); let ppc_line = pending_line( &bot_state, "pending_pfsense_change", "- pending_pfsense_change: none", ); let ovpn_warn_line = if bot_state .get("last_openvpn_expiry_signature") .and_then(Value::as_str) .unwrap_or("") .is_empty() { "- openvpn_expiry_warning_signature: none".to_string() } else { "- openvpn_expiry_warning_signature: set".to_string() }; let pps_line = pending_line( &bot_state, "pending_proxmox_selection", "- pending_proxmox_selection: none", ); let ppr_line = pending_line( &bot_state, "pending_proxmox_restore", "- pending_proxmox_restore: none", ); let povpn_line = pending_line( &bot_state, "pending_openvpn_config", "- pending_openvpn_config: none", ); let update_line = status_bool_line(&bot_state, "pending_update_install_confirm"); let rollback_line = status_bool_line(&bot_state, "pending_rollback_confirm"); let status_text = if let Some(pi) = pending_incident { let created_ts = value_i64(pi.get("created_ts")).unwrap_or(now_epoch); let age = (now_epoch - created_ts).max(0); let failures = pi .get("failures") .and_then(Value::as_array) .map(|items| { items .iter() .map(|item| value_string(Some(item), "")) .filter(|item| !item.is_empty()) .collect::>() .join(" | ") }) .unwrap_or_default(); [ format!( "Статус: активный инцидент {}", value_string(pi.get("incident_id"), "unknown") ), pfsense_status.clone(), aw_rus_slo_line.clone(), detmir_auto_line.clone(), format!("- возраст: {age}s"), format!( "- autoheal attempts: {}", value_string(pi.get("autoheal_attempts"), "0") ), format!( "- operator_acked: {}", value_string(pi.get("operator_acked"), "false") ), format!( "- escalated_to_ai: {}", value_string(pi.get("escalated_to_ai"), "false") ), format!( "- fallback_executed: {}", value_string(pi.get("fallback_executed"), "false") ), format!("- failures: {failures}"), ppc_line, ovpn_warn_line, pps_line, ppr_line, povpn_line, update_line, rollback_line, format!("- rollback_pending_items: {rollback_pending_items}"), ] .join("\n") } else { [ "Статус: инцидентов нет.".to_string(), pfsense_status.clone(), aw_rus_slo_line.clone(), detmir_auto_line.clone(), ppc_line, ovpn_warn_line, pps_line, ppr_line, povpn_line, update_line, rollback_line, format!("- rollback_pending_items: {rollback_pending_items}"), ] .join("\n") }; StatusTextPayload { status_text, pfsense_status, aw_rus_slo_line, detmir_auto_line, rollback_pending_items, } } fn now_epoch() -> i64 { SystemTime::now() .duration_since(UNIX_EPOCH) .map(|duration| duration.as_secs() as i64) .unwrap_or(0) } fn main() -> Result<()> { let cli = Cli::parse(); if cli.incident_suggestions { let input = read_stdin_json()?; println!( "{}", serde_json::to_string_pretty(&SuggestionsPayload { suggestions: suggestions_from_failures(&input.failures), })? ); return Ok(()); } if cli.incident_defer_decision { let input = read_stdin_json()?; let payload = incident_defer_decision( &input.failures, &input.state, cli.incident_failure_quorum_checks, cli.now_epoch.unwrap_or_else(now_epoch), ); println!("{}", serde_json::to_string_pretty(&payload)?); return Ok(()); } if cli.escalation_decision { let input = read_stdin_json()?; let payload = escalation_decision( &input.state, cli.operator_timeout_seconds, cli.now_epoch.unwrap_or_else(now_epoch), ); println!("{}", serde_json::to_string_pretty(&payload)?); return Ok(()); } if cli.operator_action_decision { let input = read_operator_action_input()?; let payload = operator_action_decision(&input); println!("{}", serde_json::to_string_pretty(&payload)?); return Ok(()); } if cli.dlp_policy_decision { let input = read_dlp_policy_input()?; let payload = dlp_policy_decision(&input, cli.now_epoch.unwrap_or_else(now_epoch))?; println!("{}", serde_json::to_string_pretty(&payload)?); return Ok(()); } if cli.confirmation_decision { let input = read_confirmation_input()?; let payload = confirmation_decision( &input, cli.confirmation_ttl_seconds, cli.now_epoch.unwrap_or_else(now_epoch), ); println!("{}", serde_json::to_string_pretty(&payload)?); return Ok(()); } if cli.autoheal_plan_decision { let input = read_autoheal_plan_input()?; let payload = autoheal_plan_decision(&input); println!("{}", serde_json::to_string_pretty(&payload)?); return Ok(()); } if cli.status_text { let payload = status_text_payload( &cli.state, cli.bot_state.as_ref(), cli.rollback_file.as_ref(), cli.pfsense_status_command.as_deref(), cli.aw_slo_summary_command.as_deref(), &cli.aw_slo_alert_window, cli.now_epoch.unwrap_or_else(now_epoch), ); if cli.json { println!("{}", serde_json::to_string_pretty(&payload)?); } else { println!("{}", payload.status_text); } return Ok(()); } if cli.aw_slo_status_line { let summary = match cli.aw_slo_summary_command.as_deref().map(str::trim) { Some(command) if !command.is_empty() => run_shell_json(command).ok(), _ => None, }; let line = aw_slo_status_line(summary.as_ref(), &cli.aw_slo_alert_window); if cli.json { println!( "{}", serde_json::to_string_pretty(&json!({ "aw_rus_slo_line": line, "generated_age_seconds": summary.as_ref().and_then(parse_generated_age_seconds), "summary": summary, }))? ); } else { println!("{line}"); } return Ok(()); } let status = read_state(&cli.state)?; let line = detmir_auto_line(&status); if cli.json { println!( "{}", serde_json::to_string_pretty(&json!({ "detmir_auto_line": line, "normalized": status, }))? ); } else { println!("{line}"); } Ok(()) } #[cfg(test)] mod tests { use super::*; #[test] fn renders_python_compatible_detmir_line() { let raw = r#"{ "severity": "OK", "check_ok": true, "dlp_ok": true, "needs_heal": false, "detmir_summary": { "bucket_ok": 8, "bucket_stale": 0, "bucket_dead": 0, "service_failures": 0, "service_warnings": 0 }, "dlp_counts": {"ok": 22, "warn": 0, "fail": 0} }"#; let state: detmir_state::DetmirState = serde_json::from_str(raw).unwrap(); let line = detmir_auto_line(&state.normalize()); assert_eq!( line, "- detmir_auto: OK check_ok=true dlp_ok=true bucket_stale=0 bucket_dead=0 service_fail=0 service_warn=0 dlp_warn=0 dlp_fail=0" ); } #[test] fn json_mode_includes_line_and_normalized_status() { let dir = tempfile::tempdir().unwrap(); let state_path = dir.path().join("state.json"); std::fs::write( &state_path, r#"{ "severity": "FAIL", "check_ok": false, "dlp_ok": true, "needs_heal": true, "detmir_summary": {"bucket_stale": 1, "service_failures": 1}, "dlp_counts": {"warn": 2, "fail": 0} }"#, ) .unwrap(); let status = read_state(&state_path).unwrap(); let payload = json!({ "detmir_auto_line": detmir_auto_line(&status), "normalized": status, }); assert_eq!(payload["normalized"]["severity"], "FAIL"); assert!( payload["detmir_auto_line"] .as_str() .unwrap() .contains("bucket_stale=1") ); } #[test] fn renders_aw_slo_recovered_status_line() { let summary = json!({ "generated_at_utc": "2026-06-01T00:00:00Z", "windows": { "24h": { "status": "fail", "availability_percent": 98.123456, "samples": 42, "budget_remaining_seconds": -12 } }, "current_sample": {"ok": true} }); assert_eq!( aw_slo_status_line(Some(&summary), "24h"), "- aw_rus_slo: recovered 24h current_sample=OK availability=98.12346% samples=42 budget_remaining_seconds=-12" ); } #[test] fn renders_aw_slo_unavailable_without_summary() { assert_eq!(aw_slo_status_line(None, "24h"), "- aw_rus_slo: unavailable"); } #[test] fn renders_full_status_without_incident() { let dir = tempfile::tempdir().unwrap(); let detmir_path = dir.path().join("detmir.json"); let bot_state_path = dir.path().join("bot.json"); let rollback_path = dir.path().join("rollback.json"); std::fs::write( &detmir_path, r#"{ "severity": "OK", "check_ok": true, "dlp_ok": true, "needs_heal": false, "detmir_summary": {"bucket_stale": 0, "bucket_dead": 0, "service_failures": 0, "service_warnings": 0}, "dlp_counts": {"ok": 22, "warn": 0, "fail": 0} }"#, ) .unwrap(); std::fs::write( &bot_state_path, r#"{ "pending_pfsense_change": null, "pending_openvpn_config": {"request_id": "ovpn-1", "common_name": "user1", "stage": "created"}, "pending_update_install_confirm": true, "pending_rollback_confirm": false, "last_openvpn_expiry_signature": "sig" }"#, ) .unwrap(); std::fs::write(&rollback_path, r#"{"pending_rollback":[{"id":"101"}]}"#).unwrap(); let payload = status_text_payload( &detmir_path, Some(&bot_state_path), Some(&rollback_path), None, None, "24h", 1000, ); assert!(payload.status_text.starts_with("Статус: инцидентов нет.")); assert!(payload.status_text.contains("- aw_rus_slo: unavailable")); assert!(payload.status_text.contains("- detmir_auto: OK")); assert!( payload .status_text .contains("- pending_openvpn_config: ovpn-1 cn=user1 stage=created") ); assert!( payload .status_text .contains("- pending_update_install_confirm: true") ); assert!(payload.status_text.contains("- rollback_pending_items: 1")); } #[test] fn renders_full_status_with_incident() { let dir = tempfile::tempdir().unwrap(); let detmir_path = dir.path().join("detmir.json"); let bot_state_path = dir.path().join("bot.json"); std::fs::write( &detmir_path, r#"{ "severity": "FAIL", "check_ok": false, "dlp_ok": true, "needs_heal": true, "detmir_summary": {"bucket_stale": 2}, "dlp_counts": {"warn": 1, "fail": 0} }"#, ) .unwrap(); std::fs::write( &bot_state_path, r#"{ "pending_incident": { "incident_id": "inc-1", "created_ts": 900, "failures": ["f1", "f2"], "suggestions": [], "last_autoheal_ts": 0, "autoheal_attempts": 2, "operator_acked": true, "escalated_to_ai": false, "fallback_executed": true } }"#, ) .unwrap(); let payload = status_text_payload( &detmir_path, Some(&bot_state_path), None, None, None, "24h", 1000, ); assert!( payload .status_text .contains("Статус: активный инцидент inc-1") ); assert!(payload.status_text.contains("- возраст: 100s")); assert!(payload.status_text.contains("- autoheal attempts: 2")); assert!(payload.status_text.contains("- operator_acked: true")); assert!(payload.status_text.contains("- fallback_executed: true")); assert!(payload.status_text.contains("- failures: f1 | f2")); } #[test] fn incident_suggestions_match_filesystem_and_aw_paths() { let suggestions = suggestions_from_failures(&[ "[FAIL] filesystem_usage: /var 96%".to_string(), "[FAIL] aw-rus:watcher-window: stale".to_string(), ]); assert!( suggestions .iter() .any(|line| line.contains("самые большие каталоги")) ); assert!( suggestions .iter() .any(|line| line.contains("Windows collector recovery")) ); } #[test] fn incident_defer_decision_suppresses_until_quorum() { let state = json!({ "failure_streak_signature": "", "failure_streak_count": 0, "failure_streak_first_ts": 0 }); let failures = vec!["[FAIL] node_13: unavailable".to_string()]; let first = incident_defer_decision(&failures, &state, 2, 1000); assert!(first.defer); assert_eq!(first.failure_streak_count, 1); assert_eq!(first.failure_streak_first_ts, 1000); let state = json!({ "failure_streak_signature": first.failure_streak_signature, "failure_streak_count": first.failure_streak_count, "failure_streak_first_ts": first.failure_streak_first_ts }); let second = incident_defer_decision(&failures, &state, 2, 1060); assert!(!second.defer); assert_eq!(second.failure_streak_count, 2); assert!(second.reset_failure_streak); } #[test] fn incident_defer_decision_never_suppresses_filesystem_critical() { let decision = incident_defer_decision( &["[FAIL] filesystem_usage: /var 96%".to_string()], &json!({}), 3, 1000, ); assert!(!decision.defer); assert!(!decision.reset_failure_streak); } #[test] fn escalation_decision_waits_for_timeout_and_ack() { let state = json!({ "pending_incident": { "created_ts": 1000, "operator_acked": false, "escalated_to_ai": false, "fallback_executed": false } }); let early = escalation_decision(&state, 900, 1200); assert!(!early.should_escalate); assert_eq!(early.reason, "timeout_not_reached"); let timed_out = escalation_decision(&state, 900, 2000); assert!(timed_out.timed_out); assert!(timed_out.should_escalate); assert!(timed_out.should_fallback); let acked = escalation_decision( &json!({"pending_incident": {"created_ts": 1000, "operator_acked": true}}), 900, 2000, ); assert!(!acked.should_escalate); assert_eq!(acked.reason, "operator_acked"); } #[test] fn operator_action_decision_normalizes_aliases() { let decision = operator_action_decision(&OperatorActionInput { action: "techsupport".to_string(), state: json!({}), }); assert!(decision.allowed); assert_eq!(decision.canonical_action, "support"); assert_eq!(decision.handler, "ai_escalation"); assert_eq!(decision.risk_level, "medium"); } #[test] fn operator_action_decision_blocks_unknown_action() { let decision = operator_action_decision(&OperatorActionInput { action: "format-disk".to_string(), state: json!({}), }); assert!(!decision.allowed); assert_eq!(decision.reason, "unknown_action"); assert_eq!(decision.message.as_deref(), Some("Неизвестное действие.")); } #[test] fn operator_action_decision_guards_update_install_confirmation() { let blocked = operator_action_decision(&OperatorActionInput { action: "updates-install-confirm".to_string(), state: json!({"pending_update_install_confirm": false}), }); assert!(!blocked.allowed); assert!(blocked.requires_confirmation); assert_eq!(blocked.reason, "missing_update_install_confirmation"); let allowed = operator_action_decision(&OperatorActionInput { action: "updates-install-confirm".to_string(), state: json!({"pending_update_install_confirm": true}), }); assert!(allowed.allowed); assert_eq!(allowed.canonical_action, "updates-install-confirm"); assert!( allowed .state_update_hints .contains(&"clear_pending_update_install_confirm".to_string()) ); } #[test] fn operator_action_decision_guards_update_rollback_confirmation() { let blocked = operator_action_decision(&OperatorActionInput { action: "/run updates-rollback-confirm".to_string(), state: json!({"pending_rollback_confirm": false}), }); assert!(!blocked.allowed); assert_eq!(blocked.reason, "missing_update_rollback_confirmation"); let allowed = operator_action_decision(&OperatorActionInput { action: "/run updates-rollback-confirm".to_string(), state: json!({"pending_rollback_confirm": true}), }); assert!(allowed.allowed); assert_eq!(allowed.canonical_action, "updates-rollback-confirm"); assert_eq!(allowed.risk_level, "critical"); } #[test] fn dlp_policy_decision_detects_monitor_and_group_counts() { let policy = json!({ "endpoint": { "clipboard": [{"id": "c1", "enabled": true, "action": "alert"}], "usb": [{"id": "u1", "enabled": true, "action": "log"}], "print": [{"id": "p1", "enabled": false, "action": "block"}] } }); let decision = dlp_policy_decision( &DlpPolicyInput { policy, target_mode: String::new(), }, 1000, ) .unwrap(); assert_eq!(decision.current_mode, "monitor"); assert_eq!(decision.reason, "mode_only"); assert_eq!(decision.groups.len(), 3); assert_eq!(decision.groups[0].name, "endpoint.clipboard"); assert_eq!(decision.groups[0].blocked, 0); assert!(decision.updated_policy.is_none()); } #[test] fn dlp_policy_decision_toggle_promotes_endpoint_rules_only() { let policy = json!({ "rules": [{"id": "web1", "enabled": true, "action": "alert"}], "endpoint": { "clipboard": [{"id": "c1", "enabled": true, "action": "alert"}], "usb": [{"id": "u1", "enabled": true, "action": "log"}], "print": [{"id": "p1", "enabled": true, "action": "block"}], "email": [{"id": "e1", "enabled": false, "action": "alert"}] } }); let decision = dlp_policy_decision( &DlpPolicyInput { policy, target_mode: "toggle".to_string(), }, 1000, ) .unwrap(); let updated = decision.updated_policy.as_ref().unwrap(); assert_eq!(decision.current_mode, "mixed"); assert_eq!(decision.target_mode.as_deref(), Some("monitor")); assert_eq!(decision.changed_count, 1); assert_eq!( updated["endpoint"]["print"][0]["action"].as_str(), Some("alert") ); assert_eq!(updated["rules"][0]["action"].as_str(), Some("alert")); assert_eq!( updated["_tsj_meta"]["updated_at_utc"].as_str(), Some("1970-01-01T00:16:40Z") ); } #[test] fn dlp_policy_decision_enforce_sets_alert_and_log_to_block() { let policy = json!({ "endpoint": { "clipboard": [{"id": "c1", "enabled": true, "action": "alert"}], "usb": [{"id": "u1", "enabled": true, "action": "log"}], "print": [{"id": "p1", "enabled": true, "action": "block"}] } }); let decision = dlp_policy_decision( &DlpPolicyInput { policy, target_mode: "enforce".to_string(), }, 1000, ) .unwrap(); let updated = decision.updated_policy.as_ref().unwrap(); assert_eq!(decision.target_mode.as_deref(), Some("enforce")); assert_eq!(decision.changed_count, 2); assert_eq!( updated["endpoint"]["clipboard"][0]["action"].as_str(), Some("block") ); assert_eq!( updated["endpoint"]["usb"][0]["action"].as_str(), Some("block") ); assert!( decision .changed_rules .contains(&"endpoint.clipboard:c1 alert->block".to_string()) ); } #[test] fn confirmation_decision_expires_pending_request() { let decision = confirmation_decision( &ConfirmationInput { kind: "pfsense".to_string(), action: "expire".to_string(), code: String::new(), state: json!({"created_ts": 1000, "stage": "awaiting_first_confirm", "confirm_code": "123456"}), }, 900, 2000, ); assert!(decision.present); assert!(decision.expired); assert!(decision.clear_pending); assert!(!decision.allowed); assert_eq!(decision.reason, "expired"); } #[test] fn confirmation_decision_first_confirm_advances_stage() { let decision = confirmation_decision( &ConfirmationInput { kind: "openvpn".to_string(), action: "first_confirm".to_string(), code: String::new(), state: json!({"created_ts": 1000, "stage": "awaiting_first_confirm", "confirm_code": "654321"}), }, 900, 1200, ); assert!(decision.allowed); assert_eq!( decision.next_stage.as_deref(), Some("awaiting_second_confirm") ); assert_eq!(decision.first_confirmed_ts, Some(1200)); assert_eq!(decision.reason, "first_confirmed"); } #[test] fn confirmation_decision_apply_guards_stage_and_code() { let wrong_stage = confirmation_decision( &ConfirmationInput { kind: "pfsense".to_string(), action: "apply".to_string(), code: "123456".to_string(), state: json!({"created_ts": 1000, "stage": "awaiting_first_confirm", "confirm_code": "123456"}), }, 900, 1200, ); assert!(!wrong_stage.allowed); assert_eq!(wrong_stage.reason, "wrong_stage"); let wrong_code = confirmation_decision( &ConfirmationInput { kind: "pfsense".to_string(), action: "apply".to_string(), code: "000000".to_string(), state: json!({"created_ts": 1000, "stage": "awaiting_second_confirm", "confirm_code": "123456"}), }, 900, 1200, ); assert!(!wrong_code.allowed); assert_eq!(wrong_code.reason, "wrong_code"); let allowed = confirmation_decision( &ConfirmationInput { kind: "pfsense".to_string(), action: "apply".to_string(), code: "123456".to_string(), state: json!({"created_ts": 1000, "stage": "awaiting_second_confirm", "confirm_code": "123456"}), }, 900, 1200, ); assert!(allowed.allowed); assert_eq!(allowed.reason, "apply_allowed"); } #[test] fn confirmation_decision_proxmox_restore_apply_uses_code_only() { let allowed = confirmation_decision( &ConfirmationInput { kind: "proxmox_restore".to_string(), action: "apply".to_string(), code: "222333".to_string(), state: json!({"created_ts": 1000, "confirm_code": "222333"}), }, 900, 1200, ); assert!(allowed.allowed); assert_eq!(allowed.reason, "apply_allowed"); } #[test] fn autoheal_plan_routes_endpoint_failure_to_windows_heal_only() { let plan = autoheal_plan_decision(&AutohealPlanInput { failures: vec!["dlp-endpoint".to_string()], slo_stale: false, }); assert!(plan.run_windows_heal); assert!(plan.include_windows_dlp); assert!(!plan.include_watchers); assert!(!plan.include_worktime); assert!(!plan.run_server_dlp_heal); assert!(!plan.run_worktime_heal); assert_eq!(plan.sleep_after_seconds, 30); assert!(plan.direct_autoheal_target); } #[test] fn autoheal_plan_routes_server_dlp_failure_to_server_dlp_heal() { let plan = autoheal_plan_decision(&AutohealPlanInput { failures: vec!["dlp-fileops-server".to_string()], slo_stale: false, }); assert!(!plan.run_windows_heal); assert!(plan.run_server_dlp_heal); assert_eq!(plan.server_dlp_failures, vec!["dlp-fileops-server"]); assert_eq!(plan.sleep_after_seconds, 5); } #[test] fn autoheal_plan_routes_watcher_and_worktime_to_windows_and_worktime() { let plan = autoheal_plan_decision(&AutohealPlanInput { failures: vec!["watcher-window".to_string(), "worktime".to_string()], slo_stale: false, }); assert!(plan.run_windows_heal); assert!(plan.include_watchers); assert!(plan.include_worktime); assert!(plan.run_worktime_heal); assert_eq!(plan.sleep_after_seconds, 30); assert_eq!(plan.report_triggers.len(), 2); } #[test] fn autoheal_plan_slo_has_no_direct_target() { let plan = autoheal_plan_decision(&AutohealPlanInput { failures: vec!["slo".to_string()], slo_stale: true, }); assert!(plan.slo_only); assert!(plan.slo_stale); assert!(!plan.direct_autoheal_target); assert_eq!( plan.report_triggers[0], "- heal trigger: SLO summary stale, check aw-slo-monitor.timer/service" ); } }