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
13use std::path::Path;
14use std::time::{Duration, SystemTime, UNIX_EPOCH};
15
16use jsonwebtoken::{Algorithm, EncodingKey, Header};
17use koan_core::config::PushConfig;
18use parking_lot::Mutex;
19use serde_json::{Value, json};
20
21/// Apple rejects a token older than an hour and throttles one refreshed more
22/// often than every twenty minutes.
23const TOKEN_LIFE: Duration = Duration::from_secs(50 * 60);
24
25pub struct Pusher {
26    key: EncodingKey,
27    key_id: String,
28    team_id: String,
29    topic: String,
30    bearer: Mutex<Option<(String, SystemTime)>>,
31    /// Built on first send, which is always on a thread of its own: a blocking
32    /// client runs a runtime, and building one inside the server's async
33    /// runtime panics.
34    http: std::sync::OnceLock<reqwest::blocking::Client>,
35}
36
37/// What became of a push.
38#[derive(Debug, PartialEq, Eq)]
39pub enum Outcome {
40    Sent,
41    /// The token is no longer valid for this app: the app was deleted, or the
42    /// token belongs to the other gateway. Forget it.
43    Gone,
44    Failed(String),
45}
46
47/// What a push asks of the device.
48pub enum Push {
49    /// Wake the app to link; whatever waits in the outbox follows.
50    Wake,
51    /// Show this, and carry `command` for the app to run when it is tapped.
52    Notify {
53        title: String,
54        body: String,
55        command: Value,
56    },
57}
58
59impl Pusher {
60    /// The pusher `cfg` describes, if it names a key that can be read.
61    pub fn from_config(cfg: &PushConfig) -> Option<Self> {
62        if cfg.key_id.is_empty() || cfg.team_id.is_empty() {
63            return None;
64        }
65        let pem = match (&cfg.key, &cfg.key_path) {
66            (Some(pem), _) if !pem.trim().is_empty() => pem.clone(),
67            (_, Some(path)) => read_key(path)?,
68            _ => return None,
69        };
70        let key = match EncodingKey::from_ec_pem(pem.as_bytes()) {
71            Ok(key) => key,
72            Err(e) => {
73                log::warn!("push: the APNs key is not an EC private key: {e}");
74                return None;
75            }
76        };
77        Some(Self {
78            key,
79            key_id: cfg.key_id.clone(),
80            team_id: cfg.team_id.clone(),
81            topic: cfg.topic.clone(),
82            bearer: Mutex::new(None),
83            http: std::sync::OnceLock::new(),
84        })
85    }
86
87    fn bearer(&self) -> Result<String, String> {
88        let mut cached = self.bearer.lock();
89        if let Some((token, at)) = cached.as_ref()
90            && at.elapsed().is_ok_and(|age| age < TOKEN_LIFE)
91        {
92            return Ok(token.clone());
93        }
94        let mut header = Header::new(Algorithm::ES256);
95        header.kid = Some(self.key_id.clone());
96        let iat = SystemTime::now()
97            .duration_since(UNIX_EPOCH)
98            .map_err(|e| e.to_string())?
99            .as_secs();
100        let token = jsonwebtoken::encode(
101            &header,
102            &json!({ "iss": self.team_id, "iat": iat }),
103            &self.key,
104        )
105        .map_err(|e| e.to_string())?;
106        *cached = Some((token.clone(), SystemTime::now()));
107        Ok(token)
108    }
109
110    /// Send `push` to the device holding `token`. Blocks for the round trip,
111    /// so never call it from async code.
112    pub fn send(&self, token: &str, sandbox: bool, push: &Push) -> Outcome {
113        let http = self.http.get_or_init(|| {
114            reqwest::blocking::Client::builder()
115                .connect_timeout(Duration::from_secs(10))
116                .timeout(Duration::from_secs(20))
117                .build()
118                .unwrap_or_default()
119        });
120        let bearer = match self.bearer() {
121            Ok(b) => b,
122            Err(e) => return Outcome::Failed(format!("signing: {e}")),
123        };
124        let host = if sandbox {
125            "api.sandbox.push.apple.com"
126        } else {
127            "api.push.apple.com"
128        };
129        let (kind, priority, expires_in) = match push {
130            // Low priority is the only priority a background push may have.
131            Push::Wake => ("background", "5", 60 * 60),
132            // "Play this" an hour late is not what anyone asked for.
133            Push::Notify { .. } => ("alert", "10", 10 * 60),
134        };
135        let expiration = SystemTime::now()
136            .duration_since(UNIX_EPOCH)
137            .map(|d| d.as_secs() + expires_in)
138            .unwrap_or_default();
139        let response = http
140            .post(format!("https://{host}/3/device/{token}"))
141            .bearer_auth(bearer)
142            .header("apns-topic", &self.topic)
143            .header("apns-push-type", kind)
144            .header("apns-priority", priority)
145            .header("apns-expiration", expiration.to_string())
146            .json(&payload(push))
147            .send();
148        match response {
149            Ok(r) if r.status().is_success() => Outcome::Sent,
150            Ok(r) => {
151                let status = r.status();
152                let reason = r
153                    .json::<Value>()
154                    .ok()
155                    .and_then(|v| v["reason"].as_str().map(str::to_owned))
156                    .unwrap_or_default();
157                if status == reqwest::StatusCode::GONE
158                    || matches!(
159                        reason.as_str(),
160                        "BadDeviceToken" | "Unregistered" | "DeviceTokenNotForTopic"
161                    )
162                {
163                    Outcome::Gone
164                } else {
165                    Outcome::Failed(format!("{status} {reason}"))
166                }
167            }
168            Err(e) => Outcome::Failed(e.to_string()),
169        }
170    }
171}
172
173/// The JSON a push carries. The command rides under `koan`, beside Apple's
174/// `aps`, so the app can act on it without asking the server first.
175pub fn payload(push: &Push) -> Value {
176    match push {
177        Push::Wake => json!({ "aps": { "content-available": 1 } }),
178        Push::Notify {
179            title,
180            body,
181            command,
182        } => json!({
183            "aps": {
184                "alert": { "title": title, "body": body },
185                "sound": "default",
186                // Asked for this moment, by the person it is for: through Focus.
187                "interruption-level": "time-sensitive",
188            },
189            "koan": command,
190        }),
191    }
192}
193
194fn read_key(path: &Path) -> Option<String> {
195    match std::fs::read_to_string(path) {
196        Ok(pem) => Some(pem),
197        Err(e) => {
198            log::warn!("push: cannot read the APNs key at {}: {e}", path.display());
199            None
200        }
201    }
202}
203
204/// The server's pusher, from the config it started with; `None` when no key
205/// is configured, and pushes are simply not sent.
206pub fn pusher() -> Option<&'static Pusher> {
207    static PUSHER: std::sync::LazyLock<Option<Pusher>> = std::sync::LazyLock::new(|| {
208        let cfg = koan_core::config::Config::load().unwrap_or_default();
209        let pusher = Pusher::from_config(&cfg.push);
210        if pusher.is_some() {
211            log::info!("push: APNs key {} for {}", cfg.push.key_id, cfg.push.topic);
212        }
213        pusher
214    });
215    PUSHER.as_ref()
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221
222    // A throwaway P-256 key, generated for these tests and used nowhere else.
223    const TEST_KEY: &str = "-----BEGIN PRIVATE KEY-----
224MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgMavrDWJ3FFXFskYn
225rsmSSRycut7lJn10pzM1NivXehuhRANCAARKklUe/y7J3SZEK36mnyt5ejhmbNKT
226AtQSJr6Wg9OtOkzZdoOhdRVcNFW8q9peFQ+S7qIcWNbXlhi+cAlpf0ce
227-----END PRIVATE KEY-----";
228
229    fn config() -> PushConfig {
230        PushConfig {
231            key: Some(TEST_KEY.into()),
232            key_id: "ABC123DEFG".into(),
233            team_id: "TEAM123456".into(),
234            ..PushConfig::default()
235        }
236    }
237
238    #[test]
239    fn no_key_no_pusher() {
240        assert!(Pusher::from_config(&PushConfig::default()).is_none());
241    }
242
243    #[test]
244    fn the_token_is_es256_with_the_key_id_and_reused() {
245        let pusher = Pusher::from_config(&config()).expect("pusher");
246        let token = pusher.bearer().unwrap();
247        let header = jsonwebtoken::decode_header(&token).unwrap();
248        assert_eq!(header.alg, Algorithm::ES256);
249        assert_eq!(header.kid.as_deref(), Some("ABC123DEFG"));
250        assert_eq!(pusher.bearer().unwrap(), token);
251    }
252
253    #[test]
254    fn a_notification_carries_its_command() {
255        let command = json!({ "type": "play", "trackIds": ["1"], "startAt": 0 });
256        let body = payload(&Push::Notify {
257            title: "Play on this iPhone".into(),
258            body: "Golden Standard".into(),
259            command: command.clone(),
260        });
261        assert_eq!(body["koan"], command);
262        assert_eq!(body["aps"]["alert"]["body"], "Golden Standard");
263        assert_eq!(body["aps"]["interruption-level"], "time-sensitive");
264        assert_eq!(payload(&Push::Wake)["aps"]["content-available"], 1);
265    }
266}