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//! `state_connected` | The widgets state if the vpn network is connected. | `info`
14//! `state_disconnected` | The widgets state if the vpn network is disconnected | `idle`
15//!
16//! Placeholder | Value                                                     | Type   | Unit
17//! ------------|-----------------------------------------------------------|--------|------
18//! `icon`      | A static icon                                             | Icon   | -
19//! `country`   | Country currently connected to                            | Text   | -
20//! `flag`      | Country specific flag (depends on a font supporting them) | Text   | -
21//! `profile`   | Currently selected profile configuration (tailnet)        | Text   | -
22//! `error`     | Error message if any                                      | Text   | -
23//!
24//! Action    | Default button | Description
25//! ----------|----------------|-----------------------------------
26//! `toggle`  | Left           | toggles the vpn network connection
27//!
28//! # Drivers
29//!
30//! ## Mullvad
31//! 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.
32//!
33//! ## nordvpn
34//! Behind the scenes the nordvpn driver uses the `nordvpn` command line binary. In order for this to work
35//! properly the binary should be executable without root privileges.
36//!
37//! ## Tailscale
38//! Behind the scenes the tailscale driver uses the `tailscale` command line binary.
39//! In order for this to work properly the tailscale daemon should be running and the user must be configured as operator:
40//! ```sh
41//! sudo tailscale set --operator=$USER
42//! ```
43//!
44//! ## Cloudflare WARP
45//! Behind the scenes the WARP driver uses the `warp-cli` command line binary. Just ensure the binary is executable without root privileges.
46//!
47//! # Example
48//!
49//! Shows the current vpn network state:
50//!
51//! ```toml
52//! [[block]]
53//! block = "vpn"
54//! driver = "nordvpn"
55//! interval = 10
56//! format_connected = "VPN: $icon "
57//! format_disconnected = "VPN: $icon "
58//! state_connected = "good"
59//! state_disconnected = "warning"
60//! ```
61//!
62//! Possible values for `state_connected` and `state_disconnected`:
63//!
64//! ```text
65//! warning
66//! critical
67//! good
68//! info
69//! idle
70//! ```
71//!
72//! # Icons Used
73//!
74//! - `net_vpn`
75//! - `net_wired`
76//! - `net_down`
77//! - country code flags (if supported by font)
78//!
79//! Flags: They are not icons but unicode glyphs. You will need a font that
80//! includes them. Tested with: <https://www.babelstone.co.uk/Fonts/Flags.html>
81
82mod mullvad;
83use mullvad::MullvadDriver;
84mod nordvpn;
85use nordvpn::NordVpnDriver;
86mod tailscale;
87use tailscale::TailscaleDriver;
88mod warp;
89use warp::WarpDriver;
90
91use super::prelude::*;
92
93#[derive(Deserialize, Debug, SmartDefault)]
94#[serde(rename_all = "snake_case")]
95pub enum DriverType {
96    Mullvad,
97    #[default]
98    Nordvpn,
99    Tailscale,
100    Warp,
101}
102
103#[derive(Deserialize, Debug, SmartDefault)]
104#[serde(deny_unknown_fields, default)]
105pub struct Config {
106    pub driver: DriverType,
107    #[default(10.into())]
108    pub interval: Seconds,
109    pub format_connected: FormatConfig,
110    pub format_disconnected: FormatConfig,
111    pub state_connected: State,
112    pub state_disconnected: State,
113}
114
115enum Status {
116    Connected {
117        country: Option<String>,
118        country_flag: Option<String>,
119        profile: Option<String>,
120    },
121    Disconnected {
122        profile: Option<String>,
123    },
124    Error(Option<String>),
125}
126
127impl Status {
128    fn icon(&self) -> Cow<'static, str> {
129        match self {
130            Status::Connected { .. } => "net_vpn".into(),
131            Status::Disconnected { .. } => "net_wired".into(),
132            Status::Error(_) => "net_down".into(),
133        }
134    }
135}
136
137pub async fn run(config: &Config, api: &CommonApi) -> Result<()> {
138    let mut actions = api.get_actions()?;
139    api.set_default_actions(&[(MouseButton::Left, None, "toggle")])?;
140
141    let format_connected = config.format_connected.with_default(" VPN: $icon ")?;
142    let format_disconnected = config.format_disconnected.with_default(" VPN: $icon ")?;
143
144    let driver: Box<dyn Driver> = match config.driver {
145        DriverType::Mullvad => Box::new(MullvadDriver::new().await),
146        DriverType::Nordvpn => Box::new(NordVpnDriver::new().await),
147        DriverType::Tailscale => Box::new(TailscaleDriver::new().await),
148        DriverType::Warp => Box::new(WarpDriver::new().await),
149    };
150
151    loop {
152        let status = driver.get_status().await?;
153
154        let mut widget = Widget::new();
155
156        widget.state = match &status {
157            Status::Connected {
158                country,
159                country_flag,
160                profile,
161            } => {
162                widget.set_values(map!(
163                        "icon" => Value::icon(status.icon()),
164                        [if let Some(country) = country] "country" => Value::text(country.into()),
165                        [if let Some(flag) = country_flag] "flag" => Value::text(flag.into()),
166                        [if let Some(profile) = profile] "profile" => Value::text(profile.into()),
167                ));
168                widget.set_format(format_connected.clone());
169                config.state_connected
170            }
171            Status::Disconnected { profile } => {
172                widget.set_values(map! {
173                    "icon" => Value::icon(status.icon()),
174                    [if let Some(profile) = profile] "profile" => Value::text(profile.into()),
175                });
176                widget.set_format(format_disconnected.clone());
177                config.state_disconnected
178            }
179            Status::Error(error) => {
180                widget.set_values(map!(
181                        "icon" => Value::icon(status.icon()),
182                        [if let Some(error) = error] "error" => Value::text(error.into())
183                ));
184                widget.set_format(format_disconnected.clone());
185                State::Critical
186            }
187        };
188
189        api.set_widget(widget)?;
190
191        select! {
192            _ = sleep(config.interval.0) => (),
193            _ = api.wait_for_update_request() => (),
194            Some(action) = actions.recv() => match action.as_ref() {
195                "toggle" => driver.toggle_connection(&status).await?,
196                _ => (),
197            }
198        }
199    }
200}
201
202#[async_trait]
203trait Driver {
204    async fn get_status(&self) -> Result<Status>;
205    async fn toggle_connection(&self, status: &Status) -> Result<()>;
206}