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}
265
266/// A Live Activity nobody has updated in this long shows as stale.
267const ACTIVITY_STALE_SECS: u64 = 8 * 60 * 60;
268
269impl ActivityState {
270    pub fn of(info: &crate::clients::ClientInfo) -> Self {
271        Self {
272            device: info.name.clone(),
273            linked: true,
274            title: info.state.title.clone(),
275            artist: info.state.artist.clone(),
276            album: info.state.album.clone(),
277            playing: info.state.playing,
278            position_ms: info.position_ms(),
279            duration_ms: info.state.duration_ms,
280            at: chrono::Utc::now().timestamp_millis() as f64 / 1000.0,
281        }
282    }
283
284    /// Whether the activity would show something different from `sent`. A
285    /// playhead moving on time is drawn by the activity itself.
286    pub fn differs(&self, sent: &Self) -> bool {
287        let strip = |s: &Self| Self {
288            position_ms: 0,
289            at: 0.0,
290            ..s.clone()
291        };
292        if strip(self) != strip(sent) {
293            return true;
294        }
295        let expected = if sent.playing {
296            sent.position_ms + ((self.at - sent.at).max(0.0) * 1000.0) as u64
297        } else {
298            sent.position_ms
299        };
300        self.position_ms.abs_diff(expected) > 3000
301    }
302}
303
304fn read_key(path: &Path) -> Option<String> {
305    match std::fs::read_to_string(path) {
306        Ok(pem) => Some(pem),
307        Err(e) => {
308            log::warn!("push: cannot read the APNs key at {}: {e}", path.display());
309            None
310        }
311    }
312}
313
314/// The server's pusher, from the config it started with; `None` when no key
315/// is configured, and pushes are simply not sent.
316pub fn pusher() -> Option<&'static Pusher> {
317    static PUSHER: std::sync::LazyLock<Option<Pusher>> = std::sync::LazyLock::new(|| {
318        let cfg = koan_core::config::Config::load().unwrap_or_default();
319        let pusher = Pusher::from_config(&cfg.push).map(|p| Pusher {
320            public_url: cfg.sharing.public_url.filter(|u| !u.trim().is_empty()),
321            ..p
322        });
323        if pusher.is_some() {
324            log::info!("push: APNs key {} for {}", cfg.push.key_id, cfg.push.topic);
325        }
326        pusher
327    });
328    PUSHER.as_ref()
329}
330
331fn unix_now() -> u64 {
332    SystemTime::now()
333        .duration_since(UNIX_EPOCH)
334        .map_or(0, |d| d.as_secs())
335}
336
337/// Minted at start-up and never stored: a restart voids outstanding cover
338/// links, which live minutes anyway.
339fn cover_mac() -> Hmac<sha2::Sha256> {
340    static KEY: std::sync::LazyLock<[u8; 32]> = std::sync::LazyLock::new(|| {
341        let mut key = [0; 32];
342        getrandom::fill(&mut key).expect("system randomness");
343        key
344    });
345    let mut mac = Hmac::<sha2::Sha256>::new_from_slice(&*KEY).expect("any key length");
346    mac.update(b"koan notification cover\0");
347    mac
348}
349
350fn cover_sig(track_id: i64, expires: u64) -> String {
351    use base64::Engine;
352    let mut mac = cover_mac();
353    mac.update(format!("{track_id}.{expires}").as_bytes());
354    base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes())
355}
356
357fn cover_sig_valid(track_id: i64, expires: u64, sig: &str) -> bool {
358    use base64::Engine;
359    let Ok(sig) = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(sig) else {
360        return false;
361    };
362    let mut mac = cover_mac();
363    mac.update(format!("{track_id}.{expires}").as_bytes());
364    expires >= unix_now() && mac.verify_slice(&sig).is_ok()
365}
366
367#[derive(Clone)]
368struct CoverState {
369    pool: std::sync::Arc<Pool>,
370    covers: std::sync::Arc<crate::covers::Covers>,
371}
372
373/// `/push/cover/{track}/{expires}/{sig}`: public, since the extension that
374/// fetches it has no login. Anything but a valid, unexpired link is a 404.
375pub fn router(
376    pool: std::sync::Arc<Pool>,
377    covers: std::sync::Arc<crate::covers::Covers>,
378) -> axum::Router {
379    axum::Router::new()
380        .route("/push/cover/{track}/{expires}/{sig}", get(cover))
381        .with_state(CoverState { pool, covers })
382}
383
384async fn cover(
385    State(s): State<CoverState>,
386    UrlPath((track, expires, sig)): UrlPath<(i64, u64, String)>,
387) -> Response {
388    if !cover_sig_valid(track, expires, &sig) {
389        return crate::share::not_found();
390    }
391    let art = crate::share::blocking(move || {
392        let db = s.pool.get().ok()?;
393        let row = koan_core::db::queries::get_track_row(&db.conn, track).ok()??;
394        s.covers
395            .cover(std::slice::from_ref(&row), crate::covers::LARGE)
396    })
397    .await;
398    crate::share::jpeg(art, false)
399}
400
401#[cfg(test)]
402mod tests {
403    use super::*;
404
405    // A throwaway P-256 key, generated for these tests and used nowhere else.
406    const TEST_KEY: &str = "-----BEGIN PRIVATE KEY-----
407MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgMavrDWJ3FFXFskYn
408rsmSSRycut7lJn10pzM1NivXehuhRANCAARKklUe/y7J3SZEK36mnyt5ejhmbNKT
409AtQSJr6Wg9OtOkzZdoOhdRVcNFW8q9peFQ+S7qIcWNbXlhi+cAlpf0ce
410-----END PRIVATE KEY-----";
411
412    fn config() -> PushConfig {
413        PushConfig {
414            key: Some(TEST_KEY.into()),
415            key_id: "ABC123DEFG".into(),
416            team_id: "TEAM123456".into(),
417            ..PushConfig::default()
418        }
419    }
420
421    #[test]
422    fn no_key_no_pusher() {
423        assert!(Pusher::from_config(&PushConfig::default()).is_none());
424    }
425
426    #[test]
427    fn the_token_is_es256_with_the_key_id_and_reused() {
428        let pusher = Pusher::from_config(&config()).expect("pusher");
429        let token = pusher.bearer().unwrap();
430        let header = jsonwebtoken::decode_header(&token).unwrap();
431        assert_eq!(header.alg, Algorithm::ES256);
432        assert_eq!(header.kid.as_deref(), Some("ABC123DEFG"));
433        assert_eq!(pusher.bearer().unwrap(), token);
434    }
435
436    #[test]
437    fn a_notification_carries_its_command() {
438        let command = json!({ "type": "play", "trackIds": ["1"], "startAt": 0 });
439        let body = payload(&Push::Notify {
440            title: "Play on this iPhone".into(),
441            body: "Golden Standard".into(),
442            command: command.clone(),
443            image: None,
444        });
445        assert_eq!(body["koan"], command);
446        assert_eq!(body["aps"]["alert"]["body"], "Golden Standard");
447        assert_eq!(body["aps"]["interruption-level"], "time-sensitive");
448        assert!(body["aps"].get("mutable-content").is_none());
449        assert_eq!(payload(&Push::Wake)["aps"]["content-available"], 1);
450
451        let body = payload(&Push::Notify {
452            title: "Play on this iPhone".into(),
453            body: "Golden Standard".into(),
454            command,
455            image: Some("https://koan.example/push/cover/1/2/x".into()),
456        });
457        assert_eq!(body["aps"]["mutable-content"], 1);
458        assert_eq!(body["image"], "https://koan.example/push/cover/1/2/x");
459    }
460
461    #[test]
462    fn cover_links_open_one_cover_until_they_expire() {
463        let later = unix_now() + 60;
464        let sig = cover_sig(7, later);
465        assert!(cover_sig_valid(7, later, &sig));
466        assert!(!cover_sig_valid(8, later, &sig));
467        assert!(!cover_sig_valid(7, later + 1, &sig));
468        assert!(!cover_sig_valid(7, later, "not-a-signature"));
469        let past = unix_now() - 1;
470        assert!(!cover_sig_valid(7, past, &cover_sig(7, past)));
471    }
472}