// SPDX-License-Identifier: AGPL-3.0-or-later use crate::config::{ApnsConfig, FcmConfig, ProviderEnvironment}; use crate::metrics::Metrics; use crate::resolver::{BLOCKED_ADDRESS_ERROR, PublicOnlyResolver}; use crate::tokens::{TokenCache, TokenError}; use reqwest::header::{AUTHORIZATION, CONTENT_TYPE}; use reqwest::redirect::Policy; use serde_json::Value; use std::time::Duration; use tracing::warn; const HTTP_TIMEOUT: Duration = Duration::from_secs(10); const APNS_TIMEOUT: Duration = Duration::from_secs(5); const CONNECT_TIMEOUT: Duration = Duration::from_secs(5); const APNS_TOPIC_HEADER: &str = "apns-topic"; pub const FCM_CONTENT_TYPE: &str = "application/json; charset=UTF-8"; const TOO_MANY_REQUESTS: u16 = 429; const DNS_ERROR_MARKER: &str = "dns error"; const MAX_ERROR_BODY_BYTES: usize = 8_192; const HTTP_ERROR: &str = "http_error"; const UNREGISTERED: &str = "UNREGISTERED"; const INVALID_ARGUMENT: &str = "INVALID_ARGUMENT"; pub fn http_client() -> reqwest::Result { reqwest::Client::builder() .redirect(Policy::none()) .connect_timeout(CONNECT_TIMEOUT) .timeout(HTTP_TIMEOUT) .build() } pub fn web_push_http_client(private_hosts: &[String]) -> reqwest::Result { reqwest::Client::builder() .redirect(Policy::none()) .dns_resolver(PublicOnlyResolver::new(private_hosts)) .connect_timeout(CONNECT_TIMEOUT) .timeout(HTTP_TIMEOUT) .build() } pub fn apns_http_client() -> reqwest::Result { reqwest::Client::builder() .redirect(Policy::none()) .http2_prior_knowledge() .connect_timeout(CONNECT_TIMEOUT) .timeout(APNS_TIMEOUT) .build() } pub struct ApnsRequest<'a> { pub environment: ProviderEnvironment, pub topic: &'a str, pub device_token: &'a str, pub headers: &'a [(String, String)], pub body: Vec, } #[derive(Debug, Eq, PartialEq)] pub enum VendorOutcome { Accepted, Refused(Refusal), Unreachable(Unreachable), } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum Unreachable { BlockedAddress, Dns, Transport, } impl Unreachable { pub fn label(self) -> &'static str { match self { Self::BlockedAddress => "blocked_address", Self::Dns => "dns", Self::Transport => "transport", } } pub fn is_permanent(self) -> bool { matches!(self, Self::BlockedAddress | Self::Dns) } pub fn of(error: &reqwest::Error) -> Self { if source_mentions(error, BLOCKED_ADDRESS_ERROR) { Self::BlockedAddress } else if source_mentions(error, DNS_ERROR_MARKER) { Self::Dns } else { Self::Transport } } } fn source_mentions(error: &reqwest::Error, marker: &str) -> bool { let mut current = std::error::Error::source(error); while let Some(error) = current { if error.to_string().contains(marker) { return true; } current = error.source(); } false } #[derive(Debug, Eq, PartialEq)] pub struct Refusal { pub status: u16, pub reason: String, pub dead_token: Option, } impl Refusal { pub fn is_transient(&self) -> bool { is_transient_status(self.status) } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum DeadToken { Gone(&'static str), Invalid(&'static str), } impl DeadToken { pub fn label(self) -> &'static str { match self { Self::Gone(label) | Self::Invalid(label) => label, } } } pub async fn send_apns( http: &reqwest::Client, tokens: &TokenCache, metrics: &Metrics, cfg: &ApnsConfig, request: ApnsRequest<'_>, ) -> Result { let token = tokens.apns(cfg, metrics).await?; let url = format!( "{}/3/device/{}", cfg.base_url(request.environment).trim_end_matches('/'), request.device_token ); let mut builder = http .post(&url) .header(AUTHORIZATION, format!("bearer {token}")) .header(APNS_TOPIC_HEADER, request.topic) .body(request.body); for (name, value) in request.headers { builder = builder.header(name, value); } Ok(outcome(builder.send().await, apns_refusal).await) } pub async fn send_fcm( http: &reqwest::Client, tokens: &TokenCache, metrics: &Metrics, cfg: &FcmConfig, project_id: &str, body: Vec, ) -> Result { let token = tokens.fcm(cfg, http, metrics).await?; let url = format!( "{}/v1/projects/{project_id}/messages:send", cfg.base_url.trim_end_matches('/') ); let response = http .post(&url) .header(AUTHORIZATION, format!("Bearer {token}")) .header(CONTENT_TYPE, FCM_CONTENT_TYPE) .body(body) .send() .await; Ok(outcome(response, fcm_refusal).await) } async fn outcome( response: reqwest::Result, refusal: fn(u16, &[u8]) -> Refusal, ) -> VendorOutcome { let response = match response { Ok(response) => response, Err(error) => { let unreachable = Unreachable::of(&error); warn!( error = %error.without_url(), kind = unreachable.label(), "vendor request did not complete" ); return VendorOutcome::Unreachable(unreachable); } }; if response.status().is_success() { return VendorOutcome::Accepted; } let status = response.status().as_u16(); VendorOutcome::Refused(refusal(status, &read_error_body(response).await)) } fn apns_refusal(status: u16, body: &[u8]) -> Refusal { let reason = reason_field(body).unwrap_or_else(|| format!("http_{status}")); Refusal { status, dead_token: apns_dead_token(status, &reason), reason, } } fn fcm_refusal(status: u16, body: &[u8]) -> Refusal { let reason = fcm_error_code(body); Refusal { status, dead_token: fcm_dead_token(&reason), reason, } } fn apns_dead_token(status: u16, reason: &str) -> Option { match (status, reason) { (_, "Unregistered") => Some(DeadToken::Gone("unregistered")), (410, _) => Some(DeadToken::Gone("gone")), (400, "BadDeviceToken") => Some(DeadToken::Invalid("bad_device_token")), _ => None, } } fn fcm_dead_token(code: &str) -> Option { match code { UNREGISTERED => Some(DeadToken::Gone("unregistered")), INVALID_ARGUMENT => Some(DeadToken::Invalid("invalid_argument")), _ => None, } } pub fn reason_field(body: &[u8]) -> Option { let parsed: Value = serde_json::from_slice(body).ok()?; Some(parsed.get("reason")?.as_str()?.to_owned()) } fn fcm_error_code(body: &[u8]) -> String { let Ok(parsed) = serde_json::from_slice::(body) else { return HTTP_ERROR.to_owned(); }; let Some(error) = parsed.get("error") else { return HTTP_ERROR.to_owned(); }; if let Some(details) = error.get("details").and_then(Value::as_array) { return details .iter() .find_map(|detail| detail.get("errorCode").and_then(Value::as_str)) .unwrap_or(HTTP_ERROR) .to_owned(); } error .get("status") .and_then(Value::as_str) .unwrap_or(HTTP_ERROR) .to_owned() } pub fn is_transient_status(status: u16) -> bool { status >= 500 || status == TOO_MANY_REQUESTS } pub async fn read_error_body(response: reqwest::Response) -> Vec { let mut body = response .bytes() .await .map(|bytes| bytes.to_vec()) .unwrap_or_default(); body.truncate(MAX_ERROR_BODY_BYTES); body } #[cfg(test)] mod tests { use super::*; async fn error_for(url: &str) -> reqwest::Error { reqwest::Client::builder() .no_proxy() .connect_timeout(CONNECT_TIMEOUT) .build() .expect("the http client builds") .post(url) .send() .await .expect_err("the request cannot complete") } #[tokio::test] async fn a_host_that_does_not_resolve_is_permanent() { let error = error_for("https://push.invalid/relay/v1/apns/stable/production/token").await; assert_eq!(Unreachable::of(&error), Unreachable::Dns); assert!(Unreachable::of(&error).is_permanent()); } #[tokio::test] async fn a_logged_error_never_contains_the_device_token() { const TOKEN: &str = "3dbc5a5ef1a1c1666afc26f466e1b3ebaaf4c66d92dddeb0fd1b69c49641d4cd"; let error = error_for(&format!("https://push.invalid/3/device/{TOKEN}")).await; assert!( error.to_string().contains(TOKEN), "reqwest still puts the url in Display, so the guard below is what matters" ); assert!(!error.without_url().to_string().contains(TOKEN)); } #[test] fn a_wrong_topic_is_not_a_dead_token() { assert_eq!(apns_dead_token(400, "DeviceTokenNotForTopic"), None); } #[test] fn a_bad_device_token_is_still_a_dead_token() { assert_eq!( apns_dead_token(400, "BadDeviceToken"), Some(DeadToken::Invalid("bad_device_token")) ); } #[tokio::test] async fn a_host_in_blocked_address_space_is_named_as_such() { let error = web_push_http_client(&[]) .expect("the http client builds") .post("https://localhost/push") .send() .await .expect_err("the request cannot complete"); assert_eq!(Unreachable::of(&error), Unreachable::BlockedAddress); assert!(Unreachable::of(&error).is_permanent()); } #[tokio::test] async fn a_listed_private_host_gets_through_to_connect() { let error = web_push_http_client(&["localhost".to_owned()]) .expect("the http client builds") .post("https://localhost:1/push") .send() .await .expect_err("the request cannot complete"); assert_eq!(Unreachable::of(&error), Unreachable::Transport); } #[tokio::test] async fn a_refused_connection_stays_retryable() { let error = error_for("http://127.0.0.1:1/").await; assert_eq!(Unreachable::of(&error), Unreachable::Transport); assert!(!Unreachable::of(&error).is_permanent()); } }