Skip to main content

max / makenotwork

5.4 KB · 156 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 chrono::{DateTime, Utc};
8 use serde::Deserialize;
9 use sqlx::PgPool;
10
11 /// Alert domain an agent may report. Closed set, deserialized straight from the
12 /// wire (`#[serde(rename_all = "snake_case")]`), so an unknown value is rejected
13 /// when the request body is parsed, there is no runtime membership check.
14 /// Extending the taxonomy is a variant here plus its `as_str`, not a migration
15 /// (the column is TEXT).
16 ///
17 /// These are domain-level, not per-event: PoM's finer categories (tls expiry vs
18 /// tls error vs tls recovery) all fold onto `Tls`, with the failure/recovery
19 /// distinction carried by [`AlertSeverity`] instead. Kept small so the operator
20 /// log filters by system, not by every sub-condition.
21 #[derive(Debug, Clone, Copy, Deserialize)]
22 #[serde(rename_all = "snake_case")]
23 pub enum AlertKind {
24 Health,
25 Tls,
26 Dns,
27 Whois,
28 Latency,
29 Cors,
30 Backup,
31 Peer,
32 Route,
33 Scan,
34 Monitoring,
35 /// Security signals raised by this server about itself: error-rate spikes,
36 /// auth-failure spikes, rate-limit volume, webhook signature failures, CSP
37 /// violations. One domain rather than one variant per condition, following
38 /// the rule above: the sub-condition rides in `dedup_key` and the urgency in
39 /// [`AlertSeverity`], exactly the way `Tls` already folds three PoM
40 /// categories. See [`crate::security_signals`].
41 Security,
42 /// Deliverability of this platform's own sending: a creator's complaint
43 /// rate crossing the line a mail provider acts on. One domain rather than a
44 /// variant per condition, following the rule above. See
45 /// [`crate::db::mail_caps`].
46 Mail,
47 }
48
49 impl AlertKind {
50 /// Canonical wire/storage token. Matches the `serde(rename_all)` mapping.
51 pub fn as_str(self) -> &'static str {
52 match self {
53 Self::Health => "health",
54 Self::Tls => "tls",
55 Self::Dns => "dns",
56 Self::Whois => "whois",
57 Self::Latency => "latency",
58 Self::Cors => "cors",
59 Self::Backup => "backup",
60 Self::Peer => "peer",
61 Self::Route => "route",
62 Self::Scan => "scan",
63 Self::Monitoring => "monitoring",
64 Self::Security => "security",
65 Self::Mail => "mail",
66 }
67 }
68 }
69
70 /// Alert urgency, most to least. Closed set, deserialized like [`AlertKind`].
71 #[derive(Debug, Clone, Copy, Deserialize)]
72 #[serde(rename_all = "snake_case")]
73 pub enum AlertSeverity {
74 Critical,
75 Warning,
76 Info,
77 }
78
79 impl AlertSeverity {
80 /// Canonical wire/storage token.
81 pub fn as_str(self) -> &'static str {
82 match self {
83 Self::Critical => "critical",
84 Self::Warning => "warning",
85 Self::Info => "info",
86 }
87 }
88 }
89
90 /// A validated inbound alert, ready to persist.
91 pub struct NewAlert<'a> {
92 pub source: &'a str,
93 pub kind: AlertKind,
94 pub severity: AlertSeverity,
95 pub title: &'a str,
96 pub body: &'a str,
97 pub dedup_key: Option<&'a str>,
98 pub details: Option<&'a serde_json::Value>,
99 }
100
101 /// Insert an ingested alert and return its generated id. `emailed` starts false;
102 /// the handler flips it via [`mark_emailed`] after the notification is sent so a
103 /// mail failure stays visible in the log.
104 #[tracing::instrument(skip_all, fields(source = alert.source, kind = alert.kind.as_str(), severity = alert.severity.as_str()))]
105 pub async fn insert_alert(pool: &PgPool, alert: &NewAlert<'_>) -> Result<i64, sqlx::Error> {
106 let id: i64 = sqlx::query_scalar(
107 "INSERT INTO admin_alerts (source, kind, severity, title, body, dedup_key, details)
108 VALUES ($1, $2, $3, $4, $5, $6, $7)
109 RETURNING id",
110 )
111 .bind(alert.source)
112 .bind(alert.kind.as_str())
113 .bind(alert.severity.as_str())
114 .bind(alert.title)
115 .bind(alert.body)
116 .bind(alert.dedup_key)
117 .bind(alert.details)
118 .fetch_one(pool)
119 .await?;
120 Ok(id)
121 }
122
123 /// Mark an alert row as successfully emailed to the operator.
124 #[tracing::instrument(skip_all)]
125 pub async fn mark_emailed(pool: &PgPool, id: i64) -> Result<(), sqlx::Error> {
126 sqlx::query("UPDATE admin_alerts SET emailed = true WHERE id = $1")
127 .bind(id)
128 .execute(pool)
129 .await?;
130 Ok(())
131 }
132
133 /// Whether an alert carrying `dedup_key` has already landed since `since`.
134 ///
135 /// The table deliberately has no unique constraint on `dedup_key` (migration
136 /// 169: deduplication is the sending agent's job), so a caller that fires on a
137 /// repeating condition has to suppress its own repeats. This is the durable way
138 /// to do that: an in-process window, the way [`crate::security_signals`]
139 /// suppresses, forgets everything on restart, which is fine for a five-minute
140 /// counter and not for a condition measured over a month.
141 #[tracing::instrument(skip_all)]
142 pub async fn alerted_since(
143 pool: &PgPool,
144 dedup_key: &str,
145 since: DateTime<Utc>,
146 ) -> Result<bool, sqlx::Error> {
147 let exists = sqlx::query_scalar::<_, bool>(
148 "SELECT EXISTS(SELECT 1 FROM admin_alerts WHERE dedup_key = $1 AND received_at >= $2)",
149 )
150 .bind(dedup_key)
151 .bind(since)
152 .fetch_one(pool)
153 .await?;
154 Ok(exists)
155 }
156