Skip to main content

max / makenotwork

5.4 KB · 164 lines History Blame Raw
1 //! Inbound infra-alert ingestion: PoM / MT monitoring agents → MNW.
2 //!
3 //! `POST /api/internal/alerts` accepts an ops alert (health failure, TLS
4 //! expiry, DNS change, ...) from an external monitoring agent, persists it to
5 //! `admin_alerts`, and emails the operator (`ALERT_EMAIL`). Admin-routed only:
6 //! these are operator signals, never creator-facing. Authed by [`AlertsAuth`], a
7 //! bearer token distinct from the CLI internal API's `ServiceAuth`.
8
9 use axum::{Json, extract::State, response::IntoResponse};
10 use serde::Deserialize;
11 use sqlx::PgPool;
12
13 use crate::{
14 auth::AlertsAuth,
15 db::{
16 self,
17 admin_alerts::{AlertKind, AlertSeverity},
18 },
19 email::EmailClient,
20 error::{AppError, Result},
21 };
22
23 #[derive(Deserialize)]
24 pub(super) struct AlertRequest {
25 /// Reporting agent, e.g. "pom" or "mt".
26 source: String,
27 /// Alert class. Unknown values are rejected at deserialization.
28 kind: AlertKind,
29 /// Alert urgency. Unknown values are rejected at deserialization.
30 severity: AlertSeverity,
31 /// Short human-readable headline.
32 title: String,
33 /// Full alert detail.
34 body: String,
35 /// Optional agent-side collapse key for repeated firings of one condition.
36 #[serde(default)]
37 dedup_key: Option<String>,
38 /// Optional structured context (JSON object), stored verbatim.
39 #[serde(default)]
40 details: Option<serde_json::Value>,
41 }
42
43 /// Mail an already-persisted alert to the operator and mark the row emailed.
44 ///
45 /// Shared with [`crate::security_signals`], which raises alerts about this
46 /// server from inside this process. That path calls `insert_alert` directly
47 /// rather than posting to the handler above: the endpoint exists for external
48 /// agents, and signalling ourselves over the loopback would be a network hop
49 /// and a bearer-token round trip to reach a function we can call. Both paths
50 /// have to produce the same row and the same mail, so the mail half lives here
51 /// instead of being written twice.
52 ///
53 /// Never returns an error. The row is already durable, and a mail failure is
54 /// logged rather than surfaced: to an ingesting agent a 5xx means retry, which
55 /// would duplicate the row.
56 #[allow(clippy::too_many_arguments)]
57 pub(crate) async fn email_alert(
58 pool: &PgPool,
59 email: &EmailClient,
60 id: i64,
61 source: &str,
62 kind: AlertKind,
63 severity: AlertSeverity,
64 title: &str,
65 body: &str,
66 ) {
67 match std::env::var("ALERT_EMAIL").ok() {
68 Some(to) if !to.is_empty() => {
69 let subject = format!("[{}/{}] {}", source, severity.as_str(), title);
70 let mail_body = format!(
71 "{}\n\nsource: {}\nkind: {}\nseverity: {}\n",
72 body,
73 source,
74 kind.as_str(),
75 severity.as_str()
76 );
77 match email.send_alert(&to, &subject, &mail_body).await {
78 Ok(()) => {
79 if let Err(e) = db::admin_alerts::mark_emailed(pool, id).await {
80 tracing::error!(alert_id = id, error = ?e, "alert emailed but mark_emailed failed");
81 }
82 }
83 Err(e) => {
84 tracing::error!(alert_id = id, error = ?e, "failed to email ingested alert");
85 }
86 }
87 }
88 _ => tracing::warn!(
89 alert_id = id,
90 "ALERT_EMAIL unset; alert logged but not emailed"
91 ),
92 }
93 }
94
95 fn require_field(name: &str, value: &str, max: usize) -> Result<()> {
96 if value.is_empty() {
97 return Err(AppError::validation(format!("{name} is required")));
98 }
99 if value.len() > max {
100 return Err(AppError::validation(format!(
101 "{name} must be at most {max} characters"
102 )));
103 }
104 Ok(())
105 }
106
107 /// POST /api/internal/alerts
108 ///
109 /// Persist an inbound alert and email the operator. Returns 202 with the row id.
110 /// A missing `ALERT_EMAIL` is not an error: the alert is still logged (the row's
111 /// `emailed` stays false so the gap is visible), because dropping the durable
112 /// record just because mail is unconfigured would defeat the point.
113 pub(super) async fn ingest_alert(
114 State(pool): State<PgPool>,
115 State(email): State<EmailClient>,
116 _auth: AlertsAuth,
117 Json(req): Json<AlertRequest>,
118 ) -> Result<impl IntoResponse> {
119 // `kind` and `severity` are already validated by the type system: the
120 // request would not have deserialized if either were outside its enum. Only
121 // the free-text fields need bounds here.
122 require_field("source", &req.source, 64)?;
123 require_field("title", &req.title, 200)?;
124 require_field("body", &req.body, 5000)?;
125 if let Some(key) = &req.dedup_key
126 && key.len() > 200
127 {
128 return Err(AppError::validation(
129 "dedup_key must be at most 200 characters".to_string(),
130 ));
131 }
132
133 let id = db::admin_alerts::insert_alert(
134 &pool,
135 &db::admin_alerts::NewAlert {
136 source: &req.source,
137 kind: req.kind,
138 severity: req.severity,
139 title: &req.title,
140 body: &req.body,
141 dedup_key: req.dedup_key.as_deref(),
142 details: req.details.as_ref(),
143 },
144 )
145 .await?;
146
147 email_alert(
148 &pool,
149 &email,
150 id,
151 &req.source,
152 req.kind,
153 req.severity,
154 &req.title,
155 &req.body,
156 )
157 .await;
158
159 Ok((
160 axum::http::StatusCode::ACCEPTED,
161 Json(serde_json::json!({ "id": id })),
162 ))
163 }
164