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