352 lines
12 KiB
Rust
352 lines
12 KiB
Rust
// 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<bool> {
|
|
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<String> {
|
|
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<String> {
|
|
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<String> {
|
|
// 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 <span>192.168.1.1</span>
|
|
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::<u8>().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 = "<span>Your IP is 192.168.1.1 today</span>";
|
|
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);
|
|
}
|
|
}
|