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//! `autolocate_interval` | How long in seconds to reuse the last result from the geolocation service instead of contacting it again. Kept small by default so that network changes are picked up promptly. | `1`
10//! `with_network_manager` | If 'true', listen for NetworkManager events and update the IP immediately if there was a change | `true`
11//! `use_ipv4` | If 'true', use IPv4 for obtaining all info | `false`
12//!
13//! Key | Value | Type | Unit
14//! -----|-------|------|------
15//! `ip` | The external IP address, as seen from a remote server | Text | -
16//! `version` | IPv4 or IPv6 | Text | -
17//! `city` | City name, such as "San Francisco" | Text | -
18//! `region` | Region name, such as "California" | Text | -
19//! `region_code` | Region code, such as "CA" for California | Text | -
20//! `country` | Country code (2 letter, ISO 3166-1 alpha-2) | Text | -
21//! `country_name` | Short country name | Text | -
22//! `country_code` | Country code (2 letter, ISO 3166-1 alpha-2) | Text | -
23//! `country_code_iso3` | Country code (3 letter, ISO 3166-1 alpha-3) | Text | -
24//! `country_capital` | Capital of the country | Text | -
25//! `country_tld` | Country specific TLD (top-level domain) | Text | -
26//! `continent_code` | Continent code | Text | -
27//! `in_eu` | Region code, such as "CA" | Flag | -
28//! `postal` | ZIP / Postal code | Text | -
29//! `latitude` | Latitude | Number | - (TODO: make degrees?)
30//! `longitude` | Longitude | Number | - (TODO: make degrees?)
31//! `timezone` | City | Text | -
32//! `utc_offset` | UTC offset (with daylight saving time) as +HHMM or -HHMM (HH is hours, MM is minutes) | Text | -
33//! `country_calling_code` | Country calling code (dial in code, comma separated) | Text | -
34//! `currency` | Currency code (ISO 4217) | Text | -
35//! `currency_name` | Currency name | Text | -
36//! `languages` | Languages spoken (comma separated 2 or 3 letter ISO 639 code with optional hyphen separated country suffix) | Text | -
37//! `country_area` | Area of the country (in sq km) | Number | -
38//! `country_population` | Population of the country | Number | -
39//! `timezone` | Time zone | Text | -
40//! `org` | Organization | Text | -
41//! `asn` | Autonomous system (AS) | Text | -
42//! `country_flag` | Flag of the country | Text (glyph) | -
43//!
44//! # Example
45//!
46//! ```toml
47//! [[block]]
48//! block = "external_ip"
49//! format = " $ip $country_code "
50//! ```
51//!
52//! # Notes
53//! All the information comes from <https://ipapi.co/json/>
54//! Check their documentation here: <https://ipapi.co/api/#complete-location5>
55//!
56//! The IP is queried, 1) When i3status-rs starts, 2) When a signal is received
57//! on D-Bus about a network configuration change, 3) Every 5 minutes. This
58//! periodic refresh exists to catch IP updates that don't trigger a notification,
59//! for example due to a IP refresh at the router.
60//!
61//! If the service reports rate limiting, the block keeps showing the last
62//! known IP and waits for the geolocator's `rate_limit_interval` (10 minutes
63//! by default) before asking again.
64//!
65//! Flags: They are not icons but unicode glyphs. You will need a font that
66//! includes them. Tested with: <https://www.babelstone.co.uk/Fonts/Flags.html>
67
68use zbus::MatchRule;
69
70use super::prelude::*;
71use crate::geolocator::is_rate_limited;
72use crate::util::{country_flag_from_iso_code, new_system_dbus_connection};
73
74make_log_macro!(debug, "external_ip");
75
76/// How long the network-change signal stream must stay quiet before the IP is
77/// re-queried; a transition keeps emitting signals for a while, often before
78/// connectivity is actually usable.
79const SETTLE_QUIET: Duration = Duration::from_secs(1);
80
81/// Upper bound on the settle wait. NetworkManager can keep emitting signals
82/// (IP config, DNS, connectivity checks) for many seconds after a transition;
83/// without a cap the quiet window keeps sliding and the update is delayed
84/// indefinitely. If the network is still not usable when we query, the retry
85/// backoff covers it.
86const SETTLE_MAX: Duration = Duration::from_secs(3);
87
88#[derive(Deserialize, Debug, SmartDefault)]
89#[serde(deny_unknown_fields, default)]
90pub struct Config {
91 pub format: FormatConfig,
92 #[default(300.into())]
93 pub interval: Seconds,
94 /// Unlike the weather block this defaults to 1 second, not `interval`:
95 /// picking up a fresh IP right after a network change is the whole point
96 /// of this block, so cached results must expire quickly.
97 #[default(1.into())]
98 pub autolocate_interval: Seconds,
99 #[default(true)]
100 pub with_network_manager: bool,
101 #[default(false)]
102 pub use_ipv4: bool,
103}
104
105pub(crate) fn prepare(config: &Config) -> Result<Arc<BlockPlan>> {
106 // Only the required `IPAddressInfo` fields are set on every render.
107 // Everything else (version, region, country_*, in_eu, ...) is optional
108 // in the geolocator response and inserted conditionally, so it stays
109 // undeclared.
110 BlockPlan::new(vec![OutputPlan::new(
111 "main",
112 config.format.with_default(" $ip $country_flag ")?,
113 )])
114}
115
116pub(crate) async fn run(config: &Config, api: &CommonApi, plan: &Arc<BlockPlan>) -> Result<()> {
117 let output_main = plan.output("main")?;
118 let format = output_main.format();
119
120 type UpdatesStream = Pin<Box<dyn Stream<Item = ()>>>;
121 let mut stream: UpdatesStream = if config.with_network_manager {
122 let dbus = new_system_dbus_connection().await?;
123 let proxy = zbus::fdo::DBusProxy::new(&dbus)
124 .await
125 .error("Failed to create DBusProxy")?;
126 proxy
127 .add_match_rule(
128 MatchRule::builder()
129 .msg_type(zbus::message::Type::Signal)
130 .path("/org/freedesktop/NetworkManager")
131 .and_then(|x| x.interface("org.freedesktop.DBus.Properties"))
132 .and_then(|x| x.member("PropertiesChanged"))
133 .unwrap()
134 .build(),
135 )
136 .await
137 .error("Failed to add match")?;
138 proxy
139 .add_match_rule(
140 MatchRule::builder()
141 .msg_type(zbus::message::Type::Signal)
142 .path_namespace("/org/freedesktop/NetworkManager/ActiveConnection")
143 .and_then(|x| x.interface("org.freedesktop.DBus.Properties"))
144 .and_then(|x| x.member("PropertiesChanged"))
145 .unwrap()
146 .build(),
147 )
148 .await
149 .error("Failed to add match")?;
150 proxy
151 .add_match_rule(
152 MatchRule::builder()
153 .msg_type(zbus::message::Type::Signal)
154 .path_namespace("/org/freedesktop/NetworkManager/IP4Config")
155 .and_then(|x| x.interface("org.freedesktop.DBus.Properties"))
156 .and_then(|x| x.member("PropertiesChanged"))
157 .unwrap()
158 .build(),
159 )
160 .await
161 .error("Failed to add match")?;
162 let stream: zbus::MessageStream = dbus.into();
163 // If the D-Bus connection dies the stream ends; without the chained
164 // pending stream, polling it again would resolve instantly forever,
165 // turning the loop below into a busy loop of API requests.
166 Box::pin(stream.map(|_| ()).chain(futures::stream::pending()))
167 } else {
168 Box::pin(futures::stream::pending())
169 };
170
171 let client = if config.use_ipv4 {
172 &REQWEST_CLIENT_IPV4
173 } else {
174 &REQWEST_CLIENT
175 };
176
177 loop {
178 let fetch_start = tokio::time::Instant::now();
179 let info = match api
180 .find_ip_location(client, config.autolocate_interval.0)
181 .await
182 {
183 Ok(info) => info,
184 Err(err) if is_rate_limited(&err) => {
185 // Keep displaying the last known IP and try again once the
186 // geolocator's rate limit interval has passed (plus a margin
187 // so we don't wake up just before it expires); erroring out
188 // here would make the block restart machinery re-query every
189 // `error_interval` seconds, which keeps the rate limit from
190 // ever lifting.
191 sleep(api.locator_rate_limit_interval() + Duration::from_secs(1)).await;
192 continue;
193 }
194 Err(err) => return Err(err),
195 };
196 debug!("got {} after {:?}", info.ip, fetch_start.elapsed());
197
198 let mut values = map! {
199 "ip" => Value::text(info.ip),
200 "city" => Value::text(info.city),
201 "latitude" => Value::number(info.latitude),
202 "longitude" => Value::number(info.longitude),
203 };
204
205 macro_rules! map_push_if_some { ($($key:ident: $type:ident),* $(,)?) => {
206 $({
207 let key = stringify!($key);
208 if let Some(value) = info.$key {
209 values.insert(key.into(), Value::$type(value));
210 } else if format.contains_key(key) {
211 return Err(Error::new(format!(
212 "The format string contains '{key}', but the {key} field is not provided by {} (an api key may be required)",
213 api.locator_name()
214 )));
215 }
216 })*
217 } }
218
219 map_push_if_some!(
220 version: text,
221 region: text,
222 region_code: text,
223 country: text,
224 country_name: text,
225 country_code_iso3: text,
226 country_capital: text,
227 country_tld: text,
228 continent_code: text,
229 postal: text,
230 timezone: text,
231 utc_offset: text,
232 country_calling_code: text,
233 currency: text,
234 currency_name: text,
235 languages: text,
236 country_area: number,
237 country_population: number,
238 asn: text,
239 org: text,
240 );
241
242 if let Some(country_code) = info.country_code {
243 values.insert(
244 "country_flag".into(),
245 Value::text(country_flag_from_iso_code(&country_code)),
246 );
247 values.insert("country_code".into(), Value::text(country_code));
248 } else if format.contains_key("country_code") || format.contains_key("country_flag") {
249 return Err(Error::new(format!(
250 "The format string contains 'country_code' or 'country_flag', but the country_code field is not provided by {}",
251 api.locator_name()
252 )));
253 }
254
255 if let Some(in_eu) = info.in_eu {
256 if in_eu {
257 values.insert("in_eu".into(), Value::flag());
258 }
259 } else if format.contains_key("in_eu") {
260 return Err(Error::new(format!(
261 "The format string contains 'in_eu', but the in_eu field is not provided by {}",
262 api.locator_name()
263 )));
264 }
265
266 let mut widget = output_main.new_widget();
267 widget.set_values(values);
268 api.set_widget(widget)?;
269
270 select! {
271 _ = sleep(config.interval.0) => (),
272 _ = api.wait_for_update_request() => (),
273 _ = stream.next_debounced() => {
274 // Wait for the burst of signals to die down before re-querying,
275 // so that one network transition results in one request, made
276 // once the new connection is likely up.
277 let settle_start = tokio::time::Instant::now();
278 while let Ok(Some(_)) = tokio::time::timeout(SETTLE_QUIET, stream.next()).await {
279 if settle_start.elapsed() >= SETTLE_MAX {
280 break;
281 }
282 }
283 debug!("signals settled after {:?}", settle_start.elapsed());
284 }
285 }
286 }
287}
288
289#[cfg(test)]
290mod tests {
291 use super::*;
292
293 #[test]
294 fn plan_declares_single_output_without_icons() {
295 let plan = prepare(&Config::default()).unwrap();
296 assert_eq!(plan.outputs().count(), 1);
297 let main = plan.output("main").unwrap();
298 assert!(main.format().contains_key("ip"));
299 assert!(main.format().contains_key("country_flag"));
300 assert_eq!(main.output().icon_placeholders().count(), 0);
301 }
302
303 #[test]
304 fn custom_format_is_used() {
305 let config = Config {
306 format: " $ip $country_code ".parse().unwrap(),
307 ..Config::default()
308 };
309 let plan = prepare(&config).unwrap();
310 let main = plan.output("main").unwrap();
311 assert!(main.format().contains_key("country_code"));
312 assert!(!main.format().contains_key("country_flag"));
313 }
314}