Skip to main content

i3status_rs/
geolocator.rs

1//! Geolocation service
2//!
3//! This global module can be used to provide geolocation information
4//! to blocks that support it.
5//!
6//! ipapi.co is the default geolocator service.
7//!
8//! # Configuration
9//!
10//! # Common Options
11//!
12//! Key | Values | Required | Default
13//! ----|--------|----------|--------
14//! `rate_limit_interval` | Seconds to wait before contacting the service again after it reported rate limiting. Until then, cached results are served and no new requests are made. | No | `600`
15//!
16//! # ipapi.co Options
17//!
18//! Key | Values | Required | Default
19//! ----|--------|----------|--------
20//! `geolocator` | `ipapi` | Yes | None
21//!
22//! # Ip2Location.io Options
23//!
24//! Key | Values | Required | Default
25//! ----|--------|----------|--------
26//! `geolocator` | `ip2location` | Yes | None
27//! `api_key` | Your Ip2Location.io API key. | No | None
28//!
29//! An api key is not required to get back basic information from ip2location.io.
30//! However, to get more additional information, an api key is required.
31//! See [pricing](https://www.ip2location.io/pricing) for more information.
32//!
33//! The `api_key` option can be omitted from configuration, in which case it
34//! can be provided in the environment variable `IP2LOCATION_API_KEY`
35//!
36//!
37//! # Examples
38//!
39//! Use the default geolocator service:
40//!
41//! ```toml
42//! [geolocator]
43//! geolocator = "ipapi"
44//! ```
45//!
46//! Use Ip2Location.io
47//!
48//! ```toml
49//! [geolocator]
50//! geolocator = "ip2location"
51//! api_key = "XXX"
52//! ```
53
54use backon::{ExponentialBuilder, Retryable as _};
55
56use crate::errors::{Error, ErrorContext as _, Result, StdError};
57use crate::wrappers::Seconds;
58use std::borrow::Cow;
59use std::fmt;
60use std::sync::{Arc, Mutex};
61use std::time::{Duration, Instant};
62
63use serde::Deserialize;
64use smart_default::SmartDefault;
65
66mod ip2location;
67mod ipapi;
68
69/// Per-request timeout, deliberately much shorter than the shared client's
70/// 10s: lookups normally take well under a second, and when a request is sent
71/// while routes are still changing (the common case for the external_ip
72/// block) it just hangs, so failing fast and retrying beats waiting.
73const REQUEST_TIMEOUT: Duration = Duration::from_secs(3);
74
75#[derive(Debug)]
76struct AutolocateResult {
77    location: IPAddressInfo,
78    timestamp: Instant,
79}
80
81/// Error cause set by backends when the service refuses to answer because of
82/// rate limiting. Callers can detect it with [`is_rate_limited`] and back off
83/// instead of retrying, which would only prolong the block.
84#[derive(Debug, Clone, Copy)]
85pub struct RateLimited;
86
87impl fmt::Display for RateLimited {
88    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
89        f.write_str("rate limited by the geolocation service")
90    }
91}
92
93impl StdError for RateLimited {}
94
95pub fn is_rate_limited(err: &Error) -> bool {
96    err.cause
97        .as_ref()
98        .is_some_and(|cause| cause.downcast_ref::<RateLimited>().is_some())
99}
100
101#[derive(Deserialize, Clone, Default, Debug)]
102pub struct IPAddressInfo {
103    // Required fields
104    pub ip: String,
105    pub latitude: f64,
106    pub longitude: f64,
107    pub city: String,
108
109    // Optional fields
110    pub version: Option<String>,
111    pub region: Option<String>,
112    pub region_code: Option<String>,
113    pub country: Option<String>,
114    pub country_name: Option<String>,
115    pub country_code: Option<String>,
116    pub country_code_iso3: Option<String>,
117    pub country_capital: Option<String>,
118    pub country_tld: Option<String>,
119    pub continent_code: Option<String>,
120    pub in_eu: Option<bool>,
121    pub postal: Option<String>,
122    pub timezone: Option<String>,
123    pub utc_offset: Option<String>,
124    pub country_calling_code: Option<String>,
125    pub currency: Option<String>,
126    pub currency_name: Option<String>,
127    pub languages: Option<String>,
128    pub country_area: Option<f64>,
129    pub country_population: Option<f64>,
130    pub asn: Option<String>,
131    pub org: Option<String>,
132}
133
134#[derive(Debug, Deserialize)]
135#[serde(from = "GeolocatorConfig")]
136pub struct Geolocator {
137    backend: GeolocatorBackend,
138    rate_limit_interval: Duration,
139    last_autolocate: Mutex<Option<AutolocateResult>>,
140    last_rate_limited: Mutex<Option<Instant>>,
141}
142
143impl Default for Geolocator {
144    fn default() -> Self {
145        GeolocatorConfig::default().into()
146    }
147}
148
149impl Geolocator {
150    pub fn name(&self) -> Cow<'static, str> {
151        self.backend.name()
152    }
153
154    pub fn rate_limit_interval(&self) -> Duration {
155        self.rate_limit_interval
156    }
157
158    /// No-op if last API call was made in the last `interval` seconds.
159    ///
160    /// Transient errors are retried with exponential backoff, so callers
161    /// don't need their own retry logic. If the service reported rate
162    /// limiting less than `rate_limit_interval` seconds ago, no request is
163    /// made and an error with a [`RateLimited`] cause is returned, so that
164    /// all callers collectively respect the limit.
165    pub async fn find_ip_location(
166        &self,
167        client: &reqwest::Client,
168        interval: Duration,
169    ) -> Result<IPAddressInfo> {
170        {
171            let guard = self.last_autolocate.lock().unwrap();
172            if let Some(cached) = &*guard
173                && cached.timestamp.elapsed() < interval
174            {
175                return Ok(cached.location.clone());
176            }
177        }
178
179        {
180            let guard = self.last_rate_limited.lock().unwrap();
181            if let Some(at) = *guard
182                && at.elapsed() < self.rate_limit_interval
183            {
184                return Err(Error {
185                    message: Some("geolocation service is rate limited, backing off".into()),
186                    cause: Some(Arc::new(RateLimited)),
187                });
188            }
189        }
190
191        let fetch = || self.backend.get_info(client);
192        let location = match fetch
193            .retry(ExponentialBuilder::default())
194            .when(|err| !is_rate_limited(err))
195            .await
196        {
197            Ok(location) => location,
198            Err(err) => {
199                if is_rate_limited(&err) {
200                    *self.last_rate_limited.lock().unwrap() = Some(Instant::now());
201                }
202                return Err(err);
203            }
204        };
205
206        {
207            let mut guard = self.last_autolocate.lock().unwrap();
208            *guard = Some(AutolocateResult {
209                location: location.clone(),
210                timestamp: Instant::now(),
211            });
212        }
213
214        Ok(location)
215    }
216}
217
218#[derive(Deserialize, Debug, SmartDefault)]
219#[serde(default)]
220pub struct GeolocatorConfig {
221    #[serde(flatten)]
222    backend: GeolocatorBackend,
223    /// How long to wait before contacting the service again after it reported
224    /// rate limiting.
225    #[default(600.into())]
226    rate_limit_interval: Seconds,
227}
228
229#[derive(Deserialize, Debug, SmartDefault, Clone)]
230#[serde(tag = "geolocator", rename_all = "lowercase", deny_unknown_fields)]
231pub enum GeolocatorBackend {
232    #[default]
233    Ipapi(ipapi::Config),
234    Ip2Location(ip2location::Config),
235}
236
237impl GeolocatorBackend {
238    fn name(&self) -> Cow<'static, str> {
239        match self {
240            GeolocatorBackend::Ipapi(_) => ipapi::Ipapi.name(),
241            GeolocatorBackend::Ip2Location(_) => ip2location::Ip2Location.name(),
242        }
243    }
244
245    async fn get_info(&self, client: &reqwest::Client) -> Result<IPAddressInfo> {
246        match self {
247            GeolocatorBackend::Ipapi(_) => ipapi::Ipapi.get_info(client).await,
248            GeolocatorBackend::Ip2Location(config) => {
249                ip2location::Ip2Location
250                    .get_info(client, config.api_key.as_ref())
251                    .await
252            }
253        }
254    }
255}
256
257impl From<GeolocatorConfig> for Geolocator {
258    fn from(config: GeolocatorConfig) -> Self {
259        Self {
260            backend: config.backend,
261            rate_limit_interval: config.rate_limit_interval.0,
262            last_autolocate: Mutex::new(None),
263            last_rate_limited: Mutex::new(None),
264        }
265    }
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271
272    #[test]
273    fn deserialize_config() {
274        let geolocator: Geolocator = toml::from_str("geolocator = \"ipapi\"").unwrap();
275        assert!(matches!(geolocator.backend, GeolocatorBackend::Ipapi(_)));
276        assert_eq!(geolocator.rate_limit_interval, Duration::from_secs(600));
277
278        // the `geolocator` key is required when the [geolocator] section is
279        // present (serde's struct-level default does not extend to the
280        // flattened backend enum)
281        assert!(toml::from_str::<Geolocator>("").is_err());
282
283        let geolocator: Geolocator =
284            toml::from_str("geolocator = \"ipapi\"\nrate_limit_interval = 120").unwrap();
285        assert_eq!(geolocator.rate_limit_interval, Duration::from_secs(120));
286
287        let geolocator: Geolocator =
288            toml::from_str("geolocator = \"ip2location\"\napi_key = \"xxx\"").unwrap();
289        let GeolocatorBackend::Ip2Location(config) = geolocator.backend else {
290            panic!("wrong backend");
291        };
292        assert_eq!(config.api_key.as_deref(), Some("xxx"));
293
294        assert!(toml::from_str::<Geolocator>("geolocator = \"ipapi\"\nbad_key = 1").is_err());
295    }
296}