Skip to content

Commit

Permalink
Fix sync for ProtonVPN OpenVPN configs
Browse files Browse the repository at this point in the history
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
  • Loading branch information
jamesmcm committed May 5, 2023
1 parent 8026b9d commit 1cb63d7
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 1cb63d7

Please sign in to comment.