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::thread;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
use std::os::unix::process::CommandExt;
|
||||||
|
|
||||||
use anyhow::{Context, Result, anyhow};
|
use anyhow::{Context, Result, anyhow};
|
||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
|
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 SNAPSHOT_CACHE_TTL: Duration = Duration::from_secs(5);
|
||||||
const DEFAULT_DEPARTMENT_LABEL: &str = "Без подразделения";
|
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>>>;
|
type SnapshotCache = Arc<Mutex<Option<CachedSnapshot>>>;
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[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)> {
|
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("-lc")
|
||||||
.arg(command)
|
.arg(command)
|
||||||
.stdout(Stdio::piped())
|
.stdout(Stdio::piped())
|
||||||
.stderr(Stdio::piped())
|
.stderr(Stdio::piped());
|
||||||
.spawn()
|
#[cfg(unix)]
|
||||||
.with_context(|| format!("spawn {command}"))?;
|
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();
|
let started = Instant::now();
|
||||||
loop {
|
loop {
|
||||||
if let Some(status) = child.try_wait()? {
|
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()));
|
return Ok((stdout, stderr, status.success()));
|
||||||
}
|
}
|
||||||
if started.elapsed() > timeout {
|
if started.elapsed() > timeout {
|
||||||
let _ = child.kill();
|
kill_shell_tree(&mut child);
|
||||||
let _ = child.wait();
|
let _ = child.wait();
|
||||||
return Err(anyhow!(
|
return Err(anyhow!(
|
||||||
"command timed out after {}s: {command}",
|
"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 {
|
fn build_health(snapshot: &Snapshot) -> HealthResponse {
|
||||||
let mut sources = BTreeMap::new();
|
let mut sources = BTreeMap::new();
|
||||||
sources.insert("detmir_status".to_string(), snapshot.detmir_status.ok);
|
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"));
|
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]
|
#[test]
|
||||||
fn agent_quality_history_counts_seven_ok_days() {
|
fn agent_quality_history_counts_seven_ok_days() {
|
||||||
let (_dir, path) = write_history(
|
let (_dir, path) = write_history(
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ const state = {
|
|||||||
period: "today",
|
period: "today",
|
||||||
links: null,
|
links: null,
|
||||||
readiness: null,
|
readiness: null,
|
||||||
|
operatorData: null,
|
||||||
reports: null,
|
reports: null,
|
||||||
cases: null,
|
cases: null,
|
||||||
pendingScrollSelector: null,
|
pendingScrollSelector: null,
|
||||||
@@ -205,6 +206,27 @@ function setViewMode(mode) {
|
|||||||
setTab("operator");
|
setTab("operator");
|
||||||
return;
|
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 });
|
refresh({ stage: VIEW_MODES[mode].stage });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2800,6 +2822,7 @@ async function refresh(options = {}) {
|
|||||||
async function loadCurrentTab() {
|
async function loadCurrentTab() {
|
||||||
if (state.tab === "operator") {
|
if (state.tab === "operator") {
|
||||||
const data = await loadJson("/operator");
|
const data = await loadJson("/operator");
|
||||||
|
state.operatorData = data;
|
||||||
state.reports = await loadJson("/reports").catch(() => state.reports);
|
state.reports = await loadJson("/reports").catch(() => state.reports);
|
||||||
if (currentViewMode() === "security") {
|
if (currentViewMode() === "security") {
|
||||||
state.cases = await loadJson("/cases").catch(error => ({ ok: false, error: error.message, cases: [] }));
|
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>
|
<section id="summary" class="summary-grid service-summary"></section>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
<script src="app.js"></script>
|
<script src="app.js?v=20260604-security-cache"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user