Skip to main content

max / makenotwork

4.2 KB · 125 lines History Blame Raw
1 //! Inbound infra-alert log (see migration `169_admin_alerts.sql`).
2 //!
3 //! Backs the `POST /api/internal/alerts` ingestion endpoint: external
4 //! monitoring agents (PoM, MT) push ops alerts here, each persisted as one row
5 //! and emailed to the operator. Admin-routed only. Never creator-facing.
6
7 use serde::Deserialize;
8 use sqlx::PgPool;
9
10 /// Alert domain an agent may report. Closed set, deserialized straight from the
11 /// wire (`#[serde(rename_all = "snake_case")]`), so an unknown value is rejected
12 /// when the request body is parsed, there is no runtime membership check.
13 /// Extending the taxonomy is a variant here plus its `as_str`, not a migration
14 /// (the column is TEXT).
15 ///
16 /// These are domain-level, not per-event: PoM's finer categories (tls expiry vs
17 /// tls error vs tls recovery) all fold onto `Tls`, with the failure/recovery
18 /// distinction carried by [`AlertSeverity`] instead. Kept small so the operator
19 /// log filters by system, not by every sub-condition.
20 #[derive(Debug, Clone, Copy, Deserialize)]
21 #[serde(rename_all = "snake_case")]
22 pub enum AlertKind {
23 Health,
24 Tls,
25 Dns,
26 Whois,
27 Latency,
28 Cors,
29 Backup,
30 Peer,
31 Route,
32 Scan,
33 Monitoring,
34 /// Security signals raised by this server about itself: error-rate spikes,
35 /// auth-failure spikes, rate-limit volume, webhook signature failures, CSP
36 /// violations. One domain rather than one variant per condition, following
37 /// the rule above: the sub-condition rides in `dedup_key` and the urgency in
38 /// [`AlertSeverity`], exactly the way `Tls` already folds three PoM
39 /// categories. See [`crate::security_signals`].
40 Security,
41 }
42
43 impl AlertKind {
44 /// Canonical wire/storage token. Matches the `serde(rename_all)` mapping.
45 pub fn as_str(self) -> &'static str {
46 match self {
47 Self::Health => "health",
48 Self::Tls => "tls",
49 Self::Dns => "dns",
50 Self::Whois => "whois",
51 Self::Latency => "latency",
52 Self::Cors => "cors",
53 Self::Backup => "backup",
54 Self::Peer => "peer",
55 Self::Route => "route",
56 Self::Scan => "scan",
57 Self::Monitoring => "monitoring",
58 Self::Security => "security",
59 }
60 }
61 }
62
63 /// Alert urgency, most to least. Closed set, deserialized like [`AlertKind`].
64 #[derive(Debug, Clone, Copy, Deserialize)]
65 #[serde(rename_all = "snake_case")]
66 pub enum AlertSeverity {
67 Critical,
68 Warning,
69 Info,
70 }
71
72 impl AlertSeverity {
73 /// Canonical wire/storage token.
74 pub fn as_str(self) -> &'static str {
75 match self {
76 Self::Critical => "critical",
77 Self::Warning => "warning",
78 Self::Info => "info",
79 }
80 }
81 }
82
83 /// A validated inbound alert, ready to persist.
84 pub struct NewAlert<'a> {
85 pub source: &'a str,
86 pub kind: AlertKind,
87 pub severity: AlertSeverity,
88 pub title: &'a str,
89 pub body: &'a str,
90 pub dedup_key: Option<&'a str>,
91 pub details: Option<&'a serde_json::Value>,
92 }
93
94 /// Insert an ingested alert and return its generated id. `emailed` starts false;
95 /// the handler flips it via [`mark_emailed`] after the notification is sent so a
96 /// mail failure stays visible in the log.
97 #[tracing::instrument(skip_all, fields(source = alert.source, kind = alert.kind.as_str(), severity = alert.severity.as_str()))]
98 pub async fn insert_alert(pool: &PgPool, alert: &NewAlert<'_>) -> Result<i64, sqlx::Error> {
99 let id: i64 = sqlx::query_scalar(
100 "INSERT INTO admin_alerts (source, kind, severity, title, body, dedup_key, details)
101 VALUES ($1, $2, $3, $4, $5, $6, $7)
102 RETURNING id",
103 )
104 .bind(alert.source)
105 .bind(alert.kind.as_str())
106 .bind(alert.severity.as_str())
107 .bind(alert.title)
108 .bind(alert.body)
109 .bind(alert.dedup_key)
110 .bind(alert.details)
111 .fetch_one(pool)
112 .await?;
113 Ok(id)
114 }
115
116 /// Mark an alert row as successfully emailed to the operator.
117 #[tracing::instrument(skip_all)]
118 pub async fn mark_emailed(pool: &PgPool, id: i64) -> Result<(), sqlx::Error> {
119 sqlx::query("UPDATE admin_alerts SET emailed = true WHERE id = $1")
120 .bind(id)
121 .execute(pool)
122 .await?;
123 Ok(())
124 }
125