mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-05-15 21:57:59 +02:00
refactor: more robust vpn handling
This commit is contained in:
@@ -67,7 +67,7 @@ jobs:
|
||||
if: matrix.os == 'ubuntu-22.04'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt install libwebkit2gtk-4.1-dev build-essential curl wget file libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev
|
||||
sudo apt install libwebkit2gtk-4.1-dev build-essential curl wget file libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev openvpn
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
@@ -117,6 +117,16 @@ jobs:
|
||||
run: cargo test --lib && cargo test --test donut_proxy_integration && cargo test --test vpn_integration
|
||||
working-directory: src-tauri
|
||||
|
||||
- name: Run OpenVPN e2e test (Ubuntu only)
|
||||
if: matrix.os == 'ubuntu-22.04'
|
||||
shell: bash
|
||||
working-directory: src-tauri
|
||||
env:
|
||||
DONUTBROWSER_RUN_OPENVPN_E2E: "1"
|
||||
run: |
|
||||
sudo --preserve-env=PATH,CARGO_HOME,RUSTUP_HOME,DONUTBROWSER_RUN_OPENVPN_E2E \
|
||||
cargo test --test vpn_integration test_openvpn_traffic_flows_through_donut_proxy -- --nocapture
|
||||
|
||||
- name: Run Rust sync e2e tests
|
||||
run: node scripts/sync-test-harness.mjs
|
||||
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
"start": "next start",
|
||||
"test": "pnpm test:rust:unit && pnpm test:sync-e2e",
|
||||
"test:rust": "cd src-tauri && cargo test",
|
||||
"test:rust:unit": "cd src-tauri && cargo test --lib && cargo test --test donut_proxy_integration",
|
||||
"test:rust:unit": "cd src-tauri && cargo test --lib && cargo test --test donut_proxy_integration && cargo test --test vpn_integration",
|
||||
"test:sync-e2e": "node scripts/sync-test-harness.mjs",
|
||||
"lint": "pnpm lint:js && pnpm lint:rust && pnpm lint:spell",
|
||||
"lint:js": "biome check src/ && tsc --noEmit && cd donut-sync && biome check src/ && tsc --noEmit",
|
||||
|
||||
@@ -55,6 +55,7 @@ pub async fn fetch_public_ip(proxy: Option<&str>) -> Result<String, IpError> {
|
||||
let proxy = reqwest::Proxy::all(proxy_url)
|
||||
.map_err(|e| IpError::Network(format!("Invalid proxy: {}", e)))?;
|
||||
client_builder
|
||||
.no_proxy()
|
||||
.proxy(proxy)
|
||||
.build()
|
||||
.map_err(|e| IpError::Network(e.to_string()))?
|
||||
@@ -64,7 +65,7 @@ pub async fn fetch_public_ip(proxy: Option<&str>) -> Result<String, IpError> {
|
||||
.map_err(|e| IpError::Network(e.to_string()))?
|
||||
};
|
||||
|
||||
let mut last_error = None;
|
||||
let mut errors = Vec::new();
|
||||
|
||||
for url in &urls {
|
||||
match client.get(*url).send().await {
|
||||
@@ -76,21 +77,29 @@ pub async fn fetch_public_ip(proxy: Option<&str>) -> Result<String, IpError> {
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
last_error = Some(format!("Failed to read response from {}: {}", url, e));
|
||||
errors.push(format!("{}: {}", url, e));
|
||||
}
|
||||
},
|
||||
Ok(response) => {
|
||||
last_error = Some(format!("HTTP {} from {}", response.status(), url));
|
||||
errors.push(format!("{}: HTTP {}", url, response.status()));
|
||||
}
|
||||
Err(e) => {
|
||||
last_error = Some(format!("Request to {} failed: {}", url, e));
|
||||
errors.push(format!("{}: {}", url, e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(IpError::Network(last_error.unwrap_or_else(|| {
|
||||
"Failed to fetch public IP from any endpoint".to_string()
|
||||
})))
|
||||
if errors.is_empty() {
|
||||
Err(IpError::Network(
|
||||
"Failed to fetch public IP from any endpoint".to_string(),
|
||||
))
|
||||
} else {
|
||||
Err(IpError::Network(format!(
|
||||
"All {} endpoints failed: {}",
|
||||
errors.len(),
|
||||
errors.join("; ")
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+103
-30
@@ -813,6 +813,42 @@ async fn download_geoip_database(app_handle: tauri::AppHandle) -> Result<(), Str
|
||||
}
|
||||
|
||||
// VPN commands
|
||||
#[derive(serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct VpnDependencyStatus {
|
||||
is_available: bool,
|
||||
requires_external_install: bool,
|
||||
missing_binary: bool,
|
||||
missing_windows_adapter: bool,
|
||||
dependency_check_failed: bool,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_vpn_dependency_status(vpn_type: vpn::VpnType) -> Result<VpnDependencyStatus, String> {
|
||||
match vpn_type {
|
||||
vpn::VpnType::WireGuard => Ok(VpnDependencyStatus {
|
||||
is_available: true,
|
||||
requires_external_install: false,
|
||||
missing_binary: false,
|
||||
missing_windows_adapter: false,
|
||||
dependency_check_failed: false,
|
||||
}),
|
||||
vpn::VpnType::OpenVPN => {
|
||||
let status = crate::vpn::openvpn_socks5::OpenVpnSocks5Server::dependency_status();
|
||||
let is_available =
|
||||
status.binary_found && !status.missing_windows_adapter && !status.dependency_check_failed;
|
||||
|
||||
Ok(VpnDependencyStatus {
|
||||
is_available,
|
||||
requires_external_install: true,
|
||||
missing_binary: !status.binary_found,
|
||||
missing_windows_adapter: status.missing_windows_adapter,
|
||||
dependency_check_failed: status.dependency_check_failed,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn import_vpn_config(
|
||||
content: String,
|
||||
@@ -986,45 +1022,81 @@ async fn check_vpn_validity(
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
|
||||
// Start a temporary VPN worker to send real traffic
|
||||
let had_existing_worker = vpn_worker_storage::find_vpn_worker_by_vpn_id(&vpn_id).is_some();
|
||||
|
||||
let vpn_worker = vpn_worker_runner::start_vpn_worker(&vpn_id)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to start VPN worker: {e}"))?;
|
||||
|
||||
let socks_url = format!("socks5://127.0.0.1:{}", vpn_worker.local_port.unwrap_or(0));
|
||||
let socks_url = format!(
|
||||
"socks5://127.0.0.1:{}",
|
||||
vpn_worker.local_port.unwrap_or_default()
|
||||
);
|
||||
|
||||
// Fetch public IP through the VPN SOCKS5 proxy
|
||||
let result = match ip_utils::fetch_public_ip(Some(&socks_url)).await {
|
||||
Ok(ip) => {
|
||||
let (city, country, country_code) =
|
||||
crate::proxy_manager::ProxyManager::get_ip_geolocation(&ip)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
crate::proxy_manager::ProxyCheckResult {
|
||||
ip,
|
||||
city,
|
||||
country,
|
||||
country_code,
|
||||
timestamp: now,
|
||||
is_valid: true,
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("VPN check failed to fetch public IP: {e}");
|
||||
crate::proxy_manager::ProxyCheckResult {
|
||||
ip: String::new(),
|
||||
city: None,
|
||||
country: None,
|
||||
country_code: None,
|
||||
timestamp: now,
|
||||
is_valid: false,
|
||||
let local_proxy = crate::proxy_runner::start_proxy_process(Some(socks_url), None)
|
||||
.await
|
||||
.map_err(|error| error.to_string());
|
||||
let local_proxy = match local_proxy {
|
||||
Ok(proxy) => proxy,
|
||||
Err(error_message) => {
|
||||
if !had_existing_worker {
|
||||
let _ = vpn_worker_runner::stop_vpn_worker(&vpn_worker.id).await;
|
||||
}
|
||||
return Err(format!("Failed to start validation proxy: {error_message}"));
|
||||
}
|
||||
};
|
||||
|
||||
// Stop the temporary VPN worker
|
||||
let _ = vpn_worker_runner::stop_vpn_worker(&vpn_worker.id).await;
|
||||
let local_proxy_url = format!(
|
||||
"http://127.0.0.1:{}",
|
||||
local_proxy.local_port.unwrap_or_default()
|
||||
);
|
||||
|
||||
let mut result = None;
|
||||
for attempt in 0..3 {
|
||||
if attempt > 0 {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
}
|
||||
|
||||
match ip_utils::fetch_public_ip(Some(&local_proxy_url)).await {
|
||||
Ok(ip) => {
|
||||
let (city, country, country_code) =
|
||||
crate::proxy_manager::ProxyManager::get_ip_geolocation(&ip)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
result = Some(crate::proxy_manager::ProxyCheckResult {
|
||||
ip,
|
||||
city,
|
||||
country,
|
||||
country_code,
|
||||
timestamp: now,
|
||||
is_valid: true,
|
||||
});
|
||||
break;
|
||||
}
|
||||
Err(error) => {
|
||||
log::warn!(
|
||||
"VPN validation attempt {} failed to fetch public IP through donut-proxy: {}",
|
||||
attempt + 1,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = crate::proxy_runner::stop_proxy_process(&local_proxy.id).await;
|
||||
if !had_existing_worker {
|
||||
let _ = vpn_worker_runner::stop_vpn_worker(&vpn_worker.id).await;
|
||||
}
|
||||
|
||||
let result = result.unwrap_or(crate::proxy_manager::ProxyCheckResult {
|
||||
ip: String::new(),
|
||||
city: None,
|
||||
country: None,
|
||||
country_code: None,
|
||||
timestamp: now,
|
||||
is_valid: false,
|
||||
});
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
@@ -1932,6 +2004,7 @@ pub fn run() {
|
||||
add_mcp_to_claude_code,
|
||||
remove_mcp_from_claude_code,
|
||||
// VPN commands
|
||||
get_vpn_dependency_status,
|
||||
import_vpn_config,
|
||||
list_vpn_configs,
|
||||
get_vpn_config,
|
||||
|
||||
@@ -2,12 +2,161 @@ use crate::proxy_storage::{
|
||||
delete_proxy_config, generate_proxy_id, get_proxy_config, is_process_running, list_proxy_configs,
|
||||
save_proxy_config, ProxyConfig,
|
||||
};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Stdio;
|
||||
lazy_static::lazy_static! {
|
||||
static ref PROXY_PROCESSES: std::sync::Mutex<std::collections::HashMap<String, u32>> =
|
||||
std::sync::Mutex::new(std::collections::HashMap::new());
|
||||
}
|
||||
|
||||
fn target_binary_name(base_name: &str) -> Option<String> {
|
||||
let target = std::env::var("TARGET").ok()?;
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
Some(format!("{base_name}-{target}.exe"))
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
Some(format!("{base_name}-{target}"))
|
||||
}
|
||||
}
|
||||
|
||||
fn unsuffixed_binary_name(base_name: &str) -> String {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
match base_name {
|
||||
"donut-proxy" => "donut-proxy.exe".to_string(),
|
||||
"donut-daemon" => "donut-daemon.exe".to_string(),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
base_name.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn binary_matches_prefix(path: &Path, base_name: &str) -> bool {
|
||||
let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
file_name.starts_with(&format!("{base_name}-")) && file_name.ends_with(".exe")
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
file_name.starts_with(&format!("{base_name}-"))
|
||||
}
|
||||
}
|
||||
|
||||
fn push_candidate_dir(dirs: &mut Vec<PathBuf>, dir: Option<PathBuf>) {
|
||||
if let Some(dir) = dir {
|
||||
if !dirs.iter().any(|existing| existing == &dir) {
|
||||
dirs.push(dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn find_sidecar_executable(
|
||||
base_name: &str,
|
||||
) -> Result<PathBuf, Box<dyn std::error::Error>> {
|
||||
let current_exe = std::env::current_exe()?;
|
||||
let current_dir = current_exe
|
||||
.parent()
|
||||
.ok_or("Failed to get parent directory of current executable")?;
|
||||
|
||||
if current_exe
|
||||
.file_stem()
|
||||
.and_then(|stem| stem.to_str())
|
||||
.is_some_and(|stem| stem == base_name)
|
||||
{
|
||||
return Ok(current_exe);
|
||||
}
|
||||
|
||||
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
let mut search_dirs = Vec::new();
|
||||
|
||||
push_candidate_dir(&mut search_dirs, Some(current_dir.to_path_buf()));
|
||||
push_candidate_dir(
|
||||
&mut search_dirs,
|
||||
current_dir.parent().map(std::path::Path::to_path_buf),
|
||||
);
|
||||
push_candidate_dir(
|
||||
&mut search_dirs,
|
||||
current_dir
|
||||
.parent()
|
||||
.and_then(|parent| parent.parent())
|
||||
.map(Path::to_path_buf),
|
||||
);
|
||||
push_candidate_dir(&mut search_dirs, Some(current_dir.join("binaries")));
|
||||
push_candidate_dir(
|
||||
&mut search_dirs,
|
||||
current_dir.parent().map(|parent| parent.join("binaries")),
|
||||
);
|
||||
push_candidate_dir(
|
||||
&mut search_dirs,
|
||||
current_dir
|
||||
.parent()
|
||||
.and_then(|parent| parent.parent())
|
||||
.map(|parent| parent.join("binaries")),
|
||||
);
|
||||
push_candidate_dir(&mut search_dirs, Some(manifest_dir.join("binaries")));
|
||||
push_candidate_dir(
|
||||
&mut search_dirs,
|
||||
Some(manifest_dir.join("target").join("debug")),
|
||||
);
|
||||
push_candidate_dir(
|
||||
&mut search_dirs,
|
||||
Some(manifest_dir.join("target").join("release")),
|
||||
);
|
||||
|
||||
let mut exact_names = vec![unsuffixed_binary_name(base_name)];
|
||||
if let Some(target_name) = target_binary_name(base_name) {
|
||||
exact_names.push(target_name);
|
||||
}
|
||||
|
||||
for dir in &search_dirs {
|
||||
for name in &exact_names {
|
||||
if name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let candidate = dir.join(name);
|
||||
if candidate.exists() {
|
||||
return Ok(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(entries) = std::fs::read_dir(dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_file() && binary_matches_prefix(&path, base_name) {
|
||||
return Ok(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(
|
||||
format!(
|
||||
"Failed to locate '{}' executable. Searched in: {}",
|
||||
base_name,
|
||||
search_dirs
|
||||
.iter()
|
||||
.map(|dir| dir.display().to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
)
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn start_proxy_process(
|
||||
upstream_url: Option<String>,
|
||||
port: Option<u16>,
|
||||
@@ -47,7 +196,7 @@ pub async fn start_proxy_process_with_profile(
|
||||
|
||||
// Spawn proxy worker process in the background using std::process::Command
|
||||
// This ensures proper process detachment on Unix systems
|
||||
let exe = std::env::current_exe()?;
|
||||
let exe = find_sidecar_executable("donut-proxy")?;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
|
||||
@@ -580,7 +580,9 @@ impl LiveTrafficTracker {
|
||||
.profile_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| self.proxy_id.clone());
|
||||
let session_file = get_traffic_stats_dir().join(format!("{}.session.json", storage_key));
|
||||
let storage_dir = get_traffic_stats_dir();
|
||||
fs::create_dir_all(&storage_dir)?;
|
||||
let session_file = storage_dir.join(format!("{}.session.json", storage_key));
|
||||
|
||||
// Write atomically using a temp file
|
||||
let temp_file = session_file.with_extension("tmp");
|
||||
@@ -761,9 +763,11 @@ impl LiveTrafficTracker {
|
||||
.profile_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| self.proxy_id.clone());
|
||||
let storage_dir = get_traffic_stats_dir();
|
||||
fs::create_dir_all(&storage_dir)?;
|
||||
|
||||
// Use file locking to prevent concurrent writes from multiple proxy processes
|
||||
let lock_path = get_traffic_stats_dir().join(format!("{}.lock", storage_key));
|
||||
let lock_path = storage_dir.join(format!("{}.lock", storage_key));
|
||||
let _lock = match acquire_file_lock(&lock_path) {
|
||||
Ok(lock) => lock,
|
||||
Err(e) => {
|
||||
|
||||
@@ -1,8 +1,24 @@
|
||||
use super::config::{OpenVpnConfig, VpnError};
|
||||
use std::path::PathBuf;
|
||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
use std::sync::Arc;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::{lookup_host, TcpListener, TcpSocket, TcpStream};
|
||||
|
||||
const OPENVPN_CONNECT_TIMEOUT_SECS: u64 = 90;
|
||||
|
||||
enum SocksTarget {
|
||||
Address(SocketAddr),
|
||||
Domain(String, u16),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) struct OpenVpnDependencyStatus {
|
||||
pub binary_found: bool,
|
||||
pub missing_windows_adapter: bool,
|
||||
pub dependency_check_failed: bool,
|
||||
}
|
||||
|
||||
pub struct OpenVpnSocks5Server {
|
||||
config: OpenVpnConfig,
|
||||
@@ -14,7 +30,167 @@ impl OpenVpnSocks5Server {
|
||||
Self { config, port }
|
||||
}
|
||||
|
||||
fn find_openvpn_binary() -> Result<PathBuf, VpnError> {
|
||||
fn read_log_tail(path: &Path, lines: usize) -> String {
|
||||
std::fs::read_to_string(path)
|
||||
.unwrap_or_default()
|
||||
.lines()
|
||||
.rev()
|
||||
.take(lines)
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.rev()
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
fn extract_vpn_ip(line: &str) -> Option<Ipv4Addr> {
|
||||
for field in line.split(',') {
|
||||
let trimmed = field.trim();
|
||||
if let Ok(ip) = trimmed.parse::<Ipv4Addr>() {
|
||||
if ip.is_private() && !ip.is_loopback() {
|
||||
return Some(ip);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn log_indicates_connected(log_content: &str) -> bool {
|
||||
log_content.contains("Initialization Sequence Completed")
|
||||
}
|
||||
|
||||
fn log_indicates_failure(log_content: &str) -> bool {
|
||||
log_content.contains("AUTH_FAILED")
|
||||
|| log_content.contains("Exiting due to fatal error")
|
||||
|| log_content.contains("Fatal error")
|
||||
|| log_content.contains("Options error")
|
||||
|| log_content.contains("Exiting")
|
||||
}
|
||||
|
||||
fn has_config_directive(config: &str, directive: &str) -> bool {
|
||||
config.lines().any(|line| {
|
||||
let trimmed = line.trim();
|
||||
!trimmed.is_empty()
|
||||
&& !trimmed.starts_with('#')
|
||||
&& !trimmed.starts_with(';')
|
||||
&& trimmed.starts_with(directive)
|
||||
})
|
||||
}
|
||||
|
||||
fn strip_config_directive(config: &str, directive: &str) -> String {
|
||||
config
|
||||
.lines()
|
||||
.filter(|line| {
|
||||
let trimmed = line.trim();
|
||||
trimmed.is_empty()
|
||||
|| trimmed.starts_with('#')
|
||||
|| trimmed.starts_with(';')
|
||||
|| !trimmed.starts_with(directive)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
fn build_runtime_config(&self) -> String {
|
||||
let mut runtime_config = self.config.raw_config.clone();
|
||||
|
||||
runtime_config = Self::strip_config_directive(&runtime_config, "redirect-gateway");
|
||||
runtime_config = Self::strip_config_directive(&runtime_config, "block-outside-dns");
|
||||
runtime_config = Self::strip_config_directive(&runtime_config, "dhcp-option");
|
||||
|
||||
if !runtime_config.contains("pull-filter ignore \"redirect-gateway\"") {
|
||||
runtime_config.push_str("\npull-filter ignore \"redirect-gateway\"\n");
|
||||
}
|
||||
if !runtime_config.contains("pull-filter ignore \"block-outside-dns\"") {
|
||||
runtime_config.push_str("pull-filter ignore \"block-outside-dns\"\n");
|
||||
}
|
||||
if !runtime_config.contains("pull-filter ignore \"dhcp-option\"") {
|
||||
runtime_config.push_str("pull-filter ignore \"dhcp-option\"\n");
|
||||
}
|
||||
|
||||
if !Self::has_config_directive(&runtime_config, "route 0.0.0.0") {
|
||||
runtime_config.push_str("\nroute 0.0.0.0 0.0.0.0 vpn_gateway 9999\n");
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if Self::has_config_directive(&runtime_config, "dev-node") {
|
||||
runtime_config = runtime_config
|
||||
.lines()
|
||||
.filter(|line| {
|
||||
let trimmed = line.trim();
|
||||
trimmed.is_empty()
|
||||
|| trimmed.starts_with('#')
|
||||
|| trimmed.starts_with(';')
|
||||
|| !trimmed.starts_with("dev-node")
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
if !Self::has_config_directive(&runtime_config, "disable-dco") {
|
||||
runtime_config.push_str("\ndisable-dco\n");
|
||||
}
|
||||
|
||||
if self.config.dev_type.starts_with("tun")
|
||||
&& !Self::has_config_directive(&runtime_config, "windows-driver")
|
||||
{
|
||||
runtime_config.push_str("\nwindows-driver wintun\n");
|
||||
}
|
||||
}
|
||||
|
||||
runtime_config
|
||||
}
|
||||
|
||||
pub(crate) fn dependency_status() -> OpenVpnDependencyStatus {
|
||||
let Ok(openvpn_bin) = Self::find_openvpn_binary() else {
|
||||
return OpenVpnDependencyStatus {
|
||||
binary_found: false,
|
||||
missing_windows_adapter: false,
|
||||
dependency_check_failed: false,
|
||||
};
|
||||
};
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
match Self::windows_openvpn_has_adapter(&openvpn_bin) {
|
||||
Ok(has_adapter) => OpenVpnDependencyStatus {
|
||||
binary_found: true,
|
||||
missing_windows_adapter: !has_adapter,
|
||||
dependency_check_failed: false,
|
||||
},
|
||||
Err(_) => OpenVpnDependencyStatus {
|
||||
binary_found: true,
|
||||
missing_windows_adapter: false,
|
||||
dependency_check_failed: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
OpenVpnDependencyStatus {
|
||||
binary_found: true,
|
||||
missing_windows_adapter: false,
|
||||
dependency_check_failed: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn find_openvpn_binary() -> Result<PathBuf, VpnError> {
|
||||
if let Ok(path) = std::env::var("DONUTBROWSER_OPENVPN_BIN") {
|
||||
let path = PathBuf::from(path);
|
||||
if path.exists() {
|
||||
return Ok(path);
|
||||
}
|
||||
|
||||
return Err(VpnError::Connection(format!(
|
||||
"Configured OpenVPN binary does not exist: {}",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
|
||||
let locations = [
|
||||
"/usr/sbin/openvpn",
|
||||
"/usr/local/sbin/openvpn",
|
||||
@@ -71,12 +247,300 @@ impl OpenVpnSocks5Server {
|
||||
))
|
||||
}
|
||||
|
||||
fn openvpn_supports_management(openvpn_bin: &Path) -> bool {
|
||||
let mut command = Command::new(openvpn_bin);
|
||||
command.arg("--version");
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
command.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
|
||||
let Ok(output) = command.output() else {
|
||||
return true;
|
||||
};
|
||||
|
||||
let version_text = format!(
|
||||
"{}{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
|
||||
!version_text.contains("enable_management=no")
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn windows_openvpn_has_adapter(openvpn_bin: &Path) -> Result<bool, VpnError> {
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
|
||||
let output = Command::new(openvpn_bin)
|
||||
.arg("--show-adapters")
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.output()
|
||||
.map_err(|e| VpnError::Connection(format!("Failed to inspect OpenVPN adapters: {e}")))?;
|
||||
|
||||
let text = format!(
|
||||
"{}{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
|
||||
Ok(
|
||||
text
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.any(|line| !line.is_empty() && !line.starts_with("Available adapters")),
|
||||
)
|
||||
}
|
||||
|
||||
fn extract_vpn_ip_from_log(log_content: &str) -> Option<Ipv4Addr> {
|
||||
for line in log_content.lines() {
|
||||
if let Some(ip) = Self::extract_vpn_ip(line) {
|
||||
return Some(ip);
|
||||
}
|
||||
|
||||
if let Some(position) = line.find("ifconfig ") {
|
||||
let after = &line[position + "ifconfig ".len()..];
|
||||
if let Some(ip_str) = after
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.or_else(|| after.split(',').next())
|
||||
{
|
||||
if let Ok(ip) = ip_str.parse::<Ipv4Addr>() {
|
||||
if ip.is_private() && !ip.is_loopback() {
|
||||
return Some(ip);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
async fn wait_for_openvpn_ready_via_management(
|
||||
child: &mut std::process::Child,
|
||||
mgmt_port: u16,
|
||||
log_path: &Path,
|
||||
) -> Result<Option<Ipv4Addr>, VpnError> {
|
||||
let deadline =
|
||||
tokio::time::Instant::now() + tokio::time::Duration::from_secs(OPENVPN_CONNECT_TIMEOUT_SECS);
|
||||
|
||||
let mgmt_stream = loop {
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
return Err(VpnError::Connection(format!(
|
||||
"Timed out connecting to OpenVPN management interface. Last OpenVPN output:\n{}",
|
||||
Self::read_log_tail(log_path, 20)
|
||||
)));
|
||||
}
|
||||
|
||||
if let Ok(Some(status)) = child.try_wait() {
|
||||
return Err(VpnError::Connection(format!(
|
||||
"OpenVPN exited (status: {}) before the tunnel was established. Last output:\n{}",
|
||||
status,
|
||||
Self::read_log_tail(log_path, 20)
|
||||
)));
|
||||
}
|
||||
|
||||
match TcpStream::connect(("127.0.0.1", mgmt_port)).await {
|
||||
Ok(stream) => break stream,
|
||||
Err(_) => tokio::time::sleep(tokio::time::Duration::from_millis(500)).await,
|
||||
}
|
||||
};
|
||||
|
||||
let (mgmt_reader, mut mgmt_writer) = mgmt_stream.into_split();
|
||||
let _ = mgmt_writer.write_all(b"state on\nstate\n").await;
|
||||
|
||||
let mut lines = BufReader::new(mgmt_reader).lines();
|
||||
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(1));
|
||||
interval.tick().await;
|
||||
|
||||
let mut vpn_ip = None;
|
||||
|
||||
loop {
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
return Err(VpnError::Connection(format!(
|
||||
"Timed out waiting for OpenVPN to reach CONNECTED state. Last OpenVPN output:\n{}",
|
||||
Self::read_log_tail(log_path, 20)
|
||||
)));
|
||||
}
|
||||
|
||||
if let Ok(Some(status)) = child.try_wait() {
|
||||
return Err(VpnError::Connection(format!(
|
||||
"OpenVPN exited (status: {}) before connecting. Last output:\n{}",
|
||||
status,
|
||||
Self::read_log_tail(log_path, 20)
|
||||
)));
|
||||
}
|
||||
|
||||
tokio::select! {
|
||||
line_result = lines.next_line() => {
|
||||
match line_result {
|
||||
Ok(Some(line)) => {
|
||||
if let Some(ip) = Self::extract_vpn_ip(&line) {
|
||||
vpn_ip = Some(ip);
|
||||
}
|
||||
|
||||
if line.contains(",CONNECTED,") {
|
||||
break;
|
||||
}
|
||||
|
||||
if line.contains("AUTH_FAILED") {
|
||||
return Err(VpnError::Connection(format!(
|
||||
"OpenVPN authentication failed. Last output:\n{}",
|
||||
Self::read_log_tail(log_path, 20)
|
||||
)));
|
||||
}
|
||||
|
||||
if line.contains(",EXITING,") || line.contains(">FATAL:") {
|
||||
return Err(VpnError::Connection(format!(
|
||||
"OpenVPN is exiting. Last output:\n{}",
|
||||
Self::read_log_tail(log_path, 20)
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
return Err(VpnError::Connection(format!(
|
||||
"OpenVPN management connection closed before CONNECTED state. Last output:\n{}",
|
||||
Self::read_log_tail(log_path, 20)
|
||||
)));
|
||||
}
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
_ = interval.tick() => {
|
||||
let _ = mgmt_writer.write_all(b"state\n").await;
|
||||
|
||||
let log_path = log_path.to_path_buf();
|
||||
let log_content = tokio::task::spawn_blocking(move || std::fs::read_to_string(log_path))
|
||||
.await
|
||||
.ok()
|
||||
.and_then(Result::ok);
|
||||
|
||||
if let Some(content) = log_content {
|
||||
if Self::log_indicates_connected(&content) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if vpn_ip.is_none() {
|
||||
if let Ok(log_content) = std::fs::read_to_string(log_path) {
|
||||
vpn_ip = Self::extract_vpn_ip_from_log(&log_content);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(vpn_ip)
|
||||
}
|
||||
|
||||
async fn wait_for_openvpn_ready_via_log(
|
||||
child: &mut std::process::Child,
|
||||
log_path: &Path,
|
||||
) -> Result<Option<Ipv4Addr>, VpnError> {
|
||||
let deadline =
|
||||
tokio::time::Instant::now() + tokio::time::Duration::from_secs(OPENVPN_CONNECT_TIMEOUT_SECS);
|
||||
|
||||
loop {
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
return Err(VpnError::Connection(format!(
|
||||
"Timed out waiting for OpenVPN to connect. Last OpenVPN output:\n{}",
|
||||
Self::read_log_tail(log_path, 40)
|
||||
)));
|
||||
}
|
||||
|
||||
if let Ok(Some(status)) = child.try_wait() {
|
||||
return Err(VpnError::Connection(format!(
|
||||
"OpenVPN exited (status: {}) before connecting. Last output:\n{}",
|
||||
status,
|
||||
Self::read_log_tail(log_path, 40)
|
||||
)));
|
||||
}
|
||||
|
||||
let log_path_buf = log_path.to_path_buf();
|
||||
let log_content = tokio::task::spawn_blocking(move || std::fs::read_to_string(log_path_buf))
|
||||
.await
|
||||
.ok()
|
||||
.and_then(Result::ok)
|
||||
.unwrap_or_default();
|
||||
|
||||
if Self::log_indicates_connected(&log_content) {
|
||||
return Ok(Self::extract_vpn_ip_from_log(&log_content));
|
||||
}
|
||||
|
||||
if Self::log_indicates_failure(&log_content) {
|
||||
return Err(VpnError::Connection(format!(
|
||||
"OpenVPN reported a fatal error while connecting. Last output:\n{}",
|
||||
Self::read_log_tail(log_path, 40)
|
||||
)));
|
||||
}
|
||||
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn connect_target(
|
||||
target: SocksTarget,
|
||||
vpn_bind_ip: Ipv4Addr,
|
||||
) -> Result<(TcpStream, SocketAddr), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let mut addresses = match target {
|
||||
SocksTarget::Address(addr) => vec![addr],
|
||||
SocksTarget::Domain(host, port) => {
|
||||
let mut resolved = lookup_host((host.as_str(), port))
|
||||
.await?
|
||||
.collect::<Vec<_>>();
|
||||
resolved.sort_by_key(|addr| if addr.is_ipv4() { 0 } else { 1 });
|
||||
resolved
|
||||
}
|
||||
};
|
||||
|
||||
if addresses.is_empty() {
|
||||
return Err("No addresses resolved for SOCKS5 target".into());
|
||||
}
|
||||
|
||||
let mut last_error = None;
|
||||
|
||||
for address in addresses.drain(..) {
|
||||
let socket = if address.is_ipv4() {
|
||||
let socket = TcpSocket::new_v4()?;
|
||||
if !vpn_bind_ip.is_unspecified() {
|
||||
socket.bind(SocketAddr::new(IpAddr::V4(vpn_bind_ip), 0))?;
|
||||
}
|
||||
socket
|
||||
} else {
|
||||
TcpSocket::new_v6()?
|
||||
};
|
||||
|
||||
match socket.connect(address).await {
|
||||
Ok(stream) => return Ok((stream, address)),
|
||||
Err(error) => last_error = Some(error),
|
||||
}
|
||||
}
|
||||
|
||||
Err(
|
||||
last_error
|
||||
.map(|error| error.into())
|
||||
.unwrap_or_else(|| "Failed to connect to any resolved SOCKS5 target".into()),
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn run(self, config_id: String) -> Result<(), VpnError> {
|
||||
let openvpn_bin = Self::find_openvpn_binary()?;
|
||||
let supports_management = Self::openvpn_supports_management(&openvpn_bin);
|
||||
|
||||
#[cfg(windows)]
|
||||
if !Self::windows_openvpn_has_adapter(&openvpn_bin)? {
|
||||
return Err(VpnError::Connection(
|
||||
"OpenVPN requires a TAP/Wintun/ovpn-dco adapter on Windows, but none were found. Install or provision an adapter before connecting.".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Write config to temp file
|
||||
let config_path = std::env::temp_dir().join(format!("openvpn_{}.ovpn", config_id));
|
||||
std::fs::write(&config_path, &self.config.raw_config).map_err(VpnError::Io)?;
|
||||
std::fs::write(&config_path, self.build_runtime_config()).map_err(VpnError::Io)?;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
@@ -84,43 +548,74 @@ impl OpenVpnSocks5Server {
|
||||
let _ = std::fs::set_permissions(&config_path, std::fs::Permissions::from_mode(0o600));
|
||||
}
|
||||
|
||||
// Find a management port
|
||||
let mgmt_listener = std::net::TcpListener::bind("127.0.0.1:0")
|
||||
.map_err(|e| VpnError::Connection(format!("Failed to bind management port: {e}")))?;
|
||||
let mgmt_port = mgmt_listener
|
||||
.local_addr()
|
||||
.map_err(|e| VpnError::Connection(format!("Failed to get management port: {e}")))?
|
||||
.port();
|
||||
drop(mgmt_listener);
|
||||
let mgmt_port = if supports_management {
|
||||
let mgmt_listener = std::net::TcpListener::bind("127.0.0.1:0")
|
||||
.map_err(|e| VpnError::Connection(format!("Failed to bind management port: {e}")))?;
|
||||
let port = mgmt_listener
|
||||
.local_addr()
|
||||
.map_err(|e| VpnError::Connection(format!("Failed to get management port: {e}")))?
|
||||
.port();
|
||||
drop(mgmt_listener);
|
||||
Some(port)
|
||||
} else {
|
||||
log::info!(
|
||||
"[vpn-worker] OpenVPN build does not support management; using log-based readiness"
|
||||
);
|
||||
None
|
||||
};
|
||||
|
||||
let openvpn_log_path = std::env::temp_dir().join(format!("openvpn-{}.log", config_id));
|
||||
let log_file = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.open(&openvpn_log_path)
|
||||
.map_err(VpnError::Io)?;
|
||||
|
||||
// Start OpenVPN with SOCKS proxy mode
|
||||
let mut cmd = Command::new(&openvpn_bin);
|
||||
cmd.arg("--config").arg(&config_path);
|
||||
if let Some(mgmt_port) = mgmt_port {
|
||||
cmd
|
||||
.arg("--management")
|
||||
.arg("127.0.0.1")
|
||||
.arg(mgmt_port.to_string());
|
||||
}
|
||||
cmd
|
||||
.arg("--config")
|
||||
.arg(&config_path)
|
||||
.arg("--management")
|
||||
.arg("127.0.0.1")
|
||||
.arg(mgmt_port.to_string())
|
||||
.arg("--socks-proxy")
|
||||
.arg("127.0.0.1")
|
||||
.arg(self.port.to_string())
|
||||
.arg("--verb")
|
||||
.arg("3")
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
.stdout(
|
||||
log_file
|
||||
.try_clone()
|
||||
.map(Stdio::from)
|
||||
.map_err(VpnError::Io)?,
|
||||
)
|
||||
.stderr(Stdio::from(log_file));
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
|
||||
cmd.arg("--disable-dco");
|
||||
if self.config.dev_type.starts_with("tun") {
|
||||
cmd.arg("--windows-driver").arg("wintun");
|
||||
}
|
||||
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| VpnError::Connection(format!("Failed to start OpenVPN: {e}")))?;
|
||||
|
||||
// Wait for OpenVPN to start
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
|
||||
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => {
|
||||
let _ = std::fs::remove_file(&config_path);
|
||||
return Err(VpnError::Connection(format!(
|
||||
"OpenVPN exited early with status: {status}. OpenVPN requires elevated privileges (sudo/admin)."
|
||||
"OpenVPN exited immediately (status: {}). Last output:\n{}",
|
||||
status,
|
||||
Self::read_log_tail(&openvpn_log_path, 20)
|
||||
)));
|
||||
}
|
||||
Ok(None) => {}
|
||||
@@ -132,8 +627,15 @@ impl OpenVpnSocks5Server {
|
||||
}
|
||||
}
|
||||
|
||||
// Start a basic SOCKS5 proxy that tunnels through the OpenVPN TUN interface
|
||||
let listener = TcpListener::bind(format!("127.0.0.1:{}", self.port))
|
||||
let vpn_bind_ip = if let Some(mgmt_port) = mgmt_port {
|
||||
Self::wait_for_openvpn_ready_via_management(&mut child, mgmt_port, &openvpn_log_path).await?
|
||||
} else {
|
||||
Self::wait_for_openvpn_ready_via_log(&mut child, &openvpn_log_path).await?
|
||||
}
|
||||
.unwrap_or(Ipv4Addr::UNSPECIFIED);
|
||||
let vpn_bind_ip = Arc::new(vpn_bind_ip);
|
||||
|
||||
let listener = TcpListener::bind(("127.0.0.1", self.port))
|
||||
.await
|
||||
.map_err(|e| VpnError::Connection(format!("Failed to bind SOCKS5: {e}")))?;
|
||||
|
||||
@@ -142,10 +644,10 @@ impl OpenVpnSocks5Server {
|
||||
.map_err(|e| VpnError::Connection(format!("Failed to get local addr: {e}")))?
|
||||
.port();
|
||||
|
||||
if let Some(mut wc) = crate::vpn_worker_storage::get_vpn_worker_config(&config_id) {
|
||||
wc.local_port = Some(actual_port);
|
||||
wc.local_url = Some(format!("socks5://127.0.0.1:{}", actual_port));
|
||||
let _ = crate::vpn_worker_storage::save_vpn_worker_config(&wc);
|
||||
if let Some(mut worker_config) = crate::vpn_worker_storage::get_vpn_worker_config(&config_id) {
|
||||
worker_config.local_port = Some(actual_port);
|
||||
worker_config.local_url = Some(format!("socks5://127.0.0.1:{}", actual_port));
|
||||
let _ = crate::vpn_worker_storage::save_vpn_worker_config(&worker_config);
|
||||
}
|
||||
|
||||
log::info!(
|
||||
@@ -156,10 +658,13 @@ impl OpenVpnSocks5Server {
|
||||
loop {
|
||||
match listener.accept().await {
|
||||
Ok((client, _)) => {
|
||||
tokio::spawn(Self::handle_socks5_client(client));
|
||||
let bind_ip = vpn_bind_ip.clone();
|
||||
tokio::spawn(async move {
|
||||
let _ = Self::handle_socks5_client(client, bind_ip).await;
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("[vpn-worker] Accept error: {e}");
|
||||
Err(error) => {
|
||||
log::warn!("[vpn-worker] Accept error: {error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -167,53 +672,119 @@ impl OpenVpnSocks5Server {
|
||||
|
||||
async fn handle_socks5_client(
|
||||
mut client: TcpStream,
|
||||
vpn_bind_ip: Arc<Ipv4Addr>,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
// SOCKS5 greeting
|
||||
let mut buf = [0u8; 256];
|
||||
let n = client.read(&mut buf).await?;
|
||||
if n < 3 || buf[0] != 0x05 {
|
||||
let mut greeting = [0u8; 2];
|
||||
if let Err(error) = client.read_exact(&mut greeting).await {
|
||||
if error.kind() != std::io::ErrorKind::UnexpectedEof {
|
||||
log::debug!("[socks5] Failed to read greeting header: {}", error);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if greeting[0] != 0x05 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut methods = vec![0u8; greeting[1] as usize];
|
||||
if let Err(error) = client.read_exact(&mut methods).await {
|
||||
if error.kind() != std::io::ErrorKind::UnexpectedEof {
|
||||
log::debug!("[socks5] Failed to read methods list: {}", error);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
client.write_all(&[0x05, 0x00]).await?;
|
||||
|
||||
// SOCKS5 connect request
|
||||
let n = client.read(&mut buf).await?;
|
||||
if n < 10 || buf[0] != 0x05 || buf[1] != 0x01 {
|
||||
let mut request_header = [0u8; 4];
|
||||
if let Err(error) = client.read_exact(&mut request_header).await {
|
||||
if error.kind() != std::io::ErrorKind::UnexpectedEof {
|
||||
log::debug!("[socks5] Failed to read request header: {}", error);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let dest_addr = match buf[3] {
|
||||
if request_header[0] != 0x05 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if request_header[1] != 0x01 {
|
||||
let _ = client
|
||||
.write_all(&[0x05, 0x07, 0x00, 0x01, 0, 0, 0, 0, 0, 0])
|
||||
.await;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let target = match request_header[3] {
|
||||
0x01 => {
|
||||
let ip = std::net::Ipv4Addr::new(buf[4], buf[5], buf[6], buf[7]);
|
||||
let port = u16::from_be_bytes([buf[8], buf[9]]);
|
||||
format!("{}:{}", ip, port)
|
||||
let mut addr_port = [0u8; 6];
|
||||
client.read_exact(&mut addr_port).await?;
|
||||
SocksTarget::Address(SocketAddr::new(
|
||||
IpAddr::V4(Ipv4Addr::new(
|
||||
addr_port[0],
|
||||
addr_port[1],
|
||||
addr_port[2],
|
||||
addr_port[3],
|
||||
)),
|
||||
u16::from_be_bytes([addr_port[4], addr_port[5]]),
|
||||
))
|
||||
}
|
||||
0x03 => {
|
||||
let domain_len = buf[4] as usize;
|
||||
let domain = String::from_utf8_lossy(&buf[5..5 + domain_len]).to_string();
|
||||
let port_start = 5 + domain_len;
|
||||
let port = u16::from_be_bytes([buf[port_start], buf[port_start + 1]]);
|
||||
format!("{}:{}", domain, port)
|
||||
let mut len = [0u8; 1];
|
||||
client.read_exact(&mut len).await?;
|
||||
if len[0] == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut domain = vec![0u8; len[0] as usize];
|
||||
client.read_exact(&mut domain).await?;
|
||||
|
||||
let mut port = [0u8; 2];
|
||||
client.read_exact(&mut port).await?;
|
||||
|
||||
SocksTarget::Domain(
|
||||
String::from_utf8_lossy(&domain).to_string(),
|
||||
u16::from_be_bytes(port),
|
||||
)
|
||||
}
|
||||
0x04 => {
|
||||
let mut addr_port = [0u8; 18];
|
||||
client.read_exact(&mut addr_port).await?;
|
||||
|
||||
let mut octets = [0u8; 16];
|
||||
octets.copy_from_slice(&addr_port[..16]);
|
||||
|
||||
SocksTarget::Address(SocketAddr::new(
|
||||
IpAddr::V6(std::net::Ipv6Addr::from(octets)),
|
||||
u16::from_be_bytes([addr_port[16], addr_port[17]]),
|
||||
))
|
||||
}
|
||||
_ => {
|
||||
let _ = client
|
||||
.write_all(&[0x05, 0x08, 0x00, 0x01, 0, 0, 0, 0, 0, 0])
|
||||
.await;
|
||||
return Ok(());
|
||||
}
|
||||
_ => return Ok(()),
|
||||
};
|
||||
|
||||
// Connect to destination through OpenVPN tunnel (OS routing handles it)
|
||||
match TcpStream::connect(&dest_addr).await {
|
||||
Ok(upstream) => {
|
||||
match Self::connect_target(target, *vpn_bind_ip).await {
|
||||
Ok((upstream, _address)) => {
|
||||
client
|
||||
.write_all(&[0x05, 0x00, 0x00, 0x01, 127, 0, 0, 1, 0, 0])
|
||||
.await?;
|
||||
|
||||
let (mut cr, mut cw) = client.into_split();
|
||||
let (mut ur, mut uw) = upstream.into_split();
|
||||
let (mut client_read, mut client_write) = client.into_split();
|
||||
let (mut upstream_read, mut upstream_write) = upstream.into_split();
|
||||
|
||||
let c2u = tokio::io::copy(&mut cr, &mut uw);
|
||||
let u2c = tokio::io::copy(&mut ur, &mut cw);
|
||||
|
||||
let _ = tokio::try_join!(c2u, u2c);
|
||||
let client_to_upstream = tokio::io::copy(&mut client_read, &mut upstream_write);
|
||||
let upstream_to_client = tokio::io::copy(&mut upstream_read, &mut client_write);
|
||||
let _ = tokio::try_join!(client_to_upstream, upstream_to_client)?;
|
||||
}
|
||||
Err(_) => {
|
||||
Err(error) => {
|
||||
log::debug!(
|
||||
"[socks5] Failed to connect through OpenVPN tunnel: {}",
|
||||
error
|
||||
);
|
||||
client
|
||||
.write_all(&[0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0])
|
||||
.await?;
|
||||
|
||||
@@ -370,6 +370,8 @@ impl WireGuardSocks5Server {
|
||||
smol_handle: SocketHandle,
|
||||
tcp_stream: TcpStream,
|
||||
socks_done: bool,
|
||||
connecting: bool,
|
||||
greeting_done: bool,
|
||||
read_buf: Vec<u8>,
|
||||
dest_addr: Option<SocketAddr>,
|
||||
}
|
||||
@@ -391,6 +393,8 @@ impl WireGuardSocks5Server {
|
||||
smol_handle: handle,
|
||||
tcp_stream: stream,
|
||||
socks_done: false,
|
||||
connecting: false,
|
||||
greeting_done: false,
|
||||
read_buf: Vec::new(),
|
||||
dest_addr: None,
|
||||
});
|
||||
@@ -409,7 +413,30 @@ impl WireGuardSocks5Server {
|
||||
// Process each connection
|
||||
let mut completed = Vec::new();
|
||||
for (idx, conn) in connections.iter_mut().enumerate() {
|
||||
if !conn.socks_done {
|
||||
if conn.connecting {
|
||||
let socket = sockets.get_mut::<TcpSocket>(conn.smol_handle);
|
||||
if socket.may_send() {
|
||||
let _ = conn.tcp_stream.try_write(&[
|
||||
0x05,
|
||||
0x00,
|
||||
0x00,
|
||||
0x01,
|
||||
127,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
(actual_port >> 8) as u8,
|
||||
(actual_port & 0xff) as u8,
|
||||
]);
|
||||
conn.connecting = false;
|
||||
conn.socks_done = true;
|
||||
} else if !socket.is_open() {
|
||||
let _ = conn
|
||||
.tcp_stream
|
||||
.try_write(&[0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0]);
|
||||
completed.push(idx);
|
||||
}
|
||||
} else if !conn.socks_done {
|
||||
// Handle SOCKS5 handshake
|
||||
let mut buf = [0u8; 512];
|
||||
match conn.tcp_stream.try_read(&mut buf) {
|
||||
@@ -427,19 +454,26 @@ impl WireGuardSocks5Server {
|
||||
}
|
||||
}
|
||||
|
||||
if conn.dest_addr.is_none() && conn.read_buf.len() >= 3 {
|
||||
if !conn.greeting_done && conn.read_buf.len() >= 3 {
|
||||
// SOCKS5 greeting: version, nmethods, methods
|
||||
if conn.read_buf[0] != 0x05 {
|
||||
completed.push(idx);
|
||||
continue;
|
||||
}
|
||||
// Reply: no auth required
|
||||
let _ = conn.tcp_stream.try_write(&[0x05, 0x00]);
|
||||
let nmethods = conn.read_buf[1] as usize;
|
||||
if conn.read_buf.len() < 2 + nmethods {
|
||||
continue;
|
||||
}
|
||||
// Reply: no auth required
|
||||
if conn.tcp_stream.try_write(&[0x05, 0x00]).is_err() {
|
||||
completed.push(idx);
|
||||
continue;
|
||||
}
|
||||
conn.read_buf.drain(..2 + nmethods);
|
||||
conn.greeting_done = true;
|
||||
}
|
||||
|
||||
if conn.dest_addr.is_none() && conn.read_buf.len() >= 10 {
|
||||
if conn.greeting_done && conn.dest_addr.is_none() && conn.read_buf.len() >= 10 {
|
||||
// SOCKS5 connect request
|
||||
if conn.read_buf[0] != 0x05 || conn.read_buf[1] != 0x01 {
|
||||
completed.push(idx);
|
||||
@@ -539,20 +573,7 @@ impl WireGuardSocks5Server {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Send SOCKS5 success reply
|
||||
let _ = conn.tcp_stream.try_write(&[
|
||||
0x05,
|
||||
0x00,
|
||||
0x00,
|
||||
0x01,
|
||||
127,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
(actual_port >> 8) as u8,
|
||||
(actual_port & 0xff) as u8,
|
||||
]);
|
||||
conn.socks_done = true;
|
||||
conn.connecting = true;
|
||||
}
|
||||
} else {
|
||||
// Data relay between SOCKS5 client and smoltcp socket
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::proxy_runner::find_sidecar_executable;
|
||||
use crate::proxy_storage::is_process_running;
|
||||
use crate::vpn_worker_storage::{
|
||||
delete_vpn_worker_config, find_vpn_worker_by_vpn_id, generate_vpn_worker_id,
|
||||
@@ -5,12 +6,124 @@ use crate::vpn_worker_storage::{
|
||||
};
|
||||
use std::process::Stdio;
|
||||
|
||||
const VPN_WORKER_POLL_INTERVAL_MS: u64 = 100;
|
||||
const VPN_WORKER_STARTUP_TIMEOUT_MS: u64 = 10_000;
|
||||
const OPENVPN_WORKER_STARTUP_TIMEOUT_MS: u64 = 100_000;
|
||||
|
||||
async fn vpn_worker_accepting_connections(config: &VpnWorkerConfig) -> bool {
|
||||
let Some(port) = config.local_port else {
|
||||
return false;
|
||||
};
|
||||
|
||||
if config
|
||||
.local_url
|
||||
.as_ref()
|
||||
.is_none_or(|local_url| local_url.is_empty())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
matches!(
|
||||
tokio::time::timeout(
|
||||
tokio::time::Duration::from_millis(VPN_WORKER_POLL_INTERVAL_MS),
|
||||
tokio::net::TcpStream::connect(("127.0.0.1", port)),
|
||||
)
|
||||
.await,
|
||||
Ok(Ok(_))
|
||||
)
|
||||
}
|
||||
|
||||
fn worker_log_path(id: &str) -> std::path::PathBuf {
|
||||
std::env::temp_dir().join(format!("donut-vpn-{}.log", id))
|
||||
}
|
||||
|
||||
fn read_worker_log(id: &str) -> String {
|
||||
std::fs::read_to_string(worker_log_path(id)).unwrap_or_else(|_| "No log available".to_string())
|
||||
}
|
||||
|
||||
async fn wait_for_vpn_worker_ready(
|
||||
id: &str,
|
||||
vpn_type: &str,
|
||||
) -> Result<VpnWorkerConfig, Box<dyn std::error::Error>> {
|
||||
let startup_timeout = if vpn_type == "openvpn" {
|
||||
tokio::time::Duration::from_millis(OPENVPN_WORKER_STARTUP_TIMEOUT_MS)
|
||||
} else {
|
||||
tokio::time::Duration::from_millis(VPN_WORKER_STARTUP_TIMEOUT_MS)
|
||||
};
|
||||
let startup_deadline = tokio::time::Instant::now() + startup_timeout;
|
||||
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(
|
||||
VPN_WORKER_POLL_INTERVAL_MS,
|
||||
))
|
||||
.await;
|
||||
|
||||
let mut attempts = 0u32;
|
||||
|
||||
loop {
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(
|
||||
VPN_WORKER_POLL_INTERVAL_MS,
|
||||
))
|
||||
.await;
|
||||
|
||||
if let Some(updated_config) = get_vpn_worker_config(id) {
|
||||
let process_running = updated_config.pid.map(is_process_running).unwrap_or(false);
|
||||
|
||||
if !process_running && attempts > 2 {
|
||||
let log_output = read_worker_log(id);
|
||||
delete_vpn_worker_config(id);
|
||||
return Err(format!("VPN worker process crashed. Log output:\n{}", log_output).into());
|
||||
}
|
||||
|
||||
if vpn_worker_accepting_connections(&updated_config).await {
|
||||
return Ok(updated_config);
|
||||
}
|
||||
}
|
||||
|
||||
attempts += 1;
|
||||
if tokio::time::Instant::now() >= startup_deadline {
|
||||
if let Some(config) = get_vpn_worker_config(id) {
|
||||
let process_running = config.pid.map(is_process_running).unwrap_or(false);
|
||||
let log_output = read_worker_log(id);
|
||||
delete_vpn_worker_config(id);
|
||||
return Err(
|
||||
format!(
|
||||
"VPN worker failed to start within {:.1}s. pid={:?}, process_running={}, local_url={:?}\n\nVPN worker log:\n{}",
|
||||
startup_timeout.as_secs_f32(),
|
||||
config.pid,
|
||||
process_running,
|
||||
config.local_url,
|
||||
log_output
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
delete_vpn_worker_config(id);
|
||||
return Err("VPN worker config not found after spawn".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn start_vpn_worker(vpn_id: &str) -> Result<VpnWorkerConfig, Box<dyn std::error::Error>> {
|
||||
for config in list_vpn_worker_configs() {
|
||||
if let Some(pid) = config.pid {
|
||||
if !is_process_running(pid) {
|
||||
delete_vpn_worker_config(&config.id);
|
||||
}
|
||||
} else {
|
||||
delete_vpn_worker_config(&config.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if a VPN worker for this vpn_id already exists and is running
|
||||
if let Some(existing) = find_vpn_worker_by_vpn_id(vpn_id) {
|
||||
if let Some(pid) = existing.pid {
|
||||
if is_process_running(pid) {
|
||||
return Ok(existing);
|
||||
if vpn_worker_accepting_connections(&existing).await {
|
||||
return Ok(existing);
|
||||
}
|
||||
|
||||
return wait_for_vpn_worker_ready(&existing.id, &existing.vpn_type).await;
|
||||
}
|
||||
}
|
||||
// Worker config exists but process is dead, clean up
|
||||
@@ -63,7 +176,7 @@ pub async fn start_vpn_worker(vpn_id: &str) -> Result<VpnWorkerConfig, Box<dyn s
|
||||
save_vpn_worker_config(&config)?;
|
||||
|
||||
// Spawn detached VPN worker process
|
||||
let exe = std::env::current_exe()?;
|
||||
let exe = find_sidecar_executable("donut-proxy")?;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
@@ -149,50 +262,7 @@ pub async fn start_vpn_worker(vpn_id: &str) -> Result<VpnWorkerConfig, Box<dyn s
|
||||
drop(child);
|
||||
}
|
||||
|
||||
// Wait for the worker to update config with local_url
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
||||
|
||||
let mut attempts = 0;
|
||||
let max_attempts = 100; // 10 seconds max
|
||||
|
||||
loop {
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
||||
|
||||
if let Some(updated_config) = get_vpn_worker_config(&id) {
|
||||
if let Some(ref local_url) = updated_config.local_url {
|
||||
if !local_url.is_empty() {
|
||||
if let Some(port) = updated_config.local_port {
|
||||
if let Ok(Ok(_)) = tokio::time::timeout(
|
||||
tokio::time::Duration::from_millis(100),
|
||||
tokio::net::TcpStream::connect(("127.0.0.1", port)),
|
||||
)
|
||||
.await
|
||||
{
|
||||
return Ok(updated_config);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
attempts += 1;
|
||||
if attempts >= max_attempts {
|
||||
if let Some(config) = get_vpn_worker_config(&id) {
|
||||
let process_running = config.pid.map(is_process_running).unwrap_or(false);
|
||||
// Clean up on failure
|
||||
delete_vpn_worker_config(&id);
|
||||
return Err(
|
||||
format!(
|
||||
"VPN worker failed to start in time. pid={:?}, process_running={}, local_url={:?}",
|
||||
config.pid, process_running, config.local_url
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
delete_vpn_worker_config(&id);
|
||||
return Err("VPN worker config not found after spawn".into());
|
||||
}
|
||||
}
|
||||
wait_for_vpn_worker_ready(&id, vpn_type_str).await
|
||||
}
|
||||
|
||||
pub async fn stop_vpn_worker(id: &str) -> Result<bool, Box<dyn std::error::Error>> {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { emit } from "@tauri-apps/api/event";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -51,6 +53,14 @@ interface OpenVpnFormData {
|
||||
rawConfig: string;
|
||||
}
|
||||
|
||||
interface VpnDependencyStatus {
|
||||
isAvailable: boolean;
|
||||
requiresExternalInstall: boolean;
|
||||
missingBinary: boolean;
|
||||
missingWindowsAdapter: boolean;
|
||||
dependencyCheckFailed: boolean;
|
||||
}
|
||||
|
||||
const defaultWireGuardForm: WireGuardFormData = {
|
||||
name: "",
|
||||
privateKey: "",
|
||||
@@ -92,12 +102,15 @@ export function VpnFormDialog({
|
||||
onClose,
|
||||
editingVpn,
|
||||
}: VpnFormDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [vpnType, setVpnType] = useState<VpnType>("WireGuard");
|
||||
const [wireGuardForm, setWireGuardForm] =
|
||||
useState<WireGuardFormData>(defaultWireGuardForm);
|
||||
const [openVpnForm, setOpenVpnForm] =
|
||||
useState<OpenVpnFormData>(defaultOpenVpnForm);
|
||||
const [vpnDependencyStatus, setVpnDependencyStatus] =
|
||||
useState<VpnDependencyStatus | null>(null);
|
||||
|
||||
const resetForms = useCallback(() => {
|
||||
setVpnType("WireGuard");
|
||||
@@ -120,6 +133,32 @@ export function VpnFormDialog({
|
||||
}
|
||||
}, [isOpen, editingVpn, resetForms]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setVpnDependencyStatus(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
void invoke<VpnDependencyStatus>("get_vpn_dependency_status", { vpnType })
|
||||
.then((status) => {
|
||||
if (!cancelled) {
|
||||
setVpnDependencyStatus(status);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to load VPN dependency status:", error);
|
||||
if (!cancelled) {
|
||||
setVpnDependencyStatus(null);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isOpen, vpnType]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (!isSubmitting) {
|
||||
onClose();
|
||||
@@ -258,6 +297,36 @@ export function VpnFormDialog({
|
||||
? "Enter your WireGuard interface and peer details."
|
||||
: "Paste your .ovpn configuration file content.";
|
||||
|
||||
let dependencyWarningTitle: string | null = null;
|
||||
let dependencyWarningDescription: string | null = null;
|
||||
|
||||
if (
|
||||
vpnType === "OpenVPN" &&
|
||||
vpnDependencyStatus?.requiresExternalInstall &&
|
||||
!vpnDependencyStatus.isAvailable
|
||||
) {
|
||||
if (vpnDependencyStatus.missingBinary) {
|
||||
dependencyWarningTitle = t("vpnForm.dependencies.openVpnMissingTitle");
|
||||
dependencyWarningDescription = t(
|
||||
"vpnForm.dependencies.openVpnMissingDescription",
|
||||
);
|
||||
} else if (vpnDependencyStatus.missingWindowsAdapter) {
|
||||
dependencyWarningTitle = t(
|
||||
"vpnForm.dependencies.openVpnAdapterMissingTitle",
|
||||
);
|
||||
dependencyWarningDescription = t(
|
||||
"vpnForm.dependencies.openVpnAdapterMissingDescription",
|
||||
);
|
||||
} else if (vpnDependencyStatus.dependencyCheckFailed) {
|
||||
dependencyWarningTitle = t(
|
||||
"vpnForm.dependencies.openVpnCheckFailedTitle",
|
||||
);
|
||||
dependencyWarningDescription = t(
|
||||
"vpnForm.dependencies.openVpnCheckFailedDescription",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-lg">
|
||||
@@ -268,6 +337,17 @@ export function VpnFormDialog({
|
||||
|
||||
<ScrollArea className="max-h-[60vh] pr-4">
|
||||
<div className="grid gap-4 py-2">
|
||||
{dependencyWarningTitle && dependencyWarningDescription && (
|
||||
<Alert className="border-warning/50 bg-warning/10">
|
||||
<AlertTitle className="text-warning">
|
||||
{dependencyWarningTitle}
|
||||
</AlertTitle>
|
||||
<AlertDescription className="text-warning">
|
||||
{dependencyWarningDescription}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!editingVpn && (
|
||||
<div className="grid gap-2">
|
||||
<Label>VPN Type</Label>
|
||||
|
||||
@@ -794,6 +794,16 @@
|
||||
"button": "Clone"
|
||||
}
|
||||
},
|
||||
"vpnForm": {
|
||||
"dependencies": {
|
||||
"openVpnMissingTitle": "OpenVPN is not installed",
|
||||
"openVpnMissingDescription": "You can save this configuration, but Donut Browser cannot connect it until OpenVPN is installed on this device.",
|
||||
"openVpnAdapterMissingTitle": "OpenVPN adapter is missing",
|
||||
"openVpnAdapterMissingDescription": "OpenVPN is installed, but no TAP/Wintun/ovpn-dco adapter was found. Repair or reinstall OpenVPN before connecting on Windows.",
|
||||
"openVpnCheckFailedTitle": "OpenVPN install could not be verified",
|
||||
"openVpnCheckFailedDescription": "Donut Browser could not inspect the local OpenVPN installation. Repair or reinstall OpenVPN before connecting on Windows."
|
||||
}
|
||||
},
|
||||
"extensions": {
|
||||
"title": "Extensions",
|
||||
"description": "Manage browser extensions and extension groups for your profiles.",
|
||||
|
||||
@@ -794,6 +794,16 @@
|
||||
"button": "Clonar"
|
||||
}
|
||||
},
|
||||
"vpnForm": {
|
||||
"dependencies": {
|
||||
"openVpnMissingTitle": "OpenVPN no está instalado",
|
||||
"openVpnMissingDescription": "Puedes guardar esta configuración, pero Donut Browser no podrá conectarse hasta que OpenVPN esté instalado en este dispositivo.",
|
||||
"openVpnAdapterMissingTitle": "Falta el adaptador de OpenVPN",
|
||||
"openVpnAdapterMissingDescription": "OpenVPN está instalado, pero no se encontró ningún adaptador TAP/Wintun/ovpn-dco. Repara o reinstala OpenVPN antes de conectarte en Windows.",
|
||||
"openVpnCheckFailedTitle": "No se pudo verificar la instalación de OpenVPN",
|
||||
"openVpnCheckFailedDescription": "Donut Browser no pudo inspeccionar la instalación local de OpenVPN. Repara o reinstala OpenVPN antes de conectarte en Windows."
|
||||
}
|
||||
},
|
||||
"extensions": {
|
||||
"title": "Extensiones",
|
||||
"description": "Administra extensiones de navegador y grupos de extensiones para tus perfiles.",
|
||||
|
||||
@@ -794,6 +794,16 @@
|
||||
"button": "Cloner"
|
||||
}
|
||||
},
|
||||
"vpnForm": {
|
||||
"dependencies": {
|
||||
"openVpnMissingTitle": "OpenVPN n'est pas installé",
|
||||
"openVpnMissingDescription": "Vous pouvez enregistrer cette configuration, mais Donut Browser ne pourra pas s'y connecter tant qu'OpenVPN n'est pas installé sur cet appareil.",
|
||||
"openVpnAdapterMissingTitle": "L'adaptateur OpenVPN est manquant",
|
||||
"openVpnAdapterMissingDescription": "OpenVPN est installé, mais aucun adaptateur TAP/Wintun/ovpn-dco n'a été trouvé. Réparez ou réinstallez OpenVPN avant de vous connecter sous Windows.",
|
||||
"openVpnCheckFailedTitle": "L'installation d'OpenVPN n'a pas pu être vérifiée",
|
||||
"openVpnCheckFailedDescription": "Donut Browser n'a pas pu inspecter l'installation locale d'OpenVPN. Réparez ou réinstallez OpenVPN avant de vous connecter sous Windows."
|
||||
}
|
||||
},
|
||||
"extensions": {
|
||||
"title": "Extensions",
|
||||
"description": "Gérez les extensions de navigateur et les groupes d'extensions pour vos profils.",
|
||||
|
||||
@@ -794,6 +794,16 @@
|
||||
"button": "複製"
|
||||
}
|
||||
},
|
||||
"vpnForm": {
|
||||
"dependencies": {
|
||||
"openVpnMissingTitle": "OpenVPN がインストールされていません",
|
||||
"openVpnMissingDescription": "この設定は保存できますが、このデバイスに OpenVPN がインストールされるまで Donut Browser では接続できません。",
|
||||
"openVpnAdapterMissingTitle": "OpenVPN アダプターが見つかりません",
|
||||
"openVpnAdapterMissingDescription": "OpenVPN はインストールされていますが、TAP/Wintun/ovpn-dco アダプターが見つかりませんでした。Windows で接続する前に OpenVPN を修復または再インストールしてください。",
|
||||
"openVpnCheckFailedTitle": "OpenVPN のインストールを確認できませんでした",
|
||||
"openVpnCheckFailedDescription": "Donut Browser はローカルの OpenVPN インストールを確認できませんでした。Windows で接続する前に OpenVPN を修復または再インストールしてください。"
|
||||
}
|
||||
},
|
||||
"extensions": {
|
||||
"title": "拡張機能",
|
||||
"description": "プロファイル用のブラウザ拡張機能と拡張機能グループを管理します。",
|
||||
|
||||
@@ -794,6 +794,16 @@
|
||||
"button": "Clonar"
|
||||
}
|
||||
},
|
||||
"vpnForm": {
|
||||
"dependencies": {
|
||||
"openVpnMissingTitle": "OpenVPN não está instalado",
|
||||
"openVpnMissingDescription": "Você pode salvar esta configuração, mas o Donut Browser não poderá se conectar até que o OpenVPN esteja instalado neste dispositivo.",
|
||||
"openVpnAdapterMissingTitle": "O adaptador do OpenVPN está ausente",
|
||||
"openVpnAdapterMissingDescription": "O OpenVPN está instalado, mas nenhum adaptador TAP/Wintun/ovpn-dco foi encontrado. Repare ou reinstale o OpenVPN antes de se conectar no Windows.",
|
||||
"openVpnCheckFailedTitle": "Não foi possível verificar a instalação do OpenVPN",
|
||||
"openVpnCheckFailedDescription": "O Donut Browser não conseguiu inspecionar a instalação local do OpenVPN. Repare ou reinstale o OpenVPN antes de se conectar no Windows."
|
||||
}
|
||||
},
|
||||
"extensions": {
|
||||
"title": "Extensões",
|
||||
"description": "Gerencie extensões de navegador e grupos de extensões para seus perfis.",
|
||||
|
||||
@@ -794,6 +794,16 @@
|
||||
"button": "Клонировать"
|
||||
}
|
||||
},
|
||||
"vpnForm": {
|
||||
"dependencies": {
|
||||
"openVpnMissingTitle": "OpenVPN не установлен",
|
||||
"openVpnMissingDescription": "Вы можете сохранить эту конфигурацию, но Donut Browser не сможет подключиться, пока OpenVPN не будет установлен на этом устройстве.",
|
||||
"openVpnAdapterMissingTitle": "Отсутствует адаптер OpenVPN",
|
||||
"openVpnAdapterMissingDescription": "OpenVPN установлен, но адаптер TAP/Wintun/ovpn-dco не найден. Восстановите или переустановите OpenVPN перед подключением в Windows.",
|
||||
"openVpnCheckFailedTitle": "Не удалось проверить установку OpenVPN",
|
||||
"openVpnCheckFailedDescription": "Donut Browser не смог проверить локальную установку OpenVPN. Восстановите или переустановите OpenVPN перед подключением в Windows."
|
||||
}
|
||||
},
|
||||
"extensions": {
|
||||
"title": "Расширения",
|
||||
"description": "Управляйте расширениями браузера и группами расширений для ваших профилей.",
|
||||
|
||||
@@ -794,6 +794,16 @@
|
||||
"button": "克隆"
|
||||
}
|
||||
},
|
||||
"vpnForm": {
|
||||
"dependencies": {
|
||||
"openVpnMissingTitle": "未安装 OpenVPN",
|
||||
"openVpnMissingDescription": "你现在可以保存这个配置,但在此设备上安装 OpenVPN 之前,Donut Browser 无法连接它。",
|
||||
"openVpnAdapterMissingTitle": "缺少 OpenVPN 适配器",
|
||||
"openVpnAdapterMissingDescription": "已安装 OpenVPN,但未找到 TAP/Wintun/ovpn-dco 适配器。在 Windows 上连接前,请修复或重新安装 OpenVPN。",
|
||||
"openVpnCheckFailedTitle": "无法验证 OpenVPN 安装",
|
||||
"openVpnCheckFailedDescription": "Donut Browser 无法检查本机 OpenVPN 安装。在 Windows 上连接前,请修复或重新安装 OpenVPN。"
|
||||
}
|
||||
},
|
||||
"extensions": {
|
||||
"title": "扩展程序",
|
||||
"description": "管理配置文件的浏览器扩展程序和扩展程序组。",
|
||||
|
||||
Reference in New Issue
Block a user