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};