Skip to main content

koan_server/
push.rs

1//! Apple's push service, for reaching a koan iOS app that iOS has suspended.
2//!
3//! A linked app is reached over its WebSocket. iOS suspends an app in the
4//! background that is not playing, and the socket dies with it; a push is the
5//! supported way back. Two kinds: a background push that wakes the app for
6//! half a minute to link and take what waits for it in the outbox, and a
7//! notification for playback, which iOS will not let a suspended app start on
8//! its own: the person taps it, the app opens and plays.
9//!
10//! Token auth: a JWT signed with the team's `.p8` key, reused for under an
11//! hour as Apple asks. HTTP/2, which is all the gateway speaks.
12//!
13//! A notification carries a link to its album's cover, which the app's
14//! notification service extension fetches and attaches. The extension holds no
15//! credentials, so the link authorises itself: an HMAC over one track id and
16//! an expiry, keyed by a secret that lives only in this process. It opens that
17//! one cover for a few minutes and nothing else.
18
19use std::path::Path;
20use std::time::{Duration, SystemTime, UNIX_EPOCH};
21
22use axum::extract::{Path as UrlPath, State};
23use axum::response::Response;
24use axum::routing::get;
25use hmac::{Hmac, KeyInit, Mac};
26use jsonwebtoken::{Algorithm, EncodingKey, Header};
27use koan_core::config::PushConfig;
28use koan_core::db::pool::Pool;
29use parking_lot::Mutex;
30use serde_json::{Value, json};
31
32/// Apple rejects a token older than an hour and throttles one refreshed more
33/// often than every twenty minutes.
34const TOKEN_LIFE: Duration = Duration::from_secs(50 * 60);
35
36/// How long a notification's cover link opens its cover. The extension fetches
37/// it as the notification arrives; a notification delivered later than this
38/// shows without art.
39const COVER_LIFE: u64 = 10 * 60;
40
41pub struct Pusher {
42    key: EncodingKey,
43    key_id: String,
44    team_id: String,
45    topic: String,
46    bearer: Mutex<Option<(String, SystemTime)>>,
47    /// Built on first send, which is always on a thread of its own: a blocking
48    /// client runs a runtime, and building one inside the server's async
49    /// runtime panics.
50    http: std::sync::OnceLock<reqwest::blocking::Client>,
51    /// `sharing.public_url`, which cover links are built on. Without it
52    /// notifications carry no art.
53    public_url: Option<String>,
54}
55
56/// What became of a push.
57#[derive(Debug, PartialEq, Eq)]
58pub enum Outcome {
59    Sent,
60    /// The token is no longer valid for this app: the app was deleted, or the
61    /// token belongs to the other gateway. Forget it.
62    Gone,
63    Failed(String),
64}
65
66/// What a push asks of the device.
67pub enum Push {
68    /// Wake the app to link; whatever waits in the outbox follows.
69    Wake,
70    /// Wake the app because someone chose it in the Control menu: no use an
71    /// hour late, unlike `Wake`'s sync.
72    WakeNow,
73    /// A notification asking to be tapped, for a device a push did not wake.
74    /// Tapping it opens the app, which links.
75    Summon { title: String, body: String },
76    /// Show this, and carry `command` for the app to run when it is tapped.
77    Notify {
78        title: String,
79        body: String,
80        command: Value,
81        /// A cover link from `Pusher::cover_link`.
82        image: Option<String>,
83    },
84    /// Update a Live Activity that shows another device.
85    Activity(ActivityState),
86}
87
88impl Pusher {
89    /// The pusher `cfg` describes, if it names a key that can be read.
90    pub fn from_config(cfg: &PushConfig) -> Option<Self> {
91        if cfg.key_id.is_empty() || cfg.team_id.is_empty() {
92            return None;
93        }
94        let pem = match (&cfg.key, &cfg.key_path) {
95            (Some(pem), _) if !pem.trim().is_empty() => pem.clone(),
96            (_, Some(path)) => read_key(path)?,
97            _ => return None,
98        };
99        let key = match EncodingKey::from_ec_pem(pem.as_bytes()) {
100            Ok(key) => key,
101            Err(e) => {
102                log::warn!("push: the APNs key is not an EC private key: {e}");
103                return None;
104            }
105        };
106        Some(Self {
107            key,
108            key_id: cfg.key_id.clone(),
109            team_id: cfg.team_id.clone(),
110            topic: cfg.topic.clone(),
111            bearer: Mutex::new(None),
112            http: std::sync::OnceLock::new(),
113            public_url: None,
114        })
115    }
116
117    /// A link that opens the cover of `track_id`'s album for `COVER_LIFE`.
118    pub fn cover_link(&self, track_id: i64) -> Option<String> {
119        let base = self.public_url.as_deref()?.trim_end_matches('/');
120        let expires = unix_now() + COVER_LIFE;
121        let sig = cover_sig(track_id, expires);
122        Some(format!("{base}/push/cover/{track_id}/{expires}/{sig}"))
123    }
124
125    fn bearer(&self) -> Result<String, String> {
126        let mut cached = self.bearer.lock();
127        if let Some((token, at)) = cached.as_ref()
128            && at.elapsed().is_ok_and(|age| age < TOKEN_LIFE)
129        {
130            return Ok(token.clone());
131        }
132        let mut header = Header::new(Algorithm::ES256);
133        header.kid = Some(self.key_id.clone());
134        let iat = SystemTime::now()
135            .duration_since(UNIX_EPOCH)
136            .map_err(|e| e.to_string())?
137            .as_secs();
138        let token = jsonwebtoken::encode(
139            &header,
140            &json!({ "iss": self.team_id, "iat": iat }),
141            &self.key,
142        )
143        .map_err(|e| e.to_string())?;
144        *cached = Some((token.clone(), SystemTime::now()));
145        Ok(token)
146    }
147
148    /// Send `push` to the device holding `token`. Blocks for the round trip,
149    /// so never call it from async code.
150    pub fn send(&self, token: &str, sandbox: bool, push: &Push) -> Outcome {
151        let http = self.http.get_or_init(|| {
152            reqwest::blocking::Client::builder()
153                .connect_timeout(Duration::from_secs(10))
154                .timeout(Duration::from_secs(20))
155                .build()
156                .unwrap_or_default()
157        });
158        let bearer = match self.bearer() {
159            Ok(b) => b,
160            Err(e) => return Outcome::Failed(format!("signing: {e}")),
161        };
162        let host = if sandbox {
163            "api.sandbox.push.apple.com"
164        } else {
165            "api.push.apple.com"
166        };
167        let Headers {
168            kind,
169            priority,
170            expires_in,
171            collapse,
172        } = headers(push);
173        // A Live Activity's pushes go to the app's topic with a suffix.
174        let topic = match push {
175            Push::Activity(_) => format!("{}.push-type.liveactivity", self.topic),
176            _ => self.topic.clone(),
177        };
178        let expiration = unix_now() + expires_in;
179        let request = http
180            .post(format!("https://{host}/3/device/{token}"))
181            .bearer_auth(bearer)
182            .header("apns-topic", topic)
183            .header("apns-push-type", kind)
184            .header("apns-priority", priority)
185            .header("apns-expiration", expiration.to_string());
186        let request = match collapse {
187            Some(id) => request.header("apns-collapse-id", id),
188            None => request,
189        };
190        let response = request.json(&payload(push)).send();
191        match response {
192            Ok(r) if r.status().is_success() => Outcome::Sent,
193            Ok(r) => {
194                let status = r.status();
195                let reason = r
196                    .json::<Value>()
197                    .ok()
198                    .and_then(|v| v["reason"].as_str().map(str::to_owned))
199                    .unwrap_or_default();
200                if status == reqwest::StatusCode::GONE
201                    || matches!(
202                        reason.as_str(),
203                        "BadDeviceToken" | "Unregistered" | "DeviceTokenNotForTopic"
204                    )
205                {
206                    Outcome::Gone
207                } else {
208                    Outcome::Failed(format!("{status} {reason}"))
209                }
210            }
211            Err(e) => Outcome::Failed(e.to_string()),
212        }
213    }
214}
215
216struct Headers {
217    kind: &'static str,
218    priority: &'static str,
219    expires_in: u64,
220    /// Pushes with one id replace each other on the way, rather than queue.
221    collapse: Option<&'static str>,
222}
223
224fn headers(push: &Push) -> Headers {
225    let (kind, priority, expires_in, collapse) = match push {
226        // Low priority is the only priority a background push may have.
227        Push::Wake => ("background", "5", 60 * 60, Some("koan-wake")),
228        Push::WakeNow => ("background", "5", 60, Some("koan-wake")),
229        // "Play this" an hour late is not what anyone asked for.
230        Push::Notify { .. } => ("alert", "10", 10 * 60, None),
231        Push::Summon { .. } => ("alert", "10", 2 * 60, Some("koan-summon")),
232        // Stale within the minute: the device will have moved on.
233        Push::Activity(_) => ("liveactivity", "10", 60, None),
234    };
235    Headers {
236        kind,
237        priority,
238        expires_in,
239        collapse,
240    }
241}
242
243/// The JSON a push carries. The command rides under `koan`, beside Apple's
244/// `aps`, so the app can act on it without asking the server first.
245pub fn payload(push: &Push) -> Value {
246    match push {
247        Push::Activity(state) => json!({
248            "aps": {
249                "timestamp": state.at as u64,
250                "event": "update",
251                "content-state": state,
252                // Past this the lock screen dims it: nothing has been heard.
253                "stale-date": state.at as u64 + ACTIVITY_STALE_SECS,
254            },
255        }),
256        Push::Wake | Push::WakeNow => json!({ "aps": { "content-available": 1 } }),
257        Push::Summon { title, body } => json!({
258            "aps": {
259                "alert": { "title": title, "body": body },
260                "sound": "default",
261                "interruption-level": "time-sensitive",
262            },
263        }),
264        Push::Notify {
265            title,
266            body,
267            command,
268            image,
269        } => {
270            let mut body = json!({
271                "aps": {
272                    "alert": { "title": title, "body": body },
273                    "sound": "default",
274                    // Asked for this moment, by the person it is for: through Focus.
275                    "interruption-level": "time-sensitive",
276                },
277                "koan": command,
278            });
279            if let Some(image) = image {
280                // Hands the notification to the service extension, which
281                // attaches the cover before it is shown.
282                body["aps"]["mutable-content"] = json!(1);
283                body["image"] = json!(image);
284            }
285            body
286        }
287    }
288}
289
290/// What a Live Activity shows of the device it follows. The field names are
291/// the app's `RemoteActivity.ContentState`, which decodes it.
292#[derive(Debug, Clone, PartialEq, serde::Serialize)]
293#[serde(rename_all = "camelCase")]
294pub struct ActivityState {
295    pub device: String,
296    pub linked: bool,
297    pub title: Option<String>,
298    pub artist: Option<String>,
299    pub album: Option<String>,
300    pub playing: bool,
301    pub position_ms: u64,
302    pub duration_ms: u64,
303    /// When `position_ms` was true, in Unix seconds.
304    pub at: f64,
305    /// The sleeve, as a small base64 JPEG: a Live Activity cannot fetch
306    /// anything, and this is how it gets one while the app is suspended.
307    #[serde(skip_serializing_if = "Option::is_none")]
308    pub art: Option<String>,
309}
310
311/// A Live Activity nobody has updated in this long shows as stale.
312const ACTIVITY_STALE_SECS: u64 = 8 * 60 * 60;
313
314impl ActivityState {
315    pub fn of(info: &crate::clients::ClientInfo) -> Self {
316        Self {
317            device: info.name.clone(),
318            linked: true,
319            title: info.state.title.clone(),
320            artist: info.state.artist.clone(),
321            album: info.state.album.clone(),
322            playing: info.state.playing,
323            position_ms: info.position_ms(),
324            duration_ms: info.state.duration_ms,
325            at: chrono::Utc::now().timestamp_millis() as f64 / 1000.0,
326            art: info
327                .state
328                .queue
329                .iter()
330                .find(|e| e.current)
331                .and_then(|e| e.track_id.as_deref())
332                .and_then(activity_art),
333        }
334    }
335
336    /// Whether the activity would show something different from `sent`. A
337    /// playhead moving on time is drawn by the activity itself.
338    pub fn differs(&self, sent: &Self) -> bool {
339        let strip = |s: &Self| Self {
340            position_ms: 0,
341            at: 0.0,
342            ..s.clone()
343        };
344        if strip(self) != strip(sent) {
345            return true;
346        }
347        let expected = if sent.playing {
348            sent.position_ms + ((self.at - sent.at).max(0.0) * 1000.0) as u64
349        } else {
350            sent.position_ms
351        };
352        self.position_ms.abs_diff(expected) > 3000
353    }
354}
355
356/// Apple refuses a Live Activity push over 4 KB, and the text, the times and
357/// the envelope take well under a kilobyte of it.
358const ART_BUDGET: usize = 2600;
359
360/// The cover of `track` (a uid or a row id) small enough to ride in a Live
361/// Activity push, as base64. Worked out once per track: the state it goes in
362/// is reported far more often than the track changes.
363fn activity_art(track: &str) -> Option<String> {
364    use base64::Engine as _;
365    static LAST: parking_lot::Mutex<Option<(String, Option<String>)>> =
366        parking_lot::Mutex::new(None);
367    static COVERS: std::sync::LazyLock<crate::covers::Covers> =
368        std::sync::LazyLock::new(crate::covers::Covers::in_config_dir);
369    if cfg!(test) {
370        return None;
371    }
372    if let Some((held, art)) = LAST.lock().as_ref()
373        && held == track
374    {
375        return art.clone();
376    }
377    let art = (|| {
378        let db = koan_core::db::pool::shared().get().ok()?;
379        let id = koan_core::db::queries::resolve_id(
380            &db.conn,
381            koan_core::db::queries::UidKind::Track,
382            track,
383        )
384        .ok()??;
385        let row = koan_core::db::queries::get_track_row(&db.conn, id).ok()??;
386        let cover = COVERS.cover(std::slice::from_ref(&row), crate::covers::SIZES[0])?;
387        let img = image::load_from_memory(&cover).ok()?;
388        // Smaller and rougher until it fits.
389        [(72, 60), (60, 50), (48, 40)]
390            .into_iter()
391            .find_map(|(side, quality)| {
392                let mut out = Vec::new();
393                let small = img.thumbnail(side, side).to_rgb8();
394                image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, quality)
395                    .encode_image(&small)
396                    .ok()?;
397                let b64 = base64::engine::general_purpose::STANDARD.encode(&out);
398                (b64.len() <= ART_BUDGET).then_some(b64)
399            })
400    })();
401    *LAST.lock() = Some((track.to_string(), art.clone()));
402    art
403}
404
405fn read_key(path: &Path) -> Option<String> {
406    match std::fs::read_to_string(path) {
407        Ok(pem) => Some(pem),
408        Err(e) => {
409            log::warn!("push: cannot read the APNs key at {}: {e}", path.display());
410            None
411        }
412    }
413}
414
415/// The server's pusher, from the config it started with; `None` when no key
416/// is configured, and pushes are simply not sent.
417pub fn pusher() -> Option<&'static Pusher> {
418    static PUSHER: std::sync::LazyLock<Option<Pusher>> = std::sync::LazyLock::new(|| {
419        let cfg = koan_core::config::Config::load().unwrap_or_default();
420        let pusher = Pusher::from_config(&cfg.push).map(|p| Pusher {
421            public_url: cfg.sharing.public_url.filter(|u| !u.trim().is_empty()),
422            ..p
423        });
424        if pusher.is_some() {
425            log::info!("push: APNs key {} for {}", cfg.push.key_id, cfg.push.topic);
426        }
427        pusher
428    });
429    PUSHER.as_ref()
430}
431
432fn unix_now() -> u64 {
433    SystemTime::now()
434        .duration_since(UNIX_EPOCH)
435        .map_or(0, |d| d.as_secs())
436}
437
438/// Minted at start-up and never stored: a restart voids outstanding cover
439/// links, which live minutes anyway.
440fn cover_mac() -> Hmac<sha2::Sha256> {
441    static KEY: std::sync::LazyLock<[u8; 32]> = std::sync::LazyLock::new(|| {
442        let mut key = [0; 32];
443        getrandom::fill(&mut key).expect("system randomness");
444        key
445    });
446    let mut mac = Hmac::<sha2::Sha256>::new_from_slice(&*KEY).expect("any key length");
447    mac.update(b"koan notification cover\0");
448    mac
449}
450
451fn cover_sig(track_id: i64, expires: u64) -> String {
452    use base64::Engine;
453    let mut mac = cover_mac();
454    mac.update(format!("{track_id}.{expires}").as_bytes());
455    base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes())
456}
457
458fn cover_sig_valid(track_id: i64, expires: u64, sig: &str) -> bool {
459    use base64::Engine;
460    let Ok(sig) = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(sig) else {
461        return false;
462    };
463    let mut mac = cover_mac();
464    mac.update(format!("{track_id}.{expires}").as_bytes());
465    expires >= unix_now() && mac.verify_slice(&sig).is_ok()
466}
467
468#[derive(Clone)]
469struct CoverState {
470    pool: std::sync::Arc<Pool>,
471    covers: std::sync::Arc<crate::covers::Covers>,
472}
473
474/// `/push/cover/{track}/{expires}/{sig}`: public, since the extension that
475/// fetches it has no login. Anything but a valid, unexpired link is a 404.
476pub fn router(
477    pool: std::sync::Arc<Pool>,
478    covers: std::sync::Arc<crate::covers::Covers>,
479) -> axum::Router {
480    axum::Router::new()
481        .route("/push/cover/{track}/{expires}/{sig}", get(cover))
482        .with_state(CoverState { pool, covers })
483}
484
485async fn cover(
486    State(s): State<CoverState>,
487    UrlPath((track, expires, sig)): UrlPath<(i64, u64, String)>,
488) -> Response {
489    if !cover_sig_valid(track, expires, &sig) {
490        return crate::share::not_found();
491    }
492    let art = crate::share::blocking(move || {
493        let db = s.pool.get().ok()?;
494        let row = koan_core::db::queries::get_track_row(&db.conn, track).ok()??;
495        s.covers
496            .cover(std::slice::from_ref(&row), crate::covers::LARGE)
497    })
498    .await;
499    crate::share::jpeg(art, false)
500}
501
502#[cfg(test)]
503mod tests {
504    use super::*;
505
506    // A throwaway P-256 key, generated for these tests and used nowhere else.
507    const TEST_KEY: &str = "-----BEGIN PRIVATE KEY-----
508MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgMavrDWJ3FFXFskYn
509rsmSSRycut7lJn10pzM1NivXehuhRANCAARKklUe/y7J3SZEK36mnyt5ejhmbNKT
510AtQSJr6Wg9OtOkzZdoOhdRVcNFW8q9peFQ+S7qIcWNbXlhi+cAlpf0ce
511-----END PRIVATE KEY-----";
512
513    fn config() -> PushConfig {
514        PushConfig {
515            key: Some(TEST_KEY.into()),
516            key_id: "ABC123DEFG".into(),
517            team_id: "TEAM123456".into(),
518            ..PushConfig::default()
519        }
520    }
521
522    /// Apple delivers a background push only at priority 5, and holds one
523    /// collapse id's pushes as one rather than queueing every retry.
524    #[test]
525    fn wakes_are_background_low_priority_and_collapse() {
526        for push in [Push::Wake, Push::WakeNow] {
527            let h = headers(&push);
528            assert_eq!(
529                (h.kind, h.priority, h.collapse),
530                ("background", "5", Some("koan-wake"))
531            );
532            assert_eq!(payload(&push)["aps"]["content-available"], 1);
533        }
534        assert!(
535            headers(&Push::WakeNow).expires_in <= 60,
536            "a late wake is no wake"
537        );
538        let summon = Push::Summon {
539            title: "Mac wants to play here".into(),
540            body: "Tap to let it".into(),
541        };
542        let h = headers(&summon);
543        assert_eq!(
544            (h.kind, h.priority, h.collapse),
545            ("alert", "10", Some("koan-summon"))
546        );
547        assert_eq!(
548            payload(&summon)["aps"]["alert"]["title"],
549            "Mac wants to play here"
550        );
551        assert!(
552            payload(&summon).get("koan").is_none(),
553            "tapping it only opens the app"
554        );
555    }
556
557    #[test]
558    fn no_key_no_pusher() {
559        assert!(Pusher::from_config(&PushConfig::default()).is_none());
560    }
561
562    #[test]
563    fn the_token_is_es256_with_the_key_id_and_reused() {
564        let pusher = Pusher::from_config(&config()).expect("pusher");
565        let token = pusher.bearer().unwrap();
566        let header = jsonwebtoken::decode_header(&token).unwrap();
567        assert_eq!(header.alg, Algorithm::ES256);
568        assert_eq!(header.kid.as_deref(), Some("ABC123DEFG"));
569        assert_eq!(pusher.bearer().unwrap(), token);
570    }
571
572    #[test]
573    fn a_notification_carries_its_command() {
574        let command = json!({ "type": "play", "trackIds": ["1"], "startAt": 0 });
575        let body = payload(&Push::Notify {
576            title: "Play on this iPhone".into(),
577            body: "Golden Standard".into(),
578            command: command.clone(),
579            image: None,
580        });
581        assert_eq!(body["koan"], command);
582        assert_eq!(body["aps"]["alert"]["body"], "Golden Standard");
583        assert_eq!(body["aps"]["interruption-level"], "time-sensitive");
584        assert!(body["aps"].get("mutable-content").is_none());
585        assert_eq!(payload(&Push::Wake)["aps"]["content-available"], 1);
586
587        let body = payload(&Push::Notify {
588            title: "Play on this iPhone".into(),
589            body: "Golden Standard".into(),
590            command,
591            image: Some("https://koan.example/push/cover/1/2/x".into()),
592        });
593        assert_eq!(body["aps"]["mutable-content"], 1);
594        assert_eq!(body["image"], "https://koan.example/push/cover/1/2/x");
595    }
596
597    #[test]
598    fn cover_links_open_one_cover_until_they_expire() {
599        let later = unix_now() + 60;
600        let sig = cover_sig(7, later);
601        assert!(cover_sig_valid(7, later, &sig));
602        assert!(!cover_sig_valid(8, later, &sig));
603        assert!(!cover_sig_valid(7, later + 1, &sig));
604        assert!(!cover_sig_valid(7, later, "not-a-signature"));
605        let past = unix_now() - 1;
606        assert!(!cover_sig_valid(7, past, &cover_sig(7, past)));
607    }
608}