From 1cb63d75420b6a11193c29e9d1cf7e73f778515b Mon Sep 17 00:00:00 2001 From: James McMurray Date: Fri, 5 May 2023 22:28:06 +0200 Subject: [PATCH] Fix sync for ProtonVPN OpenVPN configs ProtonVPN now requires the user to be authenticated to access the configuration files. We now request that the user log in via their browser and then copy the value of the `AUTH-` cookie. Note the `Feature` specification for filtering for Tor / Torrent optimised servers has been removed from ProtonVPN's configuration page, and so is removed here too. Also note that for SecureCore configs, the first name in the filename is the output node country, as this is probably the most useful for filtering. Fixes issue #201 --- Cargo.toml | 2 +- README.md | 11 +- USERGUIDE.md | 17 +++ vopono_core/Cargo.toml | 4 +- vopono_core/src/config/providers/mod.rs | 61 +-------- .../src/config/providers/protonvpn/openvpn.rs | 118 ++++++------------ vopono_core/src/config/providers/ui.rs | 57 +++++++++ vopono_core/src/config/vpn.rs | 3 +- 8 files changed, 131 insertions(+), 142 deletions(-) create mode 100644 vopono_core/src/config/providers/ui.rs diff --git a/Cargo.toml b/Cargo.toml index 58a7f3f..c010a3a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "vopono" description = "Launch applications via VPN tunnels using temporary network namespaces" -version = "0.10.5" +version = "0.10.6" authors = ["James McMurray "] edition = "2021" license = "GPL-3.0-or-later" diff --git a/README.md b/README.md index fbd3792..e05c3cb 100644 --- a/README.md +++ b/README.md @@ -28,14 +28,21 @@ lynx all running through different VPN connections: | Mullvad | ✅ | ✅ | | AzireVPN | ✅ | ✅ | | iVPN | ✅ | ✅ | -| PrivateInternetAccess | ✅ | ✅ | +| PrivateInternetAccess | ✅ | ✅\*\* | | TigerVPN | ✅ | ❌ | -| ProtonVPN | ✅ | ❌ | +| ProtonVPN | ✅ | ❓\* | | MozillaVPN | ❌ | ✅ | | NordVPN | ✅ | ❌ | | HMA (HideMyAss) | ✅ | ❌ | | AirVPN | ✅ | ❌ | +\* For ProtonVPN you can generate and download specific Wireguard config +files, and use them as a custom provider config. See the [User Guide](USERGUIDE.md) +for details - note that port forwarding is not supported for ProtonVPN +(but is for Mullvad). + +\*\* Port forwarding is not currently supported for PrivateInternetAccess. + ## Usage Set up VPN provider configuration files: diff --git a/USERGUIDE.md b/USERGUIDE.md index 3ef1c28..acd30bf 100644 --- a/USERGUIDE.md +++ b/USERGUIDE.md @@ -453,8 +453,25 @@ for the same (note the instructions on disabling WebRTC). I noticed that when using IPv6 with OpenVPN it incorrectly states you are not connected via AzireVPN though (Wireguard works correctly). +ProtonVPN users must log-in to the dashboard via a web browser during +the `vopono sync` process in order to copy the `AUTH-*` cookie to +access the OpenVPN configuration files, and the OpenVPN specific +credentials to use them. + ### VPN Provider limitations +#### ProtonVPN + +Due to the way Wireguard configuration is handled, this should be +generated online and then used as a custom configuration, e.g.: + +```bash +$ vopono -v exec --provider custom --custom testwg-UK-17.conf --protocol wireguard firefox-developer-edition +``` + +Note that port forwarding is not supported for ProtonVPN (but is for +Mullvad). + #### PrivateInternetAccess Wireguard support for PrivateInternetAccess (PIA) requires the use of a diff --git a/vopono_core/Cargo.toml b/vopono_core/Cargo.toml index 01c9eec..7bbef90 100644 --- a/vopono_core/Cargo.toml +++ b/vopono_core/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "vopono_core" description = "Library code for running VPN connections in network namespaces" -version = "0.1.5" +version = "0.1.6" edition = "2021" authors = ["James McMurray "] license = "GPL-3.0-or-later" @@ -31,7 +31,7 @@ reqwest = { default-features = false, version = "0.11", features = [ "json", "rustls-tls", ] } # TODO: Can we remove Tokio dependency? -sysinfo = "0.28" +sysinfo = "0.29" base64 = "0.21" x25519-dalek = "1" strum = "0.24" diff --git a/vopono_core/src/config/providers/mod.rs b/vopono_core/src/config/providers/mod.rs index 24a203a..bc2a810 100644 --- a/vopono_core/src/config/providers/mod.rs +++ b/vopono_core/src/config/providers/mod.rs @@ -8,6 +8,7 @@ mod nordvpn; mod pia; mod protonvpn; mod tigervpn; +mod ui; use crate::config::vpn::Protocol; use crate::util::vopono_dir; @@ -16,6 +17,8 @@ use serde::{Deserialize, Serialize}; use std::path::PathBuf; use std::{net::IpAddr, path::Path}; use strum_macros::{Display, EnumIter}; +// TODO: Consider removing this re-export +pub use ui::*; // Command-line arguments use VpnProvider enum // We pattern match on that to build an instance of the actual provider struct @@ -142,61 +145,3 @@ pub trait ShadowsocksProvider: Provider { fn password(&self) -> String; fn encrypt_method(&self) -> String; } - -/// Implement this trait for enums used as configuration choices e.g. when deciding which set of -/// config files to generate -/// The default option will be used if generated in non-interactive mode -pub trait ConfigurationChoice { - /// Prompt string for the selector (automatically terminates in ':') - fn prompt(&self) -> String; - - /// Descriptions are a user-friendly descriptions for each enum variant - fn description(&self) -> Option; - - /// Descriptions are a user-friendly descriptions for each enum variant - fn all_descriptions(&self) -> Option>; - - /// Get all enum variant names (this order will be used for other methods) - fn all_names(&self) -> Vec; -} -// TODO: FromStr, ToString - -pub struct BoolChoice { - pub prompt: String, - pub default: bool, -} - -#[allow(clippy::type_complexity)] -/// Only supports strings -pub struct Input { - pub prompt: String, - pub validator: Option core::result::Result<(), String>>>, -} - -#[allow(clippy::type_complexity)] -/// Only supports u16 input - so UI Client can allow numbers only -pub struct InputNumericu16 { - pub prompt: String, - pub validator: Option core::result::Result<(), String>>>, - pub default: Option, -} - -pub struct Password { - pub prompt: String, - pub confirm: bool, -} - -/// Trait to be implemented by a struct wrapping the user-facing client code -/// e.g. separate implementations for CLI, TUI, GUI, etc. -/// For GUI and TUI may want to override `process_choices()` to get the responses in a batch -pub trait UiClient { - /// Returns index of chosen element from ConfigurationChoice - this can then be used with concrete enum::variants() for concrete variant - fn get_configuration_choice( - &self, - conf_choice: &dyn ConfigurationChoice, - ) -> anyhow::Result; - fn get_bool_choice(&self, bool_choice: BoolChoice) -> anyhow::Result; - fn get_input(&self, input: Input) -> anyhow::Result; - fn get_input_numeric_u16(&self, input: InputNumericu16) -> anyhow::Result; - fn get_password(&self, password: Password) -> anyhow::Result; -} diff --git a/vopono_core/src/config/providers/protonvpn/openvpn.rs b/vopono_core/src/config/providers/protonvpn/openvpn.rs index f03dc74..5410006 100644 --- a/vopono_core/src/config/providers/protonvpn/openvpn.rs +++ b/vopono_core/src/config/providers/protonvpn/openvpn.rs @@ -3,7 +3,10 @@ use super::{ConfigurationChoice, OpenVpnProvider}; use crate::config::providers::{Input, Password, UiClient}; use crate::config::vpn::OpenVpnProtocol; use crate::util::delete_all_files_in_dir; +use anyhow::anyhow; use log::{debug, info}; +use regex::Regex; +use reqwest::header::{HeaderMap, HeaderName, HeaderValue, COOKIE}; use reqwest::Url; use std::fmt::Display; use std::fs::create_dir_all; @@ -20,7 +23,6 @@ impl ProtonVPN { &self, category: &ConfigType, tier: &Tier, - feature: &Feature, protocol: &OpenVpnProtocol, ) -> anyhow::Result { let cat = if tier == &Tier::Free { @@ -28,12 +30,7 @@ impl ProtonVPN { } else { category.url_part() }; - let fet = if tier == &Tier::Free { - "Normal".to_string() - } else { - feature.url_part() - }; - Ok(Url::parse(&format!("https://account.protonvpn.com/api/vpn/config?Category={}&Tier={}&Feature={}&Platform=Linux&Protocol={}", cat, tier.url_part(), fet, protocol))?) + Ok(Url::parse(&format!("https://account.protonvpn.com/api/vpn/config?Category={}&Tier={}&Platform=Linux&Protocol={}", cat, tier.url_part(), protocol))?) } } impl OpenVpnProvider for ProtonVPN { @@ -92,16 +89,34 @@ impl OpenVpnProvider for ProtonVPN { // Dummy as not used for Free ConfigType::Standard }; - let feature_choice = if tier != Tier::Free { - Feature::index_to_variant(uiclient.get_configuration_choice(&Feature::default())?) - } else { - Feature::Normal - }; let protocol = OpenVpnProtocol::index_to_variant( uiclient.get_configuration_choice(&OpenVpnProtocol::default())?, ); - let url = self.build_url(&config_choice, &tier, &feature_choice, &protocol)?; - let zipfile = reqwest::blocking::get(url)?; + + let auth_cookie: &'static str = Box::leak(uiclient.get_input(Input { + prompt: "Please log-in at https://account.protonvpn.com/dashboard and then visit https://account.protonvpn.com/api/vpn/v2/users and copy the value of the cookie starting with \"AUTH-\" in the request from your browser's network request inspector".to_owned(), + validator: Some(Box::new(|s: &String| if s.starts_with("AUTH-") {Ok(())} else {Err("AUTH cookie must start with AUTH-".to_owned())})) + })?.replace(';', "").trim().to_owned().into_boxed_str()); + debug!("Using AUTH cookie: {}", &auth_cookie); + + let re = Regex::new("AUTH-([^=]+)=").unwrap(); + let uid = re + .captures(auth_cookie) + .and_then(|c| c.get(1)) + .ok_or(anyhow!("Failed to parse auth cookie"))?; + let url = self.build_url(&config_choice, &tier, &protocol)?; + + let mut headers = HeaderMap::new(); + headers.insert(COOKIE, HeaderValue::from_static(auth_cookie)); + + headers.insert( + HeaderName::from_static("x-pm-uid"), + HeaderValue::from_static(uid.as_str()), + ); + let client = reqwest::blocking::Client::new(); + + let zipfile = client.get(url).headers(headers).send()?; + let mut zip = ZipArchive::new(Cursor::new(zipfile.bytes()?))?; let openvpn_dir = self.openvpn_dir()?; create_dir_all(&openvpn_dir)?; @@ -127,13 +142,22 @@ impl OpenVpnProvider for ProtonVPN { .map(|x| x.to_str().expect("Could not convert OsStr")) { // Also handle server case from free servers - let mut hostname = None; + let mut hostname: Option = None; let mut code = file.name().split('.').next().unwrap(); - if code.contains('-') { + if code.contains("free") { + // Free case let mut iter_split = code.split('-'); let fcode = iter_split.next().unwrap(); - hostname = Some(iter_split.next().unwrap()); + hostname = Some(iter_split.next().unwrap().to_owned()); code = fcode; + } else if code.contains('-') { + // SecureCore + let mut iter_split = code.split('-'); + let start = iter_split.next().unwrap(); + let end = iter_split.next().unwrap(); + let number = iter_split.next().unwrap(); + hostname = Some(format!("{}_{}", start, number)); + code = end; } let country = code_map .get(code) @@ -235,66 +259,6 @@ impl ConfigurationChoice for Tier { ) } } -// {0: "Normal", 1: "Secure-Core", 2: "Tor", 4: "P2P"} -#[derive(EnumIter, PartialEq)] -enum Feature { - P2P, - Tor, - Normal, -} - -impl Feature { - fn url_part(&self) -> String { - match self { - Self::P2P => "4".to_string(), - Self::Tor => "2".to_string(), - Self::Normal => "0".to_string(), - } - } - fn index_to_variant(index: usize) -> Self { - Self::iter().nth(index).expect("Invalid index") - } -} - -impl Display for Feature { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let s = match self { - Self::P2P => "P2P", - Self::Tor => "Tor", - Self::Normal => "Normal", - }; - write!(f, "{s}") - } -} - -impl Default for Feature { - fn default() -> Self { - Self::Normal - } -} - -impl ConfigurationChoice for Feature { - fn prompt(&self) -> String { - "Please choose a server feature".to_string() - } - - fn all_names(&self) -> Vec { - Self::iter().map(|x| format!("{x}")).collect() - } - fn all_descriptions(&self) -> Option> { - Some(Self::iter().map(|x| x.description().unwrap()).collect()) - } - fn description(&self) -> Option { - Some( - match self { - Self::P2P => "Connect via torrent optmized network (Plus accounts only)", - Self::Tor => "Connect via Tor network (Plus accounts only)", - Self::Normal => "Standard (available servers depend on account tier)", - } - .to_string(), - ) - } -} #[derive(EnumIter, PartialEq)] enum ConfigType { diff --git a/vopono_core/src/config/providers/ui.rs b/vopono_core/src/config/providers/ui.rs new file mode 100644 index 0000000..76ccf80 --- /dev/null +++ b/vopono_core/src/config/providers/ui.rs @@ -0,0 +1,57 @@ +/// Implement this trait for enums used as configuration choices e.g. when deciding which set of +/// config files to generate +/// The default option will be used if generated in non-interactive mode +pub trait ConfigurationChoice { + /// Prompt string for the selector (automatically terminates in ':') + fn prompt(&self) -> String; + + /// Descriptions are a user-friendly descriptions for each enum variant + fn description(&self) -> Option; + + /// Descriptions are a user-friendly descriptions for each enum variant + fn all_descriptions(&self) -> Option>; + + /// Get all enum variant names (this order will be used for other methods) + fn all_names(&self) -> Vec; +} +// TODO: FromStr, ToString + +pub struct BoolChoice { + pub prompt: String, + pub default: bool, +} + +#[allow(clippy::type_complexity)] +/// Only supports strings +pub struct Input { + pub prompt: String, + pub validator: Option core::result::Result<(), String>>>, +} + +#[allow(clippy::type_complexity)] +/// Only supports u16 input - so UI Client can allow numbers only +pub struct InputNumericu16 { + pub prompt: String, + pub validator: Option core::result::Result<(), String>>>, + pub default: Option, +} + +pub struct Password { + pub prompt: String, + pub confirm: bool, +} + +/// Trait to be implemented by a struct wrapping the user-facing client code +/// e.g. separate implementations for CLI, TUI, GUI, etc. +/// For GUI and TUI may want to override `process_choices()` to get the responses in a batch +pub trait UiClient { + /// Returns index of chosen element from ConfigurationChoice - this can then be used with concrete enum::variants() for concrete variant + fn get_configuration_choice( + &self, + conf_choice: &dyn ConfigurationChoice, + ) -> anyhow::Result; + fn get_bool_choice(&self, bool_choice: BoolChoice) -> anyhow::Result; + fn get_input(&self, input: Input) -> anyhow::Result; + fn get_input_numeric_u16(&self, input: InputNumericu16) -> anyhow::Result; + fn get_password(&self, password: Password) -> anyhow::Result; +} diff --git a/vopono_core/src/config/vpn.rs b/vopono_core/src/config/vpn.rs index fd488c4..58c149e 100644 --- a/vopono_core/src/config/vpn.rs +++ b/vopono_core/src/config/vpn.rs @@ -1,6 +1,5 @@ -use super::providers::ConfigurationChoice; use super::providers::OpenVpnProvider; -use super::providers::UiClient; +use super::providers::{ConfigurationChoice, UiClient}; use anyhow::{anyhow, Context}; use log::{debug, info}; use serde::{Deserialize, Serialize};