mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-07-25 05:40:50 +02:00
refactor: more robust vpn handling
This commit is contained in:
@@ -16,12 +16,21 @@ const WIREGUARD_IMAGE: &str = "linuxserver/wireguard:latest";
|
||||
const OPENVPN_IMAGE: &str = "kylemanna/openvpn:latest";
|
||||
const WG_CONTAINER: &str = "donut-wg-test";
|
||||
const OVPN_CONTAINER: &str = "donut-ovpn-test";
|
||||
const OVPN_VOLUME: &str = "donut-ovpn-test-data";
|
||||
|
||||
/// Check if running in CI environment
|
||||
pub fn is_ci() -> bool {
|
||||
std::env::var("CI").is_ok() || std::env::var("GITHUB_ACTIONS").is_ok()
|
||||
}
|
||||
|
||||
fn has_external_wireguard_service() -> bool {
|
||||
std::env::var("VPN_TEST_WG_HOST").is_ok()
|
||||
}
|
||||
|
||||
fn has_external_openvpn_service() -> bool {
|
||||
std::env::var("VPN_TEST_OVPN_HOST").is_ok()
|
||||
}
|
||||
|
||||
/// Check if Docker is available
|
||||
pub fn is_docker_available() -> bool {
|
||||
Command::new("docker")
|
||||
@@ -33,14 +42,10 @@ pub fn is_docker_available() -> bool {
|
||||
|
||||
/// Start a WireGuard test server and return client config
|
||||
pub async fn start_wireguard_server() -> Result<WireGuardTestConfig, String> {
|
||||
if is_ci() {
|
||||
// In CI, use the service container configured in workflow
|
||||
if has_external_wireguard_service() {
|
||||
let host = std::env::var("VPN_TEST_WG_HOST").unwrap_or_else(|_| "localhost".into());
|
||||
let port = std::env::var("VPN_TEST_WG_PORT").unwrap_or_else(|_| "51820".into());
|
||||
|
||||
// Wait for service to be ready
|
||||
wait_for_service(&host, port.parse().unwrap_or(51820)).await?;
|
||||
|
||||
return get_ci_wireguard_config(&host, &port);
|
||||
}
|
||||
|
||||
@@ -71,6 +76,8 @@ pub async fn start_wireguard_server() -> Result<WireGuardTestConfig, String> {
|
||||
"SERVERPORT=51820",
|
||||
"-e",
|
||||
"PEERDNS=auto",
|
||||
"-e",
|
||||
"INTERNAL_SUBNET=10.64.0.0",
|
||||
WIREGUARD_IMAGE,
|
||||
])
|
||||
.output()
|
||||
@@ -105,14 +112,10 @@ pub async fn start_wireguard_server() -> Result<WireGuardTestConfig, String> {
|
||||
|
||||
/// Start an OpenVPN test server and return client config
|
||||
pub async fn start_openvpn_server() -> Result<OpenVpnTestConfig, String> {
|
||||
if is_ci() {
|
||||
// In CI, use the service container configured in workflow
|
||||
if has_external_openvpn_service() {
|
||||
let host = std::env::var("VPN_TEST_OVPN_HOST").unwrap_or_else(|_| "localhost".into());
|
||||
let port = std::env::var("VPN_TEST_OVPN_PORT").unwrap_or_else(|_| "1194".into());
|
||||
|
||||
// Wait for service to be ready
|
||||
wait_for_service(&host, port.parse().unwrap_or(1194)).await?;
|
||||
|
||||
return get_ci_openvpn_config(&host, &port);
|
||||
}
|
||||
|
||||
@@ -125,9 +128,139 @@ pub async fn start_openvpn_server() -> Result<OpenVpnTestConfig, String> {
|
||||
.args(["rm", "-f", OVPN_CONTAINER])
|
||||
.output();
|
||||
|
||||
// For OpenVPN, we need to initialize PKI first, which is complex
|
||||
// For simplicity in tests, we'll use a pre-configured test config
|
||||
Err("OpenVPN container setup requires pre-configured PKI. Use test fixtures instead.".to_string())
|
||||
let _ = Command::new("docker")
|
||||
.args(["volume", "rm", "-f", OVPN_VOLUME])
|
||||
.output();
|
||||
|
||||
let create_volume = Command::new("docker")
|
||||
.args(["volume", "create", OVPN_VOLUME])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to create OpenVPN test volume: {e}"))?;
|
||||
if !create_volume.status.success() {
|
||||
return Err(format!(
|
||||
"Failed to create OpenVPN test volume: {}",
|
||||
String::from_utf8_lossy(&create_volume.stderr)
|
||||
));
|
||||
}
|
||||
|
||||
let genconfig = Command::new("docker")
|
||||
.args([
|
||||
"run",
|
||||
"--rm",
|
||||
"-v",
|
||||
&format!("{OVPN_VOLUME}:/etc/openvpn"),
|
||||
"-e",
|
||||
"EASYRSA_BATCH=1",
|
||||
OPENVPN_IMAGE,
|
||||
"ovpn_genconfig",
|
||||
"-u",
|
||||
"udp://127.0.0.1",
|
||||
"-s",
|
||||
"10.9.0.0/24",
|
||||
])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to generate OpenVPN config: {e}"))?;
|
||||
if !genconfig.status.success() {
|
||||
return Err(format!(
|
||||
"OpenVPN config generation failed: {}",
|
||||
String::from_utf8_lossy(&genconfig.stderr)
|
||||
));
|
||||
}
|
||||
|
||||
let init_pki = Command::new("docker")
|
||||
.args([
|
||||
"run",
|
||||
"--rm",
|
||||
"-v",
|
||||
&format!("{OVPN_VOLUME}:/etc/openvpn"),
|
||||
"-e",
|
||||
"EASYRSA_BATCH=1",
|
||||
OPENVPN_IMAGE,
|
||||
"ovpn_initpki",
|
||||
"nopass",
|
||||
])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to initialize OpenVPN PKI: {e}"))?;
|
||||
if !init_pki.status.success() {
|
||||
return Err(format!(
|
||||
"OpenVPN PKI initialization failed: {}",
|
||||
String::from_utf8_lossy(&init_pki.stderr)
|
||||
));
|
||||
}
|
||||
|
||||
let build_client = Command::new("docker")
|
||||
.args([
|
||||
"run",
|
||||
"--rm",
|
||||
"-v",
|
||||
&format!("{OVPN_VOLUME}:/etc/openvpn"),
|
||||
"-e",
|
||||
"EASYRSA_BATCH=1",
|
||||
OPENVPN_IMAGE,
|
||||
"easyrsa",
|
||||
"build-client-full",
|
||||
"donut-test-client",
|
||||
"nopass",
|
||||
])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to build OpenVPN client certificate: {e}"))?;
|
||||
if !build_client.status.success() {
|
||||
return Err(format!(
|
||||
"OpenVPN client certificate build failed: {}",
|
||||
String::from_utf8_lossy(&build_client.stderr)
|
||||
));
|
||||
}
|
||||
|
||||
let start_server = Command::new("docker")
|
||||
.args([
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
OVPN_CONTAINER,
|
||||
"--cap-add=NET_ADMIN",
|
||||
"-p",
|
||||
"1194:1194/udp",
|
||||
"-v",
|
||||
&format!("{OVPN_VOLUME}:/etc/openvpn"),
|
||||
OPENVPN_IMAGE,
|
||||
])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to start OpenVPN container: {e}"))?;
|
||||
if !start_server.status.success() {
|
||||
return Err(format!(
|
||||
"OpenVPN container start failed: {}",
|
||||
String::from_utf8_lossy(&start_server.stderr)
|
||||
));
|
||||
}
|
||||
|
||||
sleep(Duration::from_secs(10)).await;
|
||||
|
||||
let client_config = Command::new("docker")
|
||||
.args([
|
||||
"run",
|
||||
"--rm",
|
||||
"-v",
|
||||
&format!("{OVPN_VOLUME}:/etc/openvpn"),
|
||||
OPENVPN_IMAGE,
|
||||
"ovpn_getclient",
|
||||
"donut-test-client",
|
||||
])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to fetch OpenVPN client config: {e}"))?;
|
||||
if !client_config.status.success() {
|
||||
return Err(format!(
|
||||
"Failed to read OpenVPN client config: {}",
|
||||
String::from_utf8_lossy(&client_config.stderr)
|
||||
));
|
||||
}
|
||||
|
||||
let raw_config = String::from_utf8_lossy(&client_config.stdout).to_string();
|
||||
Ok(OpenVpnTestConfig {
|
||||
raw_config,
|
||||
remote_host: "127.0.0.1".to_string(),
|
||||
remote_port: 1194,
|
||||
protocol: "udp".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Stop all VPN test servers
|
||||
@@ -135,21 +268,9 @@ pub async fn stop_vpn_servers() {
|
||||
let _ = Command::new("docker")
|
||||
.args(["rm", "-f", WG_CONTAINER, OVPN_CONTAINER])
|
||||
.output();
|
||||
}
|
||||
|
||||
/// Wait for a network service to be ready
|
||||
async fn wait_for_service(host: &str, port: u16) -> Result<(), String> {
|
||||
let timeout = Duration::from_secs(30);
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
while start.elapsed() < timeout {
|
||||
if std::net::TcpStream::connect(format!("{host}:{port}")).is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
|
||||
Err(format!("Timeout waiting for service at {host}:{port}"))
|
||||
let _ = Command::new("docker")
|
||||
.args(["volume", "rm", "-f", OVPN_VOLUME])
|
||||
.output();
|
||||
}
|
||||
|
||||
/// WireGuard test configuration
|
||||
@@ -160,6 +281,7 @@ pub struct WireGuardTestConfig {
|
||||
pub peer_public_key: String,
|
||||
pub peer_endpoint: String,
|
||||
pub allowed_ips: Vec<String>,
|
||||
pub preshared_key: Option<String>,
|
||||
}
|
||||
|
||||
/// OpenVPN test configuration
|
||||
@@ -178,6 +300,7 @@ fn parse_wireguard_test_config(content: &str) -> Result<WireGuardTestConfig, Str
|
||||
let mut peer_public_key = String::new();
|
||||
let mut peer_endpoint = String::new();
|
||||
let mut allowed_ips = vec!["0.0.0.0/0".to_string()];
|
||||
let mut preshared_key = None;
|
||||
let mut current_section = "";
|
||||
|
||||
for line in content.lines() {
|
||||
@@ -205,6 +328,7 @@ fn parse_wireguard_test_config(content: &str) -> Result<WireGuardTestConfig, Str
|
||||
("interface", "DNS") => dns = Some(value.to_string()),
|
||||
("peer", "PublicKey") => peer_public_key = value.to_string(),
|
||||
("peer", "Endpoint") => peer_endpoint = value.to_string(),
|
||||
("peer", "PresharedKey") => preshared_key = Some(value.to_string()),
|
||||
("peer", "AllowedIPs") => {
|
||||
allowed_ips = value.split(',').map(|s| s.trim().to_string()).collect();
|
||||
}
|
||||
@@ -230,12 +354,21 @@ fn parse_wireguard_test_config(content: &str) -> Result<WireGuardTestConfig, Str
|
||||
peer_public_key,
|
||||
peer_endpoint,
|
||||
allowed_ips,
|
||||
preshared_key,
|
||||
})
|
||||
}
|
||||
|
||||
/// Get WireGuard config from CI environment
|
||||
fn get_ci_wireguard_config(host: &str, port: &str) -> Result<WireGuardTestConfig, String> {
|
||||
// In CI, use environment variables or test fixtures
|
||||
if std::env::var("VPN_TEST_WG_PRIVATE_KEY").is_err()
|
||||
|| std::env::var("VPN_TEST_WG_PUBLIC_KEY").is_err()
|
||||
{
|
||||
return Err(
|
||||
"External WireGuard test service is configured, but VPN_TEST_WG_PRIVATE_KEY and VPN_TEST_WG_PUBLIC_KEY are missing"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let private_key =
|
||||
std::env::var("VPN_TEST_WG_PRIVATE_KEY").unwrap_or_else(|_| "test-private-key".to_string());
|
||||
let public_key =
|
||||
@@ -248,11 +381,21 @@ fn get_ci_wireguard_config(host: &str, port: &str) -> Result<WireGuardTestConfig
|
||||
peer_public_key: public_key,
|
||||
peer_endpoint: format!("{host}:{port}"),
|
||||
allowed_ips: vec!["0.0.0.0/0".to_string()],
|
||||
preshared_key: std::env::var("VPN_TEST_WG_PRESHARED_KEY").ok(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Get OpenVPN config from CI environment
|
||||
fn get_ci_openvpn_config(host: &str, port: &str) -> Result<OpenVpnTestConfig, String> {
|
||||
if let Ok(raw_config) = std::env::var("VPN_TEST_OVPN_RAW_CONFIG") {
|
||||
return Ok(OpenVpnTestConfig {
|
||||
raw_config,
|
||||
remote_host: host.to_string(),
|
||||
remote_port: port.parse().unwrap_or(1194),
|
||||
protocol: "udp".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let raw_config = format!(
|
||||
r#"
|
||||
client
|
||||
|
||||
@@ -3,13 +3,22 @@
|
||||
//! These tests verify VPN config parsing, storage, and tunnel functionality.
|
||||
//! Connection tests require Docker and are skipped if Docker is not available.
|
||||
|
||||
mod common;
|
||||
mod test_harness;
|
||||
|
||||
use common::TestUtils;
|
||||
use donutbrowser_lib::vpn::{
|
||||
detect_vpn_type, parse_openvpn_config, parse_wireguard_config, OpenVpnConfig, VpnConfig,
|
||||
VpnStorage, VpnType, WireGuardConfig,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use serial_test::serial;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::OnceLock;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::time::sleep;
|
||||
|
||||
// ============================================================================
|
||||
// Config Parsing Tests
|
||||
@@ -420,6 +429,530 @@ async fn test_tunnel_manager() {
|
||||
assert_eq!(manager.active_count(), 0);
|
||||
}
|
||||
|
||||
// NOTE: Actual connection tests require Docker containers running.
|
||||
// These are meant to be run with the CI workflow that sets up service containers.
|
||||
// To run locally: docker run -d --cap-add=NET_ADMIN -p 51820:51820/udp -e PEERS=1 linuxserver/wireguard
|
||||
struct TestEnvGuard {
|
||||
_root: PathBuf,
|
||||
previous_data_dir: Option<String>,
|
||||
previous_cache_dir: Option<String>,
|
||||
}
|
||||
|
||||
impl TestEnvGuard {
|
||||
fn new() -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
|
||||
static TEST_RUNTIME_ROOT: OnceLock<PathBuf> = OnceLock::new();
|
||||
|
||||
let root = TEST_RUNTIME_ROOT
|
||||
.get_or_init(|| {
|
||||
std::env::temp_dir().join(format!("donutbrowser-vpn-e2e-{}", std::process::id()))
|
||||
})
|
||||
.clone();
|
||||
let data_dir = root.join("data");
|
||||
let cache_dir = root.join("cache");
|
||||
let vpn_dir = data_dir.join("vpn");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&data_dir);
|
||||
let _ = std::fs::remove_dir_all(&cache_dir);
|
||||
std::fs::create_dir_all(&vpn_dir)?;
|
||||
std::fs::create_dir_all(&data_dir)?;
|
||||
std::fs::create_dir_all(&cache_dir)?;
|
||||
|
||||
let previous_data_dir = std::env::var("DONUTBROWSER_DATA_DIR").ok();
|
||||
let previous_cache_dir = std::env::var("DONUTBROWSER_CACHE_DIR").ok();
|
||||
|
||||
std::env::set_var("DONUTBROWSER_DATA_DIR", &data_dir);
|
||||
std::env::set_var("DONUTBROWSER_CACHE_DIR", &cache_dir);
|
||||
|
||||
Ok(Self {
|
||||
_root: root,
|
||||
previous_data_dir,
|
||||
previous_cache_dir,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestEnvGuard {
|
||||
fn drop(&mut self) {
|
||||
if let Some(value) = &self.previous_data_dir {
|
||||
std::env::set_var("DONUTBROWSER_DATA_DIR", value);
|
||||
} else {
|
||||
std::env::remove_var("DONUTBROWSER_DATA_DIR");
|
||||
}
|
||||
|
||||
if let Some(value) = &self.previous_cache_dir {
|
||||
std::env::set_var("DONUTBROWSER_CACHE_DIR", value);
|
||||
} else {
|
||||
std::env::remove_var("DONUTBROWSER_CACHE_DIR");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ProxyProcess {
|
||||
id: String,
|
||||
local_port: u16,
|
||||
local_url: String,
|
||||
}
|
||||
|
||||
async fn ensure_donut_proxy_binary() -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let cargo_manifest_dir = std::env::var("CARGO_MANIFEST_DIR")?;
|
||||
let project_root = PathBuf::from(cargo_manifest_dir)
|
||||
.parent()
|
||||
.unwrap()
|
||||
.to_path_buf();
|
||||
|
||||
let proxy_binary_name = if cfg!(windows) {
|
||||
"donut-proxy.exe"
|
||||
} else {
|
||||
"donut-proxy"
|
||||
};
|
||||
let proxy_binary = project_root
|
||||
.join("src-tauri")
|
||||
.join("target")
|
||||
.join("debug")
|
||||
.join(proxy_binary_name);
|
||||
|
||||
if !proxy_binary.exists() {
|
||||
let build_status = tokio::process::Command::new("cargo")
|
||||
.args(["build", "--bin", "donut-proxy"])
|
||||
.current_dir(project_root.join("src-tauri"))
|
||||
.status()
|
||||
.await?;
|
||||
|
||||
if !build_status.success() {
|
||||
return Err("Failed to build donut-proxy binary".into());
|
||||
}
|
||||
}
|
||||
|
||||
if !proxy_binary.exists() {
|
||||
return Err("donut-proxy binary was not created successfully".into());
|
||||
}
|
||||
|
||||
Ok(proxy_binary)
|
||||
}
|
||||
|
||||
fn new_test_vpn_config(name: &str, vpn_type: VpnType, config_data: String) -> VpnConfig {
|
||||
let created_at = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs() as i64;
|
||||
|
||||
VpnConfig {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
name: name.to_string(),
|
||||
vpn_type,
|
||||
config_data,
|
||||
created_at,
|
||||
last_used: None,
|
||||
sync_enabled: false,
|
||||
last_sync: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_wireguard_config(config: &test_harness::WireGuardTestConfig) -> String {
|
||||
format!(
|
||||
"[Interface]\nPrivateKey = {}\nAddress = {}\n{}\n[Peer]\nPublicKey = {}\n{}Endpoint = {}\nAllowedIPs = {}\nPersistentKeepalive = 25\n",
|
||||
config.private_key,
|
||||
config.address,
|
||||
config
|
||||
.dns
|
||||
.as_ref()
|
||||
.map(|dns| format!("DNS = {dns}\n"))
|
||||
.unwrap_or_default(),
|
||||
config.peer_public_key,
|
||||
config
|
||||
.preshared_key
|
||||
.as_ref()
|
||||
.map(|key| format!("PresharedKey = {key}\n"))
|
||||
.unwrap_or_default(),
|
||||
config.peer_endpoint,
|
||||
config.allowed_ips.join(", ")
|
||||
)
|
||||
}
|
||||
|
||||
fn openvpn_client_available() -> bool {
|
||||
if let Ok(path) = std::env::var("DONUTBROWSER_OPENVPN_BIN") {
|
||||
return PathBuf::from(path).exists();
|
||||
}
|
||||
|
||||
std::process::Command::new(if cfg!(windows) { "where" } else { "which" })
|
||||
.arg("openvpn")
|
||||
.output()
|
||||
.map(|output| output.status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn openvpn_adapter_available() -> bool {
|
||||
let openvpn = std::process::Command::new("openvpn")
|
||||
.arg("--show-adapters")
|
||||
.output();
|
||||
|
||||
openvpn
|
||||
.ok()
|
||||
.map(|output| {
|
||||
let text = format!(
|
||||
"{}{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
text
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.any(|line| !line.is_empty() && !line.starts_with("Available adapters"))
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn openvpn_adapter_available() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
async fn start_proxy_with_upstream(
|
||||
binary_path: &PathBuf,
|
||||
upstream_proxy: &str,
|
||||
bypass_rules: &[String],
|
||||
blocklist_file: Option<&str>,
|
||||
profile_id: Option<&str>,
|
||||
) -> Result<ProxyProcess, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let upstream_url = url::Url::parse(upstream_proxy)?;
|
||||
let host = upstream_url
|
||||
.host_str()
|
||||
.ok_or("Upstream proxy host is missing")?
|
||||
.to_string();
|
||||
let port = upstream_url
|
||||
.port()
|
||||
.ok_or("Upstream proxy port is missing")?;
|
||||
|
||||
let mut args = vec![
|
||||
"proxy".to_string(),
|
||||
"start".to_string(),
|
||||
"--host".to_string(),
|
||||
host,
|
||||
"--proxy-port".to_string(),
|
||||
port.to_string(),
|
||||
"--type".to_string(),
|
||||
upstream_url.scheme().to_string(),
|
||||
];
|
||||
|
||||
if !bypass_rules.is_empty() {
|
||||
args.push("--bypass-rules".to_string());
|
||||
args.push(serde_json::to_string(bypass_rules)?);
|
||||
}
|
||||
|
||||
if let Some(blocklist_file) = blocklist_file {
|
||||
args.push("--blocklist-file".to_string());
|
||||
args.push(blocklist_file.to_string());
|
||||
}
|
||||
|
||||
if let Some(profile_id) = profile_id {
|
||||
args.push("--profile-id".to_string());
|
||||
args.push(profile_id.to_string());
|
||||
}
|
||||
|
||||
let arg_refs = args.iter().map(String::as_str).collect::<Vec<_>>();
|
||||
let output = TestUtils::execute_command(binary_path, &arg_refs).await?;
|
||||
if !output.status.success() {
|
||||
return Err(
|
||||
format!(
|
||||
"Failed to start local proxy - stdout: {}, stderr: {}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
let config: Value = serde_json::from_str(&String::from_utf8(output.stdout)?)?;
|
||||
Ok(ProxyProcess {
|
||||
id: config["id"].as_str().ok_or("Missing proxy id")?.to_string(),
|
||||
local_port: config["localPort"].as_u64().ok_or("Missing local port")? as u16,
|
||||
local_url: config["localUrl"]
|
||||
.as_str()
|
||||
.ok_or("Missing local URL")?
|
||||
.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn stop_proxy(
|
||||
binary_path: &PathBuf,
|
||||
proxy_id: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let output =
|
||||
TestUtils::execute_command(binary_path, &["proxy", "stop", "--id", proxy_id]).await?;
|
||||
if !output.status.success() {
|
||||
return Err(
|
||||
format!(
|
||||
"Failed to stop proxy '{}' - stdout: {}, stderr: {}",
|
||||
proxy_id,
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn raw_http_request_via_proxy(
|
||||
local_port: u16,
|
||||
url: &str,
|
||||
host_header: &str,
|
||||
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let mut stream = TcpStream::connect(("127.0.0.1", local_port)).await?;
|
||||
let request = format!("GET {url} HTTP/1.1\r\nHost: {host_header}\r\nConnection: close\r\n\r\n");
|
||||
stream.write_all(request.as_bytes()).await?;
|
||||
|
||||
let mut response = Vec::new();
|
||||
stream.read_to_end(&mut response).await?;
|
||||
Ok(String::from_utf8_lossy(&response).to_string())
|
||||
}
|
||||
|
||||
async fn https_get_via_proxy(
|
||||
local_proxy_url: &str,
|
||||
url: &str,
|
||||
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(20))
|
||||
.no_proxy()
|
||||
.proxy(reqwest::Proxy::all(local_proxy_url)?)
|
||||
.build()?;
|
||||
|
||||
Ok(client.get(url).send().await?.text().await?)
|
||||
}
|
||||
|
||||
async fn cleanup_runtime() {
|
||||
let _ = donutbrowser_lib::proxy_runner::stop_all_proxy_processes().await;
|
||||
let _ = donutbrowser_lib::vpn_worker_runner::stop_all_vpn_workers().await;
|
||||
test_harness::stop_vpn_servers().await;
|
||||
}
|
||||
|
||||
async fn wait_for_file(
|
||||
path: &std::path::Path,
|
||||
timeout: Duration,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let deadline = tokio::time::Instant::now() + timeout;
|
||||
|
||||
while tokio::time::Instant::now() < deadline {
|
||||
if path.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
sleep(Duration::from_millis(250)).await;
|
||||
}
|
||||
|
||||
Err(format!("Timed out waiting for file: {}", path.display()).into())
|
||||
}
|
||||
|
||||
async fn run_proxy_feature_suite(
|
||||
binary_path: &PathBuf,
|
||||
vpn_id: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let vpn_worker = donutbrowser_lib::vpn_worker_runner::start_vpn_worker(vpn_id)
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
let vpn_upstream = vpn_worker
|
||||
.local_url
|
||||
.clone()
|
||||
.ok_or("VPN worker did not expose a local URL")?;
|
||||
|
||||
let profile_id = format!("vpn-e2e-{}", uuid::Uuid::new_v4());
|
||||
let proxy =
|
||||
start_proxy_with_upstream(binary_path, &vpn_upstream, &[], None, Some(&profile_id)).await?;
|
||||
|
||||
sleep(Duration::from_millis(500)).await;
|
||||
|
||||
let http_response =
|
||||
raw_http_request_via_proxy(proxy.local_port, "http://example.com/", "example.com").await?;
|
||||
assert!(
|
||||
http_response.contains("Example Domain"),
|
||||
"HTTP traffic through donut-proxy+VPN should succeed, got: {}",
|
||||
&http_response[..http_response.len().min(300)]
|
||||
);
|
||||
|
||||
let https_body = https_get_via_proxy(&proxy.local_url, "https://example.com/").await?;
|
||||
assert!(
|
||||
https_body.contains("Example Domain"),
|
||||
"HTTPS traffic through donut-proxy+VPN should succeed"
|
||||
);
|
||||
|
||||
let stats_file = donutbrowser_lib::app_dirs::cache_dir()
|
||||
.join("traffic_stats")
|
||||
.join(format!("{}.json", profile_id));
|
||||
wait_for_file(&stats_file, Duration::from_secs(8)).await?;
|
||||
|
||||
assert!(
|
||||
stats_file.exists(),
|
||||
"Traffic stats should exist for VPN-backed local proxy"
|
||||
);
|
||||
let stats: Value = serde_json::from_str(&std::fs::read_to_string(&stats_file)?)?;
|
||||
let total_requests = stats["total_requests"].as_u64().unwrap_or_default();
|
||||
assert!(
|
||||
total_requests > 0,
|
||||
"Traffic stats should record requests for VPN-backed local proxy"
|
||||
);
|
||||
let domains = stats["domains"]
|
||||
.as_object()
|
||||
.ok_or("Traffic stats are missing per-domain data")?;
|
||||
assert!(
|
||||
domains.contains_key("example.com"),
|
||||
"Traffic stats should include example.com domain activity"
|
||||
);
|
||||
|
||||
stop_proxy(binary_path, &proxy.id).await?;
|
||||
|
||||
let blocklist_file = tempfile::NamedTempFile::new()?;
|
||||
std::fs::write(blocklist_file.path(), "example.com\n")?;
|
||||
let blocked_proxy = start_proxy_with_upstream(
|
||||
binary_path,
|
||||
&vpn_upstream,
|
||||
&[],
|
||||
blocklist_file.path().to_str(),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
let blocked_response = raw_http_request_via_proxy(
|
||||
blocked_proxy.local_port,
|
||||
"http://example.com/",
|
||||
"example.com",
|
||||
)
|
||||
.await?;
|
||||
assert!(
|
||||
blocked_response.contains("403") || blocked_response.contains("Blocked by DNS blocklist"),
|
||||
"DNS blocklist should be enforced before forwarding to the VPN upstream"
|
||||
);
|
||||
stop_proxy(binary_path, &blocked_proxy.id).await?;
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
|
||||
let bypass_target_port = listener.local_addr()?.port();
|
||||
let bypass_server = tokio::spawn(async move {
|
||||
while let Ok((stream, _)) = listener.accept().await {
|
||||
let io = hyper_util::rt::TokioIo::new(stream);
|
||||
tokio::spawn(async move {
|
||||
let service = hyper::service::service_fn(|_req| async move {
|
||||
Ok::<_, hyper::Error>(
|
||||
hyper::Response::builder()
|
||||
.status(hyper::StatusCode::OK)
|
||||
.body(http_body_util::Full::new(hyper::body::Bytes::from(
|
||||
"VPN-BYPASS-OK",
|
||||
)))
|
||||
.unwrap(),
|
||||
)
|
||||
});
|
||||
let _ = hyper::server::conn::http1::Builder::new()
|
||||
.serve_connection(io, service)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
let bypass_proxy = start_proxy_with_upstream(
|
||||
binary_path,
|
||||
&vpn_upstream,
|
||||
&["127.0.0.1".to_string(), "localhost".to_string()],
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
let bypass_response = raw_http_request_via_proxy(
|
||||
bypass_proxy.local_port,
|
||||
&format!("http://127.0.0.1:{bypass_target_port}/"),
|
||||
&format!("127.0.0.1:{bypass_target_port}"),
|
||||
)
|
||||
.await?;
|
||||
assert!(
|
||||
bypass_response.contains("VPN-BYPASS-OK"),
|
||||
"Bypass rules should still work when donut-proxy is chained to a VPN worker"
|
||||
);
|
||||
stop_proxy(binary_path, &bypass_proxy.id).await?;
|
||||
bypass_server.abort();
|
||||
|
||||
donutbrowser_lib::vpn_worker_runner::stop_vpn_worker(&vpn_worker.id)
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_wireguard_traffic_flows_through_donut_proxy(
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let _env = TestEnvGuard::new()?;
|
||||
cleanup_runtime().await;
|
||||
|
||||
if !test_harness::is_docker_available() {
|
||||
eprintln!("skipping WireGuard e2e test because Docker is unavailable");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let binary_path = ensure_donut_proxy_binary().await?;
|
||||
let wg_config = match test_harness::start_wireguard_server().await {
|
||||
Ok(config) => config,
|
||||
Err(error) => {
|
||||
eprintln!("skipping WireGuard e2e test: {error}");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
let vpn_config = new_test_vpn_config(
|
||||
"WireGuard E2E",
|
||||
VpnType::WireGuard,
|
||||
build_wireguard_config(&wg_config),
|
||||
);
|
||||
{
|
||||
let storage = donutbrowser_lib::vpn::VPN_STORAGE.lock().unwrap();
|
||||
storage.save_config(&vpn_config)?;
|
||||
}
|
||||
|
||||
let result = run_proxy_feature_suite(&binary_path, &vpn_config.id).await;
|
||||
cleanup_runtime().await;
|
||||
result
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_openvpn_traffic_flows_through_donut_proxy(
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let _env = TestEnvGuard::new()?;
|
||||
cleanup_runtime().await;
|
||||
|
||||
if std::env::var("DONUTBROWSER_RUN_OPENVPN_E2E")
|
||||
.ok()
|
||||
.as_deref()
|
||||
!= Some("1")
|
||||
{
|
||||
eprintln!("skipping OpenVPN e2e test because DONUTBROWSER_RUN_OPENVPN_E2E is not set");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if !test_harness::is_docker_available() {
|
||||
eprintln!("skipping OpenVPN e2e test because Docker is unavailable");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if !openvpn_client_available() {
|
||||
eprintln!("skipping OpenVPN e2e test because the OpenVPN client binary is unavailable");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if !openvpn_adapter_available() {
|
||||
eprintln!("skipping OpenVPN e2e test because no Windows OpenVPN adapter is available");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let binary_path = ensure_donut_proxy_binary().await?;
|
||||
let ovpn_config = match test_harness::start_openvpn_server().await {
|
||||
Ok(config) => config,
|
||||
Err(error) => {
|
||||
eprintln!("skipping OpenVPN e2e test: {error}");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
let vpn_config = new_test_vpn_config("OpenVPN E2E", VpnType::OpenVPN, ovpn_config.raw_config);
|
||||
{
|
||||
let storage = donutbrowser_lib::vpn::VPN_STORAGE.lock().unwrap();
|
||||
storage.save_config(&vpn_config)?;
|
||||
}
|
||||
|
||||
let result = run_proxy_feature_suite(&binary_path, &vpn_config.id).await;
|
||||
cleanup_runtime().await;
|
||||
result
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user