mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +09:00
357 lines
10 KiB
Rust
357 lines
10 KiB
Rust
// 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> {
|
|
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> {
|
|
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> {
|
|
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<u8>,
|
|
}
|
|
|
|
#[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<DeadToken>,
|
|
}
|
|
|
|
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<VendorOutcome, TokenError> {
|
|
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<u8>,
|
|
) -> Result<VendorOutcome, TokenError> {
|
|
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<reqwest::Response>,
|
|
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<DeadToken> {
|
|
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<DeadToken> {
|
|
match code {
|
|
UNREGISTERED => Some(DeadToken::Gone("unregistered")),
|
|
INVALID_ARGUMENT => Some(DeadToken::Invalid("invalid_argument")),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
pub fn reason_field(body: &[u8]) -> Option<String> {
|
|
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::<Value>(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<u8> {
|
|
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());
|
|
}
|
|
}
|