diff --git a/README.md b/README.md index 71049c5..7f0d16c 100644 --- a/README.md +++ b/README.md @@ -37,13 +37,13 @@ lynx all running through different VPN connections: | AirVPN | ✅ | ❌ | | Cloudflare Warp\*\*\*\* | ❌ | ❌ | -\* Port forwarding is not currently supported for PrivateInternetAccess. PRs welcome. +\* Port forwarding supported with the `--port-forwarding` option and `--port-forwarding-callback` to run a command when the port is refreshed. \*\* See the [User Guide](USERGUIDE.md) for authentication instructions for generating the OpenVPN config files via `vopono sync`. You must copy the authentication header of the form `AUTH-xxx=yyy` where `yyy` is the value of the `x-pm-uid` header in the same request when logged in, in your web browser. \*\*\* 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. [Port Forwarding](https://protonvpn.com/support/port-forwarding-manual-setup/) is supported with the `--protonvpn-port-forwarding` argument for both OpenVPN and Wireguard (with `--provider custom --custom xxx.conf --protocol wireguard` ). `natpmpc` must be installed. Note for OpenVPN you must generate the OpenVPN config files appending `+pmp` to your OpenVPN username, and you must choose servers which support this feature (e.g. at the time of writing, the Romania servers do). The assigned port is then printed to the terminal where vopono was launched - this should then be set in any applications that require it. +for details. [Port Forwarding](https://protonvpn.com/support/port-forwarding-manual-setup/) is supported with the `--port-forwarding` argument for both OpenVPN and Wireguard (with `--provider custom --custom xxx.conf --protocol wireguard` ). `natpmpc` must be installed. Note for OpenVPN you must generate the OpenVPN config files appending `+pmp` to your OpenVPN username, and you must choose servers which support this feature (e.g. at the time of writing, the Romania servers do). The assigned port is then printed to the terminal where vopono was launched - this should then be set in any applications that require it. \*\*\*\* Cloudflare Warp uses its own protocol. Set both the provider and diff --git a/USERGUIDE.md b/USERGUIDE.md index 4b62b73..f180bd1 100644 --- a/USERGUIDE.md +++ b/USERGUIDE.md @@ -488,12 +488,12 @@ Due to the way Wireguard configuration generation 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 --protonvpn-port-forwarding firefox-developer-edition +$ vopono -v exec --provider custom --custom testwg-UK-17.conf --protocol wireguard --port-forwarding firefox-developer-edition ``` #### Port Forwarding -Port forwarding can be enabled with the `--protonvpn-port-forwarding` argument, but requires using a server that supports port forwarding. +Port forwarding can be enabled with the `--port-forwarding` argument, but requires using a server that supports port forwarding. `natpmpc` must be installed e.g. via the `libnatpmp` package on Arch Linux. @@ -508,6 +508,10 @@ The port you are allocated will then be printed to the console like: And that is the port you would then set up in applications that require it. +### PrivateInternetAccess + +Port forwaring supported with the `--port-forwarding` option, use the `--port-forwarding-callback` option to specify a command to run when the port is refreshed. + ### Cloudflare Warp Cloudflare Warp users must first register with Warp via the CLI client: @@ -525,9 +529,9 @@ You can then kill `warp-svc` and run it via vopono: $ vopono -v exec --no-killswitch --provider warp --protocol warp firefox-developer-edition ``` -### VPN Provider limitations +## VPN Provider limitations -#### PrivateInternetAccess +### PrivateInternetAccess Wireguard support for PrivateInternetAccess (PIA) requires the use of a user token to get the latest servers at time of use. See [issue 9](https://github.com/jamesmcm/vopono/issues/9) for details, @@ -535,21 +539,21 @@ and PIA's [official script for Wireguard access](https://github.com/pia-foss/man So if you encounter connection issues, first try re-running `vopono sync`. -#### MozillaVPN +### MozillaVPN There is no easy way to delete MozillaVPN devices (Wireguard keypairs), unlike Mullvad this _cannot_ be done on the webpage. I recommend using [MozWire](https://github.com/NilsIrl/MozWire) to manage this. -#### iVPN +### iVPN iVPN Wireguard keypairs must be uploaded manually, as the Client Area is behind a captcha login. -#### NordVPN +### NordVPN Starting 27 June 2023, the required user credentials are no longer your NordVPN login details but need to be generated in the user control panel, under Services → NordVPN. Scroll down and locate the Manual Setup tab, then click on Set up NordVPN manually and follow instructions. Copy your service credentials and re-sync NordVPN configuration inside Vopono. -### Tunnel Port Forwarding +## Tunnel Port Forwarding Some providers allow port forwarding inside the tunnel, so you can open some ports inside the network namespace which can be accessed via the diff --git a/src/args.rs b/src/args.rs index d607b46..c539be7 100644 --- a/src/args.rs +++ b/src/args.rs @@ -213,9 +213,14 @@ pub struct ExecCommand { #[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, + /// Enable port forwarding for if supported + #[clap(long = "port-forwarding")] + pub port_forwarding: bool, + + /// Path or alias to executable script or binary to be called with the port as an argumnet + /// when the port forwarding is refreshed (PIA only) + #[clap(long = "port-forwarding-callback")] + pub port_forwarding_callback: Option, /// Only create network namespace (does not run application) #[clap(long = "create-netns-only")] diff --git a/src/exec.rs b/src/exec.rs index ff2f06b..7af6071 100644 --- a/src/exec.rs +++ b/src/exec.rs @@ -17,8 +17,10 @@ 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::piapf::Piapf; use vopono_core::network::shadowsocks::uses_shadowsocks; use vopono_core::network::sysctl::SysCtl; +use vopono_core::network::Forwarder; use vopono_core::util::vopono_dir; use vopono_core::util::{get_config_file_protocol, get_config_from_alias}; use vopono_core::util::{get_existing_namespaces, get_target_subnet}; @@ -139,15 +141,15 @@ pub fn exec(command: ExecCommand, uiclient: &dyn UiClient) -> anyhow::Result<()> command.working_directory }; - // Port forwarding for ProtonVPN - let protonvpn_port_forwarding = if !command.protonvpn_port_forwarding { + // Port forwarding + let port_forwarding = if !command.port_forwarding { vopono_config_settings - .get("protonvpn-port-forwarding") + .get("port-forwarding") .map_err(|_e| anyhow!("Failed to read config file")) .ok() .unwrap_or(false) } else { - command.protonvpn_port_forwarding + command.port_forwarding }; // Create netns only @@ -432,7 +434,7 @@ pub fn exec(command: ExecCommand, uiclient: &dyn UiClient) -> anyhow::Result<()> } ns.run_openvpn( - config_file.expect("No config file provided"), + config_file.clone().expect("No config file provided"), auth_file, &dns, !command.no_killswitch, @@ -467,7 +469,7 @@ pub fn exec(command: ExecCommand, uiclient: &dyn UiClient) -> anyhow::Result<()> } Protocol::Wireguard => { ns.run_wireguard( - config_file.expect("No config file provided"), + config_file.clone().expect("No config file provided"), !command.no_killswitch, command.open_ports.as_ref(), command.forward_ports.as_ref(), @@ -482,7 +484,9 @@ pub fn exec(command: ExecCommand, uiclient: &dyn UiClient) -> anyhow::Result<()> // TODO: DNS suffixes? ns.dns_config(&dns, &[], command.hosts_entries.as_ref())?; ns.run_openconnect( - config_file.expect("No OpenConnect config file provided"), + config_file + .clone() + .expect("No OpenConnect config file provided"), command.open_ports.as_ref(), command.forward_ports.as_ref(), firewall, @@ -493,7 +497,9 @@ pub fn exec(command: ExecCommand, uiclient: &dyn UiClient) -> anyhow::Result<()> Protocol::OpenFortiVpn => { // TODO: DNS handled by OpenFortiVpn directly? ns.run_openfortivpn( - config_file.expect("No OpenFortiVPN config file provided"), + config_file + .clone() + .expect("No OpenFortiVPN config file provided"), command.open_ports.as_ref(), command.forward_ports.as_ref(), command.hosts_entries.as_ref(), @@ -550,19 +556,48 @@ pub fn exec(command: ExecCommand, uiclient: &dyn UiClient) -> anyhow::Result<()> 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)?) + let forwarder: Option> = if port_forwarding { + let callback = command.port_forwarding_callback.or_else(|| { + vopono_config_settings + .get("port_forwarding_callback") + .map_err(|_e| anyhow!("Failed to read config file")) + .ok() + }); + match provider { + VpnProvider::PrivateInternetAccess => { + let conf_path = config_file.expect("No PIA config file provided"); + let conf_name = conf_path + .file_name() + .unwrap() + .to_str() + .expect("No filename for PIA config file") + .to_string(); + Some(Box::new(Piapf::new( + &ns, + &conf_name, + &protocol, + callback.as_ref(), + )?)) + } + VpnProvider::ProtonVPN => { + vopono_core::util::open_hosts( + &ns, + vec![vopono_core::network::natpmpc::PROTONVPN_GATEWAY], + firewall, + )?; + Some(Box::new(Natpmpc::new(&ns)?)) + } + _ => { + anyhow::bail!("Port forwarding not supported for the selected provider"); + } + } } else { None }; - if let Some(pmpc) = natpmpc.as_ref() { - vopono_core::util::open_ports(&ns, &[pmpc.local_port], firewall)?; + // TODO: The forwarder should probably be able to do this (pass firewall?) + if let Some(fwd) = forwarder.as_ref() { + vopono_core::util::open_ports(&ns, &[fwd.forwarded_port()], firewall)?; } // Launch TCP proxy server on other threads if forwarding ports @@ -592,7 +627,7 @@ pub fn exec(command: ExecCommand, uiclient: &dyn UiClient) -> anyhow::Result<()> user, group, working_directory.map(PathBuf::from), - natpmpc, + forwarder, )?; let pid = application.handle.id(); @@ -601,8 +636,8 @@ pub fn exec(command: ExecCommand, uiclient: &dyn UiClient) -> anyhow::Result<()> &command.application, &ns.name, pid ); - if let Some(pmpc) = application.protonvpn_port_forwarding.as_ref() { - info!("ProtonVPN Port Forwarding on port {}", pmpc.local_port) + if let Some(fwd) = application.port_forwarding.as_ref() { + info!("Port Forwarding on port {}", fwd.forwarded_port()) } let output = application.wait_with_output()?; io::stdout().write_all(output.stdout.as_slice())?; diff --git a/vopono_core/Cargo.toml b/vopono_core/Cargo.toml index c77709a..be71f38 100644 --- a/vopono_core/Cargo.toml +++ b/vopono_core/Cargo.toml @@ -43,3 +43,4 @@ signal-hook = "0.3" sha2 = "0.10" tiny_http = "0.12" chrono = "0.4" +json = "0.12" diff --git a/vopono_core/src/config/providers/mod.rs b/vopono_core/src/config/providers/mod.rs index b3cd19f..ae088ad 100644 --- a/vopono_core/src/config/providers/mod.rs +++ b/vopono_core/src/config/providers/mod.rs @@ -5,7 +5,7 @@ mod ivpn; mod mozilla; mod mullvad; mod nordvpn; -mod pia; +pub mod pia; mod protonvpn; mod ui; mod warp; @@ -14,8 +14,12 @@ use crate::config::vpn::Protocol; use crate::util::vopono_dir; use anyhow::anyhow; use serde::{Deserialize, Serialize}; -use std::path::PathBuf; -use std::{net::IpAddr, path::Path}; +use std::{ + fs::File, + io::{BufRead, BufReader}, + net::IpAddr, + path::{Path, PathBuf}, +}; use strum_macros::{Display, EnumIter}; // TODO: Consider removing this re-export pub use ui::*; @@ -137,6 +141,20 @@ pub trait OpenVpnProvider: Provider { fn prompt_for_auth(&self, uiclient: &dyn UiClient) -> anyhow::Result<(String, String)>; fn auth_file_path(&self) -> anyhow::Result>; + fn load_openvpn_auth(&self) -> anyhow::Result<(String, String)> { + let auth_file = self.auth_file_path()?; + if let Some(auth_file) = auth_file { + let mut reader = BufReader::new(File::open(auth_file)?); + let mut user = String::new(); + reader.read_line(&mut user)?; + let mut pass = String::new(); + reader.read_line(&mut pass)?; + Ok((user.trim().to_string(), pass.trim().to_string())) + } else { + Err(anyhow!("Auth file required to load credentials!")) + } + } + fn openvpn_dir(&self) -> anyhow::Result { Ok(self.provider_dir()?.join("openvpn")) } diff --git a/vopono_core/src/config/providers/pia/openvpn.rs b/vopono_core/src/config/providers/pia/openvpn.rs index 5d0116a..74356ea 100644 --- a/vopono_core/src/config/providers/pia/openvpn.rs +++ b/vopono_core/src/config/providers/pia/openvpn.rs @@ -2,8 +2,14 @@ use super::PrivateInternetAccess; use super::{ConfigurationChoice, OpenVpnProvider}; use crate::config::providers::UiClient; use crate::util::delete_all_files_in_dir; -use log::debug; +use anyhow::Context; +use log::info; +use log::{debug, warn}; +use regex::Regex; use reqwest::Url; +use serde::Deserialize; +use serde::Serialize; +use std::collections::HashMap; use std::fmt::Display; use std::fs::create_dir_all; use std::fs::File; @@ -14,6 +20,32 @@ use strum::IntoEnumIterator; use strum_macros::EnumIter; use zip::ZipArchive; +#[derive(Debug, Deserialize, Serialize)] +pub struct Config { + pub hostname_lookup: HashMap, +} + +impl PrivateInternetAccess { + fn openvpn_config_file_path(&self) -> anyhow::Result { + Ok(self.openvpn_dir()?.join("config.txt")) + } + + //This only works if openvpn was sync'd + pub fn hostname_for_openvpn_conf(&self, config_file: &String) -> anyhow::Result { + let pia_config_file = File::open(self.openvpn_config_file_path()?)?; + let pia_config: Config = serde_json::from_reader(pia_config_file)?; + + let hostname = pia_config + .hostname_lookup + .get(config_file) + .with_context(|| { + format!("Could not find matching hostname for openvpn conf {config_file}") + })?; + + Ok(hostname.to_string()) + } +} + impl OpenVpnProvider for PrivateInternetAccess { fn provider_dns(&self) -> Option> { Some(vec![ @@ -40,6 +72,11 @@ impl OpenVpnProvider for PrivateInternetAccess { let country_map = crate::util::country_map::country_to_code_map(); create_dir_all(&openvpn_dir)?; delete_all_files_in_dir(&openvpn_dir)?; + + let mut config = Config { + hostname_lookup: HashMap::new(), + }; + for i in 0..zip.len() { // For each file, detect if ovpn, crl or crt // Modify auth line for config @@ -75,6 +112,21 @@ impl OpenVpnProvider for PrivateInternetAccess { file.name().to_string() }; + let re = Regex::new(r"\n *remote +([^ ]+) +\d+ *\n") + .expect("Failed to compile hostname regex"); + if let Some(capture) = re.captures(&String::from_utf8_lossy(&file_contents)) { + let hostname = capture + .get(1) + .expect("No matching hostname group in openvpn config") + .as_str() + .to_string(); + + info!("Associating {filename} with hostname {hostname}"); + config.hostname_lookup.insert(filename.clone(), hostname); + } else { + warn!("Configuration {filename} did not have a parseable hostname - port forwarding will not work!"); + } + debug!("Reading file: {}", file.name()); let mut outfile = File::create(openvpn_dir.join(filename.to_lowercase().replace(' ', "_")))?; @@ -88,6 +140,14 @@ impl OpenVpnProvider for PrivateInternetAccess { let mut outfile = File::create(auth_file)?; write!(outfile, "{user}\n{pass}")?; } + + // Write PrivateInternetAccess openvpn config file + let pia_config_file = File::create(self.openvpn_config_file_path()?)?; + serde_json::to_writer(pia_config_file, &config)?; + + // Write PIA certificate + self.write_pia_cert()?; + Ok(()) } } diff --git a/vopono_core/src/config/providers/pia/wireguard.rs b/vopono_core/src/config/providers/pia/wireguard.rs index ce37c72..eb8d7fc 100644 --- a/vopono_core/src/config/providers/pia/wireguard.rs +++ b/vopono_core/src/config/providers/pia/wireguard.rs @@ -1,5 +1,4 @@ -use super::PrivateInternetAccess; -use super::WireguardProvider; +use super::{PrivateInternetAccess, Provider, WireguardProvider}; use crate::config::providers::{BoolChoice, UiClient}; use crate::network::wireguard::{WireguardConfig, WireguardInterface, WireguardPeer}; use crate::util::delete_all_files_in_dir; @@ -87,13 +86,14 @@ pub struct Config { pub pass: String, pub pubkey: String, pub cn_lookup: HashMap, + pub hostname_lookup: HashMap, } impl PrivateInternetAccess { const PORT: u16 = 1337; const CERT: &'static [u8] = include_bytes!("ca.rsa.4096.crt"); - fn get_pia_token(user: &str, pass: &str) -> anyhow::Result { + pub fn get_pia_token(user: &str, pass: &str) -> anyhow::Result { let token: PiaToken = Client::new() .get("https://www.privateinternetaccess.com/gtoken/generateToken") .basic_auth(user, Some(pass)) @@ -106,6 +106,16 @@ impl PrivateInternetAccess { } } + pub fn pia_cert_path(&self) -> anyhow::Result { + Ok(self.provider_dir()?.join("ca.rsa.4096.crt")) + } + + pub fn write_pia_cert(&self) -> anyhow::Result<()> { + let mut cert_file = File::create(self.pia_cert_path()?)?; + cert_file.write_all(Self::CERT)?; + Ok(()) + } + fn add_key( ip: &IpAddr, cn: &str, @@ -134,9 +144,30 @@ impl PrivateInternetAccess { } } - fn config_file_path(&self) -> anyhow::Result { + fn wireguard_config_file_path(&self) -> anyhow::Result { Ok(self.wireguard_dir()?.join("config.txt")) } + + pub fn load_wireguard_auth(&self) -> anyhow::Result<(String, String)> { + let config_file = File::open(self.wireguard_config_file_path()?)?; + let config: Config = serde_json::from_reader(config_file)?; + Ok((config.user, config.pass)) + } + + //This only works if wireguard was sync'd + pub fn hostname_for_wireguard_conf(&self, config_file: &String) -> anyhow::Result { + let pia_config_file = File::open(self.wireguard_config_file_path()?)?; + let pia_config: Config = serde_json::from_reader(pia_config_file)?; + + let hostname = pia_config + .hostname_lookup + .get(config_file) + .with_context(|| { + format!("Could not find matching hostname for wireguard conf {config_file}") + })?; + + Ok(hostname.to_string()) + } } impl WireguardProvider for PrivateInternetAccess { @@ -181,6 +212,7 @@ impl WireguardProvider for PrivateInternetAccess { pass, pubkey: keypair.public, cn_lookup: HashMap::new(), + hostname_lookup: HashMap::new(), }; for region in vpn_info.regions { @@ -189,6 +221,11 @@ impl WireguardProvider for PrivateInternetAccess { continue; } + info!("Associating {id} with hostname {}", region.dns); + config + .hostname_lookup + .insert(format!("{id}.conf"), region.dns); + // The servers are randomized on each request so we can just use the first one if let Some(wg_server) = region.servers.wg.as_ref().and_then(|s| s.first()) { let wireguard_peer = WireguardPeer { @@ -218,15 +255,18 @@ impl WireguardProvider for PrivateInternetAccess { wireguard_dir.display() ); - // Write PrivateInternetAccess config file - let pia_config_file = File::create(self.config_file_path()?)?; + // Write PrivateInternetAccess wireguard config file + let pia_config_file = File::create(self.wireguard_config_file_path()?)?; serde_json::to_writer(pia_config_file, &config)?; + // Write PIA certificate + self.write_pia_cert()?; + Ok(()) } fn wireguard_preup(&self, wg_config_file: &Path) -> anyhow::Result<()> { - let pia_config_file = File::open(self.config_file_path()?)?; + let pia_config_file = File::open(self.wireguard_config_file_path()?)?; let pia_config: Config = serde_json::from_reader(pia_config_file)?; let token = PrivateInternetAccess::get_pia_token(&pia_config.user, &pia_config.pass)?; diff --git a/vopono_core/src/config/providers/protonvpn/openvpn.rs b/vopono_core/src/config/providers/protonvpn/openvpn.rs index f2692bf..2c44bf4 100644 --- a/vopono_core/src/config/providers/protonvpn/openvpn.rs +++ b/vopono_core/src/config/providers/protonvpn/openvpn.rs @@ -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 ) - add +pmp suffix if using --protonvpn-port-forwarding - note not all servers support this feature" + "ProtonVPN OpenVPN username (see: https://account.protonvpn.com/account#openvpn ) - add +pmp suffix if using --port-forwarding - note not all servers support this feature" .to_string(), validator: None, })?; diff --git a/vopono_core/src/network/application_wrapper.rs b/vopono_core/src/network/application_wrapper.rs index 5ba017a..53236e2 100644 --- a/vopono_core/src/network/application_wrapper.rs +++ b/vopono_core/src/network/application_wrapper.rs @@ -1,12 +1,12 @@ use std::path::PathBuf; -use super::{natpmpc::Natpmpc, netns::NetworkNamespace}; +use super::{netns::NetworkNamespace, Forwarder}; use crate::util::get_all_running_process_names; use log::warn; pub struct ApplicationWrapper { pub handle: std::process::Child, - pub protonvpn_port_forwarding: Option, + pub port_forwarding: Option>, } impl ApplicationWrapper { @@ -16,7 +16,7 @@ impl ApplicationWrapper { user: Option, group: Option, working_directory: Option, - protonvpn_port_forwarding: Option, + port_forwarding: Option>, ) -> anyhow::Result { let running_processes = get_all_running_process_names(); let app_vec = application.split_whitespace().collect::>(); @@ -51,7 +51,7 @@ impl ApplicationWrapper { )?; Ok(Self { handle, - protonvpn_port_forwarding, + port_forwarding, }) } diff --git a/vopono_core/src/network/mod.rs b/vopono_core/src/network/mod.rs index e7fbb7e..dea3d04 100644 --- a/vopono_core/src/network/mod.rs +++ b/vopono_core/src/network/mod.rs @@ -8,8 +8,13 @@ pub mod network_interface; pub mod openconnect; pub mod openfortivpn; pub mod openvpn; +pub mod piapf; pub mod shadowsocks; pub mod sysctl; pub mod veth_pair; pub mod warp; pub mod wireguard; + +pub trait Forwarder { + fn forwarded_port(&self) -> u16; +} diff --git a/vopono_core/src/network/natpmpc.rs b/vopono_core/src/network/natpmpc.rs index 93a1894..bd5a13a 100644 --- a/vopono_core/src/network/natpmpc.rs +++ b/vopono_core/src/network/natpmpc.rs @@ -8,6 +8,7 @@ use std::{ }; use super::netns::NetworkNamespace; +use super::Forwarder; // TODO: Move this to ProtonVPN provider pub const PROTONVPN_GATEWAY: IpAddr = IpAddr::V4(Ipv4Addr::new(10, 2, 0, 1)); @@ -128,3 +129,9 @@ impl Drop for Natpmpc { } } } + +impl Forwarder for Natpmpc { + fn forwarded_port(&self) -> u16 { + self.local_port + } +} diff --git a/vopono_core/src/network/piapf.rs b/vopono_core/src/network/piapf.rs new file mode 100644 index 0000000..68f9daa --- /dev/null +++ b/vopono_core/src/network/piapf.rs @@ -0,0 +1,255 @@ +use base64::prelude::*; +use regex::Regex; +use std::sync::mpsc::{self, Receiver}; +use std::{sync::mpsc::Sender, thread::JoinHandle}; +use which::which; + +use super::netns::NetworkNamespace; +use super::Forwarder; + +use crate::config::providers::pia::PrivateInternetAccess; +use crate::config::providers::OpenVpnProvider; +use crate::config::vpn::Protocol; + +/// Used to provide port forwarding for PrivateInternetAccess +pub struct Piapf { + pub port: u16, + loop_thread_handle: Option>, + send_channel: Sender, +} + +struct ThreadParams { + pub port: u16, + pub netns_name: String, + pub signature: String, + pub payload: String, + pub hostname: String, + pub gateway: String, + pub pia_cert_path: String, + pub callback: Option, +} + +impl Piapf { + pub fn new( + ns: &NetworkNamespace, + config_file: &String, + protocol: &Protocol, + callback: Option<&String>, + ) -> anyhow::Result { + let pia = PrivateInternetAccess {}; + + if which("traceroute").is_err() { + log::error!("The traceroute utility is necessary for PIA port forwarding. Please install traceroute."); + anyhow::bail!("The traceroute utility is necessary for PIA port forwarding. Please install traceroute.") + } + + let traceroute_response = NetworkNamespace::exec_with_output( + &ns.name, + &["traceroute", "-n", "-m", "1", "privateinternetaccess.com"], + )?; + if !traceroute_response.status.success() { + log::error!("Could not locate gateway with traceroute"); + anyhow::bail!("Could not locate gateway with traceroute") + } + let re = Regex::new(r" *1 *(?P\d{1,3}.\d{1,3}.\d{1,3}.\d{1,3}).*") + .expect("Unable to compile regex"); + let result = String::from_utf8_lossy(&traceroute_response.stdout); + let second_line = result + .lines() + .nth(1) + .expect("Missing second line (first hop) in traceroute"); + let vpn_gateway = re + .captures(second_line) + .expect("No captures from traceroute output") + .get(1) + .expect("No matching IP group in traceroute") + .as_str() + .to_string(); + + log::info!("PIA gateway: {}", vpn_gateway); + + let vpn_hostname = match protocol { + Protocol::OpenVpn => pia.hostname_for_openvpn_conf(config_file)?, + Protocol::Wireguard => pia.hostname_for_wireguard_conf(config_file)?, + _ => { + log::error!("PIA port forwarding only supported for OpenVPN and Wireguard"); + anyhow::bail!("PIA port forwarding only supported for OpenVPN and Wireguard") + } + }; + + log::info!("PIA hostname: {}", vpn_hostname); + + let (pia_user, pia_pass) = match protocol { + Protocol::OpenVpn => pia.load_openvpn_auth()?, + Protocol::Wireguard => pia.load_wireguard_auth()?, + _ => { + log::error!("PIA port forwarding only supported for OpenVPN and Wireguard"); + anyhow::bail!("PIA port forwarding only supported for OpenVPN and Wireguard") + } + }; + + //log::info!("PIA u/p: {} / {}", pia_user, pia_pass); + + let pia_token = PrivateInternetAccess::get_pia_token(&pia_user, &pia_pass)?; + let pia_cert_path = pia.pia_cert_path()?.display().to_string(); + + log::info!("PIA pia_token: {}", pia_token); + log::info!("PIA pia_cert_path: {}", pia_cert_path); + + if which("curl").is_err() { + log::error!( + "The curl utility is necessary for PIA port forwarding. Please install curl." + ); + anyhow::bail!( + "The curl utility is necessary for PIA port forwarding. Please install curl." + ) + } + + let get_response = NetworkNamespace::exec_with_output( + &ns.name, + &[ + "curl", + "-s", + "-m", + "5", + "--connect-to", + &format!("{}::{}:", vpn_hostname, vpn_gateway).to_string(), + "--cacert", + &pia_cert_path, + "-G", + "--data-urlencode", + &format!("token={}", pia_token).to_string(), + &format!("https://{}:19999/getSignature", vpn_hostname).to_string(), + ], + )?; + if !get_response.status.success() { + log::error!("Could not obtain signature for port forward from PIA API"); + anyhow::bail!("Could not obtain signature for port forward from PIA API") + } + + let parsed = json::parse(String::from_utf8_lossy(&get_response.stdout).as_ref())?; + if parsed["status"] != "OK" { + log::error!("Signature for port forward from PIA API not OK"); + anyhow::bail!("Signature for port forward from PIA API not OK"); + } + + let signature = parsed["signature"] + .as_str() + .expect("getSignature response missing signature") + .to_string(); + let payload = parsed["payload"] + .as_str() + .expect("getSignature response missing payload") + .to_string(); + let decoded = BASE64_STANDARD.decode(&payload)?; + let parsed = json::parse(String::from_utf8_lossy(&decoded).as_ref())?; + let port = parsed["port"] + .as_u16() + .expect("getSignature response missing port"); + + let params = ThreadParams { + netns_name: ns.name.clone(), + hostname: vpn_hostname, + gateway: vpn_gateway, + pia_cert_path, + signature, + payload, + port, + callback: callback.cloned(), + }; + Self::refresh_port(¶ms)?; + let (send, recv) = mpsc::channel::(); + let handle = std::thread::spawn(move || Self::thread_loop(params, recv)); + + log::info!("PIA forwarded local port: {port}"); + Ok(Self { + port, + loop_thread_handle: Some(handle), + send_channel: send, + }) + } + + fn refresh_port(params: &ThreadParams) -> anyhow::Result { + let bind_response = NetworkNamespace::exec_with_output( + ¶ms.netns_name, + &[ + "curl", + "-Gs", + "-m", + "5", + "--connect-to", + &format!("{}::{}:", params.hostname, params.gateway).to_string(), + "--cacert", + ¶ms.pia_cert_path, + "--data-urlencode", + &format!("payload={}", params.payload).to_string(), + "--data-urlencode", + &format!("signature={}", params.signature).to_string(), + &format!("https://{}:19999/bindPort", params.hostname).to_string(), + ], + )?; + if !bind_response.status.success() { + log::error!("Could not bind port forward from PIA API"); + anyhow::bail!("Could not bind port forward from PIA API") + } + + let parsed = json::parse(String::from_utf8_lossy(&bind_response.stdout).as_ref())?; + + if parsed["status"] != "OK" { + log::error!("Bind for port forward from PIA API not OK"); + anyhow::bail!("Bind for port forward from PIA API not OK"); + } + + if let Some(cb) = ¶ms.callback { + let refresh_response = NetworkNamespace::exec_with_output( + ¶ms.netns_name, + &[&cb, ¶ms.port.to_string()], + )?; + if !refresh_response.status.success() { + log::info!("Callback script was unsuccessful!"); + } + } + + log::info!("Successfully updated claim to port {}", params.port); + + Ok(params.port) + } + + // Spawn thread to repeat above every 15 minutes + fn thread_loop(params: ThreadParams, recv: Receiver) { + loop { + let resp = recv.recv_timeout(std::time::Duration::from_secs(60 * 15)); + if resp.is_ok() { + log::debug!("Thread exiting..."); + return; + } else { + let port = Self::refresh_port(¶ms); + match port { + Err(e) => { + log::error!("Thread failed to refresh port: {e:?}"); + return; + } + Ok(p) => log::debug!("Thread refreshed port: {p}"), + } + + // TODO: Communicate port change via channel? + } + } + } +} + +impl Drop for Piapf { + fn drop(&mut self) { + let handle = self.loop_thread_handle.take(); + if let Some(h) = handle { + self.send_channel.send(true).ok(); + h.join().ok(); + } + } +} + +impl Forwarder for Piapf { + fn forwarded_port(&self) -> u16 { + self.port + } +}