Skip to main content

pipedash_core/domain/
pairing.rs

1use std::net::{
2    IpAddr,
3    Ipv4Addr,
4    Ipv6Addr,
5};
6
7use chrono::{
8    DateTime,
9    Utc,
10};
11use sha2::{
12    Digest,
13    Sha256,
14};
15
16const CODE_ALPHABET: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ";
17const CODE_LEN: usize = 8;
18const MAX_DEVICE_NAME_CHARS: usize = 100;
19const PRIVATE_SUFFIXES: [&str; 4] = [".local", ".lan", ".home.arpa", ".internal"];
20
21pub const DEVICE_PLATFORMS: [&str; 5] = ["macos", "windows", "linux", "ios", "android"];
22
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct ServerUrl {
25    pub url: String,
26    pub scheme: String,
27    pub host: String,
28}
29
30impl ServerUrl {
31    pub fn parse(input: &str) -> Result<Self, &'static str> {
32        let parsed = reqwest::Url::parse(input.trim()).map_err(|_| "not a valid URL")?;
33        if !matches!(parsed.scheme(), "http" | "https") {
34            return Err("must start with http:// or https://");
35        }
36        let host = parsed
37            .host_str()
38            .filter(|host| !host.is_empty())
39            .ok_or("missing host")?;
40        if !parsed.username().is_empty() || parsed.password().is_some() {
41            return Err("must not contain credentials");
42        }
43        if parsed.query().is_some() || parsed.fragment().is_some() {
44            return Err("must not contain a query or fragment");
45        }
46        let port = parsed
47            .port()
48            .map(|port| format!(":{port}"))
49            .unwrap_or_default();
50        let path = parsed.path().trim_end_matches('/');
51        Ok(Self {
52            url: format!("{}://{host}{port}{path}", parsed.scheme()),
53            scheme: parsed.scheme().to_owned(),
54            host: host.to_owned(),
55        })
56    }
57
58    fn bare_host(&self) -> &str {
59        self.host.trim_start_matches('[').trim_end_matches(']')
60    }
61
62    pub fn is_loopback(&self) -> bool {
63        let host = self.bare_host();
64        host == "localhost" || host.parse::<IpAddr>().is_ok_and(|ip| ip.is_loopback())
65    }
66
67    pub fn is_private(&self) -> bool {
68        let host = self.bare_host();
69        match host.parse::<IpAddr>() {
70            Ok(ip) => is_private_ip(ip),
71            Err(_) => {
72                !host.contains('.') || PRIVATE_SUFFIXES.iter().any(|suffix| host.ends_with(suffix))
73            }
74        }
75    }
76
77    pub fn websocket_url(&self) -> String {
78        let scheme = if self.scheme == "https" { "wss" } else { "ws" };
79        let rest = self.url.split_once("://").map_or("", |(_, rest)| rest);
80        format!("{scheme}://{rest}/api/v1/ws")
81    }
82}
83
84pub fn is_private_ip(ip: IpAddr) -> bool {
85    if ip.is_loopback() {
86        return true;
87    }
88
89    match ip {
90        IpAddr::V4(ip) => {
91            let [first, second, ..] = ip.octets();
92            ip.is_private() || ip.is_link_local() || (first == 100 && second & 0xc0 == 64)
93        }
94        IpAddr::V6(ip) => {
95            if let Some(embedded) = ip.to_ipv4_mapped().or_else(|| nat64_embedded(ip)) {
96                return is_private_ip(IpAddr::V4(embedded));
97            }
98            let first = ip.segments()[0];
99            first & 0xfe00 == 0xfc00 || first & 0xffc0 == 0xfe80
100        }
101    }
102}
103
104fn nat64_embedded(ip: Ipv6Addr) -> Option<Ipv4Addr> {
105    let [a, b, c, d, e, f, g, h] = ip.segments();
106    ([a, b, c, d, e, f] == [0x64, 0xff9b, 0, 0, 0, 0]).then(|| {
107        let [hi_a, hi_b] = g.to_be_bytes();
108        let [lo_a, lo_b] = h.to_be_bytes();
109        Ipv4Addr::new(hi_a, hi_b, lo_a, lo_b)
110    })
111}
112
113pub fn generate_pairing_code() -> String {
114    let bits = rand::random::<u64>();
115    (0..CODE_LEN)
116        .map(|i| CODE_ALPHABET[((bits >> (i * 5)) & 31) as usize] as char)
117        .collect()
118}
119
120pub fn normalize_pairing_code(input: &str) -> Option<String> {
121    let code: String = input
122        .chars()
123        .filter(|c| *c != '-' && !c.is_whitespace())
124        .flat_map(char::to_uppercase)
125        .collect();
126
127    (code.len() == CODE_LEN && code.bytes().all(|b| CODE_ALPHABET.contains(&b))).then_some(code)
128}
129
130pub fn display_pairing_code(code: &str) -> String {
131    match code.split_at_checked(CODE_LEN / 2) {
132        Some((head, tail)) => format!("{head}-{tail}"),
133        None => code.to_string(),
134    }
135}
136
137pub fn pairing_link(server_url: &str, code: &str) -> String {
138    reqwest::Url::parse_with_params("pipedash://pair", [("url", server_url), ("code", code)])
139        .map_or_else(|_| String::new(), String::from)
140}
141
142pub fn parse_pairing_link(input: &str) -> Option<(String, String)> {
143    let parsed = reqwest::Url::parse(input.trim()).ok()?;
144    if parsed.scheme() != "pipedash" || parsed.host_str() != Some("pair") {
145        return None;
146    }
147    let mut url = None;
148    let mut code = None;
149    for (key, value) in parsed.query_pairs() {
150        match &*key {
151            "url" => url = Some(value.into_owned()),
152            "code" => code = Some(value.into_owned()),
153            _ => {}
154        }
155    }
156
157    Some((url?, code?))
158}
159
160pub fn validate_device(name: &str, platform: &str) -> Result<String, (&'static str, &'static str)> {
161    let name = name.trim();
162    if !(1..=MAX_DEVICE_NAME_CHARS).contains(&name.chars().count()) {
163        return Err((
164            "INVALID_DEVICE_NAME",
165            "Device name must be 1 to 100 characters.",
166        ));
167    }
168    if !DEVICE_PLATFORMS.contains(&platform) {
169        return Err(("INVALID_PLATFORM", "Unknown device platform."));
170    }
171
172    Ok(name.to_string())
173}
174
175#[derive(Debug, Clone, PartialEq, Eq)]
176pub struct PairedDevice {
177    pub id: String,
178    pub token_hash: String,
179    pub name: String,
180    pub platform: String,
181    pub created_at: DateTime<Utc>,
182    pub last_seen_at: Option<DateTime<Utc>>,
183}
184
185impl PairedDevice {
186    pub fn issue(name: &str, platform: &str) -> (Self, String) {
187        let token = format!(
188            "{:032x}{:032x}",
189            rand::random::<u128>(),
190            rand::random::<u128>()
191        );
192        let now = Utc::now();
193        let device = Self {
194            id: format!("{:032x}", rand::random::<u128>()),
195            token_hash: Self::hash_token(&token),
196            name: name.to_string(),
197            platform: platform.to_string(),
198            created_at: now,
199            last_seen_at: Some(now),
200        };
201
202        (device, token)
203    }
204
205    pub fn hash_token(token: &str) -> String {
206        let mut hasher = Sha256::new();
207        hasher.update(token.as_bytes());
208
209        hasher
210            .finalize()
211            .iter()
212            .map(|b| format!("{b:02x}"))
213            .collect()
214    }
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220
221    fn normalized(input: &str) -> String {
222        ServerUrl::parse(input).unwrap().url
223    }
224
225    #[test]
226    fn normalizes_accepted_urls() {
227        assert_eq!(
228            normalized("  HTTPS://CI.Example.COM/  "),
229            "https://ci.example.com"
230        );
231        assert_eq!(normalized("http://10.0.0.5:8080"), "http://10.0.0.5:8080");
232        assert_eq!(
233            normalized("https://host/Base/path//"),
234            "https://host/Base/path"
235        );
236        assert_eq!(normalized("https://host:443/x/"), "https://host/x");
237        assert_eq!(normalized("http://[::1]:3000/"), "http://[::1]:3000");
238    }
239
240    #[test]
241    fn reports_the_host_and_scheme() {
242        let n = ServerUrl::parse("https://User.example.com:8443/a").unwrap();
243        assert_eq!(n.host, "user.example.com");
244        assert_eq!(n.scheme, "https");
245    }
246
247    #[test]
248    fn rejects_unusable_urls() {
249        for input in [
250            "",
251            "   ",
252            "ci.example.com",
253            "ftp://ci.example.com",
254            "mailto:a@b.c",
255            "https://",
256            "https://user@ci.example.com",
257            "https://user:pw@ci.example.com",
258            "https://ci.example.com?a=1",
259            "https://ci.example.com/#frag",
260            "https://ci.example.com/?",
261        ] {
262            assert!(
263                ServerUrl::parse(input).is_err(),
264                "{input:?} should be rejected"
265            );
266        }
267    }
268
269    #[test]
270    fn classifies_private_and_loopback_hosts() {
271        for (input, private, loopback) in [
272            ("http://localhost:8080", true, true),
273            ("http://127.0.0.1", true, true),
274            ("http://[::1]:3000", true, true),
275            ("http://192.168.1.20", true, false),
276            ("http://10.1.2.3", true, false),
277            ("http://172.16.0.1", true, false),
278            ("http://172.32.0.1", false, false),
279            ("http://169.254.1.1", true, false),
280            ("http://100.64.0.1", true, false),
281            ("http://100.128.0.1", false, false),
282            ("http://[fd00::1]", true, false),
283            ("http://[fe80::1]", true, false),
284            ("http://[2001:db8::1]", false, false),
285            ("http://mac.local", true, false),
286            ("http://nas", true, false),
287            ("http://pc.lan", true, false),
288            ("http://router.home.arpa", true, false),
289            ("http://ci.internal", true, false),
290            ("http://example.com", false, false),
291            ("http://8.8.8.8", false, false),
292        ] {
293            let url = ServerUrl::parse(input).unwrap();
294            assert_eq!(url.is_private(), private, "{input} private");
295            assert_eq!(url.is_loopback(), loopback, "{input} loopback");
296        }
297    }
298
299    #[test]
300    fn private_addresses_exclude_every_public_range() {
301        for (input, private) in [
302            ("127.0.0.1", true),
303            ("::1", true),
304            ("10.0.0.1", true),
305            ("172.31.255.255", true),
306            ("192.168.0.1", true),
307            ("169.254.0.1", true),
308            ("100.127.255.255", true),
309            ("fc00::1", true),
310            ("fe80::1", true),
311            ("172.32.0.1", false),
312            ("100.128.0.1", false),
313            ("8.8.8.8", false),
314            ("0.0.0.0", false),
315            ("::", false),
316            ("2001:4860:4860::8888", false),
317            ("::ffff:8.8.8.8", false),
318            ("::ffff:192.168.1.10", true),
319            ("::ffff:127.0.0.1", true),
320            ("64:ff9b::c0a8:10a", true),
321            ("64:ff9b::a00:1", true),
322            ("64:ff9b::808:808", false),
323            ("64:ff9b:1::c0a8:10a", false),
324        ] {
325            let ip: IpAddr = input.parse().unwrap();
326            assert_eq!(is_private_ip(ip), private, "{input}");
327        }
328    }
329
330    #[test]
331    fn websocket_url_follows_the_scheme_and_base_path() {
332        for (input, expected) in [
333            ("http://10.0.0.5:8080", "ws://10.0.0.5:8080/api/v1/ws"),
334            (
335                "https://ci.example.com/pipedash/",
336                "wss://ci.example.com/pipedash/api/v1/ws",
337            ),
338        ] {
339            assert_eq!(ServerUrl::parse(input).unwrap().websocket_url(), expected);
340        }
341    }
342
343    #[test]
344    fn pairing_links_round_trip() {
345        let link = pairing_link("https://ci.example.com/base", "ABCDEFGH");
346
347        assert!(link.starts_with("pipedash://pair?"));
348        assert_eq!(
349            parse_pairing_link(&link),
350            Some(("https://ci.example.com/base".into(), "ABCDEFGH".into()))
351        );
352        for input in [
353            "https://pair?url=a&code=b",
354            "pipedash://other?url=a&code=b",
355            "pipedash://pair?url=a",
356            "pipedash://pair?code=b",
357            "not a link",
358        ] {
359            assert_eq!(parse_pairing_link(input), None, "{input}");
360        }
361    }
362
363    #[test]
364    fn pairing_codes_normalize_and_display() {
365        assert_eq!(
366            normalize_pairing_code("abcd-efgh").as_deref(),
367            Some("ABCDEFGH")
368        );
369        assert_eq!(
370            normalize_pairing_code(" ab cd-ef gh ").as_deref(),
371            Some("ABCDEFGH")
372        );
373        for input in ["ABCD-EFGI", "ABCDEFG", "ABCDEFGHJ", "", "ÄBCDEFGH"] {
374            assert_eq!(normalize_pairing_code(input), None, "{input}");
375        }
376        assert_eq!(display_pairing_code("ABCDEFGH"), "ABCD-EFGH");
377
378        let generated = generate_pairing_code();
379        assert_eq!(normalize_pairing_code(&generated), Some(generated));
380    }
381
382    #[test]
383    fn device_input_is_validated() {
384        assert_eq!(validate_device("  Pixel  ", "android"), Ok("Pixel".into()));
385        assert_eq!(
386            validate_device("   ", "android"),
387            Err((
388                "INVALID_DEVICE_NAME",
389                "Device name must be 1 to 100 characters."
390            ))
391        );
392        assert_eq!(
393            validate_device(&"x".repeat(101), "android").map_err(|e| e.0),
394            Err("INVALID_DEVICE_NAME")
395        );
396        assert_eq!(
397            validate_device("Pixel", "palm"),
398            Err(("INVALID_PLATFORM", "Unknown device platform."))
399        );
400    }
401
402    #[test]
403    fn issued_devices_store_only_the_token_hash() {
404        let (device, token) = PairedDevice::issue("Pixel", "android");
405
406        assert_eq!(device.id.len(), 32);
407        assert_eq!(token.len(), 64);
408        assert_eq!(device.token_hash, PairedDevice::hash_token(&token));
409        assert_eq!(device.token_hash.len(), 64);
410        assert_ne!(device.token_hash, token);
411    }
412}