i3status_rs/blocks/
external_ip.rs

1//! External IP address and various information about it
2//!
3//! # Configuration
4//!
5//! Key | Values | Default
6//! ----|--------|--------
7//! `format` | A string to customise the output of this block. See below for available placeholders. | `" $ip $country_flag "`
8//! `interval` | Interval in seconds for automatic updates | `300`
9//! `with_network_manager` | If 'true', listen for NetworkManager events and update the IP immediately if there was a change | `true`
10//! `use_ipv4` | If 'true', use IPv4 for obtaining all info | `false`
11//!
12//!  Key | Value | Type | Unit
13//! -----|-------|------|------
14//! `ip` | The external IP address, as seen from a remote server | Text | -
15//! `version` | IPv4 or IPv6 | Text | -
16//! `city` | City name, such as "San Francisco" | Text | -
17//! `region` | Region name, such as "California" | Text | -
18//! `region_code` | Region code, such as "CA" for California | Text | -
19//! `country` | Country code (2 letter, ISO 3166-1 alpha-2) | Text | -
20//! `country_name` | Short country name | Text | -
21//! `country_code` | Country code (2 letter, ISO 3166-1 alpha-2) | Text | -
22//! `country_code_iso3` | Country code (3 letter, ISO 3166-1 alpha-3) | Text | -
23//! `country_capital` | Capital of the country | Text | -
24//! `country_tld` | Country specific TLD (top-level domain) | Text | -
25//! `continent_code` | Continent code | Text | -
26//! `in_eu` | Region code, such as "CA" | Flag | -
27//! `postal` | ZIP / Postal code | Text | -
28//! `latitude` | Latitude | Number | - (TODO: make degrees?)
29//! `longitude` | Longitude | Number | - (TODO: make degrees?)
30//! `timezone` | City | Text | -
31//! `utc_offset` | UTC offset (with daylight saving time) as +HHMM or -HHMM (HH is hours, MM is minutes) | Text | -
32//! `country_calling_code` | Country calling code (dial in code, comma separated) | Text | -
33//! `currency` | Currency code (ISO 4217) | Text | -
34//! `currency_name` | Currency name | Text | -
35//! `languages` | Languages spoken (comma separated 2 or 3 letter ISO 639 code with optional hyphen separated country suffix) | Text | -
36//! `country_area` | Area of the country (in sq km) | Number | -
37//! `country_population` | Population of the country | Number | -
38//! `timezone` | Time zone | Text | -
39//! `org` | Organization | Text | -
40//! `asn` | Autonomous system (AS) | Text | -
41//! `country_flag` | Flag of the country | Text (glyph) | -
42//!
43//! # Example
44//!
45//! ```toml
46//! [[block]]
47//! block = "external_ip"
48//! format = " $ip $country_code "
49//! ```
50//!
51//! # Notes
52//! All the information comes from <https://ipapi.co/json/>
53//! Check their documentation here: <https://ipapi.co/api/#complete-location5>
54//!
55//! The IP is queried, 1) When i3status-rs starts, 2) When a signal is received
56//! on D-Bus about a network configuration change, 3) Every 5 minutes. This
57//! periodic refresh exists to catch IP updates that don't trigger a notification,
58//! for example due to a IP refresh at the router.
59//!
60//! Flags: They are not icons but unicode glyphs. You will need a font that
61//! includes them. Tested with: <https://www.babelstone.co.uk/Fonts/Flags.html>
62
63use zbus::MatchRule;
64
65use super::prelude::*;
66use crate::util::{country_flag_from_iso_code, new_system_dbus_connection};
67
68const API_ENDPOINT: &str = "https://ipapi.co/json/";
69
70#[derive(Deserialize, Debug, SmartDefault)]
71#[serde(deny_unknown_fields, default)]
72pub struct Config {
73    pub format: FormatConfig,
74    #[default(300.into())]
75    pub interval: Seconds,
76    #[default(true)]
77    pub with_network_manager: bool,
78    #[default(false)]
79    pub use_ipv4: bool,
80}
81
82pub async fn run(config: &Config, api: &CommonApi) -> Result<()> {
83    let format = config.format.with_default(" $ip $country_flag ")?;
84
85    type UpdatesStream = Pin<Box<dyn Stream<Item = ()>>>;
86    let mut stream: UpdatesStream = if config.with_network_manager {
87        let dbus = new_system_dbus_connection().await?;
88        let proxy = zbus::fdo::DBusProxy::new(&dbus)
89            .await
90            .error("Failed to create DBusProxy")?;
91        proxy
92            .add_match_rule(
93                MatchRule::builder()
94                    .msg_type(zbus::message::Type::Signal)
95                    .path("/org/freedesktop/NetworkManager")
96                    .and_then(|x| x.interface("org.freedesktop.DBus.Properties"))
97                    .and_then(|x| x.member("PropertiesChanged"))
98                    .unwrap()
99                    .build(),
100            )
101            .await
102            .error("Failed to add match")?;
103        proxy
104            .add_match_rule(
105                MatchRule::builder()
106                    .msg_type(zbus::message::Type::Signal)
107                    .path_namespace("/org/freedesktop/NetworkManager/ActiveConnection")
108                    .and_then(|x| x.interface("org.freedesktop.DBus.Properties"))
109                    .and_then(|x| x.member("PropertiesChanged"))
110                    .unwrap()
111                    .build(),
112            )
113            .await
114            .error("Failed to add match")?;
115        proxy
116            .add_match_rule(
117                MatchRule::builder()
118                    .msg_type(zbus::message::Type::Signal)
119                    .path_namespace("/org/freedesktop/NetworkManager/IP4Config")
120                    .and_then(|x| x.interface("org.freedesktop.DBus.Properties"))
121                    .and_then(|x| x.member("PropertiesChanged"))
122                    .unwrap()
123                    .build(),
124            )
125            .await
126            .error("Failed to add match")?;
127        let stream: zbus::MessageStream = dbus.into();
128        Box::pin(stream.map(|_| ()))
129    } else {
130        Box::pin(futures::stream::empty())
131    };
132
133    let client = if config.use_ipv4 {
134        &REQWEST_CLIENT_IPV4
135    } else {
136        &REQWEST_CLIENT
137    };
138
139    loop {
140        let fetch_info = || IPAddressInfo::new(client);
141        let info = fetch_info.retry(ExponentialBuilder::default()).await?;
142
143        let mut values = map! {
144            "ip" => Value::text(info.ip),
145            "version" => Value::text(info.version),
146            "city" => Value::text(info.city),
147            "region" => Value::text(info.region),
148            "region_code" => Value::text(info.region_code),
149            "country" => Value::text(info.country),
150            "country_name" => Value::text(info.country_name),
151            "country_flag" => Value::text(country_flag_from_iso_code(&info.country_code)),
152            "country_code" => Value::text(info.country_code),
153            "country_code_iso3" => Value::text(info.country_code_iso3),
154            "country_capital" => Value::text(info.country_capital),
155            "country_tld" => Value::text(info.country_tld),
156            "continent_code" => Value::text(info.continent_code),
157            "latitude" => Value::number(info.latitude),
158            "longitude" => Value::number(info.longitude),
159            "timezone" => Value::text(info.timezone),
160            "utc_offset" => Value::text(info.utc_offset),
161            "country_calling_code" => Value::text(info.country_calling_code),
162            "currency" => Value::text(info.currency),
163            "currency_name" => Value::text(info.currency_name),
164            "languages" => Value::text(info.languages),
165            "country_area" => Value::number(info.country_area),
166            "country_population" => Value::number(info.country_population),
167            "asn" => Value::text(info.asn),
168            "org" => Value::text(info.org),
169        };
170        info.postal
171            .map(|x| values.insert("postal".into(), Value::text(x)));
172        if info.in_eu {
173            values.insert("in_eu".into(), Value::flag());
174        }
175
176        let mut widget = Widget::new().with_format(format.clone());
177        widget.set_values(values);
178        api.set_widget(widget)?;
179
180        select! {
181            _ = sleep(config.interval.0) => (),
182            _ = api.wait_for_update_request() => (),
183            _ = stream.next_debounced() => ()
184        }
185    }
186}
187
188#[derive(Deserialize, Default)]
189#[serde(default)]
190struct IPAddressInfo {
191    error: bool,
192    reason: String,
193    ip: String,
194    version: String,
195    city: String,
196    region: String,
197    region_code: String,
198    country: String,
199    country_name: String,
200    country_code: String,
201    country_code_iso3: String,
202    country_capital: String,
203    country_tld: String,
204    continent_code: String,
205    in_eu: bool,
206    postal: Option<String>,
207    latitude: f64,
208    longitude: f64,
209    timezone: String,
210    utc_offset: String,
211    country_calling_code: String,
212    currency: String,
213    currency_name: String,
214    languages: String,
215    country_area: f64,
216    country_population: f64,
217    asn: String,
218    org: String,
219}
220
221impl IPAddressInfo {
222    async fn new(client: &reqwest::Client) -> Result<Self> {
223        let info: Self = client
224            .get(API_ENDPOINT)
225            .send()
226            .await
227            .error("Failed to request current location")?
228            .json::<Self>()
229            .await
230            .error("Failed to parse JSON")?;
231        if info.error {
232            Err(Error::new(info.reason))
233        } else {
234            Ok(info)
235        }
236    }
237}