Skip to main content

i3status_rs/blocks/
vpn.rs

1//! Shows the current connection status for VPN networks
2//!
3//! This widget toggles the connection on left click.
4//!
5//! # Configuration
6//!
7//! Key | Values | Default
8//! ----|--------|--------
9//! `driver` | Which vpn should be used . Available drivers are: `"nordvpn"`, `"mullvad"`, `"tailscale"` | `"nordvpn"`
10//! `interval` | Update interval in seconds. | `10`
11//! `format_connected` | A string to customise the output in case the network is connected. See below for available placeholders. | `" VPN: $icon "`
12//! `format_disconnected` | A string to customise the output in case the network is disconnected. See below for available placeholders. | `" VPN: $icon "`
13//! `format_connecting` | A string to customise the output in case the network is in a connecting state. See below for available placeholders. | `" VPN: $icon "`
14//! `state_connected` | The widgets state if the vpn network is connected. | `info`
15//! `state_disconnected` | The widgets state if the vpn network is disconnected | `idle`
16//!
17//! Placeholder | Value                                                     | Type   | Unit
18//! ------------|-----------------------------------------------------------|--------|------
19//! `icon`      | A static icon                                             | Icon   | -
20//! `country`   | Country currently connected to                            | Text   | -
21//! `flag`      | Country specific flag (depends on a font supporting them) | Text   | -
22//! `profile`   | Currently selected profile configuration (tailnet)        | Text   | -
23//! `error`     | Error message if any                                      | Text   | -
24//!
25//! Action    | Default button | Description
26//! ----------|----------------|-----------------------------------
27//! `toggle`  | Left           | toggles the vpn network connection
28//!
29//! # Drivers
30//!
31//! ## Mullvad
32//! Behind the scenes the mullvad driver uses the `mullvad` command line binary. In order for this to work properly the binary should be executable and mullvad daemon should be running.
33//!
34//! ## nordvpn
35//! Behind the scenes the nordvpn driver uses the `nordvpn` command line binary. In order for this to work
36//! properly the binary should be executable without root privileges.
37//!
38//! ## Tailscale
39//! Behind the scenes the tailscale driver uses the `tailscale` command line binary.
40//! In order for this to work properly the tailscale daemon should be running and the user must be configured as operator:
41//! ```sh
42//! sudo tailscale set --operator=$USER
43//! ```
44//!
45//! ## Cloudflare WARP
46//! Behind the scenes the WARP driver uses the `warp-cli` command line binary. Just ensure the binary is executable without root privileges.
47//!
48//! # Example
49//!
50//! Shows the current vpn network state:
51//!
52//! ```toml
53//! [[block]]
54//! block = "vpn"
55//! driver = "nordvpn"
56//! interval = 10
57//! format_connected = "VPN: $icon "
58//! format_disconnected = "VPN: $icon "
59//! format_connecting = "VPN: $icon "
60//! state_connected = "good"
61//! state_disconnected = "warning"
62//! ```
63//!
64//! Possible values for `state_connected` and `state_disconnected`:
65//!
66//! ```text
67//! warning
68//! critical
69//! good
70//! info
71//! idle
72//! ```
73//!
74//! # Icons Used
75//!
76//! - `net_vpn` (`$icon`, in `format_connected`)
77//! - `net_wired` (`$icon`, in `format_disconnected`)
78//! - `net_wireless` (`$icon`, in `format_connecting`)
79//! - `net_down` (`$icon`, in `format_disconnected`, on errors)
80//! - country code flags (if supported by font)
81//!
82//! Flags: They are not icons but unicode glyphs. You will need a font that
83//! includes them. Tested with: <https://www.babelstone.co.uk/Fonts/Flags.html>
84
85mod mullvad;
86use mullvad::MullvadDriver;
87mod nordvpn;
88use nordvpn::NordVpnDriver;
89mod tailscale;
90use tailscale::TailscaleDriver;
91mod warp;
92use warp::WarpDriver;
93
94use super::prelude::*;
95
96#[derive(Deserialize, Debug, SmartDefault)]
97#[serde(rename_all = "snake_case")]
98pub enum DriverType {
99    Mullvad,
100    #[default]
101    Nordvpn,
102    Tailscale,
103    Warp,
104}
105
106#[derive(Deserialize, Debug, SmartDefault)]
107#[serde(deny_unknown_fields, default)]
108pub struct Config {
109    pub driver: DriverType,
110    #[default(10.into())]
111    pub interval: Seconds,
112    pub format_connected: FormatConfig,
113    pub format_disconnected: FormatConfig,
114    pub format_connecting: FormatConfig,
115    pub state_connected: State,
116    pub state_disconnected: State,
117}
118
119enum Status {
120    Connected {
121        country: Option<String>,
122        country_flag: Option<String>,
123        profile: Option<String>,
124    },
125    Connecting {
126        profile: Option<String>,
127    },
128    Disconnected {
129        profile: Option<String>,
130    },
131    Error(Option<String>),
132}
133
134impl DriverType {
135    /// Whether the driver's `get_status` can ever report `Connecting`.
136    /// Only mullvad has an intermediate connecting state; the other CLIs
137    /// report connected, disconnected, or an error.
138    fn can_report_connecting(&self) -> bool {
139        matches!(self, DriverType::Mullvad)
140    }
141}
142
143pub(crate) fn prepare(config: &Config) -> Result<Arc<BlockPlan>> {
144    let mut outputs = vec![
145        OutputPlan::new(
146            "connected",
147            config.format_connected.with_default(" VPN: $icon ")?,
148        )
149        .icon("icon", IconChoices::one(icons::NET_VPN)),
150        OutputPlan::new(
151            "disconnected",
152            config.format_disconnected.with_default(" VPN: $icon ")?,
153        )
154        .icon("icon", IconChoices::one(icons::NET_WIRED)),
155    ];
156    if config.driver.can_report_connecting() {
157        outputs.push(
158            OutputPlan::new(
159                "connecting",
160                config.format_connecting.with_default(" VPN: $icon ")?,
161            )
162            .icon("icon", IconChoices::one(icons::NET_WIRELESS)),
163        );
164    }
165    outputs.push(
166        OutputPlan::new(
167            "error",
168            config.format_disconnected.with_default(" VPN: $icon ")?,
169        )
170        .icon("icon", IconChoices::one(icons::NET_DOWN)),
171    );
172    BlockPlan::new(outputs)
173}
174
175pub(crate) async fn run(config: &Config, api: &CommonApi, plan: &Arc<BlockPlan>) -> Result<()> {
176    let mut actions = api.get_actions()?;
177    api.set_default_actions(&[(MouseButton::Left, None, "toggle")])?;
178
179    let output_connected = plan.output("connected")?;
180    let output_disconnected = plan.output("disconnected")?;
181    let output_connecting = if config.driver.can_report_connecting() {
182        Some(plan.output("connecting")?)
183    } else {
184        None
185    };
186    let output_error = plan.output("error")?;
187
188    let driver: Box<dyn Driver> = match config.driver {
189        DriverType::Mullvad => Box::new(MullvadDriver::new().await),
190        DriverType::Nordvpn => Box::new(NordVpnDriver::new().await),
191        DriverType::Tailscale => Box::new(TailscaleDriver::new().await),
192        DriverType::Warp => Box::new(WarpDriver::new().await),
193    };
194
195    loop {
196        let status = driver.get_status().await?;
197
198        let output = match &status {
199            Status::Connected { .. } => &output_connected,
200            Status::Disconnected { .. } => &output_disconnected,
201            // A driver reporting a state outside its declared capability is
202            // an internal bug; degrade to the disconnected output.
203            Status::Connecting { .. } => output_connecting.as_ref().unwrap_or_else(|| {
204                debug_assert!(false, "driver reported Connecting without the capability");
205                &output_disconnected
206            }),
207            Status::Error(_) => &output_error,
208        };
209        let mut widget = output.new_widget();
210        let icon = output.icon_value("icon")?;
211
212        widget.state = match &status {
213            Status::Connected {
214                country,
215                country_flag,
216                profile,
217            } => {
218                widget.set_values(map!(
219                        "icon" => icon.clone(),
220                        [if let Some(country) = country] "country" => Value::text(country.into()),
221                        [if let Some(flag) = country_flag] "flag" => Value::text(flag.into()),
222                        [if let Some(profile) = profile] "profile" => Value::text(profile.into()),
223                ));
224                config.state_connected
225            }
226            Status::Disconnected { profile } => {
227                widget.set_values(map! {
228                    "icon" => icon.clone(),
229                    [if let Some(profile) = profile] "profile" => Value::text(profile.into()),
230                });
231                config.state_disconnected
232            }
233            Status::Connecting { profile } => {
234                widget.set_values(map!(
235                        "icon" => icon.clone(),
236                        [if let Some(profile) = profile] "profile" => Value::text(profile.into()),
237                ));
238                State::Info
239            }
240            Status::Error(error) => {
241                widget.set_values(map!(
242                        "icon" => icon.clone(),
243                        [if let Some(error) = error] "error" => Value::text(error.into())
244                ));
245                State::Critical
246            }
247        };
248
249        api.set_widget(widget)?;
250
251        select! {
252            _ = sleep(config.interval.0) => (),
253            _ = api.wait_for_update_request() => (),
254            Some(action) = actions.recv() => match action.as_ref() {
255                "toggle" => driver.toggle_connection(&status).await?,
256                _ => (),
257            }
258        }
259    }
260}
261
262#[async_trait]
263trait Driver {
264    async fn get_status(&self) -> Result<Status>;
265    async fn toggle_connection(&self, status: &Status) -> Result<()>;
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271
272    #[test]
273    fn plan_declares_states_reachable_for_the_driver() {
274        // Default driver is nordvpn, which never reports Connecting.
275        let plan = prepare(&Config::default()).unwrap();
276        let declared: Vec<_> = plan.outputs().map(|o| o.id()).collect();
277        assert_eq!(declared, ["connected", "disconnected", "error"]);
278
279        let mullvad = Config {
280            driver: DriverType::Mullvad,
281            ..Config::default()
282        };
283        let plan = prepare(&mullvad).unwrap();
284        let declared: Vec<_> = plan.outputs().map(|o| o.id()).collect();
285        assert_eq!(
286            declared,
287            ["connected", "disconnected", "connecting", "error"]
288        );
289        for (id, icon) in [
290            ("connected", "net_vpn"),
291            ("disconnected", "net_wired"),
292            ("connecting", "net_wireless"),
293            ("error", "net_down"),
294        ] {
295            let output = plan.output(id).unwrap();
296            let choices = output.output().choices_for("icon").unwrap();
297            assert!(choices.permits(icon), "{id} must permit {icon}");
298        }
299    }
300
301    #[test]
302    fn each_output_declares_exactly_one_icon() {
303        let plan = prepare(&Config::default()).unwrap();
304        for (id, icon) in [
305            ("connected", "net_vpn"),
306            ("disconnected", "net_wired"),
307            ("error", "net_down"),
308        ] {
309            let output = plan.output(id).unwrap();
310            assert_eq!(output.single_icon("icon").unwrap(), icon);
311        }
312    }
313
314    #[test]
315    fn error_state_uses_disconnected_format() {
316        let config = Config {
317            format_disconnected: " off ".parse().unwrap(),
318            ..Config::default()
319        };
320        let plan = prepare(&config).unwrap();
321        let error = plan.output("error").unwrap();
322        assert!(!error.format().contains_key("icon"));
323    }
324}