Skip to main content

linkprobe_core/
discovery.rs

1use std::time::Instant;
2
3use reqwest::blocking::Client;
4use serde::Deserialize;
5use url::Url;
6
7use crate::Error;
8use crate::server::Server;
9
10pub const DEFAULT_LIBRESPEED_SERVERS_URL: &str =
11    "https://librespeed.org/backend-servers/servers.php";
12
13pub const DEFAULT_IPERF3_SERVERS_URL: &str =
14    "https://export.iperf3serverlist.net/listed_iperf3_servers.json";
15
16/// How many additional list servers the CLI may try after the preferred host fails
17/// (auto-pick and `--server-id` only; explicit `--server` URLs do not rotate).
18pub const FAILOVER_EXTRA: usize = 2;
19
20#[derive(Debug, Clone, Deserialize)]
21pub struct LibreSpeedListEntry {
22    pub id: Option<u64>,
23    pub name: String,
24    pub server: String,
25    #[serde(rename = "dlURL")]
26    pub dl_url: String,
27    #[serde(rename = "ulURL")]
28    pub ul_url: String,
29    #[serde(rename = "pingURL")]
30    pub ping_url: String,
31    #[serde(rename = "sponsorName")]
32    pub sponsor_name: Option<String>,
33}
34
35impl LibreSpeedListEntry {
36    pub fn into_server(self) -> Server {
37        let base_url = normalize_base_url(&self.server);
38        let id = self
39            .id
40            .map(|i| i.to_string())
41            .unwrap_or_else(|| base_url.clone());
42        Server {
43            id,
44            name: self.name,
45            base_url,
46            country: None,
47            sponsor: self.sponsor_name,
48            port: None,
49            dl_path: self.dl_url,
50            ul_path: self.ul_url,
51            ping_path: self.ping_url,
52        }
53    }
54}
55
56fn normalize_base_url(raw: &str) -> String {
57    let s = if let Some(rest) = raw.strip_prefix("//") {
58        format!("https://{rest}")
59    } else {
60        raw.to_string()
61    };
62    if s.ends_with('/') { s } else { format!("{s}/") }
63}
64
65fn join(base: &str, path: &str) -> Result<Url, Error> {
66    let base = if base.ends_with('/') {
67        base.to_string()
68    } else {
69        format!("{base}/")
70    };
71    Ok(Url::parse(&base)?.join(path)?)
72}
73
74/// Parse a LibreSpeed server list JSON document into [`Server`] values.
75pub fn parse_librespeed_servers(json: &str) -> Result<Vec<Server>, Error> {
76    let entries: Vec<LibreSpeedListEntry> = serde_json::from_str(json)?;
77    Ok(entries.into_iter().map(|e| e.into_server()).collect())
78}
79
80/// Download and parse a LibreSpeed server list from `list_url`.
81pub fn fetch_librespeed_servers(client: &Client, list_url: &str) -> Result<Vec<Server>, Error> {
82    let text = client.get(list_url).send()?.error_for_status()?.text()?;
83    parse_librespeed_servers(&text)
84}
85
86#[derive(Debug, Clone, Deserialize)]
87pub struct Iperf3ListEntry {
88    #[serde(rename = "IP/HOST")]
89    pub host: String,
90    #[serde(rename = "PORT")]
91    pub port: String,
92    #[serde(rename = "CONTINENT")]
93    #[allow(dead_code)]
94    pub continent: Option<String>,
95    #[serde(rename = "COUNTRY")]
96    pub country: Option<String>,
97    #[serde(rename = "SITE")]
98    pub site: Option<String>,
99    #[serde(rename = "PROVIDER")]
100    pub provider: Option<String>,
101}
102
103fn iperf3_display_name(entry: &Iperf3ListEntry, port: u16) -> String {
104    let site = entry
105        .site
106        .as_deref()
107        .filter(|s| !s.is_empty())
108        .unwrap_or(&entry.host);
109    let country = entry
110        .country
111        .as_deref()
112        .filter(|s| !s.is_empty())
113        .unwrap_or("??");
114    let provider = entry
115        .provider
116        .as_deref()
117        .filter(|s| !s.is_empty())
118        .unwrap_or("unknown");
119    format!("{site}, {country} ({provider}) :{port}")
120}
121
122pub fn parse_port_range(raw: &str) -> Result<Vec<u16>, Error> {
123    let raw = raw.trim();
124    if raw.is_empty() {
125        return Err(Error::Message("empty iperf3 port".into()));
126    }
127    if let Some((start, end)) = raw.split_once('-') {
128        let start: u16 = start
129            .trim()
130            .parse()
131            .map_err(|_| Error::Message(format!("invalid iperf3 port range start: {raw}")))?;
132        let end: u16 = end
133            .trim()
134            .parse()
135            .map_err(|_| Error::Message(format!("invalid iperf3 port range end: {raw}")))?;
136        if start > end {
137            return Err(Error::Message(format!(
138                "invalid iperf3 port range: (start > end): {raw}"
139            )));
140        }
141        return Ok((start..=end).collect());
142    }
143    let port: u16 = raw
144        .parse()
145        .map_err(|_| Error::Message(format!("invalid iperf3 port: {raw}")))?;
146    Ok(vec![port])
147}
148
149/// Parse an iperf3 public server list JSON document into [`Server`] values.
150pub fn parse_iperf3_servers(json: &str) -> Result<Vec<Server>, Error> {
151    let entries: Vec<Iperf3ListEntry> = serde_json::from_str(json)?;
152    let mut servers = Vec::new();
153    let mut next_id = 1_u64;
154    for entry in entries {
155        for port in parse_port_range(&entry.port)? {
156            let id = next_id.to_string();
157            next_id += 1;
158            servers.push(Server {
159                id,
160                name: iperf3_display_name(&entry, port),
161                base_url: entry.host.clone(),
162                country: entry.country.clone(),
163                sponsor: entry.provider.clone(),
164                port: Some(port),
165                ..Server::iperf3(&entry.host, port)
166            });
167        }
168    }
169    Ok(servers)
170}
171
172/// Download and parse an iperf3 server list from `list_url`.
173pub fn fetch_iperf3_servers(client: &Client, list_url: &str) -> Result<Vec<Server>, Error> {
174    let text = client.get(list_url).send()?.error_for_status()?.text()?;
175    parse_iperf3_servers(&text)
176}
177
178/// Pick the default iperf3 list URL when the CLI still has the LibreSpeed default configured.
179pub fn servers_list_url(backend_is_iperf3: bool, servers_url: &str) -> &str {
180    if backend_is_iperf3 && servers_url == DEFAULT_LIBRESPEED_SERVERS_URL {
181        DEFAULT_IPERF3_SERVERS_URL
182    } else {
183        servers_url
184    }
185}
186
187/// One GET to ping_path; returns RTT in ms.
188pub fn ping_ms(client: &Client, server: &Server) -> Result<f64, Error> {
189    let url = join(&server.base_url, &server.ping_path)?;
190    let start = Instant::now();
191    let resp = client.get(url).send()?.error_for_status()?;
192    let _ = resp.bytes()?;
193    Ok(start.elapsed().as_secs_f64() * 1000.0)
194}
195
196/// Ping servers and return those that responded, sorted by ascending latency (ms).
197pub fn rank_by_latency(client: &Client, servers: &[Server]) -> Vec<(Server, f64)> {
198    let mut ranked = Vec::new();
199    for s in servers {
200        if let Ok(ms) = ping_ms(client, s) {
201            ranked.push((s.clone(), ms));
202        }
203    }
204    ranked.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
205    ranked
206}
207
208/// Return the server with the lowest ping latency, or an error if none responded.
209pub fn pick_lowest_latency(client: &Client, servers: &[Server]) -> Result<(Server, f64), Error> {
210    rank_by_latency(client, servers)
211        .into_iter()
212        .next()
213        .ok_or_else(|| Error::Message("no LibreSpeed servers responded to ping".into()))
214}
215
216/// Build a probe order: `preferred` first, then up to `extra` other servers by ping rank.
217pub fn failover_candidates(
218    ranked: &[(Server, f64)],
219    preferred: &Server,
220    extra: usize,
221) -> Vec<Server> {
222    let mut out = vec![preferred.clone()];
223    for (s, _) in ranked {
224        if out.len() > extra {
225            break;
226        }
227        if s.id == preferred.id || s.base_url == preferred.base_url {
228            continue;
229        }
230        out.push(s.clone());
231    }
232    out
233}
234
235/// Look up a server by numeric list id (string match on [`Server::id`]).
236pub fn server_by_id(servers: &[Server], id: u64) -> Result<Server, Error> {
237    let key = id.to_string();
238    servers
239        .iter()
240        .find(|s| s.id == key)
241        .cloned()
242        .ok_or_else(|| Error::Message(format!("no server with id {id} found")))
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248
249    #[test]
250    fn parses_fixture_list() {
251        let json = include_str!("../tests/fixtures/librespeed/servers.json");
252        let servers = parse_librespeed_servers(json).unwrap();
253        assert_eq!(servers.len(), 2);
254        assert_eq!(servers[0].id, "52");
255        assert!(servers[0].base_url.starts_with("https://"));
256        assert_eq!(
257            servers[0].base_url,
258            ("https://nyc.speedtest.clouvider.net/backend/")
259        ); // no leftover //
260        assert_eq!(servers[0].dl_path, "garbage.php");
261        assert_eq!(servers[1].ping_path, "backend/empty.php");
262    }
263
264    #[test]
265    fn normalizes_protocol_relative() {
266        let json = r#"[{
267            "id": 1,
268            "name": "Test",
269            "server": "//example.com/backend",
270            "dlURL": "garbage.php",
271            "ulURL": "empty.php",
272            "pingURL": "empty.php"
273        }]"#;
274        let servers = parse_librespeed_servers(json).unwrap();
275        assert_eq!(servers[0].base_url, "https://example.com/backend/");
276    }
277
278    #[test]
279    fn parses_iperf3_fixture_list() {
280        let json = include_str!("../tests/fixtures/iperf3/servers.json");
281        let servers = parse_iperf3_servers(json).unwrap();
282        assert_eq!(servers.len(), 4);
283        assert_eq!(servers[0].id, "1");
284        assert_eq!(servers[0].base_url, "41.110.39.130");
285        assert_eq!(servers[0].port, Some(5201));
286        assert_eq!(servers[0].name, "Algiers, DZ (DATAPACKET) :5201");
287        assert_eq!(servers[3].port, Some(5203));
288        let picked = server_by_id(&servers, 3).unwrap();
289        assert_eq!(picked.base_url, "105.235.237.2");
290        assert_eq!(picked.port, Some(5202));
291    }
292
293    #[test]
294    fn parse_port_range_single_and_span() {
295        assert_eq!(parse_port_range("5201").unwrap(), vec![5201]);
296        assert_eq!(
297            parse_port_range("5201-5203").unwrap(),
298            vec![5201, 5202, 5203]
299        );
300        assert!(parse_port_range("5203-5201").is_err());
301    }
302
303    #[test]
304    fn servers_list_url_switches_for_iperf3() {
305        assert_eq!(
306            servers_list_url(true, DEFAULT_LIBRESPEED_SERVERS_URL),
307            DEFAULT_IPERF3_SERVERS_URL
308        );
309        assert_eq!(
310            servers_list_url(true, "https://example/list.json"),
311            "https://example/list.json"
312        );
313    }
314
315    #[test]
316    fn failover_candidates_preferred_then_ping_order() {
317        let a = Server::librespeed("https://a/");
318        let b = Server::librespeed("https://b/");
319        let c = Server::librespeed("https://c/");
320        let ranked = vec![(a.clone(), 5.0), (b.clone(), 10.0), (c.clone(), 20.0)];
321        let got = failover_candidates(&ranked, &b, 2);
322        assert_eq!(got.len(), 3);
323        assert_eq!(got[0].base_url, "https://b/");
324        assert_eq!(got[1].base_url, "https://a/");
325        assert_eq!(got[2].base_url, "https://c/");
326    }
327}