//! Inbound infra-alert ingestion: PoM / MT monitoring agents → MNW. //! //! `POST /api/internal/alerts` accepts an ops alert (health failure, TLS //! expiry, DNS change, ...) from an external monitoring agent, persists it to //! `admin_alerts`, and emails the operator (`ALERT_EMAIL`). Admin-routed only: //! these are operator signals, never creator-facing. Authed by [`AlertsAuth`], a //! bearer token distinct from the CLI internal API's `ServiceAuth`. use axum::{Json, extract::State, response::IntoResponse}; use serde::Deserialize; use sqlx::PgPool; use crate::{ auth::AlertsAuth, db::{ self, admin_alerts::{AlertKind, AlertSeverity}, }, email::EmailClient, error::{AppError, Result}, }; #[derive(Deserialize)] pub(super) struct AlertRequest { /// Reporting agent, e.g. "pom" or "mt". source: String, /// Alert class. Unknown values are rejected at deserialization. kind: AlertKind, /// Alert urgency. Unknown values are rejected at deserialization. severity: AlertSeverity, /// Short human-readable headline. title: String, /// Full alert detail. body: String, /// Optional agent-side collapse key for repeated firings of one condition. #[serde(default)] dedup_key: Option, /// Optional structured context (JSON object), stored verbatim. #[serde(default)] details: Option, } /// Mail an already-persisted alert to the operator and mark the row emailed. /// /// Shared with [`crate::security_signals`], which raises alerts about this /// server from inside this process. That path calls `insert_alert` directly /// rather than posting to the handler above: the endpoint exists for external /// agents, and signalling ourselves over the loopback would be a network hop /// and a bearer-token round trip to reach a function we can call. Both paths /// have to produce the same row and the same mail, so the mail half lives here /// instead of being written twice. /// /// Never returns an error. The row is already durable, and a mail failure is /// logged rather than surfaced: to an ingesting agent a 5xx means retry, which /// would duplicate the row. #[allow(clippy::too_many_arguments)] pub(crate) async fn email_alert( pool: &PgPool, email: &EmailClient, id: i64, source: &str, kind: AlertKind, severity: AlertSeverity, title: &str, body: &str, ) { match std::env::var("ALERT_EMAIL").ok() { Some(to) if !to.is_empty() => { let subject = format!("[{}/{}] {}", source, severity.as_str(), title); let mail_body = format!( "{}\n\nsource: {}\nkind: {}\nseverity: {}\n", body, source, kind.as_str(), severity.as_str() ); match email.send_alert(&to, &subject, &mail_body).await { Ok(()) => { if let Err(e) = db::admin_alerts::mark_emailed(pool, id).await { tracing::error!(alert_id = id, error = ?e, "alert emailed but mark_emailed failed"); } } Err(e) => { tracing::error!(alert_id = id, error = ?e, "failed to email ingested alert"); } } } _ => tracing::warn!( alert_id = id, "ALERT_EMAIL unset; alert logged but not emailed" ), } } fn require_field(name: &str, value: &str, max: usize) -> Result<()> { if value.is_empty() { return Err(AppError::validation(format!("{name} is required"))); } if value.len() > max { return Err(AppError::validation(format!( "{name} must be at most {max} characters" ))); } Ok(()) } /// POST /api/internal/alerts /// /// Persist an inbound alert and email the operator. Returns 202 with the row id. /// A missing `ALERT_EMAIL` is not an error: the alert is still logged (the row's /// `emailed` stays false so the gap is visible), because dropping the durable /// record just because mail is unconfigured would defeat the point. pub(super) async fn ingest_alert( State(pool): State, State(email): State, _auth: AlertsAuth, Json(req): Json, ) -> Result { // `kind` and `severity` are already validated by the type system: the // request would not have deserialized if either were outside its enum. Only // the free-text fields need bounds here. require_field("source", &req.source, 64)?; require_field("title", &req.title, 200)?; require_field("body", &req.body, 5000)?; if let Some(key) = &req.dedup_key && key.len() > 200 { return Err(AppError::validation( "dedup_key must be at most 200 characters".to_string(), )); } let id = db::admin_alerts::insert_alert( &pool, &db::admin_alerts::NewAlert { source: &req.source, kind: req.kind, severity: req.severity, title: &req.title, body: &req.body, dedup_key: req.dedup_key.as_deref(), details: req.details.as_ref(), }, ) .await?; email_alert( &pool, &email, id, &req.source, req.kind, req.severity, &req.title, &req.body, ) .await; Ok(( axum::http::StatusCode::ACCEPTED, Json(serde_json::json!({ "id": id })), )) }