Harden DetMir DLP production runtime
CI / Rust checks (push) Canceled after 0s
CI / Docs and registry checks (push) Canceled after 0s
CI / Smoke checks (push) Canceled after 0s
Coverage / Coverage baseline (push) Canceled after 0s
Security / Cargo audit (push) Canceled after 0s
Security / Cargo deny (push) Canceled after 0s
Security / Secret pattern check (push) Canceled after 0s
Security / Dependency review (push) Canceled after 0s
CI / Rust checks (push) Canceled after 0s
CI / Docs and registry checks (push) Canceled after 0s
CI / Smoke checks (push) Canceled after 0s
Coverage / Coverage baseline (push) Canceled after 0s
Security / Cargo audit (push) Canceled after 0s
Security / Cargo deny (push) Canceled after 0s
Security / Secret pattern check (push) Canceled after 0s
Security / Dependency review (push) Canceled after 0s
- default DetMir DLP runtime to core_only/disabled with load-guard protection - add fail-closed placeholder validation and runtime-scoped artifact checks - document operator re-enable flow for light profile and guard rollback - update prod docs, env examples, and Ansible DLP defaults
This commit is contained in:
Generated
+33
-2
@@ -122,9 +122,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.102"
|
||||
version = "1.0.103"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
|
||||
|
||||
[[package]]
|
||||
name = "arbitrary"
|
||||
@@ -561,6 +561,18 @@ version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||
|
||||
[[package]]
|
||||
name = "containment-engine"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
"clap",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation-sys"
|
||||
version = "0.8.7"
|
||||
@@ -698,6 +710,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -739,6 +752,7 @@ dependencies = [
|
||||
"sha2",
|
||||
"tempfile",
|
||||
"tiny_http",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1255,8 +1269,10 @@ dependencies = [
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"tempfile",
|
||||
"urlencoding",
|
||||
"zip 2.4.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2120,6 +2136,21 @@ dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-finding-inbox"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
"clap",
|
||||
"hayabusa-tools",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
|
||||
@@ -6,7 +6,7 @@ use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use chrono::{DateTime, Duration as ChronoDuration, SecondsFormat, Utc};
|
||||
use clap::Parser;
|
||||
use detmir_core::{exit_codes, parse_utc_rfc3339};
|
||||
@@ -25,10 +25,10 @@ struct Cli {
|
||||
#[arg(long, default_value = "http://127.0.0.1:5610")]
|
||||
worktime_api: String,
|
||||
|
||||
#[arg(long, default_value = "198.51.100.18")]
|
||||
#[arg(long, default_value = "")]
|
||||
rdp_host: String,
|
||||
|
||||
#[arg(long, default_value = "HOST-EXAMPLE")]
|
||||
#[arg(long, default_value = "")]
|
||||
rdp_hostname: String,
|
||||
|
||||
#[arg(long, default_value = "/var/lib/activitywatch/health")]
|
||||
@@ -61,6 +61,9 @@ struct Cli {
|
||||
#[arg(long, default_value_t = 3.0)]
|
||||
tcp_timeout_seconds: f64,
|
||||
|
||||
#[arg(long, default_value_t = true)]
|
||||
rdp_tcp_required: bool,
|
||||
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
}
|
||||
@@ -129,6 +132,10 @@ impl Cli {
|
||||
self.tcp_timeout_seconds,
|
||||
);
|
||||
}
|
||||
if !cli_arg_present("--rdp-tcp-required") {
|
||||
self.rdp_tcp_required =
|
||||
env_bool_default("AW_RUS_HEALTH_RDP_TCP_REQUIRED", self.rdp_tcp_required);
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
@@ -221,9 +228,42 @@ fn env_f64(name: &str, fallback: f64) -> f64 {
|
||||
}
|
||||
|
||||
fn env_bool(name: &str) -> bool {
|
||||
env_bool_default(name, false)
|
||||
}
|
||||
|
||||
fn env_bool_default(name: &str, fallback: bool) -> bool {
|
||||
env_string(name)
|
||||
.map(|value| matches!(value.to_ascii_lowercase().as_str(), "1" | "true" | "yes"))
|
||||
.unwrap_or(false)
|
||||
.map(|value| match value.to_ascii_lowercase().as_str() {
|
||||
"1" | "true" | "yes" | "on" => true,
|
||||
"0" | "false" | "no" | "off" => false,
|
||||
_ => fallback,
|
||||
})
|
||||
.unwrap_or(fallback)
|
||||
}
|
||||
|
||||
fn validate_cli_config(cli: &Cli) -> Result<()> {
|
||||
validate_prod_host("rdp_host", &cli.rdp_host)?;
|
||||
validate_prod_host("rdp_hostname", &cli.rdp_hostname)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_prod_host(name: &str, value: &str) -> Result<()> {
|
||||
let value = value.trim();
|
||||
if value.is_empty() {
|
||||
return Err(anyhow!("invalid config {name}: value is empty"));
|
||||
}
|
||||
let lowered = value.to_ascii_lowercase();
|
||||
if lowered == "host-example"
|
||||
|| lowered.ends_with(".example")
|
||||
|| lowered.starts_with("192.0.2.")
|
||||
|| lowered.starts_with("198.51.100.")
|
||||
|| lowered.starts_with("203.0.113.")
|
||||
{
|
||||
return Err(anyhow!(
|
||||
"invalid config {name}: placeholder/documentation host is not allowed"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load_env_file(path: &Path) {
|
||||
@@ -681,6 +721,16 @@ fn normalize_aw_api_base(aw_server: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn tcp_check_status(ok: bool, required: bool) -> &'static str {
|
||||
if ok {
|
||||
"ok"
|
||||
} else if required {
|
||||
"fail"
|
||||
} else {
|
||||
"warn"
|
||||
}
|
||||
}
|
||||
|
||||
fn validation_check(report: &mut ReportBuilder, validation_dir: &Path, max_age_seconds: i64) {
|
||||
let Some(path) = latest_validation_report(validation_dir) else {
|
||||
report.add(
|
||||
@@ -812,15 +862,18 @@ fn run(cli: &Cli) -> Result<HealthReport> {
|
||||
|
||||
for (port, label) in [(5985_u16, "winrm"), (3389_u16, "rdp")] {
|
||||
let (ok, message) = tcp_connect(&cli.rdp_host, port, cli.tcp_timeout_seconds);
|
||||
let status = tcp_check_status(ok, cli.rdp_tcp_required);
|
||||
report.add(
|
||||
format!("tcp:{label}"),
|
||||
if ok { "ok" } else { "fail" },
|
||||
status,
|
||||
if ok {
|
||||
message
|
||||
} else {
|
||||
} else if cli.rdp_tcp_required {
|
||||
format!("unreachable: {message}")
|
||||
} else {
|
||||
format!("optional unreachable: {message}")
|
||||
},
|
||||
json!({"host": cli.rdp_host, "port": port}),
|
||||
json!({"host": cli.rdp_host, "port": port, "required": cli.rdp_tcp_required}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -949,6 +1002,7 @@ fn run(cli: &Cli) -> Result<HealthReport> {
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let cli = Cli::parse().apply_env();
|
||||
validate_cli_config(&cli)?;
|
||||
let report = run(&cli)?;
|
||||
let json_text = serde_json::to_string_pretty(&report)? + "\n";
|
||||
let text = render_text(&report) + "\n";
|
||||
@@ -1029,4 +1083,20 @@ mod tests {
|
||||
"http://127.0.0.1:5600/api/0"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn optional_rdp_tcp_downgrades_unreachable_to_warn() {
|
||||
assert_eq!(tcp_check_status(false, true), "fail");
|
||||
assert_eq!(tcp_check_status(false, false), "warn");
|
||||
assert_eq!(tcp_check_status(true, false), "ok");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn healthd_rejects_placeholder_hosts() {
|
||||
assert!(validate_prod_host("rdp_host", "192.168.100.19").is_ok());
|
||||
assert!(validate_prod_host("rdp_hostname", "SHARKON2025").is_ok());
|
||||
assert!(validate_prod_host("rdp_host", "198.51.100.18").is_err());
|
||||
assert!(validate_prod_host("rdp_hostname", "HOST-EXAMPLE").is_err());
|
||||
assert!(validate_prod_host("rdp_host", "").is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ serde_json.workspace = true
|
||||
serde_yaml.workspace = true
|
||||
sha2.workspace = true
|
||||
tiny_http.workspace = true
|
||||
url.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,7 @@ use anyhow::{Result, anyhow};
|
||||
use chrono::NaiveDate;
|
||||
use serde_json::{Value, json};
|
||||
use tiny_http::StatusCode;
|
||||
use url::Url;
|
||||
|
||||
use crate::{
|
||||
Cli, MAX_ALLOWED_PAGE_SIZE, MAX_ALLOWED_REPORT_DATE_RANGE_DAYS, MAX_ALLOWED_REQUEST_BODY_BYTES,
|
||||
@@ -77,6 +78,11 @@ pub(crate) fn validate_portal_config(args: &Cli) -> Result<()> {
|
||||
"invalid config max_request_body_bytes: expected 1024..={MAX_ALLOWED_REQUEST_BODY_BYTES}"
|
||||
));
|
||||
}
|
||||
validate_runtime_url("worktime_url", &args.worktime_url)?;
|
||||
validate_runtime_url("one_c_url", &args.one_c_url)?;
|
||||
validate_probe_command("status_cmd", &args.status_cmd)?;
|
||||
validate_probe_command("check_cmd", &args.check_cmd)?;
|
||||
validate_probe_command("failed_units_cmd", &args.failed_units_cmd)?;
|
||||
|
||||
// SECURITY: environment and module names can reach metrics/log labels.
|
||||
// Restrict them to short ASCII tokens to avoid label injection and runaway
|
||||
@@ -112,6 +118,46 @@ pub(crate) fn validate_portal_config(args: &Cli) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_runtime_url(name: &str, value: &str) -> Result<()> {
|
||||
let url = Url::parse(value).map_err(|err| anyhow!("invalid config {name}: {err}"))?;
|
||||
if !matches!(url.scheme(), "http" | "https") {
|
||||
return Err(anyhow!("invalid config {name}: expected http or https URL"));
|
||||
}
|
||||
let Some(host) = url.host_str() else {
|
||||
return Err(anyhow!("invalid config {name}: missing host"));
|
||||
};
|
||||
if is_placeholder_host(host) {
|
||||
return Err(anyhow!(
|
||||
"invalid config {name}: placeholder/documentation host is not allowed in production"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_placeholder_host(host: &str) -> bool {
|
||||
let host = host.trim().to_ascii_lowercase();
|
||||
host.is_empty()
|
||||
|| host == "host-example"
|
||||
|| host.ends_with(".example")
|
||||
|| host.starts_with("192.0.2.")
|
||||
|| host.starts_with("198.51.100.")
|
||||
|| host.starts_with("203.0.113.")
|
||||
}
|
||||
|
||||
fn validate_probe_command(name: &str, command: &str) -> Result<()> {
|
||||
let command = command.trim();
|
||||
if command.is_empty() {
|
||||
return Err(anyhow!("invalid config {name}: command is empty"));
|
||||
}
|
||||
let forbidden = ['\n', '\r', '\0', ';', '|', '&', '<', '>', '`'];
|
||||
if command.contains("$(") || command.chars().any(|ch| forbidden.contains(&ch)) {
|
||||
return Err(anyhow!(
|
||||
"invalid config {name}: shell control operators are not allowed"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_safe_environment_name(value: &str) -> bool {
|
||||
let value = value.trim();
|
||||
!value.is_empty()
|
||||
@@ -230,6 +276,7 @@ mod tests {
|
||||
slow_request_log_ms: DEFAULT_SLOW_REQUEST_LOG_MS,
|
||||
environment: "test".to_string(),
|
||||
enabled_modules: "executive,workforce,security,forensics,admin".to_string(),
|
||||
dlp_module_enabled: true,
|
||||
state_dir: dir.join("state"),
|
||||
dlp_db_path: dir.join("dlp.sqlite"),
|
||||
evidence_root: dir.to_path_buf(),
|
||||
@@ -293,6 +340,39 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_validation_rejects_placeholder_endpoints_and_shell_operators() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let args = test_cli(dir.path());
|
||||
|
||||
let mut invalid = args.clone();
|
||||
invalid.worktime_url = "http://192.0.2.13:5610".to_string();
|
||||
assert!(
|
||||
validate_portal_config(&invalid)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("placeholder")
|
||||
);
|
||||
|
||||
let mut invalid = args.clone();
|
||||
invalid.one_c_url = "http://198.51.100.2:8710".to_string();
|
||||
assert!(
|
||||
validate_portal_config(&invalid)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("placeholder")
|
||||
);
|
||||
|
||||
let mut invalid = args.clone();
|
||||
invalid.check_cmd = "detmir-check --json; curl http://127.0.0.1".to_string();
|
||||
assert!(
|
||||
validate_portal_config(&invalid)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("shell control")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn query_limits_reject_page_size_and_report_range() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -34,6 +34,10 @@ struct HttpMetricValue {
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct PortalMetrics {
|
||||
http: BTreeMap<HttpMetricKey, HttpMetricValue>,
|
||||
report_requests_total: u64,
|
||||
report_cache_hits_total: u64,
|
||||
report_cache_misses_total: u64,
|
||||
report_cache_stale_hits_total: u64,
|
||||
reports_generated_total: u64,
|
||||
ingestion_records_total: u64,
|
||||
ingestion_rejected_total: u64,
|
||||
@@ -70,6 +74,32 @@ pub(crate) fn record_report_generated() {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn record_report_request() {
|
||||
if let Ok(mut metrics) = portal_metrics().lock() {
|
||||
metrics.report_requests_total = metrics.report_requests_total.saturating_add(1);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn record_report_cache_hit() {
|
||||
if let Ok(mut metrics) = portal_metrics().lock() {
|
||||
metrics.report_cache_hits_total = metrics.report_cache_hits_total.saturating_add(1);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn record_report_cache_stale_hit() {
|
||||
if let Ok(mut metrics) = portal_metrics().lock() {
|
||||
metrics.report_cache_hits_total = metrics.report_cache_hits_total.saturating_add(1);
|
||||
metrics.report_cache_stale_hits_total =
|
||||
metrics.report_cache_stale_hits_total.saturating_add(1);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn record_report_cache_miss() {
|
||||
if let Ok(mut metrics) = portal_metrics().lock() {
|
||||
metrics.report_cache_misses_total = metrics.report_cache_misses_total.saturating_add(1);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn record_ingestion_accepted() {
|
||||
if let Ok(mut metrics) = portal_metrics().lock() {
|
||||
metrics.ingestion_records_total = metrics.ingestion_records_total.saturating_add(1);
|
||||
@@ -149,6 +179,26 @@ pub(crate) fn render_prometheus_metrics(args: &Cli) -> String {
|
||||
.ok();
|
||||
}
|
||||
for (name, help, value) in [
|
||||
(
|
||||
"awatch_report_requests_total",
|
||||
"Report payload requests handled by the portal cache layer",
|
||||
metrics.report_requests_total,
|
||||
),
|
||||
(
|
||||
"awatch_report_cache_hits_total",
|
||||
"Report payload requests served from the in-process cache",
|
||||
metrics.report_cache_hits_total,
|
||||
),
|
||||
(
|
||||
"awatch_report_cache_misses_total",
|
||||
"Report payload requests that triggered report regeneration",
|
||||
metrics.report_cache_misses_total,
|
||||
),
|
||||
(
|
||||
"awatch_report_cache_stale_hits_total",
|
||||
"Report payload requests served from stale cache while refresh runs",
|
||||
metrics.report_cache_stale_hits_total,
|
||||
),
|
||||
(
|
||||
"awatch_reports_generated_total",
|
||||
"Reports generated by the portal",
|
||||
|
||||
@@ -22,7 +22,8 @@ pub(crate) use limits::{is_limited_api_route, validate_api_query_limits, validat
|
||||
pub(crate) use logging::log_http_request;
|
||||
pub(crate) use metrics::{
|
||||
record_http_metric, record_ingestion_accepted, record_ingestion_rejected,
|
||||
record_report_generated, render_prometheus_metrics,
|
||||
record_report_cache_hit, record_report_cache_miss, record_report_cache_stale_hit,
|
||||
record_report_generated, record_report_request, render_prometheus_metrics,
|
||||
};
|
||||
pub(crate) use readiness::build_readyz;
|
||||
pub(crate) use request_context::{http_request_metadata, mark_request_started};
|
||||
|
||||
@@ -6,13 +6,20 @@
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::{Cli, HealthResponse, Snapshot, build_health, build_snapshot, now};
|
||||
|
||||
const SNAPSHOT_CACHE_TTL: Duration = Duration::from_secs(120);
|
||||
|
||||
pub(crate) type SnapshotCache = Arc<Mutex<Option<CachedSnapshot>>>;
|
||||
pub(crate) type SnapshotCache = Arc<Mutex<SnapshotCacheState>>;
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct SnapshotCacheState {
|
||||
pub(crate) entry: Option<CachedSnapshot>,
|
||||
pub(crate) refresh_in_progress: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct CachedSnapshot {
|
||||
@@ -21,7 +28,7 @@ pub(crate) struct CachedSnapshot {
|
||||
}
|
||||
|
||||
pub(crate) fn new_snapshot_cache() -> SnapshotCache {
|
||||
Arc::new(Mutex::new(None))
|
||||
Arc::new(Mutex::new(SnapshotCacheState::default()))
|
||||
}
|
||||
|
||||
pub(crate) fn clone_snapshot_cache(cache: &SnapshotCache) -> SnapshotCache {
|
||||
@@ -29,23 +36,76 @@ pub(crate) fn clone_snapshot_cache(cache: &SnapshotCache) -> SnapshotCache {
|
||||
}
|
||||
|
||||
pub(crate) fn cached_snapshot(args: &Cli, cache: &SnapshotCache) -> Snapshot {
|
||||
let mut guard = cache.lock().expect("snapshot cache mutex poisoned");
|
||||
if let Some(cached) = guard.as_ref() {
|
||||
if cached.created.elapsed() <= SNAPSHOT_CACHE_TTL {
|
||||
return cached.snapshot.clone();
|
||||
{
|
||||
let guard = cache.lock().expect("snapshot cache mutex poisoned");
|
||||
if let Some(cached) = guard.entry.as_ref() {
|
||||
if cached.created.elapsed() <= SNAPSHOT_CACHE_TTL {
|
||||
return cached.snapshot.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let snapshot = build_snapshot(args);
|
||||
*guard = Some(CachedSnapshot {
|
||||
let mut guard = cache.lock().expect("snapshot cache mutex poisoned");
|
||||
guard.entry = Some(CachedSnapshot {
|
||||
created: Instant::now(),
|
||||
snapshot: snapshot.clone(),
|
||||
});
|
||||
guard.refresh_in_progress = false;
|
||||
snapshot
|
||||
}
|
||||
|
||||
pub(crate) fn cached_snapshot_or_refresh(args: &Cli, cache: &SnapshotCache) -> Option<Snapshot> {
|
||||
let mut should_spawn = false;
|
||||
let mut snapshot_to_return = None;
|
||||
{
|
||||
let mut guard = cache.lock().expect("snapshot cache mutex poisoned");
|
||||
if let Some(cached) = guard.entry.as_ref() {
|
||||
let snapshot = cached.snapshot.clone();
|
||||
if cached.created.elapsed() <= SNAPSHOT_CACHE_TTL {
|
||||
return Some(snapshot);
|
||||
}
|
||||
if !guard.refresh_in_progress {
|
||||
guard.refresh_in_progress = true;
|
||||
should_spawn = true;
|
||||
}
|
||||
snapshot_to_return = Some(snapshot);
|
||||
} else if !guard.refresh_in_progress {
|
||||
guard.refresh_in_progress = true;
|
||||
should_spawn = true;
|
||||
}
|
||||
}
|
||||
|
||||
if should_spawn {
|
||||
spawn_snapshot_refresh(args.clone(), clone_snapshot_cache(cache));
|
||||
}
|
||||
snapshot_to_return
|
||||
}
|
||||
|
||||
fn spawn_snapshot_refresh(args: Cli, cache: SnapshotCache) {
|
||||
thread::spawn(move || {
|
||||
let result =
|
||||
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| build_snapshot(&args)));
|
||||
let mut guard = cache.lock().expect("snapshot cache mutex poisoned");
|
||||
match result {
|
||||
Ok(snapshot) => {
|
||||
guard.entry = Some(CachedSnapshot {
|
||||
created: Instant::now(),
|
||||
snapshot,
|
||||
});
|
||||
}
|
||||
Err(_) => {
|
||||
eprintln!("detmir-portal snapshot cache refresh panicked");
|
||||
}
|
||||
}
|
||||
guard.refresh_in_progress = false;
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn build_fast_health(cache: &SnapshotCache) -> HealthResponse {
|
||||
match cache.try_lock() {
|
||||
Ok(guard) => guard
|
||||
.entry
|
||||
.as_ref()
|
||||
.map(|cached| build_health(&cached.snapshot))
|
||||
.unwrap_or_else(lightweight_health),
|
||||
|
||||
@@ -549,6 +549,7 @@ mod tests {
|
||||
};
|
||||
Snapshot {
|
||||
generated_at_utc: "2026-06-07T10:00:00Z".to_string(),
|
||||
dlp_module_enabled: true,
|
||||
detmir_status: SourceStatus {
|
||||
ok: true,
|
||||
status: "OK".to_string(),
|
||||
|
||||
@@ -18,7 +18,9 @@ const DEFAULT_AW_ENV_FILE: &str = "/etc/activitywatch/aw-server.env";
|
||||
const DEFAULT_GRAFANA_ENV_FILE: &str = "/etc/detmir-grafana-check.env";
|
||||
const DEFAULT_GRAFANA_URL: &str = "http://127.0.0.1:3000";
|
||||
const DEFAULT_GRAFANA_DATASOURCE_UID: &str = "influxdb_aw";
|
||||
const DEFAULT_SYSTEMD_SERVICES: &str = "activitywatch-server,aw-worktime-api,aw-worktime-influx-exporter.timer,aw-dlp-influx-exporter.timer";
|
||||
const DEFAULT_SYSTEMD_SERVICES: &str =
|
||||
"activitywatch-server,aw-worktime-api,aw-worktime-influx-exporter.timer";
|
||||
const DEFAULT_DLP_SYSTEMD_SERVICES: &str = "aw-dlp-influx-exporter.timer";
|
||||
const DEFAULT_RETENTION_DAYS: i64 = 30;
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
@@ -218,14 +220,26 @@ fn run(cli: &Cli) -> Result<Report> {
|
||||
let mut checks = Vec::new();
|
||||
let worktime = influx_config(&aw_env, "AW_WORKTIME_INFLUX");
|
||||
let dlp = influx_config(&aw_env, "AW_DLP_INFLUX");
|
||||
let dlp_enabled = env_bool(&aw_env, "AW_DLP_ENABLED", true);
|
||||
|
||||
checks.push(check_influx_env(&worktime, cli.allow_disabled_influx));
|
||||
checks.push(check_influx_env(&dlp, cli.allow_disabled_influx));
|
||||
if dlp_enabled {
|
||||
checks.push(check_influx_env(&dlp, cli.allow_disabled_influx));
|
||||
} else {
|
||||
checks.push(warn(
|
||||
"env:AW_DLP_INFLUX",
|
||||
"DLP Influx runtime disabled by AW_DLP_ENABLED=false",
|
||||
json!({"enabled": false, "mode": "disabled"}),
|
||||
));
|
||||
}
|
||||
|
||||
if cli.skip_systemd {
|
||||
checks.push(warn("systemd", "systemd checks skipped", json!({})));
|
||||
} else {
|
||||
checks.extend(check_systemd_services(&cli.systemd_services));
|
||||
checks.extend(check_systemd_services(&systemd_services_for_mode(
|
||||
&cli.systemd_services,
|
||||
dlp_enabled,
|
||||
)));
|
||||
}
|
||||
|
||||
if cli.skip_influx_write {
|
||||
@@ -236,7 +250,15 @@ fn run(cli: &Cli) -> Result<Report> {
|
||||
));
|
||||
} else {
|
||||
checks.push(check_influx_write(&client, "worktime", &worktime));
|
||||
checks.push(check_influx_write(&client, "dlp", &dlp));
|
||||
if dlp_enabled {
|
||||
checks.push(check_influx_write(&client, "dlp", &dlp));
|
||||
} else {
|
||||
checks.push(warn(
|
||||
"influx:write:dlp",
|
||||
"DLP write probe skipped because DLP is disabled",
|
||||
json!({"enabled": false, "mode": "disabled"}),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if cli.skip_grafana {
|
||||
@@ -270,7 +292,7 @@ fn run(cli: &Cli) -> Result<Report> {
|
||||
git_commit: cli.git_commit.clone(),
|
||||
counts,
|
||||
checks,
|
||||
limitations: build_limitations(cli),
|
||||
limitations: build_limitations(cli, dlp_enabled),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -338,6 +360,20 @@ fn split_csv(value: &str) -> Vec<String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn systemd_services_for_mode(csv: &str, dlp_enabled: bool) -> String {
|
||||
let mut services = split_csv(csv);
|
||||
if dlp_enabled {
|
||||
for service in split_csv(DEFAULT_DLP_SYSTEMD_SERVICES) {
|
||||
if !services.iter().any(|item| item == &service) {
|
||||
services.push(service);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
services.retain(|service| !service.contains("dlp"));
|
||||
}
|
||||
services.into_iter().collect::<Vec<_>>().join(",")
|
||||
}
|
||||
|
||||
fn hostname() -> String {
|
||||
Command::new("hostname")
|
||||
.output()
|
||||
@@ -348,7 +384,7 @@ fn hostname() -> String {
|
||||
.unwrap_or_else(|| "unknown".to_string())
|
||||
}
|
||||
|
||||
fn build_limitations(cli: &Cli) -> Vec<String> {
|
||||
fn build_limitations(cli: &Cli, dlp_enabled: bool) -> Vec<String> {
|
||||
let mut limitations = Vec::new();
|
||||
limitations.push(
|
||||
"Проверка подтверждает состояние runtime на момент формирования акта и не заменяет аудит конфигурации, нагрузочное тестирование или приемочные испытания заказчика.".to_string(),
|
||||
@@ -377,6 +413,11 @@ fn build_limitations(cli: &Cli) -> Vec<String> {
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
if !dlp_enabled {
|
||||
limitations.push(
|
||||
"DLP runtime отключен штатно через AW_DLP_ENABLED=false; readiness не считает DLP services/timers и DLP Influx write обязательными.".to_string(),
|
||||
);
|
||||
}
|
||||
limitations
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user