max / makenotwork
8 files changed,
+104 insertions,
-30 deletions
| @@ -155,11 +155,17 @@ fn within_cooldown_secs(sent_at: &str, cooldown_secs: u64) -> bool { | |||
| 155 | 155 | } | |
| 156 | 156 | ||
| 157 | 157 | impl Alerter { | |
| 158 | - | pub fn new(config: AlertConfig, pool: SqlitePool, instance_name: String) -> Self { | |
| 158 | + | pub fn new( | |
| 159 | + | config: AlertConfig, | |
| 160 | + | pool: SqlitePool, | |
| 161 | + | instance_name: String, | |
| 162 | + | ) -> Result<Self, reqwest::Error> { | |
| 163 | + | // reqwest 0.13's builder can fail (platform trust-store load) and | |
| 164 | + | // `Client::new()`/`unwrap_or_default()` panic on that same failure. Return | |
| 165 | + | // the error so the caller can disable alerting rather than crash at startup. | |
| 159 | 166 | let client = reqwest::Client::builder() | |
| 160 | 167 | .timeout(std::time::Duration::from_secs(10)) | |
| 161 | - | .build() | |
| 162 | - | .unwrap_or_default(); | |
| 168 | + | .build()?; | |
| 163 | 169 | let wam_url = config.wam_url.clone(); | |
| 164 | 170 | if wam_url.is_none() { | |
| 165 | 171 | warn!( | |
| @@ -177,13 +183,13 @@ impl Alerter { | |||
| 177 | 183 | ), | |
| 178 | 184 | _ => {} | |
| 179 | 185 | } | |
| 180 | - | Self { | |
| 186 | + | Ok(Self { | |
| 181 | 187 | config, | |
| 182 | 188 | client, | |
| 183 | 189 | pool, | |
| 184 | 190 | instance_name, | |
| 185 | 191 | wam_url, | |
| 186 | - | } | |
| 192 | + | }) | |
| 187 | 193 | } | |
| 188 | 194 | ||
| 189 | 195 | /// Push one alert to MNW's operator log (`POST /api/internal/alerts`). | |
| @@ -715,7 +721,7 @@ mod tests { | |||
| 715 | 721 | mnw_url: None, | |
| 716 | 722 | alerts_ingest_token: None, | |
| 717 | 723 | }; | |
| 718 | - | Alerter::new(config, pool, "test-instance".to_string()) | |
| 724 | + | Alerter::new(config, pool, "test-instance".to_string()).unwrap() | |
| 719 | 725 | } | |
| 720 | 726 | ||
| 721 | 727 | #[tokio::test] |
| @@ -9,18 +9,34 @@ use crate::types::CorsCheckResult; | |||
| 9 | 9 | /// Returns one `CorsCheckResult` per `CorsCheck` in the input. | |
| 10 | 10 | #[instrument(skip_all)] | |
| 11 | 11 | pub async fn check_cors(target: &str, checks: &[CorsCheck]) -> Vec<CorsCheckResult> { | |
| 12 | - | // Never `.unwrap()` the client build: a build failure would panic this | |
| 13 | - | // per-target CORS task and silently kill CORS monitoring for the target with | |
| 14 | - | // no alert that the check died (fuzz-2026-07-06 HIGH). Degrade gracefully to | |
| 15 | - | // a default client, mirroring the health probe. | |
| 16 | - | let client = reqwest::Client::builder() | |
| 12 | + | // Never `.unwrap()` (or fall back to `Client::new()`) on the client build: a | |
| 13 | + | // build failure would panic this per-target CORS task and silently kill CORS | |
| 14 | + | // monitoring for the target with no alert that the check died (fuzz-2026-07-06 | |
| 15 | + | // HIGH). `Client::new()` panics on the same failure, so surface it as a failed | |
| 16 | + | // result per check instead. | |
| 17 | + | let client = match reqwest::Client::builder() | |
| 17 | 18 | .timeout(std::time::Duration::from_secs(10)) | |
| 18 | 19 | .redirect(reqwest::redirect::Policy::none()) | |
| 19 | 20 | .build() | |
| 20 | - | .unwrap_or_else(|e| { | |
| 21 | - | tracing::warn!(error = %e, "cors: client build failed, using default client"); | |
| 22 | - | reqwest::Client::new() | |
| 23 | - | }); | |
| 21 | + | { | |
| 22 | + | Ok(c) => c, | |
| 23 | + | Err(e) => { | |
| 24 | + | tracing::warn!(error = %e, "cors: client build failed"); | |
| 25 | + | let now = chrono::Utc::now().to_rfc3339(); | |
| 26 | + | return checks | |
| 27 | + | .iter() | |
| 28 | + | .map(|check| CorsCheckResult { | |
| 29 | + | target: target.to_string(), | |
| 30 | + | url: check.url.clone(), | |
| 31 | + | origin: check.origin.clone(), | |
| 32 | + | method: check.method.clone(), | |
| 33 | + | passes: false, | |
| 34 | + | checked_at: now.clone(), | |
| 35 | + | error: Some(format!("client build: {e}")), | |
| 36 | + | }) | |
| 37 | + | .collect(); | |
| 38 | + | } | |
| 39 | + | }; | |
| 24 | 40 | ||
| 25 | 41 | let mut results = Vec::with_capacity(checks.len()); | |
| 26 | 42 | for check in checks { |
| @@ -17,13 +17,29 @@ pub async fn check_health( | |||
| 17 | 17 | config: &HealthConfig, | |
| 18 | 18 | expect: Option<&HealthExpectation>, | |
| 19 | 19 | ) -> HealthSnapshot { | |
| 20 | - | let client = reqwest::Client::builder() | |
| 20 | + | let checked_at = chrono::Utc::now().to_rfc3339(); | |
| 21 | + | // reqwest 0.13's builder can genuinely fail (platform trust-store load), and | |
| 22 | + | // `Client::new()` panics on that same failure, so return an Unreachable result | |
| 23 | + | // instead of a fallback client that discards the timeout or panics. | |
| 24 | + | let client = match reqwest::Client::builder() | |
| 21 | 25 | .timeout(std::time::Duration::from_secs(config.timeout_secs)) | |
| 22 | 26 | .build() | |
| 23 | - | .unwrap_or_else(|_| reqwest::Client::new()); | |
| 27 | + | { | |
| 28 | + | Ok(c) => c, | |
| 29 | + | Err(e) => { | |
| 30 | + | return HealthSnapshot { | |
| 31 | + | id: None, | |
| 32 | + | target: target_name.to_string(), | |
| 33 | + | status: HealthStatus::Unreachable, | |
| 34 | + | checked_at, | |
| 35 | + | response_time_ms: 0, | |
| 36 | + | details: None, | |
| 37 | + | error: Some(format!("client build: {e}")), | |
| 38 | + | }; | |
| 39 | + | } | |
| 40 | + | }; | |
| 24 | 41 | ||
| 25 | 42 | let start = Instant::now(); | |
| 26 | - | let checked_at = chrono::Utc::now().to_rfc3339(); | |
| 27 | 43 | ||
| 28 | 44 | match client.get(&config.url).send().await { | |
| 29 | 45 | Ok(response) => { |
| @@ -32,10 +32,19 @@ pub(crate) async fn cmd_serve(pool: &sqlx::SqlitePool, config: &Config) -> Resul | |||
| 32 | 32 | }; | |
| 33 | 33 | ||
| 34 | 34 | // Alerter | |
| 35 | - | let alerter = config.alerts.as_ref().map(|alert_config| { | |
| 36 | - | info!("Alerts enabled (to: {})", alert_config.to); | |
| 37 | - | Alerter::new(alert_config.clone(), pool.clone(), instance_name.clone()) | |
| 38 | - | }); | |
| 35 | + | let alerter = match config.alerts.as_ref() { | |
| 36 | + | Some(alert_config) => { | |
| 37 | + | info!("Alerts enabled (to: {})", alert_config.to); | |
| 38 | + | match Alerter::new(alert_config.clone(), pool.clone(), instance_name.clone()) { | |
| 39 | + | Ok(a) => Some(a), | |
| 40 | + | Err(e) => { | |
| 41 | + | tracing::error!("alerts: client build failed, alerting disabled: {e}"); | |
| 42 | + | None | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | None => None, | |
| 47 | + | }; | |
| 39 | 48 | ||
| 40 | 49 | info!("Instance: {instance_name} (id={instance_id})"); | |
| 41 | 50 | info!("Starting serve mode (default interval: {default_interval}s, prune: {prune_days}d)"); |
| @@ -48,10 +48,19 @@ pub(crate) fn spawn_route_tasks( | |||
| 48 | 48 | } | |
| 49 | 49 | ||
| 50 | 50 | let mut ticks = CheckInterval::new(route_interval, cancel); | |
| 51 | - | let client = reqwest::Client::builder() | |
| 51 | + | // reqwest 0.13's builder can fail and `unwrap_or_default()` panics on | |
| 52 | + | // the same failure, silently killing this spawned route-check task. Log | |
| 53 | + | // and exit visibly instead. | |
| 54 | + | let client = match reqwest::Client::builder() | |
| 52 | 55 | .redirect(reqwest::redirect::Policy::none()) | |
| 53 | 56 | .build() | |
| 54 | - | .unwrap_or_default(); | |
| 57 | + | { | |
| 58 | + | Ok(c) => c, | |
| 59 | + | Err(e) => { | |
| 60 | + | tracing::error!("{name}: route-check client build failed: {e}"); | |
| 61 | + | return; | |
| 62 | + | } | |
| 63 | + | }; | |
| 55 | 64 | let mut prev_failed: std::collections::HashSet<String> = | |
| 56 | 65 | std::collections::HashSet::new(); | |
| 57 | 66 |
| @@ -85,9 +85,18 @@ enum Commands { | |||
| 85 | 85 | ||
| 86 | 86 | #[tokio::main] | |
| 87 | 87 | async fn main() -> Result<()> { | |
| 88 | - | // Install the default rustls crypto provider before any TLS operations. | |
| 89 | - | // reqwest's rustls backend pulls aws-lc-rs, so standardize on it here; with more | |
| 90 | - | // than one provider reachable in the tree rustls can't auto-select one. | |
| 88 | + | // Install the default rustls crypto provider before any TLS operations. This | |
| 89 | + | // provider verifies signatures for every certificate pom validates (reqwest 0.13 | |
| 90 | + | // reads the process default via CryptoProvider::get_default). | |
| 91 | + | // | |
| 92 | + | // reqwest 0.13's `rustls` feature pulls aws-lc-rs and is the only provider | |
| 93 | + | // feature enabled (`ring` enters only as an rcgen dev-dependency), so this | |
| 94 | + | // install_default() is belt-and-braces rather than load-bearing for | |
| 95 | + | // auto-selection. WATCHED PIN: aws-lc-sys 0.38.0 carries RUSTSEC-2026-0044 | |
| 96 | + | // (Name Constraints bypass) and RUSTSEC-2026-0048 (CRL scope check), both X.509 | |
| 97 | + | // validation defects on this path. The upgrade is upstream-blocked and reqwest | |
| 98 | + | // pulls aws-lc-rs regardless, so reverting the provider alone would leave two | |
| 99 | + | // providers reachable. Re-check when reqwest/rustls-webpki advance the pin. | |
| 91 | 100 | let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default(); | |
| 92 | 101 | ||
| 93 | 102 | let cli = Cli::parse(); |
| @@ -236,10 +236,19 @@ async fn heartbeat_loop( | |||
| 236 | 236 | }; | |
| 237 | 237 | let base = peer_base_url(&address); | |
| 238 | 238 | ||
| 239 | - | let client = reqwest::Client::builder() | |
| 239 | + | let client = match reqwest::Client::builder() | |
| 240 | 240 | .timeout(std::time::Duration::from_secs(10)) | |
| 241 | 241 | .build() | |
| 242 | - | .unwrap_or_default(); | |
| 242 | + | { | |
| 243 | + | Ok(c) => c, | |
| 244 | + | Err(e) => { | |
| 245 | + | // reqwest 0.13's builder can fail and `unwrap_or_default()` panics on | |
| 246 | + | // the same failure, silently killing this spawned poll task. Log and | |
| 247 | + | // exit visibly instead. | |
| 248 | + | tracing::error!(peer = %peer_name, error = %e, "peer: client build failed, poll task exiting"); | |
| 249 | + | return; | |
| 250 | + | } | |
| 251 | + | }; | |
| 243 | 252 | ||
| 244 | 253 | loop { | |
| 245 | 254 | tokio::select! { |
| @@ -2141,7 +2141,7 @@ async fn alert_cooldown_key_matches_across_send_and_check() { | |||
| 2141 | 2141 | mnw_url: None, | |
| 2142 | 2142 | alerts_ingest_token: None, | |
| 2143 | 2143 | }; | |
| 2144 | - | let alerter = pom::alerts::Alerter::new(config, pool.clone(), "test".to_string()); | |
| 2144 | + | let alerter = pom::alerts::Alerter::new(config, pool.clone(), "test".to_string()).unwrap(); | |
| 2145 | 2145 | ||
| 2146 | 2146 | // Send a health alert for target "example.com" | |
| 2147 | 2147 | alerter |