Files
WebScraper/src/scraper/vpn_manager.rs

1422 lines
53 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// src/scraper/vpn_manager.rs
use anyhow::{Context, Result, anyhow};
use fantoccini::client;
use serde_json;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::process::{Child, Command};
use tokio::sync::Mutex;
use tokio::time::{sleep, timeout, Duration};
use std::process::Stdio;
use reqwest::Client;
use tokio::fs as tokio_fs;
use std::time::{SystemTime, UNIX_EPOCH};
use crate::logger;
/// Represents a single OpenVPN connection with its associated state
pub struct VpnInstance {
/// The OpenVPN process handle
process: Option<Child>,
/// Path to the .ovpn configuration file
config_path: PathBuf,
/// The external IP address assigned by this VPN
external_ip: Option<String>,
/// Baseline (pre-VPN) external IP for verification
baseline_ip: Option<String>,
/// Hostname derived from the config file (e.g., "ca149.vpnbook.com")
hostname: String,
/// Number of tasks completed in the current session
tasks_completed: usize,
/// VPN credentials
username: String,
password: String,
/// Health status
is_healthy: bool,
/// Path to credentials file created for this instance (if any)
cred_path: Option<PathBuf>,
/// Path to temporary modified .ovpn file for this instance (if any)
temp_config_path: Option<PathBuf>,
/// Path to OpenVPN log file created for this instance (if any)
log_path: Option<PathBuf>,
}
impl VpnInstance {
/// Creates a new VPN instance without starting the connection
pub fn new(
config_path: PathBuf,
username: String,
password: String,
baseline_ip: Option<String>,
) -> Result<Self> {
// Use the file stem (filename without extension) as the hostname/identifier.
// This avoids using the parent directory name which can be the same
// for many configs and cause collisions when copying into config-auto.
let hostname = config_path
.file_stem()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_string();
Ok(Self {
process: None,
config_path,
external_ip: None,
baseline_ip,
hostname,
tasks_completed: 0,
username,
password,
is_healthy: false,
cred_path: None,
temp_config_path: None,
log_path: None,
})
}
/// Starts the OpenVPN connection and detects the assigned IP
pub async fn connect(&mut self) -> Result<()> {
crate::util::logger::log_info(&format!("Starting VPN connection for {}", self.hostname)).await;
// Create fixed config first
//let fixed_config_path = self.create_fixed_config().await
// .context("Failed to create fixed OpenVPN config")?;
// Store the temp config path so we can clean it up later
self.temp_config_path = Some(self.config_path.clone());
let cred_file = self.create_credentials_file().await?;
self.cred_path = Some(cred_file.clone());
// Use baseline IP supplied when pool was created, otherwise detect once
let baseline_ip = if self.baseline_ip.is_some() {
self.baseline_ip.clone()
} else {
let b = self.detect_external_ip().await.ok().flatten();
if let Some(ref ip) = b {
crate::util::logger::log_info(&format!("Baseline IP (before VPN): {}", ip)).await;
}
b
};
self.baseline_ip = baseline_ip;
#[cfg(target_os = "windows")]
{
// Windows: Spawn individual openvpn.exe process for this instance
let temp_dir = std::env::temp_dir();
let nanos = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos();
let instance_config = temp_dir.join(format!("ovpn_{}_{}.ovpn", self.hostname, nanos));
let log_path = temp_dir.join(format!("openvpn_{}_{}.log", self.hostname, nanos));
// Copy and modify the .ovpn file to include auth-user-pass (async)
let ovpn_content = tokio_fs::read_to_string(&self.config_path).await?;
let mut modified_content = ovpn_content;
// Remove existing auth-user-pass lines if present
modified_content = modified_content
.lines()
.filter(|line| !line.trim().starts_with("auth-user-pass"))
.collect::<Vec<_>>()
.join("\n");
// Add auth-user-pass pointing to credentials file at the end
modified_content.push('\n');
let cred_path_str = cred_file.to_string_lossy().replace('\\', "/");
modified_content.push_str(&format!("auth-user-pass \"{}\"\n", cred_path_str));
tokio_fs::write(&instance_config, modified_content).await?;
self.temp_config_path = Some(instance_config.clone());
self.log_path = Some(log_path.clone());
// Verify OpenVPN executable exists
let openvpn_exe = r"C:\Program Files\OpenVPN\bin\openvpn.exe";
if !std::path::Path::new(openvpn_exe).exists() {
return Err(anyhow!(
"OpenVPN executable not found at: {}\nPlease install OpenVPN from: https://openvpn.net/community-downloads/",
openvpn_exe
));
}
// Spawn openvpn.exe process directly
let mut cmd = Command::new(openvpn_exe);
cmd.arg("--config")
.arg(&instance_config)
.arg("--log")
.arg(&log_path)
.stdout(Stdio::piped())
.stderr(Stdio::piped());
crate::util::logger::log_info(&format!(
"Spawning OpenVPN: {} --config {} --log {}",
openvpn_exe,
instance_config.display(),
log_path.display()
)).await;
let mut process = cmd
.spawn()
.context("Failed to spawn openvpn.exe. Ensure OpenVPN is installed.")?;
// Log OpenVPN stdout in background
if let Some(stdout) = process.stdout.take() {
let hostname = self.hostname.clone();
tokio::spawn(async move {
use tokio::io::AsyncBufReadExt;
let mut reader = tokio::io::BufReader::new(stdout).lines();
while let Ok(Some(line)) = reader.next_line().await {
crate::util::logger::log_info(&format!("OpenVPN-OUT [{}]: {}", hostname, line)).await;
}
});
}
// Store process handle
self.process = Some(process);
// Wait for OpenVPN to initialize and verify connection
// Initial wait: OpenVPN takes ~8-10 seconds typically
crate::util::logger::log_info(&format!(
"Waiting for OpenVPN initialization ({})",
self.hostname
)).await;
tokio::time::sleep(Duration::from_secs(40)).await;
for i in 0..3 {
if self.scan_openvpn_logs().await? {
break;
}
crate::util::logger::log_info("OpenVPN not initialized yet, waiting 10 seconds...").await;
tokio::time::sleep(Duration::from_secs(10)).await;
}
// Now run comprehensive verification with retries built-in
match self.verify_vpn_connection().await {
Ok(true) => {
self.is_healthy = true;
crate::util::logger::log_info(&format!(
"✓ VPN {} connected successfully with IP: {}",
self.hostname,
self.external_ip.as_ref().unwrap_or(&"unknown".to_string())
)).await;
return Ok(());
}
Ok(false) => {
// Verification failed - kill process
if let Some(mut p) = self.process.take() {
let _ = p.kill().await;
}
return Err(anyhow!(
"VPN connection verification failed for {}",
self.hostname
));
}
Err(e) => {
// Error during verification
if let Some(mut p) = self.process.take() {
let _ = p.kill().await;
}
return Err(anyhow!(
"VPN verification error for {}: {}",
self.hostname,
e
));
}
}
}
#[cfg(not(target_os = "windows"))]
{
// Non-Windows implementation
let mut cmd = Command::new("openvpn");
cmd.arg("--config")
.arg(&self.config_path)
.arg("--auth-user-pass")
.arg(&cred_file)
.arg("--daemon")
.stdout(Stdio::piped())
.stderr(Stdio::piped());
match cmd.spawn() {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Err(anyhow!(
"OpenVPN not found. Please install it:\n \
Linux: sudo apt-get install openvpn\n \
macOS: brew install openvpn\n \
Error: {}", e
));
}
Err(e) => {
return Err(anyhow!("Failed to spawn OpenVPN: {}", e));
}
Ok(mut process) => {
// Capture output for monitoring
if let Some(stdout) = process.stdout.take() {
let hostname = self.hostname.clone();
tokio::spawn(async move {
use tokio::io::AsyncBufReadExt;
let mut reader = tokio::io::BufReader::new(stdout).lines();
while let Ok(Some(line)) = reader.next_line().await {
crate::util::logger::log_info(&format!("[VPN-{}] {}", hostname, line)).await;
}
});
}
self.process = Some(process);
}
}
// Wait for connection and verify (Unix)
sleep(Duration::from_secs(10)).await;
match self.verify_vpn_connection().await {
Ok(true) => {
self.is_healthy = true;
crate::util::logger::log_info(&format!(
"✓ VPN {} connected successfully",
self.hostname
)).await;
return Ok(());
}
Ok(false) => {
self.disconnect().await?;
return Err(anyhow!(
"Failed to verify VPN connection for {}",
self.hostname
));
}
Err(e) => {
self.disconnect().await?;
return Err(e);
}
}
}
}
async fn verify_vpn_connection(&mut self) -> Result<bool> {
logger::log_info(&format!("Verifying VPN connection for {}", self.hostname)).await;
// 1. Quick process check
if self.process.is_none() {
logger::log_warn("Process check failed - no process").await;
return Ok(false);
}
// 2. Check logs for success markers (with retries for file buffering)
let mut log_success = false;
for attempt in 1..=5 {
tokio::time::sleep(Duration::from_secs(2)).await; // Wait for log flush
match self.scan_openvpn_logs().await {
Ok(true) => {
log_success = true;
logger::log_info(&format!(
"✓ Log verification passed (attempt {})",
attempt
)).await;
break;
}
Ok(false) => {
if attempt < 5 {
logger::log_info(&format!(
"Log verification pending (attempt {}/5)...",
attempt
)).await;
}
}
Err(e) => {
logger::log_warn(&format!("Log read error: {}", e)).await;
}
}
}
if !log_success {
logger::log_warn("Log verification failed after 5 attempts").await;
return Ok(false);
}
// 3. Wait for routes to be applied (happens ~2 seconds after "Initialization Sequence Completed")
logger::log_info("Waiting for routes to stabilize...").await;
tokio::time::sleep(Duration::from_secs(3)).await;
// 4. Network route verification (with retries)
let mut route_ok = false;
for attempt in 1..=3 {
if self.check_routing_table().await.unwrap_or(false) {
route_ok = true;
logger::log_info(&format!(
"✓ Route verification passed (attempt {})",
attempt
)).await;
break;
}
if attempt < 3 {
logger::log_info(&format!(
"Route verification pending (attempt {}/3)...",
attempt
)).await;
tokio::time::sleep(Duration::from_secs(2)).await;
}
}
if !route_ok {
logger::log_warn("Route verification failed - routes may not be established").await;
// Don't fail yet - some VPNs work without perfect routing table state
}
// 5. External IP change verification (with retries and longer timeout)
logger::log_info("Verifying IP change...").await;
for attempt in 1..=3 {
match timeout(Duration::from_secs(8), self.verify_ip_change()).await {
Ok(Ok(true)) => {
let ip = self.external_ip.as_ref().unwrap();
logger::log_info(&format!(
"✓ VPN connection verified - New IP: {} (attempt {})",
ip, attempt
)).await;
return Ok(true);
}
Ok(Ok(false)) => {
if attempt < 3 {
logger::log_info(&format!(
"IP verification pending (attempt {}/3)...",
attempt
)).await;
tokio::time::sleep(Duration::from_secs(2)).await;
}
}
Ok(Err(e)) => {
logger::log_warn(&format!("IP check error: {}", e)).await;
}
Err(_) => {
logger::log_warn("IP check timeout").await;
}
}
}
logger::log_error(&format!(
"✗ VPN connection verification failed for {} - IP unchanged after 3 attempts",
self.hostname
)).await;
Ok(false)
}
// Improved routing table check with better Windows detection
async fn check_routing_table(&self) -> Result<bool> {
#[cfg(target_os = "windows")]
{
use std::process::Command;
let output = Command::new("cmd")
.args(&["/C", "route", "print", "0.0.0.0"])
.output()
.context("Failed to execute route print")?;
if !output.status.success() {
return Ok(false);
}
let output_str = String::from_utf8_lossy(&output.stdout);
// Check for VPN-related routing entries
// Look for: TAP adapter in routes, or 10.x.x.x destinations (common VPN subnets)
let has_vpn_routes = output_str.contains("TAP")
|| output_str.contains("10.8.0") // Common OpenVPN subnet
|| output_str.contains("10.9.0") // Another common subnet
|| output_str.to_lowercase().contains("openvpn");
// Also check for 0.0.0.0/128.0.0.0 split routing (redirect-gateway)
let has_redirect = output_str.contains("128.0.0.0")
&& output_str.matches("0.0.0.0").count() >= 2;
let result = has_vpn_routes || has_redirect;
if result {
logger::log_info("✓ Routing table shows VPN routes").await;
} else {
logger::log_warn("✗ No VPN routes detected in routing table").await;
logger::log_info(&format!("Route output preview: {}",
&output_str.lines().take(10).collect::<Vec<_>>().join("\n")
)).await;
}
Ok(result)
}
#[cfg(not(target_os = "windows"))]
{
// For non-Windows, just check if we can detect IP change
// (routing is less critical to verify on Unix)
Ok(true)
}
}
// Improved log scanning with better error handling
async fn scan_openvpn_logs(&self) -> Result<bool> {
#[cfg(target_os = "windows")]
{
if let Some(ref log_path) = self.log_path {
// Try to read the file, but handle "not found" gracefully
let content = match tokio::fs::read_to_string(log_path).await {
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
// Log file doesn't exist yet
return Ok(false);
}
Err(e) => {
return Err(anyhow::anyhow!("Failed to read log file: {}", e));
}
};
// Success marker
if content.contains("Initialization Sequence Completed") {
// Check for fatal errors that might have occurred after success
if content.contains("Exiting due to fatal error") {
return Ok(false);
}
// Check for excessive retries/errors
let error_count = content.matches("TLS Error").count()
+ content.matches("Connection reset").count()
+ content.matches("SIGTERM").count()
+ content.matches("Restart pause").count();
if error_count > 3 {
logger::log_warn(&format!(
"Logs show {} errors despite initialization complete",
error_count
)).await;
return Ok(false);
}
return Ok(true);
}
// Check for immediate fatal errors
if content.contains("Cannot open TUN/TAP dev")
|| content.contains("Exiting due to fatal error")
|| content.contains("AUTH: Received control message: AUTH_FAILED") {
return Ok(false);
}
}
}
Ok(false)
}
// Improved IP verification with better error handling
async fn verify_ip_change(&mut self) -> Result<bool, anyhow::Error> {
//let detected_ip = self.detect_external_ip().await?;
let new_ip = self
.detect_external_ip()
.await
.context("Failed to query external IP after VPN connection")?
.with_context(|| "VPN connected, but no external IP received tunnel broken?")?;
// Compare with baseline
if let Some(baseline) = &self.baseline_ip {
if &new_ip == baseline {
logger::log_warn(&format!(
"IP unchanged from baseline: {} (VPN may not be routing traffic)",
baseline
)).await;
return Ok(false);
}
logger::log_info(&format!(
"IP changed: {}{}",
baseline, new_ip
)).await;
} else {
logger::log_info(&format!("Detected IP: {}", new_ip)).await;
}
self.external_ip = Some(new_ip);
Ok(true)
}
/// Creates a fixed version of the OpenVPN config to work with modern OpenVPN
async fn create_fixed_config(&self) -> Result<PathBuf> {
// Read the original config
let content = tokio::fs::read_to_string(&self.config_path)
.await
.context("Failed to read OpenVPN config")?;
// Create a temporary file for the fixed config
let temp_dir = std::env::temp_dir();
let temp_config_path = temp_dir.join(format!("fixed_{}.ovpn",
self.hostname.replace(|c: char| !c.is_alphanumeric(), "_")));
let mut fixed_lines = Vec::new();
let mut has_data_ciphers = false;
let mut has_compression_setting = false;
// Process each line
for line in content.lines() {
let trimmed = line.trim();
if trimmed.starts_with("cipher ") {
// Skip old cipher line, we'll add data-ciphers later if needed
continue;
} else if trimmed.starts_with("data-ciphers") {
has_data_ciphers = true;
fixed_lines.push(line.to_string());
} else if trimmed.contains("allow-compression") {
has_compression_setting = true;
fixed_lines.push(line.to_string());
} else if trimmed.starts_with(";") || trimmed.starts_with("#") {
// Keep comments
fixed_lines.push(line.to_string());
} else if !trimmed.is_empty() {
fixed_lines.push(line.to_string());
}
}
// Add modern cipher suite if missing
if !has_data_ciphers {
fixed_lines.push("data-ciphers AES-256-GCM:AES-128-GCM:CHACHA20-POLY1305".to_string());
}
// Add compression setting if missing
if !has_compression_setting {
fixed_lines.push("allow-compression no".to_string());
}
// Remove any duplicate 'auth-user-pass' lines (we'll add our own later)
fixed_lines.retain(|line| !line.trim().starts_with("auth-user-pass"));
// Write the fixed config
let fixed_content = fixed_lines.join("\n");
tokio::fs::write(&temp_config_path, fixed_content)
.await
.context("Failed to write fixed OpenVPN config")?;
Ok(temp_config_path)
}
/// Disconnects the VPN connection
pub async fn disconnect(&mut self) -> Result<()> {
#[cfg(target_os = "windows")]
{
// Kill the openvpn.exe process if it exists
if let Some(mut process) = self.process.take() {
crate::util::logger::log_info(&format!("Disconnecting VPN {}", self.hostname)).await;
let _ = process.kill().await;
// Clean up temp config file and log file if present
if let Some(ref p) = self.temp_config_path {
let _ = tokio_fs::remove_file(p).await;
}
if let Some(ref lp) = self.log_path {
let _ = tokio_fs::remove_file(lp).await;
}
}
// Remove credential file if present
if let Some(ref cred) = self.cred_path {
let _ = tokio_fs::remove_file(cred).await;
}
self.external_ip = None;
self.is_healthy = false;
self.tasks_completed = 0;
Ok(())
}
#[cfg(not(target_os = "windows"))]
{
if let Some(mut process) = self.process.take() {
crate::util::logger::log_info(&format!("Disconnecting VPN {}", self.hostname)).await;
process.kill().await.context("Failed to kill OpenVPN process")?;
self.external_ip = None;
self.is_healthy = false;
self.tasks_completed = 0;
}
// Remove credentials file if present
if let Some(ref cred) = self.cred_path {
let _ = tokio_fs::remove_file(cred).await;
}
Ok(())
}
}
/// Reconnects the VPN (disconnect + connect)
pub async fn reconnect(&mut self) -> Result<()> {
crate::util::logger::log_info(&format!("Reconnecting VPN {}", self.hostname)).await;
self.disconnect().await?;
sleep(Duration::from_secs(10)).await; // Brief delay between disconnect and reconnect
self.connect().await
}
/// Ermittelt die aktuelle externe IPv4-Adresse über ipify.org.
///
/// Nutzt einen wiederverwendbaren reqwest-Client aus dem Pool (vermutlich vorhandenen)
/// shared Client-Pool oder erstellt einen mit sinnvollen Defaults.
///
/// # Returns
/// - `Ok(Some(ip))` bei erfolgreicher Erkennung
/// - `Ok(None)` wenn der Server antwortet, aber kein gültiger IP-String kommt
/// - `Err(_)` bei Netzwerk-, Timeout- oder Parse-Fehlern (wird mit Kontext angereichert)
async fn detect_external_ip(&self) -> anyhow::Result<Option<String>> {
// Empfohlen: Nutze einen shared Client (z. B. aus Arc<reqwest::Client>)
// Falls du keinen hast, ist das hier immer noch besser als jedes Mal neu bauen:
static CLIENT: once_cell::sync::Lazy<reqwest::Client> = once_cell::sync::Lazy::new(|| {
reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(8))
.user_agent("my-scraper/1.0")
.build()
.expect("Failed to build static reqwest client")
});
let resp = CLIENT
.get("https://api.ipify.org?format=json")
.send()
.await
.context("Failed to reach ipify.org no internet or DNS issue")?;
// 4xx/5xx → Fehler
let resp = resp
.error_for_status()
.context("ipify.org returned error status")?;
#[derive(serde::Deserialize)]
struct IpResponse {
ip: String,
}
let ip_obj: IpResponse = resp
.json()
.await
.context("Failed to parse JSON from ipify.org")?;
// Einfache Validierung: sieht nach IPv4 aus?
if ip_obj.ip.contains('.') && !ip_obj.ip.is_empty() {
Ok(Some(ip_obj.ip))
} else {
Ok(None)
}
}
/// Performs a simple health check without reconnection
pub async fn health_check(&mut self) -> Result<bool> {
if self.process.is_none() {
self.is_healthy = false;
return Ok(false);
}
match self.detect_external_ip().await {
Ok(Some(_)) => {
self.is_healthy = true;
Ok(true)
}
_ => {
self.is_healthy = false;
Ok(false)
}
}
}
/// Performs a health check and automatically reconnects if failed
pub async fn health_check_with_reconnect(&mut self) -> Result<bool> {
if self.process.is_none() {
self.is_healthy = false;
return Ok(false);
}
// Try to detect IP again
match self.detect_external_ip().await {
Ok(Some(ip)) => {
if let Some(ref current_ip) = self.external_ip {
if &ip != current_ip {
crate::util::logger::log_warn(&format!(
"VPN {} IP changed unexpectedly: {} -> {}",
self.hostname, current_ip, ip
)).await;
self.external_ip = Some(ip);
}
}
self.is_healthy = true;
Ok(true)
}
_ => {
crate::util::logger::log_warn(&format!(
"Health check failed for VPN {}, attempting reconnect...",
self.hostname
)).await;
self.is_healthy = false;
// Attempt automatic reconnection
match self.reconnect().await {
Ok(()) => {
crate::util::logger::log_info(&format!(
"✓ VPN {} reconnected successfully",
self.hostname
)).await;
Ok(true)
}
Err(e) => {
crate::util::logger::log_error(&format!(
"✗ VPN {} reconnection failed: {}",
self.hostname, e
)).await;
Ok(false)
}
}
}
}
}
/// Creates a temporary credentials file for OpenVPN authentication
async fn create_credentials_file(&self) -> Result<PathBuf> {
let temp_dir = std::env::temp_dir();
let nanos = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos();
let cred_path = temp_dir.join(format!("vpn_creds_{}_{}.txt", self.hostname, nanos));
let content = format!("{}\n{}\n", self.username, self.password);
tokio::fs::write(&cred_path, content)
.await
.context("Failed to write credentials file")?;
Ok(cred_path)
}
/// Increments the task counter and returns whether rotation is needed
pub fn increment_task_count(&mut self, tasks_per_session: usize) -> bool {
self.tasks_completed += 1;
if tasks_per_session > 0 && self.tasks_completed >= tasks_per_session {
true
} else {
false
}
}
/// Returns the external IP if connected
pub fn external_ip(&self) -> Option<&str> {
self.external_ip.as_deref()
}
/// Returns the hostname
pub fn hostname(&self) -> &str {
&self.hostname
}
/// Returns whether the VPN is healthy
pub fn is_healthy(&self) -> bool {
self.is_healthy
}
/// Resets the task counter
pub fn reset_task_count(&mut self) {
self.tasks_completed = 0;
}
}
impl Drop for VpnInstance {
fn drop(&mut self) {
if let Some(mut process) = self.process.take() {
let _ = process.start_kill();
}
}
}
/// Manages a pool of VPN instances for rotation
pub struct VpnPool {
instances: Vec<Arc<Mutex<VpnInstance>>>,
current_index: Arc<Mutex<usize>>,
enable_rotation: bool,
tasks_per_session: usize,
}
impl VpnPool {
/// Creates a new VPN pool from .ovpn configuration files
/// Automatically ensures sufficient TAP adapters are installed
pub async fn new(
ovpn_dir: &Path,
username: String,
password: String,
enable_rotation: bool,
tasks_per_session: usize,
amount_of_openvpn_servers: usize,
) -> Result<Self> {
// STEP 1: Ensure we have enough TAP adapters (auto-install if needed)
#[cfg(target_os = "windows")]
{
crate::util::logger::log_info("=== TAP Adapter Check ===").await;
let _ = Self::ensure_tap_adapters(amount_of_openvpn_servers).await?;
crate::util::logger::log_info("=== TAP Adapter Check Complete ===").await;
}
// STEP 2: Continue with normal VPN pool initialization
let mut ovpn_files = Vec::new();
// Recursively find all .ovpn files
let mut entries = tokio::fs::read_dir(ovpn_dir).await?;
while let Some(entry) = entries.next_entry().await? {
let path = entry.path();
if path.is_dir() {
let mut sub_entries = tokio::fs::read_dir(&path).await?;
while let Some(sub_entry) = sub_entries.next_entry().await? {
let sub_path = sub_entry.path();
if sub_path.extension().and_then(|s| s.to_str()) == Some("ovpn")
&& is_tcp_443_config(&sub_path) {
ovpn_files.push(sub_path);
}
}
} else if path.extension().and_then(|s| s.to_str()) == Some("ovpn")
&& is_tcp_443_config(&path) {
ovpn_files.push(path);
}
}
// Deduplicate configs by server
let mut unique = Vec::new();
use std::collections::HashSet;
let mut seen: HashSet<String> = HashSet::new();
for cfg in ovpn_files {
match tokio_fs::read_to_string(&cfg).await {
Ok(contents) => {
let mut server_key = None;
for line in contents.lines() {
let l = line.trim();
if l.starts_with('#') || l.is_empty() { continue; }
if l.starts_with("remote ") {
let parts: Vec<&str> = l.split_whitespace().collect();
if parts.len() >= 2 {
let host = parts[1];
let port = if parts.len() >= 3 { parts[2] } else { "" };
let key = if port.is_empty() {
host.to_string()
} else {
format!("{}:{}", host, port)
};
server_key = Some(key);
break;
}
}
}
let key = server_key.unwrap_or_else(|| {
cfg.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown")
.to_string()
});
if !seen.contains(&key) {
seen.insert(key);
unique.push(cfg);
}
}
Err(_) => {
unique.push(cfg);
}
}
}
ovpn_files = unique;
if ovpn_files.is_empty() {
return Err(anyhow!("No .ovpn files found in {:?}", ovpn_dir));
}
crate::util::logger::log_info(&format!(
"Found {} OpenVPN configurations",
ovpn_files.len()
)).await;
// Build shared HTTP client
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(5))
.pool_max_idle_per_host(8)
.build()
.context("Failed to build HTTP client for IP detection")?;
// Detect baseline IP
let baseline_ip: Option<String> = match client
.get("https://api.ipify.org?format=json")
.send()
.await
{
Ok(resp) => {
if resp.status().is_success() {
if let Ok(json) = resp.json::<serde_json::Value>().await {
json.get("ip")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
} else { None }
} else { None }
}
Err(_) => None,
};
if let Some(ref ip) = baseline_ip {
crate::util::logger::log_info(&format!("Baseline IP for pool: {}", ip)).await;
}
// Create VPN instances
let mut instances = Vec::new();
for config_path in ovpn_files {
let instance = VpnInstance::new(
config_path,
username.clone(),
password.clone(),
baseline_ip.clone(),
)?;
instances.push(Arc::new(Mutex::new(instance)));
}
Ok(Self {
instances,
current_index: Arc::new(Mutex::new(0)),
enable_rotation,
tasks_per_session,
})
}
/// Connects all VPN instances in parallel (much faster than sequential)
pub async fn connect_all(&self) -> Result<()> {
crate::util::logger::log_info("Connecting all VPN instances in batches...").await;
let start_time = std::time::Instant::now();
// Auto-detect optimal batch size based on available TAP adapters
let batch_size = Self::detect_optimal_batch_size().await;
let mut connected = 0;
let mut failed = 0;
// Process instances in batches
for (batch_num, chunk) in self.instances.chunks(batch_size).enumerate() {
crate::util::logger::log_info(&format!(
"Starting batch {}/{} ({} VPNs)...",
batch_num + 1,
(self.instances.len() + batch_size - 1) / batch_size,
chunk.len()
)).await;
// Spawn parallel tasks for this batch
let mut tasks = Vec::new();
for (i, instance) in chunk.iter().enumerate() {
let instance_clone = Arc::clone(instance);
let global_index = batch_num * batch_size + i;
let task = tokio::spawn(async move {
let mut inst = instance_clone.lock().await;
let hostname = inst.hostname().to_string();
match inst.connect().await {
Ok(_) => {
let ip = inst.external_ip().unwrap_or("unknown");
crate::util::logger::log_info(&format!(
"✓ VPN {} ({}) connected with IP: {}",
global_index + 1, hostname, ip
)).await;
Ok(())
}
Err(e) => {
crate::util::logger::log_warn(&format!(
"⚠ Failed to connect VPN {} ({}): {}",
global_index + 1, hostname, e
)).await;
Err(e)
}
}
});
tasks.push(task);
}
// Wait for this batch to complete
let results = futures::future::join_all(tasks).await;
for result in results {
match result {
Ok(Ok(_)) => connected += 1,
Ok(Err(_)) | Err(_) => failed += 1,
}
}
// Small delay between batches to let TAP adapters stabilize
if batch_num < (self.instances.len() + batch_size - 1) / batch_size - 1 {
tokio::time::sleep(Duration::from_secs(2)).await;
}
}
let elapsed = start_time.elapsed();
if connected == 0 && failed > 0 {
crate::util::logger::log_error(&format!(
"✗ FATAL: All {} VPN connection attempts failed in {:.1}s. Make sure OpenVPN is installed:\n Windows: https://openvpn.net/community-downloads/\n Linux: sudo apt-get install openvpn\n macOS: brew install openvpn",
failed, elapsed.as_secs_f64()
)).await;
return Err(anyhow!("Failed to connect any VPN instances. OpenVPN may not be installed."));
} else if failed > 0 {
crate::util::logger::log_warn(&format!(
"⚠ Connected {} VPN instances successfully, {} failed in {:.1}s (will use available instances)",
connected, failed, elapsed.as_secs_f64()
)).await;
} else {
crate::util::logger::log_info(&format!(
"✓ All {} VPN instances connected successfully in {:.1}s",
connected, elapsed.as_secs_f64()
)).await;
}
Ok(())
}
/// Gets the next available VPN instance (round-robin)
pub async fn acquire(&self) -> Result<Arc<Mutex<VpnInstance>>> {
let mut index = self.current_index.lock().await;
let instance = self.instances[*index % self.instances.len()].clone();
*index += 1;
Ok(instance)
}
/// Rotates a VPN instance if rotation is enabled and threshold is met
pub async fn rotate_if_needed(&self, instance: Arc<Mutex<VpnInstance>>) -> Result<()> {
if !self.enable_rotation {
return Ok(());
}
let mut inst = instance.lock().await;
if inst.increment_task_count(self.tasks_per_session) {
crate::util::logger::log_info(&format!(
"Task threshold reached for VPN {}, rotating...",
inst.hostname()
)).await;
let old_ip = inst.external_ip().map(|s| s.to_string());
inst.reconnect().await?;
let new_ip = inst.external_ip().map(|s| s.to_string());
match (old_ip, new_ip) {
(Some(old), Some(new)) if old != new => {
crate::util::logger::log_info(&format!(
"✓ VPN {} rotated: {} -> {}",
inst.hostname(), old, new
)).await;
}
(Some(old), Some(new)) if old == new => {
crate::util::logger::log_warn(&format!(
"⚠ VPN {} reconnected but IP unchanged: {}",
inst.hostname(), old
)).await;
}
_ => {
crate::util::logger::log_error(&format!(
"✗ VPN {} rotation verification failed",
inst.hostname()
)).await;
}
}
inst.reset_task_count();
}
Ok(())
}
/// Performs health checks on all VPN instances with automatic reconnection
pub async fn health_check_all_with_reconnect(&self) -> Result<()> {
for instance in &self.instances {
let mut inst = instance.lock().await;
let _ = inst.health_check_with_reconnect().await;
}
Ok(())
}
/// Returns the number of VPN instances
pub fn len(&self) -> usize {
self.instances.len()
}
/// Disconnects all VPN instances
pub async fn disconnect_all(&self) -> Result<()> {
crate::util::logger::log_info("Disconnecting all VPN instances...").await;
for instance in &self.instances {
let mut inst = instance.lock().await;
inst.disconnect().await?;
}
Ok(())
}
/// Amount of taps adapters currently installed
/// One VPN connection requires one TAP adapter
pub async fn ensure_tap_adapters(amount_of_openvpn_servers: usize) -> Result<usize> {
#[cfg(target_os = "windows")]
{
use std::fs;
crate::util::logger::log_info("Checking TAP adapter availability...").await;
let tap_count = Self::count_tap_adapters().await?;
if tap_count >= amount_of_openvpn_servers {
crate::util::logger::log_info(&format!(
"✓ Found {} TAP adapters - sufficient for parallel VPN connections",
tap_count
)).await;
return Ok(tap_count);
}
crate::util::logger::log_warn(&format!(
"⚠ Only {} TAP adapter(s) found. Installing {} more for optimal performance...",
tap_count,
amount_of_openvpn_servers - tap_count
)).await;
// Create PowerShell script
let script_content = format!(
r#"# Auto-generated TAP adapter installation script
# Requires Administrator privileges
$ErrorActionPreference = "Stop"
# Check if running as Administrator
$currentPrincipal = New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent())
$isAdmin = $currentPrincipal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
if (-not $isAdmin) {{
Write-Host "ERROR: This script must be run as Administrator!" -ForegroundColor Red
Write-Host "Right-click PowerShell and select 'Run as Administrator'" -ForegroundColor Yellow
exit 1
}}
Write-Host "Installing additional TAP adapters..." -ForegroundColor Cyan
$tapctlPath = "C:\Program Files\OpenVPN\bin\tapctl.exe"
if (-not (Test-Path $tapctlPath)) {{
Write-Host "ERROR: OpenVPN not found at: $tapctlPath" -ForegroundColor Red
Write-Host "Please install OpenVPN from: https://openvpn.net/community-downloads/" -ForegroundColor Yellow
exit 1
}}
$existingCount = {}
$targetCount = 10
for ($i = ($existingCount + 1); $i -le $targetCount; $i++) {{
Write-Host "Creating TAP adapter #$i..." -ForegroundColor Yellow
try {{
& $tapctlPath create --name "OpenVPN-TAP-$i"
if ($LASTEXITCODE -eq 0) {{
Write-Host " ✓ Created OpenVPN-TAP-$i" -ForegroundColor Green
}} else {{
Write-Host " ⚠ Failed to create adapter (exit code: $LASTEXITCODE)" -ForegroundColor Red
}}
}} catch {{
Write-Host " ✗ Error: $_" -ForegroundColor Red
}}
Start-Sleep -Milliseconds 500
}}
Write-Host "`n✓ TAP adapter installation complete!" -ForegroundColor Green
Write-Host "Verifying installation..." -ForegroundColor Cyan
$finalCount = (Get-NetAdapter | Where-Object {{ $_.InterfaceDescription -like "*TAP*" }}).Count
Write-Host "Total TAP adapters now: $finalCount" -ForegroundColor Cyan
exit 0
"#,
tap_count
);
let script_path = std::env::current_dir()?.join("install_tap_adapters.ps1");
// Write script to file
fs::write(&script_path, script_content)
.context("Failed to create PowerShell script")?;
crate::util::logger::log_info(&format!(
"Created installation script: {:?}",
script_path
)).await;
// Execute PowerShell script as Administrator
crate::util::logger::log_info(
"Executing TAP adapter installation (requires Administrator)..."
).await;
let output = Command::new("powershell")
.args(&[
"-ExecutionPolicy", "Bypass",
"-NoProfile",
"-File", script_path.to_str().unwrap()
])
.output().await;
match output {
Ok(result) => {
let stdout = String::from_utf8_lossy(&result.stdout);
let stderr = String::from_utf8_lossy(&result.stderr);
if !stdout.is_empty() {
crate::util::logger::log_info(&format!("Script output:\n{}", stdout)).await;
}
if result.status.success() {
crate::util::logger::log_info(
"✓ TAP adapters installed successfully"
).await;
// Recount adapters
tokio::time::sleep(Duration::from_secs(2)).await;
let new_count = Self::count_tap_adapters().await?;
crate::util::logger::log_info(&format!(
"✓ Total TAP adapters now: {}",
new_count
)).await;
return Ok(new_count);
} else {
let error_msg = if stderr.contains("Administrator") {
"Administrator privileges required. Please run as Administrator."
} else if !stderr.is_empty() {
&stderr
} else {
"Unknown error during installation"
};
crate::util::logger::log_error(&format!(
"✗ TAP adapter installation failed: {}",
error_msg
)).await;
crate::util::logger::log_warn(
"Continuing with existing adapters. VPN connections will be sequential."
).await;
return Ok(tap_count);
}
}
Err(e) => {
crate::util::logger::log_error(&format!(
"Failed to execute installation script: {}",
e
)).await;
crate::util::logger::log_info(&format!(
"You can manually run: {:?}",
script_path
)).await;
return Ok(tap_count);
}
}
}
#[cfg(not(target_os = "windows"))]
{
// Non-Windows: TAP adapters not needed
Ok(10)
}
}
/// Counts existing TAP adapters on the system
async fn count_tap_adapters() -> Result<usize> {
#[cfg(target_os = "windows")]
{
let output = Command::new("ipconfig")
.arg("/all")
.output().await
.context("Failed to execute ipconfig")?;
let output_str = String::from_utf8_lossy(&output.stdout);
let count = output_str
.lines()
.filter(|line| {
let lower = line.to_lowercase();
(lower.contains("tap") && lower.contains("adapter"))
|| lower.contains("tap-windows")
})
.count() / 2; // Two lines per adapter
Ok(count)
}
#[cfg(not(target_os = "windows"))]
{
Ok(10) // Not applicable on non-Windows
}
}
async fn detect_optimal_batch_size() -> usize {
#[cfg(target_os = "windows")]
{
use std::process::Command;
// Try to detect TAP adapters via ipconfig
match Command::new("ipconfig")
.arg("/all")
.output()
{
Ok(output) => {
let output_str = String::from_utf8_lossy(&output.stdout);
// Count lines containing "TAP" adapter references
// Two lines per tap adapter typically
let tap_count = output_str
.lines()
.filter(|line| {
let lower = line.to_lowercase();
(lower.contains("tap") && lower.contains("adapter"))
|| lower.contains("tap-windows")
})
.count() / 2;
if tap_count == 0 {
crate::util::logger::log_warn(
"⚠ No TAP adapters detected! VPN connections will likely fail."
).await;
return 1;
} else if tap_count == 1 {
crate::util::logger::log_warn(&format!(
"⚠ Only 1 TAP adapter found. VPNs will connect sequentially.\n\
For parallel connections, install more TAP adapters:\n\
Run as Admin: cd 'C:\\Program Files\\OpenVPN\\bin' && .\\tapctl.exe create --name 'OpenVPN-TAP-2'"
)).await;
return 1;
} else {
crate::util::logger::log_info(&format!(
"✓ Found {} TAP adapters - enabling parallel VPN connections",
tap_count
)).await;
// Use 75% of available adapters to leave some headroom
let optimal = ((tap_count as f32 * 0.75).ceil() as usize).max(1);
return optimal.min(6); // Cap at 6 for stability
}
}
Err(e) => {
crate::util::logger::log_warn(&format!(
"⚠ Failed to detect TAP adapters: {}. Using sequential connection.",
e
)).await;
return 1;
}
}
}
#[cfg(not(target_os = "windows"))]
{
// Linux/macOS: TAP adapters aren't a limitation
// Can run more in parallel
return 4;
}
}
}
fn is_tcp_443_config(path: &Path) -> bool {
// Get filename as string
let filename = match path.file_name().and_then(|n| n.to_str()) {
Some(name) => name.to_lowercase(),
None => return false,
};
// Check if contains both "tcp" and "443"
filename.contains("tcp") && filename.contains("443")
}