diff --git a/adk-rust/crates/detmir-portal/src/main.rs b/adk-rust/crates/detmir-portal/src/main.rs index 2221b7a..02ab667 100644 --- a/adk-rust/crates/detmir-portal/src/main.rs +++ b/adk-rust/crates/detmir-portal/src/main.rs @@ -7,6 +7,9 @@ use std::sync::{Arc, Mutex}; use std::thread; use std::time::{Duration, Instant}; +#[cfg(unix)] +use std::os::unix::process::CommandExt; + use anyhow::{Context, Result, anyhow}; use base64::Engine; use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; @@ -27,6 +30,15 @@ const UEBA_BASELINE_MIN_SAMPLES: usize = 3; const SNAPSHOT_CACHE_TTL: Duration = Duration::from_secs(5); const DEFAULT_DEPARTMENT_LABEL: &str = "Без подразделения"; +#[cfg(unix)] +const SIGKILL: i32 = 9; + +#[cfg(unix)] +unsafe extern "C" { + fn setpgid(pid: i32, pgid: i32) -> i32; + fn kill(pid: i32, sig: i32) -> i32; +} + type SnapshotCache = Arc>>; #[derive(Clone, Debug)] @@ -2570,13 +2582,25 @@ fn security_events_block(summary: &SecurityEventsSummary) -> SummaryBlock { } fn run_shell(command: &str, timeout: Duration) -> Result<(String, String, bool)> { - let mut child = Command::new("/bin/sh") + let mut shell = Command::new("/bin/sh"); + shell .arg("-lc") .arg(command) .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .with_context(|| format!("spawn {command}"))?; + .stderr(Stdio::piped()); + #[cfg(unix)] + unsafe { + shell.pre_exec(|| { + // Isolate portal probes so a timeout can kill helper grandchildren + // such as detmir-check, not only the shell wrapper. + if setpgid(0, 0) == 0 { + Ok(()) + } else { + Err(std::io::Error::last_os_error()) + } + }); + } + let mut child = shell.spawn().with_context(|| format!("spawn {command}"))?; let started = Instant::now(); loop { if let Some(status) = child.try_wait()? { @@ -2591,7 +2615,7 @@ fn run_shell(command: &str, timeout: Duration) -> Result<(String, String, bool)> return Ok((stdout, stderr, status.success())); } if started.elapsed() > timeout { - let _ = child.kill(); + kill_shell_tree(&mut child); let _ = child.wait(); return Err(anyhow!( "command timed out after {}s: {command}", @@ -2602,6 +2626,21 @@ fn run_shell(command: &str, timeout: Duration) -> Result<(String, String, bool)> } } +#[cfg(unix)] +fn kill_shell_tree(child: &mut std::process::Child) { + let pgid = child.id() as i32; + // Negative pid targets the process group created in pre_exec above. + // Best-effort cleanup: the caller still waits on the direct child. + unsafe { + let _ = kill(-pgid, SIGKILL); + } +} + +#[cfg(not(unix))] +fn kill_shell_tree(child: &mut std::process::Child) { + let _ = child.kill(); +} + fn build_health(snapshot: &Snapshot) -> HealthResponse { let mut sources = BTreeMap::new(); sources.insert("detmir_status".to_string(), snapshot.detmir_status.ok); @@ -9304,6 +9343,15 @@ mod tests { assert!(!serde_json::to_string(&value).unwrap().contains("\\uFFFD")); } + #[cfg(unix)] + #[test] + fn run_shell_timeout_kills_grandchildren_without_blocking_stdout() { + let started = Instant::now(); + let err = run_shell("sh -c 'sleep 5 & wait'", Duration::from_millis(200)).unwrap_err(); + assert!(started.elapsed() < Duration::from_secs(2)); + assert!(err.to_string().contains("command timed out")); + } + #[test] fn agent_quality_history_counts_seven_ok_days() { let (_dir, path) = write_history( diff --git a/adk-rust/crates/detmir-portal/src/static/app.js b/adk-rust/crates/detmir-portal/src/static/app.js index 02a6cac..d557124 100644 --- a/adk-rust/crates/detmir-portal/src/static/app.js +++ b/adk-rust/crates/detmir-portal/src/static/app.js @@ -4,6 +4,7 @@ const state = { period: "today", links: null, readiness: null, + operatorData: null, reports: null, cases: null, pendingScrollSelector: null, @@ -205,6 +206,27 @@ function setViewMode(mode) { setTab("operator"); return; } + if (state.operatorData && state.reports) { + const content = document.getElementById("content"); + if (content) { + content.innerHTML = renderOperator(state.operatorData, state.reports, { cases: state.cases }); + } + setLoadStatus("READY", "Данные готовы", 100); + consumePendingScroll(); + if (mode === "security" && !state.cases) { + loadJson("/cases") + .then(cases => { + state.cases = cases; + if (currentViewMode() === "security" && state.tab === "operator" && content) { + content.innerHTML = renderOperator(state.operatorData, state.reports, { cases: state.cases }); + } + }) + .catch(() => { + state.cases = { ok: false, cases: [] }; + }); + } + return; + } refresh({ stage: VIEW_MODES[mode].stage }); } @@ -2800,6 +2822,7 @@ async function refresh(options = {}) { async function loadCurrentTab() { if (state.tab === "operator") { const data = await loadJson("/operator"); + state.operatorData = data; state.reports = await loadJson("/reports").catch(() => state.reports); if (currentViewMode() === "security") { state.cases = await loadJson("/cases").catch(error => ({ ok: false, error: error.message, cases: [] })); diff --git a/adk-rust/crates/detmir-portal/src/static/index.html b/adk-rust/crates/detmir-portal/src/static/index.html index 5150901..7510ddb 100644 --- a/adk-rust/crates/detmir-portal/src/static/index.html +++ b/adk-rust/crates/detmir-portal/src/static/index.html @@ -82,6 +82,6 @@
- +