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}