Skip to main content

koan_core/remote/
pair.rs

1//! Pairing: signing in a device that has no keyboard to type a password with.
2//!
3//! The device knows only the server's address. It opens a WebSocket at
4//! `/rest/koanPair` and is given a pairing: an unguessable id for a link, and
5//! a short code to read off the screen. Someone signed in elsewhere — the
6//! phone, the Mac, the server's web page — approves it, and the server makes
7//! an API key on their account and sends it down the waiting socket. Nothing
8//! asks the server whether it has happened yet: the answer arrives when it
9//! does. A koan extension, `koanPair`.
10//!
11//! The link carries the server and the id in the fragment of a koan.rocks
12//! address, as invites do, so the site serving the page never sees them.
13
14use std::net::{Shutdown, TcpStream};
15use std::time::Duration;
16
17use serde::{Deserialize, Serialize};
18use tungstenite::stream::MaybeTlsStream;
19use url::{Url, form_urlencoded};
20
21use crate::config::Config;
22use crate::helpers::SignInError;
23use crate::remote::client::SubsonicError;
24
25pub const PAIR_PAGE: &str = "https://koan.rocks/pair/";
26
27/// The server pings a waiting device every half minute. Three missed mean
28/// the connection is gone.
29const SILENCE: Duration = Duration::from_secs(90);
30
31/// What the server sends down a pairing socket: `Pending` once it is open,
32/// then one outcome before it closes.
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(tag = "type", rename_all = "camelCase")]
35pub enum PairMessage {
36    #[serde(rename_all = "camelCase")]
37    Pending {
38        id: String,
39        /// `XXXX-XXXX`, for someone to type.
40        code: String,
41        /// Seconds until the pairing lapses.
42        expires_in: u64,
43    },
44    #[serde(rename_all = "camelCase")]
45    Approved {
46        username: String,
47        api_key: String,
48    },
49    Declined,
50    Expired,
51}
52
53#[derive(Debug, thiserror::Error)]
54pub enum PairError {
55    #[error("not an http(s) server address: {0}")]
56    BadUrl(String),
57    #[error("could not reach the server: {0}")]
58    Connect(String),
59    /// The server answered, and has no pairing to offer: not koan, or a koan
60    /// from before pairing.
61    #[error("this server does not pair devices")]
62    Unsupported,
63    #[error("too many devices are waiting to pair from this network; try again in a few minutes")]
64    TooMany,
65    #[error("the server answered with something unexpected")]
66    BadResponse,
67    #[error("the sign-in was declined")]
68    Declined,
69    #[error("the code expired before anyone approved it")]
70    Expired,
71    #[error("the connection to the server closed")]
72    Closed,
73    #[error("no server is signed in")]
74    NotSignedIn,
75    #[error(transparent)]
76    Remote(#[from] SubsonicError),
77    #[error(transparent)]
78    SignIn(#[from] SignInError),
79}
80
81type Socket = tungstenite::WebSocket<MaybeTlsStream<TcpStream>>;
82
83/// A pairing the server is holding open for this device.
84pub struct Pending {
85    pub id: String,
86    pub code: String,
87    /// The universal link that opens the approval in the app, or the server's
88    /// own page for someone without it.
89    pub link: String,
90    server: String,
91    socket: Socket,
92}
93
94/// Ends a wait from another thread.
95pub struct Cancel(TcpStream);
96
97impl Cancel {
98    pub fn cancel(&self) {
99        let _ = self.0.shutdown(Shutdown::Both);
100    }
101}
102
103/// Ask the server at `url` to pair this device, which approvers will see
104/// called `device`.
105pub fn start(url: &str, device: &str) -> Result<Pending, PairError> {
106    let server = url.trim().trim_end_matches('/').to_owned();
107    let socket_url = pair_url(&server, device)?;
108    let (mut socket, _) = tungstenite::connect(socket_url.as_str()).map_err(|e| match e {
109        // Any answer but an upgrade is a server without pairing, except a
110        // koan turning away a busy address and a proxy whose server is down.
111        tungstenite::Error::Http(ref response) => match response.status().as_u16() {
112            429 => PairError::TooMany,
113            502..=504 => PairError::Connect(e.to_string()),
114            _ => PairError::Unsupported,
115        },
116        e => PairError::Connect(e.to_string()),
117    })?;
118    if let Some(tcp) = tcp(&socket) {
119        let _ = tcp.set_read_timeout(Some(SILENCE));
120    }
121    match read(&mut socket)? {
122        PairMessage::Pending { id, code, .. } => Ok(Pending {
123            link: link(&server, &id),
124            id,
125            code,
126            server,
127            socket,
128        }),
129        _ => Err(PairError::BadResponse),
130    }
131}
132
133impl Pending {
134    /// What stops `wait`, for whoever gives up on it.
135    pub fn canceller(&self) -> Option<Cancel> {
136        tcp(&self.socket)?.try_clone().ok().map(Cancel)
137    }
138
139    /// Block until the pairing is approved, declined or lapses. Approved, the
140    /// key it brings is stored as an invite's is, and the app is signed in.
141    pub fn wait(mut self) -> Result<(), PairError> {
142        let outcome = read(&mut self.socket);
143        let _ = self.socket.close(None);
144        match outcome? {
145            PairMessage::Approved { username, api_key } => {
146                crate::helpers::adopt_api_key(&self.server, &username, &api_key)?;
147                Ok(())
148            }
149            PairMessage::Declined => Err(PairError::Declined),
150            PairMessage::Expired => Err(PairError::Expired),
151            PairMessage::Pending { .. } => Err(PairError::BadResponse),
152        }
153    }
154}
155
156/// The next message, answering pings while it waits.
157fn read(socket: &mut Socket) -> Result<PairMessage, PairError> {
158    loop {
159        match socket.read() {
160            Ok(tungstenite::Message::Text(text)) => {
161                return serde_json::from_str(&text).map_err(|_| PairError::BadResponse);
162            }
163            Ok(tungstenite::Message::Close(_)) => return Err(PairError::Closed),
164            Ok(_) => {}
165            Err(_) => return Err(PairError::Closed),
166        }
167    }
168}
169
170fn tcp(socket: &Socket) -> Option<&TcpStream> {
171    match socket.get_ref() {
172        MaybeTlsStream::Plain(s) => Some(s),
173        MaybeTlsStream::Rustls(s) => Some(s.get_ref()),
174        _ => None,
175    }
176}
177
178fn pair_url(server: &str, device: &str) -> Result<Url, PairError> {
179    let bad = || PairError::BadUrl(server.to_owned());
180    let mut url = Url::parse(&format!("{server}/rest/koanPair")).map_err(|_| bad())?;
181    let scheme = match url.scheme() {
182        "https" => "wss",
183        "http" => "ws",
184        _ => return Err(bad()),
185    };
186    url.set_scheme(scheme).map_err(|()| bad())?;
187    url.query_pairs_mut().append_pair("name", device);
188    Ok(url)
189}
190
191/// The link that approves pairing `id` on `server`.
192pub fn link(server: &str, id: &str) -> String {
193    let fragment = form_urlencoded::Serializer::new(String::new())
194        .append_pair("s", server)
195        .append_pair("p", id)
196        .finish();
197    format!("{PAIR_PAGE}#{fragment}")
198}
199
200/// A pairing link's server and pairing id. `None` for anything else.
201#[derive(Debug, Clone, PartialEq, Eq)]
202pub struct PairLink {
203    pub server: String,
204    pub id: String,
205}
206
207impl PairLink {
208    pub fn parse(link: &str) -> Option<Self> {
209        let url = Url::parse(link.trim()).ok()?;
210        let params = match (url.scheme(), url.host_str()) {
211            ("https", Some("koan.rocks")) if url.path().trim_end_matches('/') == "/pair" => {
212                url.fragment()?
213            }
214            // The same link through the app's own scheme, where the universal
215            // link cannot reach the app: a simulator, an unsigned build.
216            ("koan", Some("pair")) => url.fragment().or(url.query())?,
217            _ => return None,
218        };
219        let (mut server, mut id) = (None, None);
220        for (k, v) in form_urlencoded::parse(params.as_bytes()) {
221            let v = Some(v.trim().to_owned()).filter(|v| !v.is_empty());
222            match &*k {
223                "s" => server = v,
224                "p" => id = v,
225                _ => {}
226            }
227        }
228        let server = server?.trim_end_matches('/').to_owned();
229        if !matches!(Url::parse(&server).ok()?.scheme(), "http" | "https") {
230            return None;
231        }
232        Some(Self { server, id: id? })
233    }
234}
235
236fn client() -> Result<std::sync::Arc<crate::remote::client::SubsonicClient>, PairError> {
237    crate::helpers::subsonic_client(&Config::load().unwrap_or_default())
238        .ok_or(PairError::NotSignedIn)
239}
240
241/// The device waiting on `pair` (an id or a code) on the signed-in server,
242/// and where it asked from.
243pub fn info(pair: &str) -> Result<crate::remote::client::KoanPair, PairError> {
244    Ok(client()?.koan_pair_info(pair)?)
245}
246
247/// Sign the device waiting on `pair` in as the signed-in account. Answers
248/// with the device's name.
249pub fn approve(pair: &str) -> Result<String, PairError> {
250    Ok(client()?.koan_pair_approve(pair, false)?)
251}
252
253/// Turn the device waiting on `pair` away.
254pub fn decline(pair: &str) -> Result<String, PairError> {
255    Ok(client()?.koan_pair_approve(pair, true)?)
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261
262    #[test]
263    fn a_link_reads_back() {
264        let made = link("https://music.example.com:8443/koan", "abc_-123");
265        assert!(made.starts_with("https://koan.rocks/pair/#s=https%3A%2F%2Fmusic"));
266        assert_eq!(
267            PairLink::parse(&made),
268            Some(PairLink {
269                server: "https://music.example.com:8443/koan".into(),
270                id: "abc_-123".into(),
271            })
272        );
273        assert!(PairLink::parse("https://koan.rocks/pair#s=https%3A%2F%2Fa.example&p=x").is_some());
274    }
275
276    #[test]
277    fn the_apps_own_scheme_carries_the_same_link() {
278        let expected = Some(PairLink {
279            server: "https://a.example".into(),
280            id: "x".into(),
281        });
282        assert_eq!(
283            PairLink::parse("koan://pair#s=https%3A%2F%2Fa.example&p=x"),
284            expected
285        );
286        assert_eq!(
287            PairLink::parse("koan://pair?s=https%3A%2F%2Fa.example&p=x"),
288            expected
289        );
290        assert_eq!(
291            PairLink::parse("koan://join?s=https%3A%2F%2Fa.example&p=x"),
292            None
293        );
294    }
295
296    #[test]
297    fn other_links_are_not_pairings() {
298        for link in [
299            "https://koan.rocks/join/#s=https%3A%2F%2Fa.example&p=x",
300            "https://evil.example/pair/#s=https%3A%2F%2Fa.example&p=x",
301            "https://koan.rocks/pair/#s=javascript%3Aalert(1)&p=x",
302            "https://koan.rocks/pair/#s=https%3A%2F%2Fa.example",
303            "https://koan.rocks/pair/#p=x",
304        ] {
305            assert_eq!(PairLink::parse(link), None, "{link}");
306        }
307    }
308
309    #[test]
310    fn the_socket_address_follows_the_scheme() {
311        let url = pair_url("https://music.example.com/koan", "Living room TV").unwrap();
312        assert_eq!(
313            url.as_str(),
314            "wss://music.example.com/koan/rest/koanPair?name=Living+room+TV"
315        );
316        let url = pair_url("http://10.0.0.2:4533", "tv").unwrap();
317        assert_eq!(url.scheme(), "ws");
318        assert!(pair_url("ftp://x", "tv").is_err());
319    }
320
321    #[test]
322    fn info_carries_where_the_request_came_from() {
323        let info: crate::remote::client::KoanPair =
324            serde_json::from_str(r#"{"device":"Den TV","from":"192.168.1.20","local":true}"#)
325                .unwrap();
326        assert_eq!((info.from.as_str(), info.local), ("192.168.1.20", true));
327    }
328
329    #[test]
330    fn messages_are_the_wire_format() {
331        let pending: PairMessage = serde_json::from_str(
332            r#"{"type":"pending","id":"i","code":"ABCD-EFGH","expiresIn":600}"#,
333        )
334        .unwrap();
335        assert_eq!(
336            pending,
337            PairMessage::Pending {
338                id: "i".into(),
339                code: "ABCD-EFGH".into(),
340                expires_in: 600
341            }
342        );
343        let approved = PairMessage::Approved {
344            username: "alice".into(),
345            api_key: "k".into(),
346        };
347        assert_eq!(
348            serde_json::to_string(&approved).unwrap(),
349            r#"{"type":"approved","username":"alice","apiKey":"k"}"#
350        );
351        assert_eq!(
352            serde_json::to_string(&PairMessage::Expired).unwrap(),
353            r#"{"type":"expired"}"#
354        );
355    }
356}