1use crate::egress::{Allowance, Egress, EgressClient, EgressError, Method, Request, header};
13use crate::hosts::Hosts;
14use crate::operator::Operator;
15use crate::push::{
16 Notice, Notices, PushPolicy, Subscription, Urgency, VAPID_TOKEN_SECS, VapidKey, audience, parse_keys, subject,
17};
18use crate::secret::unix_now;
19use anyhow::Result;
20use base64::Engine;
21use base64::engine::general_purpose::URL_SAFE_NO_PAD;
22use hennery_proto::rest::PushPayload;
23use std::collections::HashMap;
24use std::future::Future;
25use std::pin::Pin;
26use std::sync::{Arc, Mutex};
27use std::time::Duration;
28
29pub const PUSH_TTL_SECS: u64 = 60 * 60;
32
33pub const PAD_TO: usize = 512;
36
37pub const MAX_PLAINTEXT: usize = 4096 - 86 - 16 - 1;
40
41pub const MAX_IN_FLIGHT: usize = 8;
43
44#[derive(Clone)]
46pub struct PushRequest {
47 pub endpoint: url::Url,
48 pub headers: Vec<(&'static str, String)>,
51 pub body: Vec<u8>,
53}
54
55impl std::fmt::Debug for PushRequest {
56 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57 f.debug_struct("PushRequest")
58 .field("endpoint_host", &self.endpoint.host_str())
59 .field("body_len", &self.body.len())
60 .finish_non_exhaustive()
61 }
62}
63
64#[derive(Debug, Clone, PartialEq, Eq)]
66pub enum Sent {
67 Status {
69 code: u16,
70 retry_after: Option<Duration>,
72 },
73 Refused,
75 Timeout,
77 Failed,
79}
80
81pub trait Transport: Send + Sync + 'static {
84 fn send(&self, request: PushRequest) -> Pin<Box<dyn Future<Output = Sent> + Send + '_>>;
85}
86
87impl Transport for EgressClient {
91 fn send(&self, push: PushRequest) -> Pin<Box<dyn Future<Output = Sent> + Send + '_>> {
92 Box::pin(async move {
93 let mut request = Request::new(Method::POST, push.endpoint);
94 for (name, value) in &push.headers {
95 let (Ok(name), Ok(value)) = (
96 header::HeaderName::from_bytes(name.as_bytes()),
97 header::HeaderValue::from_str(value),
98 ) else {
99 return Sent::Failed;
100 };
101 request.headers_mut().insert(name, value);
102 }
103 *request.body_mut() = Some(push.body.into());
104 match EgressClient::send(self, request).await {
105 Ok(response) => Sent::Status {
106 code: response.status().as_u16(),
107 retry_after: retry_after(response.headers()),
108 },
109 Err(EgressError::Refused(_)) => Sent::Refused,
110 Err(EgressError::Timeout) => Sent::Timeout,
111 Err(EgressError::Http(_)) => Sent::Failed,
112 }
113 })
114 }
115}
116
117fn retry_after(headers: &header::HeaderMap) -> Option<Duration> {
120 let secs = headers
121 .get(header::RETRY_AFTER)?
122 .to_str()
123 .ok()?
124 .trim()
125 .parse::<u64>()
126 .ok()?;
127 Some(Duration::from_secs(secs))
128}
129
130pub fn spawn(hosts: Arc<Hosts>, operator: Arc<Operator>, vapid: Arc<VapidKey>, egress: &Egress) -> crate::push::Push {
134 let (push, notices) = crate::push::Push::new();
135 let delivery = Delivery::new(
136 hosts,
137 operator,
138 vapid,
139 egress.client(Allowance::PublicOnly),
140 RetryPolicy::default(),
141 );
142 tokio::spawn(delivery.run(notices));
143 push
144}
145
146#[derive(Debug, Clone, PartialEq, Eq)]
148pub struct RetryPolicy {
149 pub delays: Vec<Duration>,
151 pub max_retry_after: Duration,
153 pub attempt_timeout: Duration,
155 pub budget: Duration,
160}
161
162impl Default for RetryPolicy {
163 fn default() -> Self {
166 Self {
167 delays: vec![Duration::from_secs(1), Duration::from_secs(4)],
168 max_retry_after: Duration::from_secs(10),
169 attempt_timeout: Duration::from_secs(10),
170 budget: Duration::from_secs(30),
171 }
172 }
173}
174
175#[derive(Debug, Clone, PartialEq, Eq)]
177pub enum Outcome {
178 Suppressed,
180 Delivered,
182 Removed,
184 Failed(String),
186}
187
188pub fn payload_for(notice: &Notice, policy: PushPolicy) -> Option<PushPayload> {
196 if policy.muted {
197 return None;
198 }
199 let body = match (¬ice.detail, policy.details) {
200 (Some(detail), true) => detail.clone(),
201 _ if policy.generic_title => String::new(),
202 _ => notice.body.clone(),
203 };
204 let title = if policy.generic_title {
205 notice.generic_title.clone()
206 } else {
207 notice.title.clone()
208 };
209 Some(PushPayload {
210 title,
211 body,
212 url: notice.url.clone(),
213 tag: notice.tag.clone(),
214 })
215}
216
217pub fn padded(payload: &PushPayload) -> Result<Vec<u8>> {
221 let mut json = serde_json::to_vec(payload)?;
222 anyhow::ensure!(json.len() <= MAX_PLAINTEXT, "a push payload of {} bytes", json.len());
223 let target = json.len().div_ceil(PAD_TO).max(1) * PAD_TO;
224 json.resize(target.min(MAX_PLAINTEXT), b' ');
225 Ok(json)
226}
227
228pub struct Delivery<T> {
230 hosts: Arc<Hosts>,
231 operator: Arc<Operator>,
232 vapid: Arc<VapidKey>,
233 transport: T,
234 retry: RetryPolicy,
235 tokens: Mutex<Tokens>,
239}
240
241#[derive(Default)]
245struct Tokens(HashMap<(String, Option<String>), (String, i64)>);
246
247impl Tokens {
248 fn authorization(&mut self, vapid: &VapidKey, endpoint: &url::Url, subject: Option<&str>, now: i64) -> String {
249 let key = (audience(endpoint), subject.map(str::to_string));
250 if let Some((header, made)) = self.0.get(&key)
251 && now - made < VAPID_TOKEN_SECS - 60 * 60
252 {
253 return header.clone();
254 }
255 self.0.retain(|_, (_, made)| now - *made < VAPID_TOKEN_SECS);
256 let header = vapid.authorization(endpoint, subject, now);
257 self.0.insert(key, (header.clone(), now));
258 header
259 }
260}
261
262impl<T: Transport> Delivery<T> {
263 pub fn new(
264 hosts: Arc<Hosts>,
265 operator: Arc<Operator>,
266 vapid: Arc<VapidKey>,
267 transport: T,
268 retry: RetryPolicy,
269 ) -> Arc<Self> {
270 Arc::new(Self {
271 hosts,
272 operator,
273 vapid,
274 transport,
275 retry,
276 tokens: Mutex::new(Tokens::default()),
277 })
278 }
279
280 pub async fn run(self: Arc<Self>, mut notices: Notices) {
284 loop {
285 let notice = notices.recv().await;
286 if let Err(err) = self.deliver_guarded(¬ice, notices.guard()).await {
287 tracing::warn!(tag = %notice.tag, error = %err, "push not delivered");
288 }
289 }
290 }
291
292 pub async fn deliver(self: &Arc<Self>, notice: &Notice) -> Result<Vec<(String, Outcome)>> {
296 self.deliver_guarded(notice, Arc::new(|_| true)).await
297 }
298
299 pub async fn deliver_guarded(
302 self: &Arc<Self>,
303 notice: &Notice,
304 guard: crate::push::NoticeGuard,
305 ) -> Result<Vec<(String, Outcome)>> {
306 if !guard(notice) {
307 return Ok(Vec::new());
308 }
309 let now = unix_now();
310 self.hosts.prune_expired_subscriptions(now)?;
311 let Some(payload) = payload_for(notice, self.hosts.push_policy(¬ice.hat_id)?) else {
312 return Ok(Vec::new());
313 };
314 let plaintext = Arc::new(padded(&payload)?);
315 let subject = match self.operator.public_url() {
316 Some(public_url) => subject(self.operator.contact()?.as_deref(), &public_url),
317 None => None,
318 };
319 let urgency = match notice.urgency {
320 Urgency::High => "high",
321 Urgency::Normal => "normal",
322 };
323 let permits = Arc::new(tokio::sync::Semaphore::new(MAX_IN_FLIGHT));
324 let mut sending = tokio::task::JoinSet::new();
325 for sub in self.hosts.subscriptions()? {
326 let this = self.clone();
327 let plaintext = plaintext.clone();
328 let subject = subject.clone();
329 let permits = permits.clone();
330 let guard = guard.clone();
331 let notice = notice.clone();
332 sending.spawn(async move {
333 let _permit = permits.acquire_owned().await;
334 let outcome = if guard(¬ice) {
335 this.send_one(&sub, &plaintext, urgency, subject.as_deref(), ¬ice, &guard)
336 .await
337 } else {
338 Outcome::Suppressed
339 };
340 (sub.id, outcome)
341 });
342 }
343 let mut outcomes = Vec::new();
344 while let Some(done) = sending.join_next().await {
347 match done {
348 Ok(outcome) => outcomes.push(outcome),
349 Err(err) => tracing::error!(task = %err.id(), panicked = err.is_panic(), "a push task failed"),
351 }
352 }
353 Ok(outcomes)
354 }
355
356 async fn send_one(
359 &self,
360 sub: &Subscription,
361 plaintext: &[u8],
362 urgency: &str,
363 subject: Option<&str>,
364 notice: &Notice,
365 guard: &crate::push::NoticeGuard,
366 ) -> Outcome {
367 let outcome = match self.request(sub, plaintext, urgency, subject) {
368 Ok(request) => self.send_retrying(request, notice, guard).await,
369 Err(err) => {
370 tracing::warn!(id = %sub.id, error = %err, "push not built");
371 Outcome::Failed("could not encrypt to this device".into())
372 }
373 };
374 let recorded = match &outcome {
375 Outcome::Suppressed => Ok(()),
376 Outcome::Delivered => self.hosts.record_push_success(&sub.id, &sub.endpoint, unix_now()),
377 Outcome::Removed => self.hosts.remove_gone_subscription(&sub.id, &sub.endpoint).map(|_| ()),
378 Outcome::Failed(why) => self.hosts.record_push_error(&sub.id, &sub.endpoint, why),
379 };
380 if let Err(err) = recorded {
381 tracing::warn!(id = %sub.id, error = %err, "push outcome not recorded");
382 }
383 match &outcome {
384 Outcome::Removed => {
385 tracing::info!(id = %sub.id, host = %sub.endpoint_host(), "push subscription gone; removed")
386 }
387 Outcome::Failed(why) => tracing::warn!(id = %sub.id, host = %sub.endpoint_host(), %why, "push failed"),
388 Outcome::Delivered | Outcome::Suppressed => {}
389 }
390 outcome
391 }
392
393 fn request(
396 &self,
397 sub: &Subscription,
398 plaintext: &[u8],
399 urgency: &str,
400 subject: Option<&str>,
401 ) -> Result<PushRequest> {
402 let endpoint = url::Url::parse(&sub.endpoint)?;
403 let (p256dh, auth) = parse_keys(&sub.p256dh, &sub.auth).map_err(anyhow::Error::msg)?;
404 let ua_public = p256::PublicKey::from_sec1_bytes(&URL_SAFE_NO_PAD.decode(p256dh)?)?;
405 let auth: [u8; 16] = URL_SAFE_NO_PAD
406 .decode(auth)?
407 .try_into()
408 .map_err(|_| anyhow::anyhow!("keys.auth is not 16 bytes"))?;
409 let body = crate::push_crypto::encrypt(plaintext, &ua_public, &auth)?;
410 Ok(PushRequest {
411 headers: vec![
412 ("TTL", PUSH_TTL_SECS.to_string()),
413 ("Urgency", urgency.to_string()),
414 ("Content-Encoding", "aes128gcm".to_string()),
415 ("Content-Type", "application/octet-stream".to_string()),
416 ("Authorization", self.authorization(&endpoint, subject)),
417 ],
418 endpoint,
419 body,
420 })
421 }
422
423 fn authorization(&self, endpoint: &url::Url, subject: Option<&str>) -> String {
426 let mut tokens = self.tokens.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
427 tokens.authorization(&self.vapid, endpoint, subject, unix_now())
428 }
429
430 async fn send_retrying(&self, request: PushRequest, notice: &Notice, guard: &crate::push::NoticeGuard) -> Outcome {
436 let started = tokio::time::Instant::now();
437 let mut delays = self.retry.delays.iter();
438 loop {
439 if !guard(notice) {
440 return Outcome::Suppressed;
441 }
442 let sent = tokio::time::timeout(self.retry.attempt_timeout, self.transport.send(request.clone()))
443 .await
444 .unwrap_or(Sent::Timeout);
445 let (retryable, failed, retry_after) = match sent {
446 Sent::Status { code: 200..=299, .. } => return Outcome::Delivered,
447 Sent::Status { code: 404 | 410, .. } => return Outcome::Removed,
448 Sent::Status { code, retry_after } if code == 429 || code >= 500 => {
449 (true, format!("the push service answered {code}"), retry_after)
450 }
451 Sent::Status { code, .. } => (false, format!("the push service refused it ({code})"), None),
452 Sent::Refused => (
453 false,
454 "refused: the push service is not at a public address".into(),
455 None,
456 ),
457 Sent::Timeout => (true, "the push service did not answer in time".into(), None),
458 Sent::Failed => (true, "the push service could not be reached".into(), None),
459 };
460 match delays.next() {
461 Some(delay) if retryable => {
462 let wait = retry_after.map_or(*delay, |after| after.min(self.retry.max_retry_after).max(*delay));
463 if started.elapsed() + wait + self.retry.attempt_timeout > self.retry.budget {
464 return Outcome::Failed(format!("{failed}; gave up within the time allowed"));
465 }
466 tokio::time::sleep(wait).await;
467 }
468 _ => return Outcome::Failed(failed),
469 }
470 }
471 }
472}
473
474#[cfg(test)]
475mod tests {
476 use super::*;
477
478 fn notice() -> Notice {
479 Notice {
480 hat_id: "hat-1".into(),
481 urgency: Urgency::High,
482 title: "Fix the flaky test".into(),
483 generic_title: "Session needs your answer".into(),
484 body: "needs your answer".into(),
485 detail: Some("Run cargo test".into()),
486 url: "/sessions/s1".into(),
487 tag: "s1".into(),
488 }
489 }
490
491 #[test]
494 fn a_token_is_renewed_an_hour_before_it_expires() {
495 let vapid = VapidKey::generate();
496 let endpoint = url::Url::parse("https://fcm.googleapis.com/fcm/send/a").unwrap();
497 let mut tokens = Tokens::default();
498 let made = 1_800_000_000;
499 let first = tokens.authorization(&vapid, &endpoint, None, made);
500 let renew_at = made + VAPID_TOKEN_SECS - 60 * 60;
501 assert_eq!(tokens.authorization(&vapid, &endpoint, None, renew_at - 1), first);
502 let renewed = tokens.authorization(&vapid, &endpoint, None, renew_at);
503 assert_ne!(renewed, first);
504 assert_ne!(
506 tokens.authorization(&vapid, &endpoint, Some("mailto:a@b.co"), renew_at),
507 renewed
508 );
509 let other = url::Url::parse("https://web.push.apple.com/b").unwrap();
511 tokens.authorization(&vapid, &other, None, made + 3 * VAPID_TOKEN_SECS);
512 assert_eq!(tokens.0.len(), 1);
513 }
514
515 #[test]
516 fn retry_after_is_read_in_seconds_only() {
517 let mut headers = header::HeaderMap::new();
518 assert_eq!(retry_after(&headers), None);
519 headers.insert(header::RETRY_AFTER, header::HeaderValue::from_static(" 30 "));
520 assert_eq!(retry_after(&headers), Some(Duration::from_secs(30)));
521 headers.insert(
522 header::RETRY_AFTER,
523 header::HeaderValue::from_static("Wed, 21 Oct 2015 07:28:00 GMT"),
524 );
525 assert_eq!(retry_after(&headers), None);
526 headers.insert(header::RETRY_AFTER, header::HeaderValue::from_static("-1"));
527 assert_eq!(retry_after(&headers), None);
528 }
529
530 #[test]
531 fn the_policy_decides_what_a_payload_shows() {
532 let default = payload_for(¬ice(), PushPolicy::default()).unwrap();
533 assert_eq!(
534 (
535 default.title.as_str(),
536 default.body.as_str(),
537 default.url.as_str(),
538 default.tag.as_str()
539 ),
540 ("Fix the flaky test", "needs your answer", "/sessions/s1", "s1")
541 );
542 let generic = PushPolicy {
543 generic_title: true,
544 ..PushPolicy::default()
545 };
546 let shown = payload_for(¬ice(), generic).unwrap();
547 assert_eq!(
548 (shown.title.as_str(), shown.body.as_str()),
549 ("Session needs your answer", "")
550 );
551 let details = PushPolicy {
552 details: true,
553 ..PushPolicy::default()
554 };
555 assert_eq!(payload_for(¬ice(), details).unwrap().body, "Run cargo test");
556 let both = PushPolicy {
557 details: true,
558 generic_title: true,
559 ..PushPolicy::default()
560 };
561 let shown = payload_for(¬ice(), both).unwrap();
562 assert_eq!(
563 (shown.title.as_str(), shown.body.as_str()),
564 ("Session needs your answer", "Run cargo test")
565 );
566 let mut plain = notice();
568 plain.detail = None;
569 assert_eq!(payload_for(&plain, details).unwrap().body, "needs your answer");
570 let muted = PushPolicy {
571 muted: true,
572 details: true,
573 generic_title: false,
574 };
575 assert_eq!(payload_for(¬ice(), muted), None);
576 }
577
578 #[test]
579 fn a_payload_is_padded_to_its_bucket_and_stays_json() {
580 let payload = payload_for(¬ice(), PushPolicy::default()).unwrap();
581 let short = padded(&payload).unwrap();
582 assert_eq!(short.len(), PAD_TO);
583 assert_eq!(serde_json::from_slice::<PushPayload>(&short).unwrap(), payload);
584 let mut longer = payload.clone();
586 longer.title = "x".repeat(100);
587 assert_eq!(padded(&longer).unwrap().len(), PAD_TO);
588 longer.title = "x".repeat(600);
589 assert_eq!(padded(&longer).unwrap().len(), 2 * PAD_TO);
590 longer.title = "x".repeat(3900);
591 assert_eq!(padded(&longer).unwrap().len(), MAX_PLAINTEXT);
592 longer.title = "x".repeat(4000);
593 assert!(padded(&longer).is_err());
594 }
595}