// src/scraper/protonvpn_extension.rs //! ProtonVPN-Chrome-Extension Automater //! //! Automatisiert Interaktionen mit der ProtonVPN-Extension im Browser: //! - Verbindung trennen/verbinden //! - Server auswählen //! - VPN-Status überprüfen //! - Externe IP-Adresse abrufen use anyhow::{anyhow, Context, Result}; use fantoccini::Client; use tokio::time::{sleep, Duration}; use tracing::{debug, info, warn}; /// Automater für die ProtonVPN-Chrome-Extension pub struct ProtonVpnAutomater { /// Chrome-Extension ID (Standardwert: offizielle ProtonVPN) extension_id: String, } impl ProtonVpnAutomater { /// Erstellt einen neuen ProtonVPN-Automater /// /// # Arguments /// * `extension_id` - Die Extension-ID (z.B. "ghmbeldphafepmbegfdlkpapadhbakde") pub fn new(extension_id: String) -> Self { Self { extension_id } } /// Trennt die Verbindung zur ProtonVPN /// /// # Arguments /// * `client` - Der Fantoccini WebDriver Client /// /// # Returns /// Ok wenn erfolgreich, oder Err mit Kontext pub async fn disconnect(&self, client: &Client) -> Result<()> { info!("🔌 Disconnecting from ProtonVPN"); let extension_url = format!("chrome-extension://{}/popup.html", self.extension_id); client .goto(&extension_url) .await .context("Failed to navigate to ProtonVPN extension popup")?; sleep(Duration::from_millis(500)).await; // Versuchen, "Disconnect"-Button zu finden und zu klicken match self.find_and_click_button(client, "disconnect").await { Ok(_) => { sleep(Duration::from_secs(2)).await; info!("✓ Successfully disconnected from ProtonVPN"); Ok(()) } Err(e) => { warn!( "Disconnect button not found (may be already disconnected): {}", e ); Ok(()) // Weiter auch wenn Button nicht found } } } /// Verbindung zu einem spezifischen ProtonVPN-Server herstellen /// /// # Arguments /// * `client` - Der Fantoccini WebDriver Client /// * `server` - Server-Name (z.B. "US-Free#1", "UK-Free#1") /// /// # Returns /// Ok wenn erfolgreich verbunden, Err wenn Timeout oder Fehler pub async fn connect_to_server(&self, client: &Client, server: &str) -> Result<()> { info!("🔗 Connecting to ProtonVPN server: {}", server); let extension_url = format!("chrome-extension://{}/popup.html", self.extension_id); client .goto(&extension_url) .await .context("Failed to navigate to ProtonVPN extension")?; sleep(Duration::from_millis(500)).await; // Server-Liste öffnen (optional, falls UI das erfordert) let _ = self.find_and_click_button(client, "server").await; sleep(Duration::from_millis(300)).await; // Auf spezifischen Server klicken let _ = self.find_and_click_button(client, server).await; sleep(Duration::from_millis(300)).await; // "Connect"-Button klicken self.find_and_click_button(client, "connect") .await .context(format!( "Failed to find or click Connect button for server {}", server ))?; debug!("Waiting for VPN connection to establish..."); // Warten bis verbunden (max 15 Sekunden, Polling alle 500ms) for attempt in 0..30 { sleep(Duration::from_millis(500)).await; if self.is_connected(client).await.unwrap_or(false) { info!( "✓ Successfully connected to {} after {} ms", server, attempt * 500 ); return Ok(()); } if attempt % 6 == 0 { debug!("Still waiting for connection... ({} sec)", attempt / 2); } } Err(anyhow!( "Failed to connect to ProtonVPN server '{}' within 15 seconds", server )) } /// Prüft, ob ProtonVPN aktuell verbunden ist /// /// # Arguments /// * `client` - Der Fantoccini WebDriver Client /// /// # Returns /// `true` wenn verbunden, `false` wenn getrennt oder Status unklar pub async fn is_connected(&self, client: &Client) -> Result { let extension_url = format!("chrome-extension://{}/popup.html", self.extension_id); client .goto(&extension_url) .await .context("Failed to navigate to extension popup")?; sleep(Duration::from_millis(200)).await; let page_source = client .source() .await .context("Failed to get page source from extension")?; // Prüfe auf verschiedene Indikatoren für "verbunden"-Status // Diese können sich zwischen Extension-Versionen ändern let is_connected = page_source.contains("Connected") || page_source.contains("connected") || page_source.contains("status-connected") || page_source.contains("connected-state") || page_source.contains("vpn-status-connected"); debug!( "VPN connection status: {}", if is_connected { "connected" } else { "disconnected" } ); Ok(is_connected) } /// Holt die aktuelle externe IP-Adresse /// /// Navigiert zu einer öffentlichen IP-Check-Webseite und extrahiert die IP. /// /// # Arguments /// * `client` - Der Fantoccini WebDriver Client /// /// # Returns /// Die externe IPv4-Adresse als String pub async fn get_current_ip(&self, client: &Client) -> Result { info!("📍 Checking current external IP address"); // Navigiere zu whatismyipaddress.com client .goto("https://whatismyipaddress.com/") .await .context("Failed to navigate to whatismyipaddress.com")?; sleep(Duration::from_secs(2)).await; let page_source = client .source() .await .context("Failed to get page source from IP check site")?; // Extrahiere IPv4-Adresse - auf verschiedene HTML-Strukturen prüfen if let Some(ip) = self.extract_ipv4(&page_source) { info!("Current external IP: {}", ip); return Ok(ip); } // Fallback: Versuche icanhazip.com (gibt nur IP zurück) debug!("Failed to extract IP from whatismyipaddress.com, trying fallback..."); self.get_current_ip_fallback(client).await } /// Fallback IP-Check mit alternativer Seite async fn get_current_ip_fallback(&self, client: &Client) -> Result { client .goto("https://icanhazip.com/") .await .context("Failed to navigate to icanhazip.com")?; sleep(Duration::from_secs(1)).await; let page_source = client .source() .await .context("Failed to get page source from icanhazip.com")?; let ip = page_source.trim().to_string(); // Validiere einfach dass es IP-ähnlich aussieht if ip.contains('.') && ip.len() > 7 && ip.len() < 16 { info!("Current external IP (from fallback): {}", ip); return Ok(ip); } Err(anyhow!("Failed to extract IP from all fallback sources")) } /// Hilfsfunktion zum Finden und Klicken von Buttons /// /// # Arguments /// * `client` - Der Fantoccini WebDriver Client /// * `text` - Der Text oder Daten-Attribut des Buttons /// /// # Returns /// Ok wenn Button gefunden und geklickt, Err sonst async fn find_and_click_button(&self, client: &Client, text: &str) -> Result<()> { let lower_text = text.to_lowercase(); // Mehrere XPath-Strategien für verschiedene UI-Implementierungen let xpath_strategies = vec![ // Text-basiert (case-insensitive) format!( "//button[contains(translate(text(), 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), '{}')]", lower_text ), // Daten-Attribut format!("//*[@data-action='{}']", lower_text), format!("//*[@data-button='{}']", lower_text), // Aria-Label format!("//*[@aria-label='{}']", text), // Span/Div als Button (Fallback) format!( "//*[contains(translate(text(), 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), '{}')][@role='button']", lower_text ), ]; for xpath in xpath_strategies { if let Ok(element) = client.find(fantoccini::Locator::XPath(&xpath)).await { element .click() .await .context(format!("Failed to click element with text '{}'", text))?; debug!("Clicked button: '{}'", text); return Ok(()); } } Err(anyhow!( "Button '{}' not found with any XPath strategy", text )) } /// Extrahiert IPv4-Adresse aus HTML-Quelle fn extract_ipv4(&self, html: &str) -> Option { // Regex für IPv4: xxx.xxx.xxx.xxx let parts: Vec<&str> = html.split(|c: char| !c.is_numeric() && c != '.').collect(); for part in parts { if self.is_valid_ipv4(part) { return Some(part.to_string()); } } // Fallback: Suche nach HTML-Strukturen wie 192.168.1.1 if let Some(start) = html.find("IPv4") { let section = &html[start..]; if let Some(ip_start) = section.find(|c: char| c.is_numeric()) { if let Some(ip_end) = section[ip_start..].find(|c: char| !c.is_numeric() && c != '.') { let ip = §ion[ip_start..ip_start + ip_end]; if self.is_valid_ipv4(ip) { return Some(ip.to_string()); } } } } None } /// Validiert ob ein String eine gültige IPv4-Adresse ist fn is_valid_ipv4(&self, ip: &str) -> bool { let parts: Vec<&str> = ip.split('.').collect(); if parts.len() != 4 { return false; } parts.iter().all(|part| part.parse::().is_ok()) } } #[cfg(test)] mod tests { use super::*; #[test] fn test_ipv4_validation() { let automater = ProtonVpnAutomater::new("test-ext-id".to_string()); assert!(automater.is_valid_ipv4("192.168.1.1")); assert!(automater.is_valid_ipv4("8.8.8.8")); assert!(automater.is_valid_ipv4("255.255.255.255")); assert!(!automater.is_valid_ipv4("256.1.1.1")); // Out of range assert!(!automater.is_valid_ipv4("192.168.1")); // Too few parts assert!(!automater.is_valid_ipv4("192.168.1.1.1")); // Too many parts assert!(!automater.is_valid_ipv4("192.168.1.abc")); // Non-numeric } #[test] fn test_extract_ipv4() { let automater = ProtonVpnAutomater::new("test-ext-id".to_string()); let html = "Your IP is 192.168.1.1 today"; assert_eq!( automater.extract_ipv4(html), Some("192.168.1.1".to_string()) ); let html2 = "IPv4: 8.8.8.8"; assert_eq!(automater.extract_ipv4(html2), Some("8.8.8.8".to_string())); let html3 = "No IP here"; assert_eq!(automater.extract_ipv4(html3), None); } }