Skip to main content

i3status_rs/blocks/vpn/
mullvad.rs

1use itertools::Itertools as _;
2use std::process::Stdio;
3use tokio::process::Command;
4
5use crate::blocks::prelude::*;
6use crate::util::country_flag_from_iso_code;
7
8use super::{Driver, Status};
9
10pub struct MullvadDriver {}
11
12impl MullvadDriver {
13    pub async fn new() -> MullvadDriver {
14        MullvadDriver {}
15    }
16
17    async fn run_network_command(arg: &str) -> Result<()> {
18        let code = Command::new("mullvad")
19            .args([arg])
20            .stdin(Stdio::null())
21            .stdout(Stdio::null())
22            .spawn()
23            .error(format!("Problem running mullvad command: {arg}"))?
24            .wait()
25            .await
26            .error(format!("Problem running mullvad command: {arg}"))?;
27
28        if code.success() {
29            // Sleep 1 sec here to allow the mullvad-daemon some time to update the status
30            // before get_status is called again.
31            sleep(Duration::from_secs(1)).await;
32            Ok(())
33        } else {
34            Err(Error::new(format!(
35                "mullvad command failed with nonzero status: {code:?}"
36            )))
37        }
38    }
39}
40
41#[async_trait]
42impl Driver for MullvadDriver {
43    async fn get_status(&self) -> Result<Status> {
44        let stdout = Command::new("mullvad")
45            .args(["status", "-j"])
46            .output()
47            .await
48            .error("Problem running `mullvad status -j`")?
49            .stdout;
50
51        let json_status_output = String::from_utf8_lossy(&stdout);
52
53        // As of 2026-04-29 there's a bug in the `mullvad status -j` command in that it sometimes
54        // prints non-JSON data before the JSON output so we must filter that out here.
55        let json_status = json_status_output
56            .lines()
57            .skip_while(|line| !line.starts_with("{"))
58            .join("\n");
59
60        let status: MullvadCliStatus =
61            serde_json::from_str(&json_status).error("`mullvad status -j` produced wrong JSON")?;
62
63        match status {
64            MullvadCliStatus::Disconnected | MullvadCliStatus::Disconnecting => {
65                Ok(Status::Disconnected { profile: None })
66            }
67            MullvadCliStatus::Connected { details } => {
68                let country_code = details
69                    .location
70                    .hostname
71                    .map(|hostname| hostname[0..2].to_uppercase());
72
73                let country_flag = country_code
74                    .as_ref()
75                    .map(|code| country_flag_from_iso_code(code));
76
77                Ok(Status::Connected {
78                    country: country_code,
79                    country_flag,
80                    profile: None,
81                })
82            }
83            MullvadCliStatus::Connecting => Ok(Status::Connecting { profile: None }),
84            MullvadCliStatus::Error { details } => {
85                let error = details.and_then(|details| details.cause).and_then(|cause| {
86                    if let Some(reason) = cause.reason
87                        && let Some(details) = cause.details
88                    {
89                        Some(format!("{}: {}", reason, details))
90                    } else {
91                        None
92                    }
93                });
94
95                Ok(Status::Error(error))
96            }
97        }
98    }
99
100    async fn toggle_connection(&self, status: &Status) -> Result<()> {
101        match status {
102            Status::Connected { .. } | Status::Connecting { .. } => {
103                Self::run_network_command("disconnect").await?;
104            }
105            Status::Disconnected { .. } => Self::run_network_command("connect").await?,
106            Status::Error(_) => (),
107        }
108        Ok(())
109    }
110}
111
112#[derive(Deserialize, Debug, Clone)]
113#[serde(tag = "state")]
114#[serde(rename_all = "snake_case")]
115enum MullvadCliStatus {
116    Connected { details: Details },
117
118    Disconnected,
119
120    Connecting,
121
122    Disconnecting,
123
124    Error { details: Option<ErrorDetails> },
125}
126
127#[derive(Deserialize, Debug, Clone)]
128struct Details {
129    location: Location,
130}
131
132#[derive(Deserialize, Debug, Clone)]
133struct Location {
134    hostname: Option<String>,
135}
136
137#[derive(Deserialize, Debug, Clone)]
138struct ErrorDetails {
139    cause: Option<ErrorCause>,
140}
141
142#[derive(Deserialize, Debug, Clone)]
143struct ErrorCause {
144    reason: Option<String>,
145    details: Option<String>,
146}