fix(portal): prevent security view loading stalls
This commit is contained in:
@@ -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<Mutex<Option<CachedSnapshot>>>;
|
||||
|
||||
#[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(
|
||||
|
||||
@@ -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: [] }));
|
||||
|
||||
@@ -82,6 +82,6 @@
|
||||
<section id="summary" class="summary-grid service-summary"></section>
|
||||
</main>
|
||||
</div>
|
||||
<script src="app.js"></script>
|
||||
<script src="app.js?v=20260604-security-cache"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user