max / makenotwork
7 files changed,
+310 insertions,
-0 deletions
| @@ -0,0 +1,33 @@ | |||
| 1 | + | -- Inbound infra-alert ingestion (pom / MT / other monitoring agents -> MNW). | |
| 2 | + | -- | |
| 3 | + | -- External monitoring agents (PoM health/TLS/DNS watchers, the Multithreaded | |
| 4 | + | -- forum's own probes) had no way to reach an operator: the health monitor in | |
| 5 | + | -- `monitor.rs` only emails on *this server's* status transitions, and there is | |
| 6 | + | -- no admin notification center. This table is the durable log behind the | |
| 7 | + | -- `POST /api/internal/alerts` ingestion endpoint: each accepted alert is | |
| 8 | + | -- persisted here and also emailed to ALERT_EMAIL. Admin-routed only -- these are | |
| 9 | + | -- ops signals for the operator, never creator-facing. | |
| 10 | + | -- | |
| 11 | + | -- `source` names the reporting agent ("pom", "mt", ...). `kind` is the alert | |
| 12 | + | -- class the agent assigns ("health", "tls_expiry", "dns_change") and `severity` | |
| 13 | + | -- the urgency ("critical"/"warning"/"info"); both are closed sets enforced at | |
| 14 | + | -- the application layer. Stored as TEXT (not an enum type) so extending either | |
| 15 | + | -- set is a code change, not a migration. | |
| 16 | + | -- `dedup_key` lets an agent collapse repeated firings of the same condition | |
| 17 | + | -- (e.g. one row per (source, dedup_key) flap) without the server modeling agent | |
| 18 | + | -- state; it is nullable for one-shot alerts. | |
| 19 | + | CREATE TABLE admin_alerts ( | |
| 20 | + | id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, | |
| 21 | + | source TEXT NOT NULL, | |
| 22 | + | kind TEXT NOT NULL, | |
| 23 | + | severity TEXT NOT NULL, | |
| 24 | + | title TEXT NOT NULL, | |
| 25 | + | body TEXT NOT NULL, | |
| 26 | + | dedup_key TEXT, | |
| 27 | + | details JSONB, | |
| 28 | + | received_at TIMESTAMPTZ NOT NULL DEFAULT now(), | |
| 29 | + | emailed BOOLEAN NOT NULL DEFAULT false | |
| 30 | + | ); | |
| 31 | + | ||
| 32 | + | -- The admin log view is "newest first", so index the sort key. | |
| 33 | + | CREATE INDEX idx_admin_alerts_received ON admin_alerts (received_at DESC); |
| @@ -455,6 +455,43 @@ impl FromRequestParts<crate::AppState> for ServiceAuth { | |||
| 455 | 455 | } | |
| 456 | 456 | } | |
| 457 | 457 | ||
| 458 | + | /// Extractor for inbound infra-alert ingestion (PoM / MT monitoring agents → | |
| 459 | + | /// `POST /api/internal/alerts`). | |
| 460 | + | /// | |
| 461 | + | /// Validates `Authorization: Bearer {token}` against | |
| 462 | + | /// `config.integrations.alerts_ingest_token`. Returns 401 if the token is | |
| 463 | + | /// missing/invalid, 503 if the token is not configured. Deliberately keyed on a | |
| 464 | + | /// token separate from `ServiceAuth`'s `cli_service_token`: these agents run on | |
| 465 | + | /// other hosts (astra, prod), so a leak there must not grant CLI internal-API | |
| 466 | + | /// access. | |
| 467 | + | pub struct AlertsAuth; | |
| 468 | + | ||
| 469 | + | impl FromRequestParts<crate::AppState> for AlertsAuth { | |
| 470 | + | type Rejection = AppError; | |
| 471 | + | ||
| 472 | + | async fn from_request_parts( | |
| 473 | + | parts: &mut Parts, | |
| 474 | + | state: &crate::AppState, | |
| 475 | + | ) -> Result<Self, Self::Rejection> { | |
| 476 | + | let expected = state.config.integrations.alerts_ingest_token.as_deref().ok_or_else(|| { | |
| 477 | + | AppError::ServiceUnavailable("Alert ingestion not configured".to_string()) | |
| 478 | + | })?; | |
| 479 | + | ||
| 480 | + | let header = parts | |
| 481 | + | .headers | |
| 482 | + | .get("authorization") | |
| 483 | + | .and_then(|v| v.to_str().ok()) | |
| 484 | + | .and_then(|v| v.strip_prefix("Bearer ")) | |
| 485 | + | .ok_or(AppError::Unauthorized)?; | |
| 486 | + | ||
| 487 | + | if !constant_time_compare(header, expected) { | |
| 488 | + | return Err(AppError::Unauthorized); | |
| 489 | + | } | |
| 490 | + | ||
| 491 | + | Ok(AlertsAuth) | |
| 492 | + | } | |
| 493 | + | } | |
| 494 | + | ||
| 458 | 495 | /// The SSH-authenticated user identity behind an internal-API request. | |
| 459 | 496 | /// | |
| 460 | 497 | /// Sourced from the `X-MNW-Actor` header, a signed assertion the server mints |
| @@ -133,6 +133,10 @@ pub struct IntegrationsConfig { | |||
| 133 | 133 | pub internal_shared_secret: Option<String>, | |
| 134 | 134 | /// Bearer token authenticating CLI SSH server → MNW internal API calls (>=32 chars). | |
| 135 | 135 | pub cli_service_token: Option<String>, | |
| 136 | + | /// Bearer token authenticating inbound infra alerts (PoM/MT → `POST | |
| 137 | + | /// /api/internal/alerts`) (>=32 chars). Distinct from `cli_service_token` so | |
| 138 | + | /// a leak on a monitoring agent can't reach the CLI internal API. | |
| 139 | + | pub alerts_ingest_token: Option<String>, | |
| 136 | 140 | } | |
| 137 | 141 | ||
| 138 | 142 | /// Upstream OAuth provider config for delegated login (`SSO_*`). | |
| @@ -445,6 +449,19 @@ impl Config { | |||
| 445 | 449 | Err(_) => None, | |
| 446 | 450 | }; | |
| 447 | 451 | ||
| 452 | + | // Inbound infra-alert ingestion token (PoM/MT monitoring agents → | |
| 453 | + | // `POST /api/internal/alerts`). Same 32-char floor as the other service | |
| 454 | + | // secrets; deliberately separate from CLI_SERVICE_TOKEN. | |
| 455 | + | let alerts_ingest_token = match std::env::var("ALERTS_INGEST_TOKEN") { | |
| 456 | + | Ok(secret) => { | |
| 457 | + | if secret.len() < 32 { | |
| 458 | + | return Err(ConfigError::WeakAlertsIngestToken); | |
| 459 | + | } | |
| 460 | + | Some(secret) | |
| 461 | + | } | |
| 462 | + | Err(_) => None, | |
| 463 | + | }; | |
| 464 | + | ||
| 448 | 465 | // WAM ticket manager URL (tailnet, e.g. "http://100.x.x.x:7890") | |
| 449 | 466 | let wam_url = std::env::var("WAM_URL").ok(); | |
| 450 | 467 | ||
| @@ -500,6 +517,7 @@ impl Config { | |||
| 500 | 517 | wam_url, | |
| 501 | 518 | internal_shared_secret, | |
| 502 | 519 | cli_service_token, | |
| 520 | + | alerts_ingest_token, | |
| 503 | 521 | }, | |
| 504 | 522 | }) | |
| 505 | 523 | } | |
| @@ -744,6 +762,7 @@ impl std::fmt::Debug for Config { | |||
| 744 | 762 | .field("postmark_inbound_webhook_token", &self.email_webhooks.inbound_webhook_token.as_ref().map(|_| "[REDACTED]")) | |
| 745 | 763 | .field("internal_shared_secret", &self.integrations.internal_shared_secret.as_ref().map(|_| "[REDACTED]")) | |
| 746 | 764 | .field("cli_service_token", &self.integrations.cli_service_token.as_ref().map(|_| "[REDACTED]")) | |
| 765 | + | .field("alerts_ingest_token", &self.integrations.alerts_ingest_token.as_ref().map(|_| "[REDACTED]")) | |
| 747 | 766 | .field("wam_url", &self.integrations.wam_url) | |
| 748 | 767 | .field("access_gate", &self.access_gate) | |
| 749 | 768 | .field("sso", &self.sso.as_ref().map(|s| &s.provider_url)) | |
| @@ -791,6 +810,8 @@ pub enum ConfigError { | |||
| 791 | 810 | WeakInternalSecret, | |
| 792 | 811 | #[error("CLI_SERVICE_TOKEN must be at least 32 characters long")] | |
| 793 | 812 | WeakCliServiceToken, | |
| 813 | + | #[error("ALERTS_INGEST_TOKEN must be at least 32 characters long")] | |
| 814 | + | WeakAlertsIngestToken, | |
| 794 | 815 | #[error("CDN_BASE_URL is required in production (HOST=0.0.0.0 or HTTPS HOST_URL detected). Without a CDN, cover/download URLs are presigned S3 URLs the storage key-derivation logic does not support. Set CDN_BASE_URL to your CDN origin.")] | |
| 795 | 816 | MissingCdnBaseUrl, | |
| 796 | 817 | #[error("S3_PUBLIC_BUCKET is required in production when storage is configured. The CDN serves ONLY the public bucket; promoted image content (covers, gallery, item/project images) is copied there. Set S3_PUBLIC_BUCKET to the public, world-readable bucket name.")] | |
| @@ -920,6 +941,7 @@ mod tests { | |||
| 920 | 941 | wam_url: None, | |
| 921 | 942 | internal_shared_secret: None, | |
| 922 | 943 | cli_service_token: None, | |
| 944 | + | alerts_ingest_token: None, | |
| 923 | 945 | }, | |
| 924 | 946 | }; | |
| 925 | 947 | let addr = config.socket_addr(); |
| @@ -0,0 +1,95 @@ | |||
| 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 class an agent may report. Closed set, deserialized straight from the | |
| 11 | + | /// wire (`#[serde(rename_all = "snake_case")]` maps `TlsExpiry` → `"tls_expiry"` | |
| 12 | + | /// etc.), so an unknown value is rejected when the request body is parsed — | |
| 13 | + | /// there is no runtime membership check. Extending the taxonomy is a variant | |
| 14 | + | /// here plus its `as_str`, not a migration (the column is TEXT). | |
| 15 | + | #[derive(Debug, Clone, Copy, Deserialize)] | |
| 16 | + | #[serde(rename_all = "snake_case")] | |
| 17 | + | pub enum AlertKind { | |
| 18 | + | Health, | |
| 19 | + | TlsExpiry, | |
| 20 | + | DnsChange, | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | impl AlertKind { | |
| 24 | + | /// Canonical wire/storage token. Matches the `serde(rename_all)` mapping. | |
| 25 | + | pub fn as_str(self) -> &'static str { | |
| 26 | + | match self { | |
| 27 | + | Self::Health => "health", | |
| 28 | + | Self::TlsExpiry => "tls_expiry", | |
| 29 | + | Self::DnsChange => "dns_change", | |
| 30 | + | } | |
| 31 | + | } | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | /// Alert urgency, most to least. Closed set, deserialized like [`AlertKind`]. | |
| 35 | + | #[derive(Debug, Clone, Copy, Deserialize)] | |
| 36 | + | #[serde(rename_all = "snake_case")] | |
| 37 | + | pub enum AlertSeverity { | |
| 38 | + | Critical, | |
| 39 | + | Warning, | |
| 40 | + | Info, | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | impl AlertSeverity { | |
| 44 | + | /// Canonical wire/storage token. | |
| 45 | + | pub fn as_str(self) -> &'static str { | |
| 46 | + | match self { | |
| 47 | + | Self::Critical => "critical", | |
| 48 | + | Self::Warning => "warning", | |
| 49 | + | Self::Info => "info", | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | /// A validated inbound alert, ready to persist. | |
| 55 | + | pub struct NewAlert<'a> { | |
| 56 | + | pub source: &'a str, | |
| 57 | + | pub kind: AlertKind, | |
| 58 | + | pub severity: AlertSeverity, | |
| 59 | + | pub title: &'a str, | |
| 60 | + | pub body: &'a str, | |
| 61 | + | pub dedup_key: Option<&'a str>, | |
| 62 | + | pub details: Option<&'a serde_json::Value>, | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | /// Insert an ingested alert and return its generated id. `emailed` starts false; | |
| 66 | + | /// the handler flips it via [`mark_emailed`] after the notification is sent so a | |
| 67 | + | /// mail failure stays visible in the log. | |
| 68 | + | #[tracing::instrument(skip_all, fields(source = alert.source, kind = alert.kind.as_str(), severity = alert.severity.as_str()))] | |
| 69 | + | pub async fn insert_alert(pool: &PgPool, alert: &NewAlert<'_>) -> Result<i64, sqlx::Error> { | |
| 70 | + | let id: i64 = sqlx::query_scalar( | |
| 71 | + | "INSERT INTO admin_alerts (source, kind, severity, title, body, dedup_key, details) | |
| 72 | + | VALUES ($1, $2, $3, $4, $5, $6, $7) | |
| 73 | + | RETURNING id", | |
| 74 | + | ) | |
| 75 | + | .bind(alert.source) | |
| 76 | + | .bind(alert.kind.as_str()) | |
| 77 | + | .bind(alert.severity.as_str()) | |
| 78 | + | .bind(alert.title) | |
| 79 | + | .bind(alert.body) | |
| 80 | + | .bind(alert.dedup_key) | |
| 81 | + | .bind(alert.details) | |
| 82 | + | .fetch_one(pool) | |
| 83 | + | .await?; | |
| 84 | + | Ok(id) | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | /// Mark an alert row as successfully emailed to the operator. | |
| 88 | + | #[tracing::instrument(skip_all)] | |
| 89 | + | pub async fn mark_emailed(pool: &PgPool, id: i64) -> Result<(), sqlx::Error> { | |
| 90 | + | sqlx::query("UPDATE admin_alerts SET emailed = true WHERE id = $1") | |
| 91 | + | .bind(id) | |
| 92 | + | .execute(pool) | |
| 93 | + | .await?; | |
| 94 | + | Ok(()) | |
| 95 | + | } |
| @@ -37,6 +37,7 @@ pub(crate) mod totp; | |||
| 37 | 37 | pub mod passkeys; // pub so the integration test crate can exercise the layer directly | |
| 38 | 38 | pub(crate) mod health; | |
| 39 | 39 | pub(crate) mod monitor; | |
| 40 | + | pub mod admin_alerts; | |
| 40 | 41 | pub mod scanning; // pub so the integration test crate can exercise the quarantine purge/stamp layer directly | |
| 41 | 42 | pub mod scan_jobs; // pub so the integration test crate can exercise the reaper/heartbeat layer directly | |
| 42 | 43 | pub(crate) mod scan_admin_actions; |
| @@ -0,0 +1,114 @@ | |||
| 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::{extract::State, response::IntoResponse, Json}; | |
| 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 | + | fn require_field(name: &str, value: &str, max: usize) -> Result<()> { | |
| 44 | + | if value.is_empty() { | |
| 45 | + | return Err(AppError::validation(format!("{name} is required"))); | |
| 46 | + | } | |
| 47 | + | if value.len() > max { | |
| 48 | + | return Err(AppError::validation(format!("{name} must be at most {max} characters"))); | |
| 49 | + | } | |
| 50 | + | Ok(()) | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | /// POST /api/internal/alerts | |
| 54 | + | /// | |
| 55 | + | /// Persist an inbound alert and email the operator. Returns 202 with the row id. | |
| 56 | + | /// A missing `ALERT_EMAIL` is not an error: the alert is still logged (the row's | |
| 57 | + | /// `emailed` stays false so the gap is visible), because dropping the durable | |
| 58 | + | /// record just because mail is unconfigured would defeat the point. | |
| 59 | + | pub(super) async fn ingest_alert( | |
| 60 | + | State(pool): State<PgPool>, | |
| 61 | + | State(email): State<EmailClient>, | |
| 62 | + | _auth: AlertsAuth, | |
| 63 | + | Json(req): Json<AlertRequest>, | |
| 64 | + | ) -> Result<impl IntoResponse> { | |
| 65 | + | // `kind` and `severity` are already validated by the type system: the | |
| 66 | + | // request would not have deserialized if either were outside its enum. Only | |
| 67 | + | // the free-text fields need bounds here. | |
| 68 | + | require_field("source", &req.source, 64)?; | |
| 69 | + | require_field("title", &req.title, 200)?; | |
| 70 | + | require_field("body", &req.body, 5000)?; | |
| 71 | + | if let Some(key) = &req.dedup_key | |
| 72 | + | && key.len() > 200 | |
| 73 | + | { | |
| 74 | + | return Err(AppError::validation("dedup_key must be at most 200 characters".to_string())); | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | let id = db::admin_alerts::insert_alert( | |
| 78 | + | &pool, | |
| 79 | + | &db::admin_alerts::NewAlert { | |
| 80 | + | source: &req.source, | |
| 81 | + | kind: req.kind, | |
| 82 | + | severity: req.severity, | |
| 83 | + | title: &req.title, | |
| 84 | + | body: &req.body, | |
| 85 | + | dedup_key: req.dedup_key.as_deref(), | |
| 86 | + | details: req.details.as_ref(), | |
| 87 | + | }, | |
| 88 | + | ) | |
| 89 | + | .await?; | |
| 90 | + | ||
| 91 | + | match std::env::var("ALERT_EMAIL").ok() { | |
| 92 | + | Some(to) if !to.is_empty() => { | |
| 93 | + | let subject = format!("[{}/{}] {}", req.source, req.severity.as_str(), req.title); | |
| 94 | + | let mail_body = format!( | |
| 95 | + | "{}\n\nsource: {}\nkind: {}\nseverity: {}\n", | |
| 96 | + | req.body, req.source, req.kind.as_str(), req.severity.as_str() | |
| 97 | + | ); | |
| 98 | + | match email.send_alert(&to, &subject, &mail_body).await { | |
| 99 | + | Ok(()) => { | |
| 100 | + | if let Err(e) = db::admin_alerts::mark_emailed(&pool, id).await { | |
| 101 | + | tracing::error!(alert_id = id, error = ?e, "alert emailed but mark_emailed failed"); | |
| 102 | + | } | |
| 103 | + | } | |
| 104 | + | // Ingestion succeeded and the row is durable; a mail failure is | |
| 105 | + | // logged, not surfaced as a 5xx to the agent (which would make it | |
| 106 | + | // retry and duplicate the row). | |
| 107 | + | Err(e) => tracing::error!(alert_id = id, error = ?e, "failed to email ingested alert"), | |
| 108 | + | } | |
| 109 | + | } | |
| 110 | + | _ => tracing::warn!(alert_id = id, "ALERT_EMAIL unset; alert logged but not emailed"), | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | Ok((axum::http::StatusCode::ACCEPTED, Json(serde_json::json!({ "id": id })))) | |
| 114 | + | } |
| @@ -3,6 +3,7 @@ | |||
| 3 | 3 | //! These endpoints are protected by `ServiceAuth` (Bearer token) and are | |
| 4 | 4 | //! called by the CLI SSH server running on the same host. | |
| 5 | 5 | ||
| 6 | + | mod alerts; | |
| 6 | 7 | mod cli_features; | |
| 7 | 8 | mod content; | |
| 8 | 9 | mod creators; | |
| @@ -75,4 +76,11 @@ pub(super) fn internal_routes() -> CsrfRouter<AppState> { | |||
| 75 | 76 | // CLI features: custom domains | |
| 76 | 77 | .route("/api/internal/creator/domain", with_csrf_skip(INTERNAL_SKIP, get(cli_features::get_domain).post(cli_features::add_domain).delete(cli_features::remove_domain))) | |
| 77 | 78 | .route("/api/internal/creator/domain/verify", post_csrf_skip(INTERNAL_SKIP, cli_features::verify_domain)) | |
| 79 | + | // Inbound infra alerts (PoM/MT). Bearer-authed via `AlertsAuth` on a | |
| 80 | + | // dedicated token, not `ServiceAuth`; no session, so CSRF is skipped. | |
| 81 | + | .route("/api/internal/alerts", post_csrf_skip(ALERTS_SKIP, alerts::ingest_alert)) | |
| 78 | 82 | } | |
| 83 | + | ||
| 84 | + | /// The alerts ingestion route is bearer-authed via `AlertsAuth` (dedicated | |
| 85 | + | /// token, no session); CSRF is not applicable. | |
| 86 | + | const ALERTS_SKIP: &str = "internal alerts: AlertsAuth bearer, no session"; |