use crate::egress::{Allowance, Egress, EgressClient, EgressError, Method, Request, header};
use crate::hosts::Hosts;
use crate::operator::Operator;
use crate::push::{
Notice, Notices, PushPolicy, Subscription, Urgency, VAPID_TOKEN_SECS, VapidKey, audience, parse_keys, subject,
};
use crate::secret::unix_now;
use anyhow::Result;
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use hennery_proto::rest::PushPayload;
use std::collections::HashMap;
use std::future::Future;
use std::pin::Pin;
use std::sync::{Arc, Mutex};
use std::time::Duration;
pub const PUSH_TTL_SECS: u64 = 60 * 60;
pub const PAD_TO: usize = 512;
pub const MAX_PLAINTEXT: usize = 4096 - 86 - 16 - 1;
pub const MAX_IN_FLIGHT: usize = 8;
#[derive(Clone)]
pub struct PushRequest {
pub endpoint: url::Url,
pub headers: Vec<(&'static str, String)>,
pub body: Vec<u8>,
}
impl std::fmt::Debug for PushRequest {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PushRequest")
.field("endpoint_host", &self.endpoint.host_str())
.field("body_len", &self.body.len())
.finish_non_exhaustive()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Sent {
Status {
code: u16,
retry_after: Option<Duration>,
},
Refused,
Timeout,
Failed,
}
pub trait Transport: Send + Sync + 'static {
fn send(&self, request: PushRequest) -> Pin<Box<dyn Future<Output = Sent> + Send + '_>>;
}
impl Transport for EgressClient {
fn send(&self, push: PushRequest) -> Pin<Box<dyn Future<Output = Sent> + Send + '_>> {
Box::pin(async move {
let mut request = Request::new(Method::POST, push.endpoint);
for (name, value) in &push.headers {
let (Ok(name), Ok(value)) = (
header::HeaderName::from_bytes(name.as_bytes()),
header::HeaderValue::from_str(value),
) else {
return Sent::Failed;
};
request.headers_mut().insert(name, value);
}
*request.body_mut() = Some(push.body.into());
match EgressClient::send(self, request).await {
Ok(response) => Sent::Status {
code: response.status().as_u16(),
retry_after: retry_after(response.headers()),
},
Err(EgressError::Refused(_)) => Sent::Refused,
Err(EgressError::Timeout) => Sent::Timeout,
Err(EgressError::Http(_)) => Sent::Failed,
}
})
}
}
fn retry_after(headers: &header::HeaderMap) -> Option<Duration> {
let secs = headers
.get(header::RETRY_AFTER)?
.to_str()
.ok()?
.trim()
.parse::<u64>()
.ok()?;
Some(Duration::from_secs(secs))
}
pub fn spawn(hosts: Arc<Hosts>, operator: Arc<Operator>, vapid: Arc<VapidKey>, egress: &Egress) -> crate::push::Push {
let (push, notices) = crate::push::Push::new();
let delivery = Delivery::new(
hosts,
operator,
vapid,
egress.client(Allowance::PublicOnly),
RetryPolicy::default(),
);
tokio::spawn(delivery.run(notices));
push
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RetryPolicy {
pub delays: Vec<Duration>,
pub max_retry_after: Duration,
pub attempt_timeout: Duration,
pub budget: Duration,
}
impl Default for RetryPolicy {
fn default() -> Self {
Self {
delays: vec![Duration::from_secs(1), Duration::from_secs(4)],
max_retry_after: Duration::from_secs(10),
attempt_timeout: Duration::from_secs(10),
budget: Duration::from_secs(30),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Outcome {
Suppressed,
Delivered,
Removed,
Failed(String),
}
pub fn payload_for(notice: &Notice, policy: PushPolicy) -> Option<PushPayload> {
if policy.muted {
return None;
}
let body = match (¬ice.detail, policy.details) {
(Some(detail), true) => detail.clone(),
_ if policy.generic_title => String::new(),
_ => notice.body.clone(),
};
let title = if policy.generic_title {
notice.generic_title.clone()
} else {
notice.title.clone()
};
Some(PushPayload {
title,
body,
url: notice.url.clone(),
tag: notice.tag.clone(),
})
}
pub fn padded(payload: &PushPayload) -> Result<Vec<u8>> {
let mut json = serde_json::to_vec(payload)?;
anyhow::ensure!(json.len() <= MAX_PLAINTEXT, "a push payload of {} bytes", json.len());
let target = json.len().div_ceil(PAD_TO).max(1) * PAD_TO;
json.resize(target.min(MAX_PLAINTEXT), b' ');
Ok(json)
}
pub struct Delivery<T> {
hosts: Arc<Hosts>,
operator: Arc<Operator>,
vapid: Arc<VapidKey>,
transport: T,
retry: RetryPolicy,
tokens: Mutex<Tokens>,
}
#[derive(Default)]
struct Tokens(HashMap<(String, Option<String>), (String, i64)>);
impl Tokens {
fn authorization(&mut self, vapid: &VapidKey, endpoint: &url::Url, subject: Option<&str>, now: i64) -> String {
let key = (audience(endpoint), subject.map(str::to_string));
if let Some((header, made)) = self.0.get(&key)
&& now - made < VAPID_TOKEN_SECS - 60 * 60
{
return header.clone();
}
self.0.retain(|_, (_, made)| now - *made < VAPID_TOKEN_SECS);
let header = vapid.authorization(endpoint, subject, now);
self.0.insert(key, (header.clone(), now));
header
}
}
impl<T: Transport> Delivery<T> {
pub fn new(
hosts: Arc<Hosts>,
operator: Arc<Operator>,
vapid: Arc<VapidKey>,
transport: T,
retry: RetryPolicy,
) -> Arc<Self> {
Arc::new(Self {
hosts,
operator,
vapid,
transport,
retry,
tokens: Mutex::new(Tokens::default()),
})
}
pub async fn run(self: Arc<Self>, mut notices: Notices) {
loop {
let notice = notices.recv().await;
if let Err(err) = self.deliver_guarded(¬ice, notices.guard()).await {
tracing::warn!(tag = %notice.tag, error = %err, "push not delivered");
}
}
}
pub async fn deliver(self: &Arc<Self>, notice: &Notice) -> Result<Vec<(String, Outcome)>> {
self.deliver_guarded(notice, Arc::new(|_| true)).await
}
pub async fn deliver_guarded(
self: &Arc<Self>,
notice: &Notice,
guard: crate::push::NoticeGuard,
) -> Result<Vec<(String, Outcome)>> {
if !guard(notice) {
return Ok(Vec::new());
}
let now = unix_now();
self.hosts.prune_expired_subscriptions(now)?;
let Some(payload) = payload_for(notice, self.hosts.push_policy(¬ice.hat_id)?) else {
return Ok(Vec::new());
};
let plaintext = Arc::new(padded(&payload)?);
let subject = match self.operator.public_url() {
Some(public_url) => subject(self.operator.contact()?.as_deref(), &public_url),
None => None,
};
let urgency = match notice.urgency {
Urgency::High => "high",
Urgency::Normal => "normal",
};
let permits = Arc::new(tokio::sync::Semaphore::new(MAX_IN_FLIGHT));
let mut sending = tokio::task::JoinSet::new();
for sub in self.hosts.subscriptions()? {
let this = self.clone();
let plaintext = plaintext.clone();
let subject = subject.clone();
let permits = permits.clone();
let guard = guard.clone();
let notice = notice.clone();
sending.spawn(async move {
let _permit = permits.acquire_owned().await;
let outcome = if guard(¬ice) {
this.send_one(&sub, &plaintext, urgency, subject.as_deref(), ¬ice, &guard)
.await
} else {
Outcome::Suppressed
};
(sub.id, outcome)
});
}
let mut outcomes = Vec::new();
while let Some(done) = sending.join_next().await {
match done {
Ok(outcome) => outcomes.push(outcome),
Err(err) => tracing::error!(task = %err.id(), panicked = err.is_panic(), "a push task failed"),
}
}
Ok(outcomes)
}
async fn send_one(
&self,
sub: &Subscription,
plaintext: &[u8],
urgency: &str,
subject: Option<&str>,
notice: &Notice,
guard: &crate::push::NoticeGuard,
) -> Outcome {
let outcome = match self.request(sub, plaintext, urgency, subject) {
Ok(request) => self.send_retrying(request, notice, guard).await,
Err(err) => {
tracing::warn!(id = %sub.id, error = %err, "push not built");
Outcome::Failed("could not encrypt to this device".into())
}
};
let recorded = match &outcome {
Outcome::Suppressed => Ok(()),
Outcome::Delivered => self.hosts.record_push_success(&sub.id, &sub.endpoint, unix_now()),
Outcome::Removed => self.hosts.remove_gone_subscription(&sub.id, &sub.endpoint).map(|_| ()),
Outcome::Failed(why) => self.hosts.record_push_error(&sub.id, &sub.endpoint, why),
};
if let Err(err) = recorded {
tracing::warn!(id = %sub.id, error = %err, "push outcome not recorded");
}
match &outcome {
Outcome::Removed => {
tracing::info!(id = %sub.id, host = %sub.endpoint_host(), "push subscription gone; removed")
}
Outcome::Failed(why) => tracing::warn!(id = %sub.id, host = %sub.endpoint_host(), %why, "push failed"),
Outcome::Delivered | Outcome::Suppressed => {}
}
outcome
}
fn request(
&self,
sub: &Subscription,
plaintext: &[u8],
urgency: &str,
subject: Option<&str>,
) -> Result<PushRequest> {
let endpoint = url::Url::parse(&sub.endpoint)?;
let (p256dh, auth) = parse_keys(&sub.p256dh, &sub.auth).map_err(anyhow::Error::msg)?;
let ua_public = p256::PublicKey::from_sec1_bytes(&URL_SAFE_NO_PAD.decode(p256dh)?)?;
let auth: [u8; 16] = URL_SAFE_NO_PAD
.decode(auth)?
.try_into()
.map_err(|_| anyhow::anyhow!("keys.auth is not 16 bytes"))?;
let auth = web_push_native::Auth::from(auth);
let body =
web_push_native::encrypt(plaintext.to_vec(), &ua_public, &auth).map_err(|e| anyhow::anyhow!("{e}"))?;
Ok(PushRequest {
headers: vec![
("TTL", PUSH_TTL_SECS.to_string()),
("Urgency", urgency.to_string()),
("Content-Encoding", "aes128gcm".to_string()),
("Content-Type", "application/octet-stream".to_string()),
("Authorization", self.authorization(&endpoint, subject)),
],
endpoint,
body,
})
}
fn authorization(&self, endpoint: &url::Url, subject: Option<&str>) -> String {
let mut tokens = self.tokens.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
tokens.authorization(&self.vapid, endpoint, subject, unix_now())
}
async fn send_retrying(&self, request: PushRequest, notice: &Notice, guard: &crate::push::NoticeGuard) -> Outcome {
let started = tokio::time::Instant::now();
let mut delays = self.retry.delays.iter();
loop {
if !guard(notice) {
return Outcome::Suppressed;
}
let sent = tokio::time::timeout(self.retry.attempt_timeout, self.transport.send(request.clone()))
.await
.unwrap_or(Sent::Timeout);
let (retryable, failed, retry_after) = match sent {
Sent::Status { code: 200..=299, .. } => return Outcome::Delivered,
Sent::Status { code: 404 | 410, .. } => return Outcome::Removed,
Sent::Status { code, retry_after } if code == 429 || code >= 500 => {
(true, format!("the push service answered {code}"), retry_after)
}
Sent::Status { code, .. } => (false, format!("the push service refused it ({code})"), None),
Sent::Refused => (
false,
"refused: the push service is not at a public address".into(),
None,
),
Sent::Timeout => (true, "the push service did not answer in time".into(), None),
Sent::Failed => (true, "the push service could not be reached".into(), None),
};
match delays.next() {
Some(delay) if retryable => {
let wait = retry_after.map_or(*delay, |after| after.min(self.retry.max_retry_after).max(*delay));
if started.elapsed() + wait + self.retry.attempt_timeout > self.retry.budget {
return Outcome::Failed(format!("{failed}; gave up within the time allowed"));
}
tokio::time::sleep(wait).await;
}
_ => return Outcome::Failed(failed),
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn notice() -> Notice {
Notice {
hat_id: "hat-1".into(),
urgency: Urgency::High,
title: "Fix the flaky test".into(),
generic_title: "Session needs your answer".into(),
body: "needs your answer".into(),
detail: Some("Run cargo test".into()),
url: "/sessions/s1".into(),
tag: "s1".into(),
}
}
#[test]
fn a_token_is_renewed_an_hour_before_it_expires() {
let vapid = VapidKey::generate();
let endpoint = url::Url::parse("https://fcm.googleapis.com/fcm/send/a").unwrap();
let mut tokens = Tokens::default();
let made = 1_800_000_000;
let first = tokens.authorization(&vapid, &endpoint, None, made);
let renew_at = made + VAPID_TOKEN_SECS - 60 * 60;
assert_eq!(tokens.authorization(&vapid, &endpoint, None, renew_at - 1), first);
let renewed = tokens.authorization(&vapid, &endpoint, None, renew_at);
assert_ne!(renewed, first);
assert_ne!(
tokens.authorization(&vapid, &endpoint, Some("mailto:a@b.co"), renew_at),
renewed
);
let other = url::Url::parse("https://web.push.apple.com/b").unwrap();
tokens.authorization(&vapid, &other, None, made + 3 * VAPID_TOKEN_SECS);
assert_eq!(tokens.0.len(), 1);
}
#[test]
fn retry_after_is_read_in_seconds_only() {
let mut headers = header::HeaderMap::new();
assert_eq!(retry_after(&headers), None);
headers.insert(header::RETRY_AFTER, header::HeaderValue::from_static(" 30 "));
assert_eq!(retry_after(&headers), Some(Duration::from_secs(30)));
headers.insert(
header::RETRY_AFTER,
header::HeaderValue::from_static("Wed, 21 Oct 2015 07:28:00 GMT"),
);
assert_eq!(retry_after(&headers), None);
headers.insert(header::RETRY_AFTER, header::HeaderValue::from_static("-1"));
assert_eq!(retry_after(&headers), None);
}
#[test]
fn the_policy_decides_what_a_payload_shows() {
let default = payload_for(¬ice(), PushPolicy::default()).unwrap();
assert_eq!(
(
default.title.as_str(),
default.body.as_str(),
default.url.as_str(),
default.tag.as_str()
),
("Fix the flaky test", "needs your answer", "/sessions/s1", "s1")
);
let generic = PushPolicy {
generic_title: true,
..PushPolicy::default()
};
let shown = payload_for(¬ice(), generic).unwrap();
assert_eq!(
(shown.title.as_str(), shown.body.as_str()),
("Session needs your answer", "")
);
let details = PushPolicy {
details: true,
..PushPolicy::default()
};
assert_eq!(payload_for(¬ice(), details).unwrap().body, "Run cargo test");
let both = PushPolicy {
details: true,
generic_title: true,
..PushPolicy::default()
};
let shown = payload_for(¬ice(), both).unwrap();
assert_eq!(
(shown.title.as_str(), shown.body.as_str()),
("Session needs your answer", "Run cargo test")
);
let mut plain = notice();
plain.detail = None;
assert_eq!(payload_for(&plain, details).unwrap().body, "needs your answer");
let muted = PushPolicy {
muted: true,
details: true,
generic_title: false,
};
assert_eq!(payload_for(¬ice(), muted), None);
}
#[test]
fn a_payload_is_padded_to_its_bucket_and_stays_json() {
let payload = payload_for(¬ice(), PushPolicy::default()).unwrap();
let short = padded(&payload).unwrap();
assert_eq!(short.len(), PAD_TO);
assert_eq!(serde_json::from_slice::<PushPayload>(&short).unwrap(), payload);
let mut longer = payload.clone();
longer.title = "x".repeat(100);
assert_eq!(padded(&longer).unwrap().len(), PAD_TO);
longer.title = "x".repeat(600);
assert_eq!(padded(&longer).unwrap().len(), 2 * PAD_TO);
longer.title = "x".repeat(3900);
assert_eq!(padded(&longer).unwrap().len(), MAX_PLAINTEXT);
longer.title = "x".repeat(4000);
assert!(padded(&longer).is_err());
}
}