9dfa06ffee
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Signed-off-by: cls_宁波本机 <908705107@qq.com>
368 lines
12 KiB
Rust
368 lines
12 KiB
Rust
//! APNs envelope construction, endpoint encryption, and response classification.
|
|
|
|
use std::{sync::Mutex, time::Duration};
|
|
|
|
use async_trait::async_trait;
|
|
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
|
use p256::{
|
|
ecdsa::{signature::Signer, Signature, SigningKey},
|
|
pkcs8::DecodePrivateKey,
|
|
};
|
|
use reqwest::{
|
|
header::{AUTHORIZATION, CONTENT_TYPE},
|
|
StatusCode,
|
|
};
|
|
use serde::Deserialize;
|
|
use thiserror::Error;
|
|
|
|
use crate::model::{AppProfile, APNS_RECONNECT_PAYLOAD};
|
|
|
|
/// Sanitized delivery outcome. Raw provider bodies never cross this boundary.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum DeliveryOutcome {
|
|
/// APNs accepted the request (not proof of device delivery).
|
|
Accepted,
|
|
/// This endpoint generation is permanently invalid. APNs may provide the time it became invalid.
|
|
InvalidEndpoint {
|
|
/// APNs' timestamp for when the endpoint became invalid, if supplied.
|
|
unregistered_at: Option<i64>,
|
|
},
|
|
/// A bounded retry is safe. A sanitized server hint may raise the delay.
|
|
Retry {
|
|
/// Retry-After delay in seconds, clamped by the transport.
|
|
retry_after_seconds: Option<i64>,
|
|
},
|
|
/// Refresh the cached provider JWT, then retry once within normal attempt bounds.
|
|
RefreshCredential,
|
|
/// Provider credential/profile configuration is unhealthy; do not invalidate endpoints.
|
|
ConfigurationFault,
|
|
/// The locally-generated request is permanently invalid.
|
|
PermanentRequestFault,
|
|
}
|
|
|
|
/// Classify APNs status/reason without conflating provider faults with endpoints.
|
|
pub fn classify(code: u16, reason: Option<&str>, timestamp: Option<i64>) -> DeliveryOutcome {
|
|
match (code, reason) {
|
|
(200, _) => DeliveryOutcome::Accepted,
|
|
(410, Some("Unregistered")) => DeliveryOutcome::InvalidEndpoint {
|
|
unregistered_at: timestamp,
|
|
},
|
|
(400, Some("BadDeviceToken" | "DeviceTokenNotForTopic")) => {
|
|
DeliveryOutcome::InvalidEndpoint {
|
|
unregistered_at: None,
|
|
}
|
|
}
|
|
(403, Some("ExpiredProviderToken")) => DeliveryOutcome::RefreshCredential,
|
|
(403, _) | (429, Some("TooManyProviderTokenUpdates")) => {
|
|
DeliveryOutcome::ConfigurationFault
|
|
}
|
|
(429 | 500 | 503, _)
|
|
| (
|
|
_,
|
|
Some(
|
|
"IdleTimeout"
|
|
| "InternalServerError"
|
|
| "ServiceUnavailable"
|
|
| "Shutdown"
|
|
| "TooManyRequests",
|
|
),
|
|
) => DeliveryOutcome::Retry {
|
|
retry_after_seconds: None,
|
|
},
|
|
_ => DeliveryOutcome::PermanentRequestFault,
|
|
}
|
|
}
|
|
|
|
/// Closed APNs transport controls. No field can be serialized into application
|
|
/// content; the concrete transport always uses `APNS_RECONNECT_PAYLOAD`.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub struct DeliveryAttempt {
|
|
pub request_id: uuid::Uuid,
|
|
pub expires_at: i64,
|
|
}
|
|
|
|
/// APNs sender abstraction for live-validation tests.
|
|
#[async_trait]
|
|
pub trait PushTransport: Send + Sync {
|
|
/// Send one durable job.
|
|
async fn send(
|
|
&self,
|
|
attempt: DeliveryAttempt,
|
|
profile: AppProfile,
|
|
endpoint: &str,
|
|
) -> DeliveryOutcome;
|
|
/// Discard a cached credential after APNs reports expiry.
|
|
fn refresh_credential(&self) {}
|
|
}
|
|
|
|
struct CachedJwt {
|
|
token: String,
|
|
issued_at: i64,
|
|
}
|
|
|
|
/// Direct HTTP/2 APNs transport using a cached ES256 provider token.
|
|
pub struct ApnsTransport {
|
|
client: reqwest::Client,
|
|
signing_key: SigningKey,
|
|
key_id: String,
|
|
team_id: String,
|
|
topic: String,
|
|
production_base_url: String,
|
|
sandbox_base_url: String,
|
|
cached_jwt: Mutex<Option<CachedJwt>>,
|
|
}
|
|
|
|
impl ApnsTransport {
|
|
/// Build a reusable APNs client from an Apple `.p8` private key.
|
|
pub fn token(p8: &[u8], key_id: &str, team_id: &str, topic: String) -> Result<Self, ApnsError> {
|
|
let client = reqwest::Client::builder()
|
|
.timeout(Duration::from_secs(15))
|
|
.build()
|
|
.map_err(|_| ApnsError::Client)?;
|
|
Self::token_with_client(
|
|
p8,
|
|
key_id,
|
|
team_id,
|
|
topic,
|
|
client,
|
|
"https://api.push.apple.com".to_owned(),
|
|
"https://api.sandbox.push.apple.com".to_owned(),
|
|
)
|
|
}
|
|
|
|
fn token_with_client(
|
|
p8: &[u8],
|
|
key_id: &str,
|
|
team_id: &str,
|
|
topic: String,
|
|
client: reqwest::Client,
|
|
production_base_url: String,
|
|
sandbox_base_url: String,
|
|
) -> Result<Self, ApnsError> {
|
|
let pem = std::str::from_utf8(p8).map_err(|_| ApnsError::Credential)?;
|
|
let signing_key = SigningKey::from_pkcs8_pem(pem).map_err(|_| ApnsError::Credential)?;
|
|
Ok(Self {
|
|
client,
|
|
signing_key,
|
|
key_id: key_id.to_owned(),
|
|
team_id: team_id.to_owned(),
|
|
topic,
|
|
production_base_url,
|
|
sandbox_base_url,
|
|
cached_jwt: Mutex::new(None),
|
|
})
|
|
}
|
|
|
|
fn jwt(&self, now: i64) -> Result<String, ApnsError> {
|
|
let mut cached = self.cached_jwt.lock().map_err(|_| ApnsError::Credential)?;
|
|
if let Some(jwt) = cached.as_ref().filter(|jwt| now - jwt.issued_at < 50 * 60) {
|
|
return Ok(jwt.token.clone());
|
|
}
|
|
let header = URL_SAFE_NO_PAD.encode(
|
|
serde_json::to_vec(&serde_json::json!({"alg":"ES256","kid":self.key_id}))
|
|
.map_err(|_| ApnsError::Credential)?,
|
|
);
|
|
let claims = URL_SAFE_NO_PAD.encode(
|
|
serde_json::to_vec(&serde_json::json!({"iss":self.team_id,"iat":now}))
|
|
.map_err(|_| ApnsError::Credential)?,
|
|
);
|
|
let signing_input = format!("{header}.{claims}");
|
|
let signature: Signature = self.signing_key.sign(signing_input.as_bytes());
|
|
let token = format!(
|
|
"{signing_input}.{}",
|
|
URL_SAFE_NO_PAD.encode(signature.to_bytes())
|
|
);
|
|
*cached = Some(CachedJwt {
|
|
token: token.clone(),
|
|
issued_at: now,
|
|
});
|
|
Ok(token)
|
|
}
|
|
}
|
|
|
|
/// APNs transport setup failure. It intentionally carries no credential material.
|
|
#[derive(Debug, Error)]
|
|
pub enum ApnsError {
|
|
/// Invalid provider key material.
|
|
#[error("invalid APNs credential")]
|
|
Credential,
|
|
/// HTTP client setup failed.
|
|
#[error("failed to construct APNs client")]
|
|
Client,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct ApnsErrorBody {
|
|
reason: Option<String>,
|
|
timestamp: Option<i64>,
|
|
}
|
|
|
|
#[async_trait]
|
|
impl PushTransport for ApnsTransport {
|
|
async fn send(
|
|
&self,
|
|
attempt: DeliveryAttempt,
|
|
profile: AppProfile,
|
|
endpoint: &str,
|
|
) -> DeliveryOutcome {
|
|
// This is the only APNs application body in the program. It is a
|
|
// byte constant, not a serialization of the relay request, grant,
|
|
// endpoint, headers, route, provider response, or any generic JSON map.
|
|
let body = APNS_RECONNECT_PAYLOAD;
|
|
let now = chrono::Utc::now().timestamp();
|
|
let token = match self.jwt(now) {
|
|
Ok(token) => token,
|
|
Err(_) => return DeliveryOutcome::ConfigurationFault,
|
|
};
|
|
let base_url = match profile {
|
|
AppProfile::BuzzIosProduction => &self.production_base_url,
|
|
AppProfile::BuzzIosSandbox => &self.sandbox_base_url,
|
|
};
|
|
let response = self
|
|
.client
|
|
.post(format!("{base_url}/3/device/{endpoint}"))
|
|
.header(AUTHORIZATION, format!("bearer {token}"))
|
|
.header(CONTENT_TYPE, "application/json")
|
|
.header("apns-id", attempt.request_id.to_string())
|
|
.header("apns-topic", &self.topic)
|
|
.header("apns-push-type", "alert")
|
|
.header("apns-priority", "10")
|
|
.header("apns-expiration", attempt.expires_at.to_string())
|
|
.body(body)
|
|
.send()
|
|
.await;
|
|
let response = match response {
|
|
Ok(response) => response,
|
|
Err(_) => {
|
|
return DeliveryOutcome::Retry {
|
|
retry_after_seconds: None,
|
|
}
|
|
}
|
|
};
|
|
if response.status() == StatusCode::OK {
|
|
return DeliveryOutcome::Accepted;
|
|
}
|
|
let code = response.status().as_u16();
|
|
let retry_after = response
|
|
.headers()
|
|
.get("retry-after")
|
|
.and_then(|value| value.to_str().ok())
|
|
.and_then(|value| value.parse::<i64>().ok())
|
|
.map(|seconds| seconds.clamp(1, 3600));
|
|
let detail = response.json::<ApnsErrorBody>().await.ok();
|
|
let timestamp = detail.as_ref().and_then(|d| d.timestamp);
|
|
match classify(
|
|
code,
|
|
detail.as_ref().and_then(|d| d.reason.as_deref()),
|
|
timestamp,
|
|
) {
|
|
DeliveryOutcome::Retry { .. } => DeliveryOutcome::Retry {
|
|
retry_after_seconds: retry_after,
|
|
},
|
|
outcome => outcome,
|
|
}
|
|
}
|
|
|
|
fn refresh_credential(&self) {
|
|
if let Ok(mut cached) = self.cached_jwt.lock() {
|
|
*cached = None;
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use axum::{body::Bytes, extract::State, http::StatusCode, routing::post, Router};
|
|
use p256::pkcs8::{EncodePrivateKey, LineEnding};
|
|
use std::sync::Arc;
|
|
|
|
async fn capture_body(
|
|
State(bodies): State<Arc<Mutex<Vec<Vec<u8>>>>>,
|
|
body: Bytes,
|
|
) -> StatusCode {
|
|
bodies.lock().unwrap().push(body.to_vec());
|
|
StatusCode::OK
|
|
}
|
|
#[tokio::test]
|
|
async fn real_outbound_http_body_is_the_exact_constant_for_every_attempt() {
|
|
let bodies = Arc::new(Mutex::new(Vec::new()));
|
|
let app = Router::new()
|
|
.route("/3/device/{endpoint}", post(capture_body))
|
|
.with_state(bodies.clone());
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let base_url = format!("http://{}", listener.local_addr().unwrap());
|
|
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
|
|
|
|
let signing_key = SigningKey::from_slice(&[7; 32]).unwrap();
|
|
let pem = signing_key.to_pkcs8_pem(LineEnding::LF).unwrap();
|
|
let transport = ApnsTransport::token_with_client(
|
|
pem.as_bytes(),
|
|
"kid",
|
|
"team",
|
|
"app.topic".to_owned(),
|
|
reqwest::Client::new(),
|
|
base_url.clone(),
|
|
base_url,
|
|
)
|
|
.unwrap();
|
|
for (request_id, expires_at, profile, endpoint) in [
|
|
(
|
|
uuid::Uuid::nil(),
|
|
1,
|
|
AppProfile::BuzzIosProduction,
|
|
"00".repeat(32),
|
|
),
|
|
(
|
|
uuid::Uuid::max(),
|
|
i64::MAX,
|
|
AppProfile::BuzzIosSandbox,
|
|
"ff".repeat(32),
|
|
),
|
|
] {
|
|
assert_eq!(
|
|
transport
|
|
.send(
|
|
DeliveryAttempt {
|
|
request_id,
|
|
expires_at,
|
|
},
|
|
profile,
|
|
&endpoint,
|
|
)
|
|
.await,
|
|
DeliveryOutcome::Accepted
|
|
);
|
|
}
|
|
let captured = bodies.lock().unwrap();
|
|
assert_eq!(captured.len(), 2);
|
|
assert!(captured
|
|
.iter()
|
|
.all(|body| body.as_slice() == APNS_RECONNECT_PAYLOAD));
|
|
}
|
|
|
|
#[test]
|
|
fn response_classes_do_not_massacre_endpoints_on_provider_faults() {
|
|
assert_eq!(
|
|
classify(410, Some("Unregistered"), Some(7)),
|
|
DeliveryOutcome::InvalidEndpoint {
|
|
unregistered_at: Some(7)
|
|
}
|
|
);
|
|
assert_eq!(
|
|
classify(403, Some("InvalidProviderToken"), None),
|
|
DeliveryOutcome::ConfigurationFault
|
|
);
|
|
assert_eq!(
|
|
classify(429, Some("TooManyRequests"), None),
|
|
DeliveryOutcome::Retry {
|
|
retry_after_seconds: None
|
|
}
|
|
);
|
|
assert_eq!(
|
|
classify(400, Some("BadTopic"), None),
|
|
DeliveryOutcome::PermanentRequestFault
|
|
);
|
|
}
|
|
}
|