Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fed7cc2eaa | ||
|
|
cd61a530d6 | ||
|
|
58535fa5c6 | ||
|
|
7f1abdb9a7 | ||
|
|
5ca325034f | ||
|
|
5312da175d |
@@ -0,0 +1,127 @@
|
||||
//! HTTP response helpers for the portal.
|
||||
//!
|
||||
//! CONTRACT: this module owns response serialization, headers, request-id /
|
||||
//! correlation-id propagation and response metrics logging. It must not change
|
||||
//! routes, payload schemas, MIME types or UI contents.
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use serde::Serialize;
|
||||
use tiny_http::{Header, Request, Response, StatusCode};
|
||||
|
||||
use crate::production::{http_request_metadata, log_http_request, record_http_metric};
|
||||
use crate::screenshot_basename;
|
||||
|
||||
pub(crate) fn respond_json<T: Serialize>(request: Request, value: &T) -> Result<()> {
|
||||
let body = serde_json::to_string_pretty(value)?;
|
||||
respond_text(
|
||||
request,
|
||||
StatusCode(200),
|
||||
&body,
|
||||
"application/json; charset=utf-8",
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn respond_json_status<T: Serialize>(
|
||||
request: Request,
|
||||
status: StatusCode,
|
||||
value: &T,
|
||||
) -> Result<()> {
|
||||
let body = serde_json::to_string_pretty(value)?;
|
||||
respond_text(request, status, &body, "application/json; charset=utf-8")
|
||||
}
|
||||
|
||||
pub(crate) fn respond_text(
|
||||
request: Request,
|
||||
status: StatusCode,
|
||||
body: &str,
|
||||
content_type: &str,
|
||||
) -> Result<()> {
|
||||
let metadata = http_request_metadata(&request);
|
||||
record_http_metric(&metadata, status);
|
||||
log_http_request(&metadata, status, body.len());
|
||||
let response = Response::from_string(body.to_string())
|
||||
.with_status_code(status)
|
||||
.with_header(header("Content-Type", content_type)?)
|
||||
.with_header(header("Cache-Control", "no-store")?)
|
||||
.with_header(header("X-Request-Id", &metadata.request_id)?)
|
||||
.with_header(header("X-Correlation-Id", &metadata.correlation_id)?);
|
||||
request.respond(response).map_err(|err| anyhow!("{err}"))
|
||||
}
|
||||
|
||||
pub(crate) fn respond_text_download(
|
||||
request: Request,
|
||||
status: StatusCode,
|
||||
body: &str,
|
||||
content_type: &str,
|
||||
download_name: &str,
|
||||
) -> Result<()> {
|
||||
let metadata = http_request_metadata(&request);
|
||||
record_http_metric(&metadata, status);
|
||||
log_http_request(&metadata, status, body.len());
|
||||
let response = Response::from_string(body.to_string())
|
||||
.with_status_code(status)
|
||||
.with_header(header("Content-Type", content_type)?)
|
||||
.with_header(header("Cache-Control", "no-store")?)
|
||||
.with_header(header("X-Request-Id", &metadata.request_id)?)
|
||||
.with_header(header("X-Correlation-Id", &metadata.correlation_id)?)
|
||||
.with_header(header(
|
||||
"Content-Disposition",
|
||||
&format!(
|
||||
"attachment; filename=\"{}\"",
|
||||
download_name.replace('"', "")
|
||||
),
|
||||
)?);
|
||||
request.respond(response).map_err(|err| anyhow!("{err}"))
|
||||
}
|
||||
|
||||
pub(crate) fn respond_file(
|
||||
request: Request,
|
||||
path: &Path,
|
||||
content_type: &str,
|
||||
download_name: Option<&str>,
|
||||
) -> Result<()> {
|
||||
let data = fs::read(path).with_context(|| format!("read {}", path.display()))?;
|
||||
let metadata = http_request_metadata(&request);
|
||||
record_http_metric(&metadata, StatusCode(200));
|
||||
log_http_request(&metadata, StatusCode(200), data.len());
|
||||
let mut response = Response::from_data(data)
|
||||
.with_status_code(StatusCode(200))
|
||||
.with_header(header("Content-Type", content_type)?)
|
||||
.with_header(header("Cache-Control", "no-store")?)
|
||||
.with_header(header("X-Request-Id", &metadata.request_id)?)
|
||||
.with_header(header("X-Correlation-Id", &metadata.correlation_id)?);
|
||||
if let Some(name) = download_name.and_then(screenshot_basename) {
|
||||
response = response.with_header(header(
|
||||
"Content-Disposition",
|
||||
&format!("attachment; filename=\"{}\"", name.replace('"', "")),
|
||||
)?);
|
||||
}
|
||||
request.respond(response).map_err(|err| anyhow!("{err}"))
|
||||
}
|
||||
|
||||
pub(crate) fn safe_download_stem(value: &str) -> String {
|
||||
let stem = value
|
||||
.chars()
|
||||
.map(|ch| {
|
||||
if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
|
||||
ch
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.take(96)
|
||||
.collect::<String>();
|
||||
if stem.is_empty() {
|
||||
"candidate".to_string()
|
||||
} else {
|
||||
stem
|
||||
}
|
||||
}
|
||||
|
||||
fn header(name: &str, value: &str) -> Result<Header> {
|
||||
Header::from_bytes(name.as_bytes(), value.as_bytes())
|
||||
.map_err(|_| anyhow!("invalid header {name}: {value}"))
|
||||
}
|
||||
@@ -3,7 +3,6 @@ use std::fs::{self, File, OpenOptions};
|
||||
use std::io::{Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
@@ -21,49 +20,54 @@ use rusqlite::{Connection, OptionalExtension, params};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use sha2::{Digest, Sha256};
|
||||
use tiny_http::{Header, Method, Request, Response, Server, StatusCode};
|
||||
use tiny_http::{Method, Request, Server, StatusCode};
|
||||
|
||||
mod api_contracts;
|
||||
mod command_runner;
|
||||
mod executive_actions;
|
||||
mod http_response;
|
||||
mod path_query;
|
||||
mod portal_roles;
|
||||
mod production;
|
||||
mod readiness_api;
|
||||
mod risk_narrative;
|
||||
mod role_access;
|
||||
mod snapshot_cache;
|
||||
mod static_assets;
|
||||
mod workforce_kpi_explain;
|
||||
|
||||
use api_contracts::api_contract_summary;
|
||||
use executive_actions::{
|
||||
actions_from_center, build_action_center_from_report, filter_actions_for_role,
|
||||
};
|
||||
pub(crate) use http_response::{
|
||||
respond_file, respond_json, respond_json_status, respond_text, respond_text_download,
|
||||
safe_download_stem,
|
||||
};
|
||||
use path_query::{
|
||||
normalize_path, parse_case_path, parse_case_status_path, parse_investigation_pack_path,
|
||||
query_flag, query_param,
|
||||
};
|
||||
use portal_roles::PortalRole;
|
||||
use production::{
|
||||
build_healthz, build_readyz, build_version, http_request_metadata, is_limited_api_route,
|
||||
log_http_request, mark_request_started, record_http_metric, record_ingestion_accepted,
|
||||
record_ingestion_rejected, record_report_generated, render_prometheus_metrics,
|
||||
validate_api_query_limits, validate_portal_config,
|
||||
build_healthz, build_readyz, build_version, is_limited_api_route, mark_request_started,
|
||||
record_ingestion_accepted, record_ingestion_rejected, record_report_generated,
|
||||
render_prometheus_metrics, validate_api_query_limits, validate_portal_config,
|
||||
};
|
||||
use readiness_api::{readiness_bundle, readiness_latest, readiness_verify};
|
||||
use risk_narrative::{
|
||||
RiskNarrativeInputs, RiskNarrativeQuery, build_risk_narrative, build_risk_narrative_from_report,
|
||||
};
|
||||
use role_access::{portal_role_from_request, respond_forbidden, role_envelope};
|
||||
use snapshot_cache::{
|
||||
SnapshotCache, build_fast_health, cached_snapshot, clone_snapshot_cache, new_snapshot_cache,
|
||||
};
|
||||
use static_assets::{
|
||||
API_CONTRACT_OPENAPI, API_CONTRACT_TYPESCRIPT, APP_CSS, APP_JS, ARCHITECTURE_HTML, INDEX_HTML,
|
||||
};
|
||||
use workforce_kpi_explain::{KpiExplainQuery, build_workforce_kpi_explain};
|
||||
|
||||
const INDEX_HTML: &str = include_str!("static/index.html");
|
||||
const ARCHITECTURE_HTML: &str = include_str!("static/architecture.html");
|
||||
const APP_CSS: &str = include_str!("static/app.css");
|
||||
const APP_JS: &str = include_str!("static/app.js");
|
||||
const API_CONTRACT_OPENAPI: &str = include_str!("contracts/openapi.json");
|
||||
const API_CONTRACT_TYPESCRIPT: &str = include_str!("contracts/typescript.d.ts");
|
||||
const UEBA_BASELINE_MIN_SAMPLES: usize = 3;
|
||||
const SNAPSHOT_CACHE_TTL: Duration = Duration::from_secs(120);
|
||||
const DEFAULT_DEPARTMENT_LABEL: &str = "Не привязано к подразделению";
|
||||
const LEGACY_UNASSIGNED_DEPARTMENT_LABEL: &str = "Без подразделения";
|
||||
const PORTAL_SCHEMA_VERSION: &str = "pilot-v1";
|
||||
@@ -87,14 +91,6 @@ unsafe extern "C" {
|
||||
fn kill(pid: i32, sig: i32) -> i32;
|
||||
}
|
||||
|
||||
type SnapshotCache = Arc<Mutex<Option<CachedSnapshot>>>;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct CachedSnapshot {
|
||||
created: Instant,
|
||||
snapshot: Snapshot,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Parser)]
|
||||
#[command(about = "Read-only AWatch-rus operator/manager/owner web portal")]
|
||||
struct Cli {
|
||||
@@ -1327,11 +1323,11 @@ fn run() -> Result<i32> {
|
||||
}
|
||||
|
||||
let server = Server::http(&args.bind).map_err(|err| anyhow!("bind {}: {err}", args.bind))?;
|
||||
let snapshot_cache: SnapshotCache = Arc::new(Mutex::new(None));
|
||||
let snapshot_cache: SnapshotCache = new_snapshot_cache();
|
||||
eprintln!("detmir-portal listening on http://{}", args.bind);
|
||||
for request in server.incoming_requests() {
|
||||
let args = args.clone();
|
||||
let snapshot_cache = Arc::clone(&snapshot_cache);
|
||||
let snapshot_cache = clone_snapshot_cache(&snapshot_cache);
|
||||
thread::spawn(move || {
|
||||
let result = if args.evidence_only {
|
||||
handle_evidence_only_request(request, &args)
|
||||
@@ -1665,42 +1661,6 @@ fn handle_evidence_only_request(request: Request, args: &Cli) -> Result<()> {
|
||||
)
|
||||
}
|
||||
|
||||
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 snapshot = build_snapshot(args);
|
||||
*guard = Some(CachedSnapshot {
|
||||
created: Instant::now(),
|
||||
snapshot: snapshot.clone(),
|
||||
});
|
||||
snapshot
|
||||
}
|
||||
|
||||
fn build_fast_health(cache: &SnapshotCache) -> HealthResponse {
|
||||
match cache.try_lock() {
|
||||
Ok(guard) => guard
|
||||
.as_ref()
|
||||
.map(|cached| build_health(&cached.snapshot))
|
||||
.unwrap_or_else(lightweight_health),
|
||||
Err(_) => lightweight_health(),
|
||||
}
|
||||
}
|
||||
|
||||
fn lightweight_health() -> HealthResponse {
|
||||
let mut sources = BTreeMap::new();
|
||||
sources.insert("portal".to_string(), true);
|
||||
HealthResponse {
|
||||
ok: true,
|
||||
generated_at_utc: now(),
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
sources,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_snapshot(args: &Cli) -> Snapshot {
|
||||
let timeout = Duration::from_secs(args.timeout_seconds);
|
||||
let security_events_config = SecurityEventsConfig {
|
||||
@@ -9888,7 +9848,7 @@ fn json_i64(value: &Value, names: &[&str]) -> Option<i64> {
|
||||
.find_map(|name| value.get(*name).and_then(Value::as_i64))
|
||||
}
|
||||
|
||||
fn screenshot_basename(path: &str) -> Option<String> {
|
||||
pub(crate) fn screenshot_basename(path: &str) -> Option<String> {
|
||||
if path.split(['/', '\\']).any(|part| part == "..") {
|
||||
return None;
|
||||
}
|
||||
@@ -10469,118 +10429,6 @@ fn source_summary(name: &str, payload: &Value) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn respond_json<T: Serialize>(request: Request, value: &T) -> Result<()> {
|
||||
let body = serde_json::to_string_pretty(value)?;
|
||||
respond_text(
|
||||
request,
|
||||
StatusCode(200),
|
||||
&body,
|
||||
"application/json; charset=utf-8",
|
||||
)
|
||||
}
|
||||
|
||||
fn respond_json_status<T: Serialize>(
|
||||
request: Request,
|
||||
status: StatusCode,
|
||||
value: &T,
|
||||
) -> Result<()> {
|
||||
let body = serde_json::to_string_pretty(value)?;
|
||||
respond_text(request, status, &body, "application/json; charset=utf-8")
|
||||
}
|
||||
|
||||
fn respond_text(
|
||||
request: Request,
|
||||
status: StatusCode,
|
||||
body: &str,
|
||||
content_type: &str,
|
||||
) -> Result<()> {
|
||||
let metadata = http_request_metadata(&request);
|
||||
record_http_metric(&metadata, status);
|
||||
log_http_request(&metadata, status, body.len());
|
||||
let response = Response::from_string(body.to_string())
|
||||
.with_status_code(status)
|
||||
.with_header(header("Content-Type", content_type)?)
|
||||
.with_header(header("Cache-Control", "no-store")?)
|
||||
.with_header(header("X-Request-Id", &metadata.request_id)?)
|
||||
.with_header(header("X-Correlation-Id", &metadata.correlation_id)?);
|
||||
request.respond(response).map_err(|err| anyhow!("{err}"))
|
||||
}
|
||||
|
||||
fn respond_text_download(
|
||||
request: Request,
|
||||
status: StatusCode,
|
||||
body: &str,
|
||||
content_type: &str,
|
||||
download_name: &str,
|
||||
) -> Result<()> {
|
||||
let metadata = http_request_metadata(&request);
|
||||
record_http_metric(&metadata, status);
|
||||
log_http_request(&metadata, status, body.len());
|
||||
let response = Response::from_string(body.to_string())
|
||||
.with_status_code(status)
|
||||
.with_header(header("Content-Type", content_type)?)
|
||||
.with_header(header("Cache-Control", "no-store")?)
|
||||
.with_header(header("X-Request-Id", &metadata.request_id)?)
|
||||
.with_header(header("X-Correlation-Id", &metadata.correlation_id)?)
|
||||
.with_header(header(
|
||||
"Content-Disposition",
|
||||
&format!(
|
||||
"attachment; filename=\"{}\"",
|
||||
download_name.replace('"', "")
|
||||
),
|
||||
)?);
|
||||
request.respond(response).map_err(|err| anyhow!("{err}"))
|
||||
}
|
||||
|
||||
fn respond_file(
|
||||
request: Request,
|
||||
path: &Path,
|
||||
content_type: &str,
|
||||
download_name: Option<&str>,
|
||||
) -> Result<()> {
|
||||
let data = fs::read(path).with_context(|| format!("read {}", path.display()))?;
|
||||
let metadata = http_request_metadata(&request);
|
||||
record_http_metric(&metadata, StatusCode(200));
|
||||
log_http_request(&metadata, StatusCode(200), data.len());
|
||||
let mut response = Response::from_data(data)
|
||||
.with_status_code(StatusCode(200))
|
||||
.with_header(header("Content-Type", content_type)?)
|
||||
.with_header(header("Cache-Control", "no-store")?)
|
||||
.with_header(header("X-Request-Id", &metadata.request_id)?)
|
||||
.with_header(header("X-Correlation-Id", &metadata.correlation_id)?);
|
||||
if let Some(name) = download_name.and_then(screenshot_basename) {
|
||||
response = response.with_header(header(
|
||||
"Content-Disposition",
|
||||
&format!("attachment; filename=\"{}\"", name.replace('"', "")),
|
||||
)?);
|
||||
}
|
||||
request.respond(response).map_err(|err| anyhow!("{err}"))
|
||||
}
|
||||
|
||||
fn safe_download_stem(value: &str) -> String {
|
||||
let stem = value
|
||||
.chars()
|
||||
.map(|ch| {
|
||||
if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
|
||||
ch
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.take(96)
|
||||
.collect::<String>();
|
||||
if stem.is_empty() {
|
||||
"candidate".to_string()
|
||||
} else {
|
||||
stem
|
||||
}
|
||||
}
|
||||
|
||||
fn header(name: &str, value: &str) -> Result<Header> {
|
||||
Header::from_bytes(name.as_bytes(), value.as_bytes())
|
||||
.map_err(|_| anyhow!("invalid header {name}: {value}"))
|
||||
}
|
||||
|
||||
pub(crate) fn now() -> String {
|
||||
Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
//! Snapshot cache helpers for the portal request path.
|
||||
//!
|
||||
//! CONTRACT: this module only owns short-lived in-process cache behavior.
|
||||
//! It must not change snapshot payloads, source collection, API routes or
|
||||
//! business calculations.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
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>>>;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct CachedSnapshot {
|
||||
created: Instant,
|
||||
snapshot: Snapshot,
|
||||
}
|
||||
|
||||
pub(crate) fn new_snapshot_cache() -> SnapshotCache {
|
||||
Arc::new(Mutex::new(None))
|
||||
}
|
||||
|
||||
pub(crate) fn clone_snapshot_cache(cache: &SnapshotCache) -> SnapshotCache {
|
||||
Arc::clone(cache)
|
||||
}
|
||||
|
||||
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 snapshot = build_snapshot(args);
|
||||
*guard = Some(CachedSnapshot {
|
||||
created: Instant::now(),
|
||||
snapshot: snapshot.clone(),
|
||||
});
|
||||
snapshot
|
||||
}
|
||||
|
||||
pub(crate) fn build_fast_health(cache: &SnapshotCache) -> HealthResponse {
|
||||
match cache.try_lock() {
|
||||
Ok(guard) => guard
|
||||
.as_ref()
|
||||
.map(|cached| build_health(&cached.snapshot))
|
||||
.unwrap_or_else(lightweight_health),
|
||||
Err(_) => lightweight_health(),
|
||||
}
|
||||
}
|
||||
|
||||
fn lightweight_health() -> HealthResponse {
|
||||
let mut sources = BTreeMap::new();
|
||||
sources.insert("portal".to_string(), true);
|
||||
HealthResponse {
|
||||
ok: true,
|
||||
generated_at_utc: now(),
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
sources,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
//! Static portal assets and generated API contract text.
|
||||
//!
|
||||
//! CONTRACT: this module only exposes embedded static files. Do not change
|
||||
//! file contents, MIME handling, routes or API contracts from here.
|
||||
|
||||
pub(crate) const INDEX_HTML: &str = include_str!("static/index.html");
|
||||
pub(crate) const ARCHITECTURE_HTML: &str = include_str!("static/architecture.html");
|
||||
pub(crate) const APP_CSS: &str = include_str!("static/app.css");
|
||||
pub(crate) const APP_JS: &str = include_str!("static/app.js");
|
||||
pub(crate) const API_CONTRACT_OPENAPI: &str = include_str!("contracts/openapi.json");
|
||||
pub(crate) const API_CONTRACT_TYPESCRIPT: &str = include_str!("contracts/typescript.d.ts");
|
||||
Reference in New Issue
Block a user