stargazer/tier1/xlx_api.rs
1//! XLX API XML reflector list fetcher.
2//!
3//! Fetches the live XLX reflector registry from
4//! `http://xlxapi.rlx.lu/api.php?do=GetReflectorList` and upserts each
5//! reflector into the `reflectors` Postgres table.
6//!
7//! The XLX API is the authoritative source for XLX-family reflectors. Unlike
8//! the Pi-Star host file (which only provides IP addresses), this feed includes
9//! dashboard URLs, uptime, country, and last-contact timestamps — making it the
10//! richest Tier 1 data source.
11//!
12//! All XLX reflectors expose a UDP JSON monitor on port 10001, so this fetcher
13//! sets `tier2_available = true` for every reflector it discovers, enabling
14//! automatic Tier 2 promotion.
15//!
16//! **Poll interval:** every 10 minutes (default 600 s). The XLX network is
17//! dynamic — reflectors come and go, and the API rate limit is generous enough
18//! for this cadence.
19
20use serde::Deserialize;
21
22use super::error::FetchError;
23use crate::db;
24
25/// XLX API endpoint URL.
26const XLX_API_URL: &str = "http://xlxapi.rlx.lu/api.php?do=GetReflectorList";
27
28/// Top-level XML envelope: `<XLXAPI>`.
29///
30/// The XLX API wraps everything in an `<answer>` element inside the root.
31#[derive(Debug, Deserialize)]
32#[serde(rename = "XLXAPI")]
33struct XlxApiResponse {
34 /// The `<answer>` wrapper element.
35 answer: XlxAnswer,
36}
37
38/// The `<answer>` element containing the reflector list.
39#[derive(Debug, Deserialize)]
40struct XlxAnswer {
41 /// The `<reflectorlist>` element containing individual `<reflector>` entries.
42 reflectorlist: XlxReflectorList,
43}
44
45/// The `<reflectorlist>` element — a wrapper around the reflector array.
46///
47/// Each child `<reflector>` element maps to one [`XlxReflector`] entry.
48#[derive(Debug, Deserialize)]
49struct XlxReflectorList {
50 /// Individual reflector entries. Uses `#[serde(default)]` so an empty
51 /// list deserializes to an empty `Vec` rather than failing.
52 #[serde(default)]
53 reflector: Vec<XlxReflector>,
54}
55
56/// A single `<reflector>` entry from the XLX API.
57///
58/// Only the fields needed for the reflector registry are deserialized; the
59/// rest (uptime, lastcontact, comment) are ignored via `#[serde(default)]` on
60/// the parent container and field-level defaults here.
61#[derive(Debug, Deserialize)]
62struct XlxReflector {
63 /// Reflector callsign, e.g. `"XLX000"`, `"XLX320"`.
64 name: String,
65
66 /// Reflector IP address (the `<lastip>` element).
67 #[serde(default)]
68 lastip: String,
69
70 /// URL of the reflector's web dashboard, if available.
71 #[serde(default)]
72 dashboardurl: Option<String>,
73
74 /// Country or region string (e.g. `"USA - Florida"`).
75 #[serde(default)]
76 country: Option<String>,
77}
78
79/// Fetches the XLX reflector list and upserts all entries into Postgres.
80///
81/// Returns the number of reflectors successfully upserted. After upserting
82/// each reflector, also sets `tier2_available = true` since all XLX reflectors
83/// support the UDP JSON monitor protocol on port 10001.
84///
85/// # Errors
86///
87/// - [`FetchError::Http`] if the HTTP request fails.
88/// - [`FetchError::Xml`] if the XML response cannot be deserialized.
89/// - [`FetchError::Database`] if any database operation fails.
90pub(crate) async fn fetch_and_store(
91 client: &reqwest::Client,
92 pool: &sqlx::PgPool,
93) -> Result<usize, FetchError> {
94 // Fetch raw XML text, then deserialize with quick-xml's serde support.
95 let body = client.get(XLX_API_URL).send().await?.text().await?;
96 let response: XlxApiResponse = quick_xml::de::from_str(&body)?;
97
98 let mut count = 0usize;
99 for entry in &response.answer.reflectorlist.reflector {
100 // Determine IP: use the lastip field if non-empty.
101 let ip = if entry.lastip.is_empty() {
102 None
103 } else {
104 Some(entry.lastip.as_str())
105 };
106
107 // XLX reflectors use the DExtra protocol as their primary link protocol.
108 db::reflectors::upsert(
109 pool,
110 &entry.name,
111 "dextra",
112 ip,
113 entry.dashboardurl.as_deref(),
114 entry.country.as_deref(),
115 )
116 .await?;
117
118 // All XLX reflectors expose the UDP JSON monitor on port 10001.
119 db::reflectors::set_tier2_available(pool, &entry.name, true).await?;
120
121 count += 1;
122 }
123
124 tracing::info!(count, "xlx-api: upserted reflectors");
125 Ok(count)
126}