Skip to content

Commit

Permalink
Merge pull request #222 from jamesmcm/fix_protonvpn
Browse files Browse the repository at this point in the history
Fix sync for ProtonVPN OpenVPN configs
  • Loading branch information
jamesmcm authored May 5, 2023
2 parents 8026b9d + 1cb63d7 commit 5246399
Show file tree
Hide file tree
Showing 8 changed files with 131 additions and 142 deletions.
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -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 <[email protected]>"]
edition = "2021"
license = "GPL-3.0-or-later"
Expand Down
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
17 changes: 17 additions & 0 deletions USERGUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions vopono_core/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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 <[email protected]>"]
license = "GPL-3.0-or-later"
Expand Down Expand Up @@ -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"
Expand Down
61 changes: 3 additions & 58 deletions vopono_core/src/config/providers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ mod nordvpn;
mod pia;
mod protonvpn;
mod tigervpn;
mod ui;

use crate::config::vpn::Protocol;
use crate::util::vopono_dir;
Expand All @@ -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
Expand Down Expand Up @@ -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<String>;

/// Descriptions are a user-friendly descriptions for each enum variant
fn all_descriptions(&self) -> Option<Vec<String>>;

/// Get all enum variant names (this order will be used for other methods)
fn all_names(&self) -> Vec<String>;
}
// 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<Box<dyn Fn(&String) -> 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<Box<dyn Fn(&u16) -> core::result::Result<(), String>>>,
pub default: Option<u16>,
}

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<usize>;
fn get_bool_choice(&self, bool_choice: BoolChoice) -> anyhow::Result<bool>;
fn get_input(&self, input: Input) -> anyhow::Result<String>;
fn get_input_numeric_u16(&self, input: InputNumericu16) -> anyhow::Result<u16>;
fn get_password(&self, password: Password) -> anyhow::Result<String>;
}
118 changes: 41 additions & 77 deletions vopono_core/src/config/providers/protonvpn/openvpn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -20,20 +23,14 @@ impl ProtonVPN {
&self,
category: &ConfigType,
tier: &Tier,
feature: &Feature,
protocol: &OpenVpnProtocol,
) -> anyhow::Result<Url> {
let cat = if tier == &Tier::Free {
"Server".to_string()
} 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 {
Expand Down Expand Up @@ -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)?;
Expand All @@ -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<String> = 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)
Expand Down Expand Up @@ -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<String> {
Self::iter().map(|x| format!("{x}")).collect()
}
fn all_descriptions(&self) -> Option<Vec<String>> {
Some(Self::iter().map(|x| x.description().unwrap()).collect())
}
fn description(&self) -> Option<String> {
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 {
Expand Down
57 changes: 57 additions & 0 deletions vopono_core/src/config/providers/ui.rs
Original file line number Diff line number Diff line change
@@ -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<String>;

/// Descriptions are a user-friendly descriptions for each enum variant
fn all_descriptions(&self) -> Option<Vec<String>>;

/// Get all enum variant names (this order will be used for other methods)
fn all_names(&self) -> Vec<String>;
}
// 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<Box<dyn Fn(&String) -> 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<Box<dyn Fn(&u16) -> core::result::Result<(), String>>>,
pub default: Option<u16>,
}

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<usize>;
fn get_bool_choice(&self, bool_choice: BoolChoice) -> anyhow::Result<bool>;
fn get_input(&self, input: Input) -> anyhow::Result<String>;
fn get_input_numeric_u16(&self, input: InputNumericu16) -> anyhow::Result<u16>;
fn get_password(&self, password: Password) -> anyhow::Result<String>;
}
3 changes: 1 addition & 2 deletions vopono_core/src/config/vpn.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down

0 comments on commit 5246399

Please sign in to comment.