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}
79
80impl Pusher {
81    /// The pusher `cfg` describes, if it names a key that can be read.
82    pub fn from_config(cfg: &PushConfig) -> Option<Self> {
83        if cfg.key_id.is_empty() || cfg.team_id.is_empty() {
84            return None;
85        }
86        let pem = match (&cfg.key, &cfg.key_path) {
87            (Some(pem), _) if !pem.trim().is_empty() => pem.clone(),
88            (_, Some(path)) => read_key(path)?,
89            _ => return None,
90        };
91        let key = match EncodingKey::from_ec_pem(pem.as_bytes()) {
92            Ok(key) => key,
93            Err(e) => {
94                log::warn!("push: the APNs key is not an EC private key: {e}");
95                return None;
96            }
97        };
98        Some(Self {
99            key,
100            key_id: cfg.key_id.clone(),
101            team_id: cfg.team_id.clone(),
102            topic: cfg.topic.clone(),
103            bearer: Mutex::new(None),
104            http: std::sync::OnceLock::new(),
105            public_url: None,
106        })
107    }
108
109    /// A link that opens the cover of `track_id`'s album for `COVER_LIFE`.
110    pub fn cover_link(&self, track_id: i64) -> Option<String> {
111        let base = self.public_url.as_deref()?.trim_end_matches('/');
112        let expires = unix_now() + COVER_LIFE;
113        let sig = cover_sig(track_id, expires);
114        Some(format!("{base}/push/cover/{track_id}/{expires}/{sig}"))
115    }
116
117    fn bearer(&self) -> Result<String, String> {
118        let mut cached = self.bearer.lock();
119        if let Some((token, at)) = cached.as_ref()
120            && at.elapsed().is_ok_and(|age| age < TOKEN_LIFE)
121        {
122            return Ok(token.clone());
123        }
124        let mut header = Header::new(Algorithm::ES256);
125        header.kid = Some(self.key_id.clone());
126        let iat = SystemTime::now()
127            .duration_since(UNIX_EPOCH)
128            .map_err(|e| e.to_string())?
129            .as_secs();
130        let token = jsonwebtoken::encode(
131            &header,
132            &json!({ "iss": self.team_id, "iat": iat }),
133            &self.key,
134        )
135        .map_err(|e| e.to_string())?;
136        *cached = Some((token.clone(), SystemTime::now()));
137        Ok(token)
138    }
139
140    /// Send `push` to the device holding `token`. Blocks for the round trip,
141    /// so never call it from async code.
142    pub fn send(&self, token: &str, sandbox: bool, push: &Push) -> Outcome {
143        let http = self.http.get_or_init(|| {
144            reqwest::blocking::Client::builder()
145                .connect_timeout(Duration::from_secs(10))
146                .timeout(Duration::from_secs(20))
147                .build()
148                .unwrap_or_default()
149        });
150        let bearer = match self.bearer() {
151            Ok(b) => b,
152            Err(e) => return Outcome::Failed(format!("signing: {e}")),
153        };
154        let host = if sandbox {
155            "api.sandbox.push.apple.com"
156        } else {
157            "api.push.apple.com"
158        };
159        let (kind, priority, expires_in) = match push {
160            // Low priority is the only priority a background push may have.
161            Push::Wake => ("background", "5", 60 * 60),
162            // "Play this" an hour late is not what anyone asked for.
163            Push::Notify { .. } => ("alert", "10", 10 * 60),
164        };
165        let expiration = unix_now() + expires_in;
166        let response = http
167            .post(format!("https://{host}/3/device/{token}"))
168            .bearer_auth(bearer)
169            .header("apns-topic", &self.topic)
170            .header("apns-push-type", kind)
171            .header("apns-priority", priority)
172            .header("apns-expiration", expiration.to_string())
173            .json(&payload(push))
174            .send();
175        match response {
176            Ok(r) if r.status().is_success() => Outcome::Sent,
177            Ok(r) => {
178                let status = r.status();
179                let reason = r
180                    .json::<Value>()
181                    .ok()
182                    .and_then(|v| v["reason"].as_str().map(str::to_owned))
183                    .unwrap_or_default();
184                if status == reqwest::StatusCode::GONE
185                    || matches!(
186                        reason.as_str(),
187                        "BadDeviceToken" | "Unregistered" | "DeviceTokenNotForTopic"
188                    )
189                {
190                    Outcome::Gone
191                } else {
192                    Outcome::Failed(format!("{status} {reason}"))
193                }
194            }
195            Err(e) => Outcome::Failed(e.to_string()),
196        }
197    }
198}
199
200/// The JSON a push carries. The command rides under `koan`, beside Apple's
201/// `aps`, so the app can act on it without asking the server first.
202pub fn payload(push: &Push) -> Value {
203    match push {
204        Push::Wake => json!({ "aps": { "content-available": 1 } }),
205        Push::Notify {
206            title,
207            body,
208            command,
209            image,
210        } => {
211            let mut body = json!({
212                "aps": {
213                    "alert": { "title": title, "body": body },
214                    "sound": "default",
215                    // Asked for this moment, by the person it is for: through Focus.
216                    "interruption-level": "time-sensitive",
217                },
218                "koan": command,
219            });
220            if let Some(image) = image {
221                // Hands the notification to the service extension, which
222                // attaches the cover before it is shown.
223                body["aps"]["mutable-content"] = json!(1);
224                body["image"] = json!(image);
225            }
226            body
227        }
228    }
229}
230
231fn read_key(path: &Path) -> Option<String> {
232    match std::fs::read_to_string(path) {
233        Ok(pem) => Some(pem),
234        Err(e) => {
235            log::warn!("push: cannot read the APNs key at {}: {e}", path.display());
236            None
237        }
238    }
239}
240
241/// The server's pusher, from the config it started with; `None` when no key
242/// is configured, and pushes are simply not sent.
243pub fn pusher() -> Option<&'static Pusher> {
244    static PUSHER: std::sync::LazyLock<Option<Pusher>> = std::sync::LazyLock::new(|| {
245        let cfg = koan_core::config::Config::load().unwrap_or_default();
246        let pusher = Pusher::from_config(&cfg.push).map(|p| Pusher {
247            public_url: cfg.sharing.public_url.filter(|u| !u.trim().is_empty()),
248            ..p
249        });
250        if pusher.is_some() {
251            log::info!("push: APNs key {} for {}", cfg.push.key_id, cfg.push.topic);
252        }
253        pusher
254    });
255    PUSHER.as_ref()
256}
257
258fn unix_now() -> u64 {
259    SystemTime::now()
260        .duration_since(UNIX_EPOCH)
261        .map_or(0, |d| d.as_secs())
262}
263
264/// Minted at start-up and never stored: a restart voids outstanding cover
265/// links, which live minutes anyway.
266fn cover_mac() -> Hmac<sha2::Sha256> {
267    static KEY: std::sync::LazyLock<[u8; 32]> = std::sync::LazyLock::new(|| {
268        let mut key = [0; 32];
269        getrandom::fill(&mut key).expect("system randomness");
270        key
271    });
272    let mut mac = Hmac::<sha2::Sha256>::new_from_slice(&*KEY).expect("any key length");
273    mac.update(b"koan notification cover\0");
274    mac
275}
276
277fn cover_sig(track_id: i64, expires: u64) -> String {
278    use base64::Engine;
279    let mut mac = cover_mac();
280    mac.update(format!("{track_id}.{expires}").as_bytes());
281    base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes())
282}
283
284fn cover_sig_valid(track_id: i64, expires: u64, sig: &str) -> bool {
285    use base64::Engine;
286    let Ok(sig) = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(sig) else {
287        return false;
288    };
289    let mut mac = cover_mac();
290    mac.update(format!("{track_id}.{expires}").as_bytes());
291    expires >= unix_now() && mac.verify_slice(&sig).is_ok()
292}
293
294#[derive(Clone)]
295struct CoverState {
296    pool: std::sync::Arc<Pool>,
297    covers: std::sync::Arc<crate::covers::Covers>,
298}
299
300/// `/push/cover/{track}/{expires}/{sig}`: public, since the extension that
301/// fetches it has no login. Anything but a valid, unexpired link is a 404.
302pub fn router(
303    pool: std::sync::Arc<Pool>,
304    covers: std::sync::Arc<crate::covers::Covers>,
305) -> axum::Router {
306    axum::Router::new()
307        .route("/push/cover/{track}/{expires}/{sig}", get(cover))
308        .with_state(CoverState { pool, covers })
309}
310
311async fn cover(
312    State(s): State<CoverState>,
313    UrlPath((track, expires, sig)): UrlPath<(i64, u64, String)>,
314) -> Response {
315    if !cover_sig_valid(track, expires, &sig) {
316        return crate::share::not_found();
317    }
318    let art = crate::share::blocking(move || {
319        let db = s.pool.get().ok()?;
320        let row = koan_core::db::queries::get_track_row(&db.conn, track).ok()??;
321        s.covers
322            .cover(std::slice::from_ref(&row), crate::covers::LARGE)
323    })
324    .await;
325    crate::share::jpeg(art, false)
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331
332    // A throwaway P-256 key, generated for these tests and used nowhere else.
333    const TEST_KEY: &str = "-----BEGIN PRIVATE KEY-----
334MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgMavrDWJ3FFXFskYn
335rsmSSRycut7lJn10pzM1NivXehuhRANCAARKklUe/y7J3SZEK36mnyt5ejhmbNKT
336AtQSJr6Wg9OtOkzZdoOhdRVcNFW8q9peFQ+S7qIcWNbXlhi+cAlpf0ce
337-----END PRIVATE KEY-----";
338
339    fn config() -> PushConfig {
340        PushConfig {
341            key: Some(TEST_KEY.into()),
342            key_id: "ABC123DEFG".into(),
343            team_id: "TEAM123456".into(),
344            ..PushConfig::default()
345        }
346    }
347
348    #[test]
349    fn no_key_no_pusher() {
350        assert!(Pusher::from_config(&PushConfig::default()).is_none());
351    }
352
353    #[test]
354    fn the_token_is_es256_with_the_key_id_and_reused() {
355        let pusher = Pusher::from_config(&config()).expect("pusher");
356        let token = pusher.bearer().unwrap();
357        let header = jsonwebtoken::decode_header(&token).unwrap();
358        assert_eq!(header.alg, Algorithm::ES256);
359        assert_eq!(header.kid.as_deref(), Some("ABC123DEFG"));
360        assert_eq!(pusher.bearer().unwrap(), token);
361    }
362
363    #[test]
364    fn a_notification_carries_its_command() {
365        let command = json!({ "type": "play", "trackIds": ["1"], "startAt": 0 });
366        let body = payload(&Push::Notify {
367            title: "Play on this iPhone".into(),
368            body: "Golden Standard".into(),
369            command: command.clone(),
370            image: None,
371        });
372        assert_eq!(body["koan"], command);
373        assert_eq!(body["aps"]["alert"]["body"], "Golden Standard");
374        assert_eq!(body["aps"]["interruption-level"], "time-sensitive");
375        assert!(body["aps"].get("mutable-content").is_none());
376        assert_eq!(payload(&Push::Wake)["aps"]["content-available"], 1);
377
378        let body = payload(&Push::Notify {
379            title: "Play on this iPhone".into(),
380            body: "Golden Standard".into(),
381            command,
382            image: Some("https://koan.example/push/cover/1/2/x".into()),
383        });
384        assert_eq!(body["aps"]["mutable-content"], 1);
385        assert_eq!(body["image"], "https://koan.example/push/cover/1/2/x");
386    }
387
388    #[test]
389    fn cover_links_open_one_cover_until_they_expire() {
390        let later = unix_now() + 60;
391        let sig = cover_sig(7, later);
392        assert!(cover_sig_valid(7, later, &sig));
393        assert!(!cover_sig_valid(8, later, &sig));
394        assert!(!cover_sig_valid(7, later + 1, &sig));
395        assert!(!cover_sig_valid(7, later, "not-a-signature"));
396        let past = unix_now() - 1;
397        assert!(!cover_sig_valid(7, past, &cover_sig(7, past)));
398    }
399}