refactor(portal): split hardening and kpi modules
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::now;
|
||||
|
||||
pub(crate) fn build_healthz() -> Value {
|
||||
json!({
|
||||
"status": "ok",
|
||||
"generated_at_utc": now(),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use anyhow::{Result, anyhow};
|
||||
use chrono::NaiveDate;
|
||||
use serde_json::{Value, json};
|
||||
use tiny_http::StatusCode;
|
||||
|
||||
use crate::{
|
||||
Cli, MAX_ALLOWED_PAGE_SIZE, MAX_ALLOWED_REPORT_DATE_RANGE_DAYS, MAX_ALLOWED_REQUEST_BODY_BYTES,
|
||||
MAX_ALLOWED_REQUEST_TIMEOUT_SECONDS, query_param,
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct ApiLimitError {
|
||||
pub(crate) status: StatusCode,
|
||||
pub(crate) payload: Value,
|
||||
}
|
||||
|
||||
pub(crate) fn validate_portal_config(args: &Cli) -> Result<()> {
|
||||
let (host, port) = args
|
||||
.bind
|
||||
.rsplit_once(':')
|
||||
.ok_or_else(|| anyhow!("invalid config bind: expected host:port"))?;
|
||||
if host.trim().is_empty() {
|
||||
return Err(anyhow!("invalid config host: value is empty"));
|
||||
}
|
||||
let port = port
|
||||
.parse::<u16>()
|
||||
.map_err(|_| anyhow!("invalid config port: expected 1..65535"))?;
|
||||
if port == 0 {
|
||||
return Err(anyhow!("invalid config port: expected 1..65535"));
|
||||
}
|
||||
if args.max_page_size == 0 || args.max_page_size > MAX_ALLOWED_PAGE_SIZE {
|
||||
return Err(anyhow!(
|
||||
"invalid config max_page_size: expected 1..={MAX_ALLOWED_PAGE_SIZE}"
|
||||
));
|
||||
}
|
||||
if args.default_page_size == 0 || args.default_page_size > args.max_page_size {
|
||||
return Err(anyhow!(
|
||||
"invalid config default_page_size: expected 1..=max_page_size"
|
||||
));
|
||||
}
|
||||
if args.max_report_date_range_days <= 0
|
||||
|| args.max_report_date_range_days > MAX_ALLOWED_REPORT_DATE_RANGE_DAYS
|
||||
{
|
||||
return Err(anyhow!(
|
||||
"invalid config max_report_date_range_days: expected 1..={MAX_ALLOWED_REPORT_DATE_RANGE_DAYS}"
|
||||
));
|
||||
}
|
||||
if args.request_timeout_seconds == 0
|
||||
|| args.request_timeout_seconds > MAX_ALLOWED_REQUEST_TIMEOUT_SECONDS
|
||||
{
|
||||
return Err(anyhow!(
|
||||
"invalid config request_timeout_seconds: expected 1..={MAX_ALLOWED_REQUEST_TIMEOUT_SECONDS}"
|
||||
));
|
||||
}
|
||||
if args.timeout_seconds == 0 || args.timeout_seconds > MAX_ALLOWED_REQUEST_TIMEOUT_SECONDS {
|
||||
return Err(anyhow!(
|
||||
"invalid config timeout_seconds: expected 1..={MAX_ALLOWED_REQUEST_TIMEOUT_SECONDS}"
|
||||
));
|
||||
}
|
||||
if args.max_request_body_bytes < 1024
|
||||
|| args.max_request_body_bytes > MAX_ALLOWED_REQUEST_BODY_BYTES
|
||||
{
|
||||
return Err(anyhow!(
|
||||
"invalid config max_request_body_bytes: expected 1024..={MAX_ALLOWED_REQUEST_BODY_BYTES}"
|
||||
));
|
||||
}
|
||||
if !is_safe_environment_name(&args.environment) {
|
||||
return Err(anyhow!(
|
||||
"invalid config environment: use 1..32 chars from A-Z, a-z, 0-9, _, -"
|
||||
));
|
||||
}
|
||||
let modules = enabled_modules(&args.enabled_modules);
|
||||
if modules.is_empty() {
|
||||
return Err(anyhow!(
|
||||
"invalid config enabled_modules: no modules enabled"
|
||||
));
|
||||
}
|
||||
let allowed = [
|
||||
"executive",
|
||||
"workforce",
|
||||
"security",
|
||||
"forensics",
|
||||
"admin",
|
||||
"ueba",
|
||||
"pfsense",
|
||||
"reports",
|
||||
];
|
||||
for module in modules {
|
||||
if !allowed.contains(&module.as_str()) {
|
||||
return Err(anyhow!(
|
||||
"invalid config enabled_modules: unsupported module {module}"
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_safe_environment_name(value: &str) -> bool {
|
||||
let value = value.trim();
|
||||
!value.is_empty()
|
||||
&& value.len() <= 32
|
||||
&& value
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-'))
|
||||
}
|
||||
|
||||
fn enabled_modules(value: &str) -> BTreeSet<String> {
|
||||
value
|
||||
.split(',')
|
||||
.map(|item| item.trim().to_ascii_lowercase())
|
||||
.filter(|item| !item.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn is_limited_api_route(path: &str) -> bool {
|
||||
matches!(
|
||||
path,
|
||||
"/api/reports"
|
||||
| "/api/executive"
|
||||
| "/api/workforce"
|
||||
| "/api/security"
|
||||
| "/api/forensics"
|
||||
| "/api/ueba"
|
||||
| "/api/pfsense"
|
||||
| "/api/workforce/kpi/explain"
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn validate_api_query_limits(
|
||||
url: &str,
|
||||
args: &Cli,
|
||||
) -> std::result::Result<(), ApiLimitError> {
|
||||
for key in ["page_size", "limit"] {
|
||||
if let Some(value) = query_param(url, key) {
|
||||
let parsed = value.parse::<u32>().ok();
|
||||
if parsed.is_none_or(|page_size| page_size == 0 || page_size > args.max_page_size) {
|
||||
return Err(api_limit_error(
|
||||
StatusCode(400),
|
||||
"invalid_page_size",
|
||||
&format!("{key} must be between 1 and {}", args.max_page_size),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (from_key, to_key) in [("date_from", "date_to"), ("from", "to"), ("start", "end")] {
|
||||
let Some(from) = query_param(url, from_key).and_then(|value| parse_query_date(&value))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Some(to) = query_param(url, to_key).and_then(|value| parse_query_date(&value)) else {
|
||||
continue;
|
||||
};
|
||||
let days = (to - from).num_days().abs() + 1;
|
||||
if days > args.max_report_date_range_days {
|
||||
return Err(api_limit_error(
|
||||
StatusCode(400),
|
||||
"report_range_too_large",
|
||||
&format!(
|
||||
"report date range must be <= {} days",
|
||||
args.max_report_date_range_days
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn parse_query_date(value: &str) -> Option<NaiveDate> {
|
||||
let date = value.split('T').next().unwrap_or(value);
|
||||
NaiveDate::parse_from_str(date, "%Y-%m-%d").ok()
|
||||
}
|
||||
|
||||
fn api_limit_error(status: StatusCode, code: &str, message: &str) -> ApiLimitError {
|
||||
ApiLimitError {
|
||||
status,
|
||||
payload: json!({
|
||||
"ok": false,
|
||||
"error_code": code,
|
||||
"message": message,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::path::Path;
|
||||
|
||||
use super::*;
|
||||
use crate::{
|
||||
DEFAULT_MAX_PAGE_SIZE, DEFAULT_MAX_REPORT_DATE_RANGE_DAYS, DEFAULT_MAX_REQUEST_BODY_BYTES,
|
||||
DEFAULT_PAGE_SIZE, DEFAULT_REQUEST_TIMEOUT_SECONDS, DEFAULT_SLOW_REQUEST_LOG_MS,
|
||||
};
|
||||
|
||||
fn test_cli(dir: &Path) -> Cli {
|
||||
Cli {
|
||||
bind: "127.0.0.1:8720".to_string(),
|
||||
status_cmd: "true".to_string(),
|
||||
check_cmd: "true".to_string(),
|
||||
failed_units_cmd: "true".to_string(),
|
||||
worktime_url: "http://127.0.0.1".to_string(),
|
||||
one_c_url: "http://127.0.0.1".to_string(),
|
||||
workforce_policy_path: dir.join("workforce-policy.json"),
|
||||
ueba_policy_path: dir.join("ueba-policy.yaml"),
|
||||
timeout_seconds: 1,
|
||||
max_page_size: DEFAULT_MAX_PAGE_SIZE,
|
||||
default_page_size: DEFAULT_PAGE_SIZE,
|
||||
max_report_date_range_days: DEFAULT_MAX_REPORT_DATE_RANGE_DAYS,
|
||||
request_timeout_seconds: DEFAULT_REQUEST_TIMEOUT_SECONDS,
|
||||
max_request_body_bytes: DEFAULT_MAX_REQUEST_BODY_BYTES,
|
||||
slow_request_log_ms: DEFAULT_SLOW_REQUEST_LOG_MS,
|
||||
environment: "test".to_string(),
|
||||
enabled_modules: "executive,workforce,security,forensics,admin".to_string(),
|
||||
state_dir: dir.join("state"),
|
||||
dlp_db_path: dir.join("dlp.sqlite"),
|
||||
evidence_root: dir.to_path_buf(),
|
||||
readiness_bundle_dir: dir.join("readiness-bundle"),
|
||||
evidence_limit: 10,
|
||||
evidence_max_bytes: 1024,
|
||||
json_smoke: false,
|
||||
evidence_only: false,
|
||||
evidence_upload_token: None,
|
||||
telemetry_api_key: "test-key".to_string(),
|
||||
telemetry_store_path: dir.join("telemetry.jsonl"),
|
||||
expected_nodes_path: dir.join("expected_nodes.json"),
|
||||
security_events_backend: "disabled".to_string(),
|
||||
clickhouse_url: "http://127.0.0.1:8123".to_string(),
|
||||
clickhouse_database: "analytics_1c".to_string(),
|
||||
clickhouse_user: "default".to_string(),
|
||||
clickhouse_password: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_validation_rejects_bad_values() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let args = test_cli(dir.path());
|
||||
assert!(validate_portal_config(&args).is_ok());
|
||||
|
||||
let mut invalid = args.clone();
|
||||
invalid.bind = "127.0.0.1:bad".to_string();
|
||||
assert!(
|
||||
validate_portal_config(&invalid)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("port")
|
||||
);
|
||||
|
||||
let mut invalid = args.clone();
|
||||
invalid.max_page_size = 0;
|
||||
assert!(
|
||||
validate_portal_config(&invalid)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("max_page_size")
|
||||
);
|
||||
|
||||
let mut invalid = args.clone();
|
||||
invalid.max_report_date_range_days = 0;
|
||||
assert!(
|
||||
validate_portal_config(&invalid)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("max_report_date_range_days")
|
||||
);
|
||||
|
||||
let mut invalid = args.clone();
|
||||
invalid.request_timeout_seconds = 0;
|
||||
assert!(
|
||||
validate_portal_config(&invalid)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("request_timeout_seconds")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn query_limits_reject_page_size_and_report_range() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let args = test_cli(dir.path());
|
||||
assert!(validate_api_query_limits("/api/reports?page_size=100", &args).is_ok());
|
||||
let page_error =
|
||||
validate_api_query_limits("/api/reports?page_size=999999", &args).unwrap_err();
|
||||
assert_eq!(page_error.status.0, 400);
|
||||
assert_eq!(page_error.payload["error_code"], "invalid_page_size");
|
||||
|
||||
let range_error = validate_api_query_limits(
|
||||
"/api/reports?date_from=2026-01-01&date_to=2026-12-31",
|
||||
&args,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(range_error.status.0, 400);
|
||||
assert_eq!(range_error.payload["error_code"], "report_range_too_large");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
use serde_json::{Value, json};
|
||||
use tiny_http::StatusCode;
|
||||
|
||||
use super::request_context::HttpRequestMetadata;
|
||||
use crate::now;
|
||||
|
||||
pub(crate) fn log_http_request(
|
||||
metadata: &HttpRequestMetadata,
|
||||
status: StatusCode,
|
||||
response_bytes: usize,
|
||||
) {
|
||||
let level = if status.0 >= 500 {
|
||||
"ERROR"
|
||||
} else if status.0 >= 400 {
|
||||
"WARN"
|
||||
} else {
|
||||
"INFO"
|
||||
};
|
||||
let error_code = if status.0 >= 400 {
|
||||
Value::String(format!("http_{}", status.0))
|
||||
} else {
|
||||
Value::Null
|
||||
};
|
||||
eprintln!(
|
||||
"{}",
|
||||
json!({
|
||||
"timestamp": now(),
|
||||
"level": level,
|
||||
"request_id": &metadata.request_id,
|
||||
"correlation_id": &metadata.correlation_id,
|
||||
"method": &metadata.method,
|
||||
"path": &metadata.path,
|
||||
"route": &metadata.route,
|
||||
"status": status.0,
|
||||
"latency_ms": metadata.latency_ms,
|
||||
"user_role": &metadata.role,
|
||||
"module": &metadata.module,
|
||||
"error_code": error_code,
|
||||
"response_bytes": response_bytes,
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt::Write as FmtWrite;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
use tiny_http::StatusCode;
|
||||
|
||||
use super::readiness::build_readyz;
|
||||
use super::request_context::HttpRequestMetadata;
|
||||
use crate::Cli;
|
||||
|
||||
static PORTAL_METRICS: OnceLock<Mutex<PortalMetrics>> = OnceLock::new();
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd)]
|
||||
struct HttpMetricKey {
|
||||
method: String,
|
||||
route: String,
|
||||
status: u16,
|
||||
module: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct HttpMetricValue {
|
||||
requests_total: u64,
|
||||
duration_seconds_sum: f64,
|
||||
duration_seconds_count: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct PortalMetrics {
|
||||
http: BTreeMap<HttpMetricKey, HttpMetricValue>,
|
||||
reports_generated_total: u64,
|
||||
ingestion_records_total: u64,
|
||||
ingestion_rejected_total: u64,
|
||||
role_denied_total: u64,
|
||||
}
|
||||
|
||||
fn portal_metrics() -> &'static Mutex<PortalMetrics> {
|
||||
PORTAL_METRICS.get_or_init(|| Mutex::new(PortalMetrics::default()))
|
||||
}
|
||||
|
||||
pub(crate) fn record_http_metric(metadata: &HttpRequestMetadata, status: StatusCode) {
|
||||
if let Ok(mut metrics) = portal_metrics().lock() {
|
||||
let entry = metrics
|
||||
.http
|
||||
.entry(HttpMetricKey {
|
||||
method: metadata.method.clone(),
|
||||
route: metadata.route.clone(),
|
||||
status: status.0,
|
||||
module: metadata.module.clone(),
|
||||
})
|
||||
.or_default();
|
||||
entry.requests_total = entry.requests_total.saturating_add(1);
|
||||
entry.duration_seconds_sum += metadata.latency_ms as f64 / 1_000.0;
|
||||
entry.duration_seconds_count = entry.duration_seconds_count.saturating_add(1);
|
||||
if status.0 == 403 {
|
||||
metrics.role_denied_total = metrics.role_denied_total.saturating_add(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn record_report_generated() {
|
||||
if let Ok(mut metrics) = portal_metrics().lock() {
|
||||
metrics.reports_generated_total = metrics.reports_generated_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);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn record_ingestion_rejected() {
|
||||
if let Ok(mut metrics) = portal_metrics().lock() {
|
||||
metrics.ingestion_rejected_total = metrics.ingestion_rejected_total.saturating_add(1);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn render_prometheus_metrics(args: &Cli) -> String {
|
||||
let mut text = String::new();
|
||||
let ready_value = if build_readyz(args)
|
||||
.get("status")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or("not_ready")
|
||||
== "ready"
|
||||
{
|
||||
1
|
||||
} else {
|
||||
0
|
||||
};
|
||||
writeln!(
|
||||
&mut text,
|
||||
"# HELP awatch_http_requests_total HTTP requests handled by AWatch-rus portal"
|
||||
)
|
||||
.ok();
|
||||
writeln!(&mut text, "# TYPE awatch_http_requests_total counter").ok();
|
||||
let metrics = portal_metrics()
|
||||
.lock()
|
||||
.map(|guard| guard.clone())
|
||||
.unwrap_or_default();
|
||||
for (key, value) in &metrics.http {
|
||||
writeln!(
|
||||
&mut text,
|
||||
"awatch_http_requests_total{{method=\"{}\",route=\"{}\",status=\"{}\",module=\"{}\"}} {}",
|
||||
prom_escape(&key.method),
|
||||
prom_escape(&key.route),
|
||||
key.status,
|
||||
prom_escape(&key.module),
|
||||
value.requests_total
|
||||
)
|
||||
.ok();
|
||||
}
|
||||
writeln!(
|
||||
&mut text,
|
||||
"# HELP awatch_http_request_duration_seconds HTTP request duration in seconds"
|
||||
)
|
||||
.ok();
|
||||
writeln!(
|
||||
&mut text,
|
||||
"# TYPE awatch_http_request_duration_seconds summary"
|
||||
)
|
||||
.ok();
|
||||
for (key, value) in &metrics.http {
|
||||
writeln!(
|
||||
&mut text,
|
||||
"awatch_http_request_duration_seconds_sum{{method=\"{}\",route=\"{}\",status=\"{}\",module=\"{}\"}} {:.6}",
|
||||
prom_escape(&key.method),
|
||||
prom_escape(&key.route),
|
||||
key.status,
|
||||
prom_escape(&key.module),
|
||||
value.duration_seconds_sum
|
||||
)
|
||||
.ok();
|
||||
writeln!(
|
||||
&mut text,
|
||||
"awatch_http_request_duration_seconds_count{{method=\"{}\",route=\"{}\",status=\"{}\",module=\"{}\"}} {}",
|
||||
prom_escape(&key.method),
|
||||
prom_escape(&key.route),
|
||||
key.status,
|
||||
prom_escape(&key.module),
|
||||
value.duration_seconds_count
|
||||
)
|
||||
.ok();
|
||||
}
|
||||
for (name, help, value) in [
|
||||
(
|
||||
"awatch_reports_generated_total",
|
||||
"Reports generated by the portal",
|
||||
metrics.reports_generated_total,
|
||||
),
|
||||
(
|
||||
"awatch_ingestion_records_total",
|
||||
"Telemetry ingestion records accepted",
|
||||
metrics.ingestion_records_total,
|
||||
),
|
||||
(
|
||||
"awatch_ingestion_rejected_total",
|
||||
"Telemetry ingestion records rejected",
|
||||
metrics.ingestion_rejected_total,
|
||||
),
|
||||
(
|
||||
"awatch_role_denied_total",
|
||||
"Requests denied by role gates",
|
||||
metrics.role_denied_total,
|
||||
),
|
||||
] {
|
||||
writeln!(&mut text, "# HELP {name} {help}").ok();
|
||||
writeln!(&mut text, "# TYPE {name} counter").ok();
|
||||
writeln!(&mut text, "{name} {value}").ok();
|
||||
}
|
||||
writeln!(
|
||||
&mut text,
|
||||
"# HELP awatch_readyz_status Portal readiness status, 1=ready, 0=not_ready"
|
||||
)
|
||||
.ok();
|
||||
writeln!(&mut text, "# TYPE awatch_readyz_status gauge").ok();
|
||||
writeln!(&mut text, "awatch_readyz_status {ready_value}").ok();
|
||||
text
|
||||
}
|
||||
|
||||
fn prom_escape(value: &str) -> String {
|
||||
value.replace('\\', "\\\\").replace('"', "\\\"")
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
pub(crate) mod health;
|
||||
pub(crate) mod limits;
|
||||
pub(crate) mod logging;
|
||||
pub(crate) mod metrics;
|
||||
pub(crate) mod readiness;
|
||||
pub(crate) mod request_context;
|
||||
pub(crate) mod version;
|
||||
|
||||
pub(crate) use health::build_healthz;
|
||||
pub(crate) use limits::{is_limited_api_route, validate_api_query_limits, validate_portal_config};
|
||||
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,
|
||||
};
|
||||
pub(crate) use readiness::build_readyz;
|
||||
pub(crate) use request_context::{http_request_metadata, mark_request_started};
|
||||
pub(crate) use version::build_version;
|
||||
@@ -0,0 +1,58 @@
|
||||
use std::path::Path;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::limits::validate_portal_config;
|
||||
use crate::{Cli, now};
|
||||
|
||||
pub(crate) fn build_readyz(args: &Cli) -> Value {
|
||||
let config_ok = validate_portal_config(args).is_ok();
|
||||
let storage_status = storage_readiness_status(&args.state_dir);
|
||||
let telemetry_status = storage_parent_readiness_status(&args.telemetry_store_path);
|
||||
let evidence_status = storage_readiness_status(&args.evidence_root);
|
||||
let ready = config_ok && !matches!(storage_status.as_str(), "error");
|
||||
json!({
|
||||
"status": if ready { "ready" } else { "not_ready" },
|
||||
"generated_at_utc": now(),
|
||||
"checks": {
|
||||
"config": if config_ok { "ok" } else { "error" },
|
||||
"storage": storage_status,
|
||||
"telemetry_store": telemetry_status,
|
||||
"evidence_storage": evidence_status,
|
||||
"pfsense": "contract_only",
|
||||
"security_events": security_events_readiness_status(args),
|
||||
"clickhouse": if args.security_events_backend == "clickhouse" { "configured" } else { "not_required" }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn storage_readiness_status(path: &Path) -> String {
|
||||
if path.exists() {
|
||||
if path.is_dir() {
|
||||
"ok".to_string()
|
||||
} else {
|
||||
"error".to_string()
|
||||
}
|
||||
} else {
|
||||
"not_configured".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn storage_parent_readiness_status(path: &Path) -> String {
|
||||
path.parent()
|
||||
.map(storage_readiness_status)
|
||||
.unwrap_or_else(|| "not_configured".to_string())
|
||||
}
|
||||
|
||||
fn security_events_readiness_status(args: &Cli) -> &'static str {
|
||||
match args
|
||||
.security_events_backend
|
||||
.trim()
|
||||
.to_ascii_lowercase()
|
||||
.as_str()
|
||||
{
|
||||
"disabled" | "" => "disabled",
|
||||
"clickhouse" => "configured",
|
||||
_ => "configured",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
use std::cell::RefCell;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use tiny_http::Request;
|
||||
|
||||
use crate::{
|
||||
normalize_path, parse_case_path, parse_case_status_path, parse_evidence_screenshot_path,
|
||||
parse_investigation_pack_path, portal_role_from_request,
|
||||
};
|
||||
|
||||
static REQUEST_SEQUENCE: AtomicU64 = AtomicU64::new(1);
|
||||
|
||||
thread_local! {
|
||||
static REQUEST_STARTED_AT: RefCell<Option<Instant>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct HttpRequestMetadata {
|
||||
pub(crate) method: String,
|
||||
pub(crate) path: String,
|
||||
pub(crate) route: String,
|
||||
pub(crate) module: String,
|
||||
pub(crate) role: String,
|
||||
pub(crate) request_id: String,
|
||||
pub(crate) correlation_id: String,
|
||||
pub(crate) latency_ms: u64,
|
||||
}
|
||||
|
||||
pub(crate) fn mark_request_started() {
|
||||
REQUEST_STARTED_AT.with(|started| {
|
||||
*started.borrow_mut() = Some(Instant::now());
|
||||
});
|
||||
}
|
||||
|
||||
fn request_latency_ms() -> u64 {
|
||||
REQUEST_STARTED_AT.with(|started| {
|
||||
started
|
||||
.borrow()
|
||||
.as_ref()
|
||||
.map(|instant| instant.elapsed().as_millis() as u64)
|
||||
.unwrap_or(0)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn http_request_metadata(request: &Request) -> HttpRequestMetadata {
|
||||
let raw_url = request.url().to_string();
|
||||
let path = normalize_path(&raw_url);
|
||||
let method = request.method().as_str().to_string();
|
||||
let route = metrics_route(&path);
|
||||
let module = metrics_module(&route).to_string();
|
||||
let role = portal_role_from_request(request, &raw_url)
|
||||
.as_str()
|
||||
.to_string();
|
||||
let request_id =
|
||||
request_header(request, "X-Request-Id").or_else(|| request_header(request, "X-Request-ID"));
|
||||
let correlation_id = request_header(request, "X-Correlation-Id")
|
||||
.or_else(|| request_header(request, "X-Correlation-ID"));
|
||||
let (request_id, correlation_id) = resolve_request_ids(request_id, correlation_id);
|
||||
HttpRequestMetadata {
|
||||
method,
|
||||
path,
|
||||
route,
|
||||
module,
|
||||
role,
|
||||
request_id,
|
||||
correlation_id,
|
||||
latency_ms: request_latency_ms(),
|
||||
}
|
||||
}
|
||||
|
||||
fn request_header(request: &Request, name: &str) -> Option<String> {
|
||||
request
|
||||
.headers()
|
||||
.iter()
|
||||
.find(|header| header.field.to_string().eq_ignore_ascii_case(name))
|
||||
.map(|header| header.value.as_str().to_string())
|
||||
}
|
||||
|
||||
fn resolve_request_ids(
|
||||
request_id: Option<String>,
|
||||
correlation_id: Option<String>,
|
||||
) -> (String, String) {
|
||||
let request_id = request_id
|
||||
.map(sanitize_request_token)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(generate_request_id);
|
||||
let correlation_id = correlation_id
|
||||
.map(sanitize_request_token)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| request_id.clone());
|
||||
(request_id, correlation_id)
|
||||
}
|
||||
|
||||
fn sanitize_request_token(value: String) -> String {
|
||||
value
|
||||
.chars()
|
||||
.filter(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | ':'))
|
||||
.take(96)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn generate_request_id() -> String {
|
||||
let seq = REQUEST_SEQUENCE.fetch_add(1, Ordering::Relaxed);
|
||||
let millis = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_millis())
|
||||
.unwrap_or(0);
|
||||
format!("awatch-{millis}-{seq}")
|
||||
}
|
||||
|
||||
fn metrics_route(path: &str) -> String {
|
||||
if parse_investigation_pack_path(path).is_some() {
|
||||
return "/api/investigation-pack/{candidate_id}".to_string();
|
||||
}
|
||||
if parse_case_path(path).is_some() {
|
||||
return "/api/cases/{case_id}".to_string();
|
||||
}
|
||||
if parse_case_status_path(path).is_some() {
|
||||
return "/api/cases/{case_id}/status".to_string();
|
||||
}
|
||||
if parse_evidence_screenshot_path(path).is_some() {
|
||||
return "/api/dlp/evidence/{evidence_id}/asset".to_string();
|
||||
}
|
||||
match path {
|
||||
"/" | "/operator" | "/manager" | "/owner" | "/incidents" | "/reports" | "/architecture" => {
|
||||
path.to_string()
|
||||
}
|
||||
"/healthz" | "/api/healthz" => "/healthz".to_string(),
|
||||
"/readyz" | "/api/readyz" => "/readyz".to_string(),
|
||||
"/version" | "/api/version" => "/version".to_string(),
|
||||
"/metrics" | "/api/metrics" => "/metrics".to_string(),
|
||||
_ if path.starts_with("/api/") => path.to_string(),
|
||||
_ => "other".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn metrics_module(route: &str) -> &'static str {
|
||||
if route.starts_with("/healthz")
|
||||
|| route.starts_with("/readyz")
|
||||
|| route.starts_with("/version")
|
||||
|| route.starts_with("/metrics")
|
||||
{
|
||||
"runtime"
|
||||
} else if route.contains("/workforce") || route == "/manager" {
|
||||
"workforce"
|
||||
} else if route.contains("/security")
|
||||
|| route.contains("/incidents")
|
||||
|| route.contains("/incident-review")
|
||||
{
|
||||
"security"
|
||||
} else if route.contains("/forensics")
|
||||
|| route.contains("/investigation-pack")
|
||||
|| route.contains("/cases")
|
||||
|| route.contains("/dlp/evidence")
|
||||
{
|
||||
"forensics"
|
||||
} else if route.contains("/ueba") {
|
||||
"ueba"
|
||||
} else if route.contains("/pfsense") {
|
||||
"pfsense"
|
||||
} else if route.contains("/reports") || route.contains("/executive") || route == "/operator" {
|
||||
"reports"
|
||||
} else {
|
||||
"portal"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn request_id_and_correlation_id_are_sanitized_and_linked() {
|
||||
let (request_id, correlation_id) = resolve_request_ids(
|
||||
Some(" req-123\nbad ".to_string()),
|
||||
Some("corr-456/ignored".to_string()),
|
||||
);
|
||||
assert_eq!(request_id, "req-123bad");
|
||||
assert_eq!(correlation_id, "corr-456ignored");
|
||||
|
||||
let (request_id, correlation_id) =
|
||||
resolve_request_ids(Some("rid_1".to_string()), Some(" \n ".to_string()));
|
||||
assert_eq!(request_id, "rid_1");
|
||||
assert_eq!(correlation_id, "rid_1");
|
||||
|
||||
let (generated, generated_correlation) = resolve_request_ids(None, None);
|
||||
assert!(generated.starts_with("awatch-"));
|
||||
assert_eq!(generated, generated_correlation);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{Cli, PORTAL_SCHEMA_VERSION};
|
||||
|
||||
pub(crate) fn build_version(args: &Cli) -> Value {
|
||||
json!({
|
||||
"app_version": env!("CARGO_PKG_VERSION"),
|
||||
"git_commit": option_env!("GIT_COMMIT").unwrap_or("unknown"),
|
||||
"build_time": option_env!("BUILD_TIME").unwrap_or("unknown"),
|
||||
"schema_version": PORTAL_SCHEMA_VERSION,
|
||||
"environment": args.environment,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user