Skip to content

Commit

Permalink
Add port forwarding for ProtonVPN connections
Browse files Browse the repository at this point in the history
Works with OpenVPN and Wireguard (downloaded ProtonVPN custom config)

Note for OpenVPN port forwarding you must generate the OpenVPN config
files appending "+pmp" to the OpenVPN username.

Note we do not currently handle if the port changes when renewed (i.e.
we do not rest firewall rules in this case).
  • Loading branch information
jamesmcm committed Nov 4, 2023
1 parent 5a914c4 commit 0ed46f6
Show file tree
Hide file tree
Showing 22 changed files with 982 additions and 656 deletions.
4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ directories-next = "2"
log = "0.4"
pretty_env_logger = "0.5"
clap = { version = "4", features = ["derive"] }
which = "4"
dialoguer = "0.10"
which = "5"
dialoguer = "0.11"
compound_duration = "1"
signal-hook = "0.3"
walkdir = "2"
Expand Down
4 changes: 4 additions & 0 deletions src/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,10 @@ pub struct ExecCommand {
/// Useful for accessing services on the host locally
#[clap(long = "allow-host-access")]
pub allow_host_access: bool,

/// Enable port forwarding for ProtonVPN connections
#[clap(long = "protonvpn-port-forwarding")]
pub protonvpn_port_forwarding: bool,
}

#[derive(Parser)]
Expand Down
17 changes: 7 additions & 10 deletions src/cli_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,26 +38,23 @@ impl UiClient for CliClient {
}

fn get_input(&self, inp: Input) -> anyhow::Result<String> {
let mut d = dialoguer::Input::<String>::new();

d.with_prompt(&inp.prompt);
let mut d = dialoguer::Input::<String>::new().with_prompt(&inp.prompt);

if inp.validator.is_some() {
d.validate_with(inp.validator.unwrap());
d = d.validate_with(inp.validator.unwrap());
};

Ok(d.interact()?)
}

fn get_input_numeric_u16(&self, inp: InputNumericu16) -> anyhow::Result<u16> {
let mut d = dialoguer::Input::<u16>::new();
d.with_prompt(&inp.prompt);
let mut d = dialoguer::Input::<u16>::new().with_prompt(&inp.prompt);

if inp.default.is_some() {
d.default(inp.default.unwrap());
d = d.default(inp.default.unwrap());
}
if inp.validator.is_some() {
d.validate_with(inp.validator.unwrap());
d = d.validate_with(inp.validator.unwrap());
}

Ok(d.interact()?)
Expand All @@ -66,9 +63,9 @@ impl UiClient for CliClient {
fn get_password(&self, pw: Password) -> anyhow::Result<String> {
let mut req = dialoguer::Password::new();
if pw.confirm {
req.with_confirmation("Confirm password", "Passwords did not match");
req = req.with_confirmation("Confirm password", "Passwords did not match");
};
req.with_prompt(pw.prompt);
req = req.with_prompt(pw.prompt);
Ok(req.interact()?)
}
}
111 changes: 51 additions & 60 deletions src/exec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ use vopono_core::config::providers::{UiClient, VpnProvider};
use vopono_core::config::vpn::{verify_auth, Protocol};
use vopono_core::network::application_wrapper::ApplicationWrapper;
use vopono_core::network::firewall::Firewall;
use vopono_core::network::natpmpc::Natpmpc;
use vopono_core::network::netns::NetworkNamespace;
use vopono_core::network::network_interface::{get_active_interfaces, NetworkInterface};
use vopono_core::network::shadowsocks::uses_shadowsocks;
Expand Down Expand Up @@ -56,84 +57,62 @@ pub fn exec(command: ExecCommand, uiclient: &dyn UiClient) -> anyhow::Result<()>
.map(|x| x.to_variant())
.ok_or_else(|| anyhow!(""))
.or_else(|_| {
vopono_config_settings.get("firewall").map_err(|e| {
debug!("vopono config.toml: {:?}", e);
anyhow!("Failed to read config file")
})
vopono_config_settings
.get("firewall")
.map_err(|_e| anyhow!("Failed to read config file"))
})
.or_else(|_x| vopono_core::util::get_firewall())?;

// Assign custom_config from args or vopono config file
let custom_config = command.custom_config.clone().or_else(|| {
vopono_config_settings
.get("custom_config")
.map_err(|e| {
debug!("vopono config.toml: {:?}", e);
anyhow!("Failed to read config file")
})
.map_err(|_e| anyhow!("Failed to read config file"))
.ok()
});

// Assign custom_config from args or vopono config file
let custom_netns_name = command.custom_netns_name.clone().or_else(|| {
vopono_config_settings
.get("custom_netns_name")
.map_err(|e| {
debug!("vopono config.toml: {:?}", e);
anyhow!("Failed to read config file")
})
.map_err(|_e| anyhow!("Failed to read config file"))
.ok()
});

// Assign open_hosts from args or vopono config file
let mut open_hosts = command.open_hosts.clone().or_else(|| {
vopono_config_settings
.get("open_hosts")
.map_err(|e| {
debug!("vopono config.toml: {:?}", e);
anyhow!("Failed to read config file")
})
.map_err(|_e| anyhow!("Failed to read config file"))
.ok()
});
let allow_host_access = command.allow_host_access
|| vopono_config_settings
.get("allow_host_access")
.map_err(|e| {
debug!("vopono config.toml: {:?}", e);
anyhow!("Failed to read config file")
})
.map_err(|_e| anyhow!("Failed to read config file"))
.unwrap_or(false);

// Assign postup script from args or vopono config file
let postup = command.postup.clone().or_else(|| {
vopono_config_settings
.get("postup")
.map_err(|e| {
debug!("vopono config.toml: {:?}", e);
anyhow!("Failed to read config file")
})
.map_err(|_e| anyhow!("Failed to read config file"))
.ok()
});

// Assign predown script from args or vopono config file
let predown = command.predown.clone().or_else(|| {
vopono_config_settings
.get("predown")
.map_err(|e| {
debug!("vopono config.toml: {:?}", e);
anyhow!("Failed to read config file")
})
.map_err(|_e| anyhow!("Failed to read config file"))
.ok()
});

// User for application command, if None will use root
let user = if command.user.is_none() {
vopono_config_settings
.get("user")
.map_err(|e| {
debug!("vopono config.toml: {:?}", e);
anyhow!("Failed to read config file")
})
.map_err(|_e| anyhow!("Failed to read config file"))
.ok()
.or_else(|| std::env::var("SUDO_USER").ok())
} else {
Expand All @@ -144,10 +123,7 @@ pub fn exec(command: ExecCommand, uiclient: &dyn UiClient) -> anyhow::Result<()>
let group = if command.group.is_none() {
vopono_config_settings
.get("group")
.map_err(|e| {
debug!("vopono config.toml: {:?}", e);
anyhow!("Failed to read config file")
})
.map_err(|_e| anyhow!("Failed to read config file"))
.ok()
} else {
command.group
Expand All @@ -157,23 +133,28 @@ pub fn exec(command: ExecCommand, uiclient: &dyn UiClient) -> anyhow::Result<()>
let working_directory = if command.working_directory.is_none() {
vopono_config_settings
.get("working-directory")
.map_err(|e| {
debug!("vopono config.toml: {:?}", e);
anyhow!("Failed to read config file")
})
.map_err(|_e| anyhow!("Failed to read config file"))
.ok()
} else {
command.working_directory
};

// Port forwarding for ProtonVPN
let protonvpn_port_forwarding = if !command.protonvpn_port_forwarding {
vopono_config_settings
.get("protonvpn-port-forwarding")
.map_err(|_e| anyhow!("Failed to read config file"))
.ok()
.unwrap_or(false)
} else {
command.protonvpn_port_forwarding
};

// Assign DNS server from args or vopono config file
let base_dns = command.dns.clone().or_else(|| {
vopono_config_settings
.get("dns")
.map_err(|e| {
debug!("vopono config.toml: {:?}", e);
anyhow!("Failed to read config file")
})
.map_err(|_e| anyhow!("Failed to read config file"))
.ok()
});

Expand All @@ -199,10 +180,7 @@ pub fn exec(command: ExecCommand, uiclient: &dyn UiClient) -> anyhow::Result<()>
.or_else(|| {
vopono_config_settings
.get("server")
.map_err(|e| {
debug!("vopono config.toml: {:?}", e);
anyhow!("Failed to read config file")
})
.map_err(|_e| anyhow!("Failed to read config file"))
.ok()
})
.or_else(|| Some(String::new()))
Expand All @@ -216,10 +194,7 @@ pub fn exec(command: ExecCommand, uiclient: &dyn UiClient) -> anyhow::Result<()>
.or_else(|| {
vopono_config_settings
.get("provider")
.map_err(|e| {
debug!("vopono config.toml: {:?}", e);
anyhow!("Failed to read config file")
})
.map_err(|_e| anyhow!("Failed to read config file"))
.ok()
})
.expect(
Expand All @@ -235,8 +210,7 @@ pub fn exec(command: ExecCommand, uiclient: &dyn UiClient) -> anyhow::Result<()>
.or_else(|| {
vopono_config_settings
.get("server")
.map_err(|e| {
debug!("vopono config.toml: {:?}", e);
.map_err(|_e| {
anyhow!("Failed to read config file")
})
.ok()
Expand All @@ -252,10 +226,7 @@ pub fn exec(command: ExecCommand, uiclient: &dyn UiClient) -> anyhow::Result<()>
.or_else(|| {
vopono_config_settings
.get("protocol")
.map_err(|e| {
debug!("vopono config.toml: {:?}", e);
anyhow!("Failed to read config file")
})
.map_err(|_e| anyhow!("Failed to read config file"))
.ok()
})
.unwrap_or_else(|| provider.get_dyn_provider().default_protocol());
Expand Down Expand Up @@ -525,7 +496,7 @@ pub fn exec(command: ExecCommand, uiclient: &dyn UiClient) -> anyhow::Result<()>
// for the PostUp script and the application:
std::env::set_var(
"VOPONO_NS_IP",
&ns.veth_pair_ips.as_ref().unwrap().namespace_ip.to_string(),
ns.veth_pair_ips.as_ref().unwrap().namespace_ip.to_string(),
);

// Run PostUp script (if any)
Expand Down Expand Up @@ -560,17 +531,33 @@ pub fn exec(command: ExecCommand, uiclient: &dyn UiClient) -> anyhow::Result<()>
// Set env var referring to the host IP for the application:
std::env::set_var(
"VOPONO_HOST_IP",
&ns.veth_pair_ips.as_ref().unwrap().host_ip.to_string(),
ns.veth_pair_ips.as_ref().unwrap().host_ip.to_string(),
);

let ns = ns.write_lockfile(&command.application)?;

let natpmpc = if protonvpn_port_forwarding {
vopono_core::util::open_hosts(
&ns,
vec![vopono_core::network::natpmpc::PROTONVPN_GATEWAY],
firewall,
)?;
Some(Natpmpc::new(&ns)?)
} else {
None
};

if let Some(pmpc) = natpmpc.as_ref() {
vopono_core::util::open_ports(&ns, &[pmpc.local_port], firewall)?;
}

let application = ApplicationWrapper::new(
&ns,
&command.application,
user,
group,
working_directory.map(PathBuf::from),
natpmpc,
)?;

// Launch TCP proxy server on other threads if forwarding ports
Expand Down Expand Up @@ -598,6 +585,10 @@ pub fn exec(command: ExecCommand, uiclient: &dyn UiClient) -> anyhow::Result<()>
"Application {} launched in network namespace {} with pid {}",
&command.application, &ns.name, pid
);

if let Some(pmpc) = application.protonvpn_port_forwarding.as_ref() {
info!("ProtonVPN Port Forwarding on port {}", pmpc.local_port)
}
let output = application.wait_with_output()?;
io::stdout().write_all(output.stdout.as_slice())?;

Expand Down
4 changes: 2 additions & 2 deletions vopono_core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ keywords = ["vopono", "vpn", "wireguard", "openvpn", "netns"]
anyhow = "1"
directories-next = "2"
log = "0.4"
which = "4"
which = "5"
users = "0.11"
nix = { version = "0.27", features = ["user", "signal", "fs", "process"] }
serde = { version = "1", features = ["derive", "std"] }
Expand All @@ -23,7 +23,7 @@ regex = "1"
ron = "0.8"
walkdir = "2"
rand = "0.8"
toml = "0.7"
toml = "0.8"
ipnet = { version = "2", features = ["serde"] }
reqwest = { default-features = false, version = "0.11", features = [
"blocking",
Expand Down
2 changes: 1 addition & 1 deletion vopono_core/src/config/providers/mozilla/wireguard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ impl WireguardProvider for MozillaVPN {

// Get user info again in case we uploaded new key
let user_info: User = client
.get(&format!("{}/vpn/account", self.base_url()))
.get(format!("{}/vpn/account", self.base_url()))
.bearer_auth(login.token)
.send()?
.json()?;
Expand Down
2 changes: 1 addition & 1 deletion vopono_core/src/config/providers/mullvad/wireguard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ impl WireguardProvider for Mullvad {

let username = self.request_mullvad_username(uiclient)?;
let auth: AuthToken = client
.get(&format!("https://api.mullvad.net/www/accounts/{username}/"))
.get(format!("https://api.mullvad.net/www/accounts/{username}/"))
.send()?
.json()?;

Expand Down
15 changes: 10 additions & 5 deletions vopono_core/src/config/providers/protonvpn/openvpn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ impl OpenVpnProvider for ProtonVPN {
fn prompt_for_auth(&self, uiclient: &dyn UiClient) -> anyhow::Result<(String, String)> {
let username = uiclient.get_input(Input {
prompt:
"ProtonVPN OpenVPN username (see: https://account.protonvpn.com/account#openvpn )"
"ProtonVPN OpenVPN username (see: https://account.protonvpn.com/account#openvpn ) - add +pmp suffix if using --protonvpn-port-forwarding - note not all servers support this feature"
.to_string(),
validator: None,
})?;
Expand Down Expand Up @@ -94,16 +94,21 @@ impl OpenVpnProvider for ProtonVPN {
);

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(),
prompt: "Please log-in at https://account.protonvpn.com/dashboard and then visit https://account.protonvpn.com/account and copy the value of the cookie of the form \"AUTH-xxx=yyy\" where xxx is equal to the value of the \"x-pm-uid\" request header, in the request from your browser's network request inspector (check the request it makes to https://account.protonvpn.com/api/vpn for example). Note there may be multiple AUTH-xxx=yyy request headers, copy the one where xxx is equal to the value of the x-pm-uid header.".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
let uid_re = Regex::new("AUTH-([^=]+)=").unwrap();
let uid = uid_re
.captures(auth_cookie)
.and_then(|c| c.get(1))
.ok_or(anyhow!("Failed to parse auth cookie"))?;
.ok_or(anyhow!("Failed to parse uid from auth cookie"))?;
info!(
"x-pm-uid should be {} according to AUTH cookie: {}",
uid.as_str(),
auth_cookie
);
let url = self.build_url(&config_choice, &tier, &protocol)?;

let mut headers = HeaderMap::new();
Expand Down
Loading

0 comments on commit 0ed46f6

Please sign in to comment.