mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-09-22 17:30:52 +02:00
refactor: vpn
This commit is contained in:
+3
-3
@@ -2,7 +2,7 @@
|
|||||||
"name": "donutbrowser",
|
"name": "donutbrowser",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"version": "0.20.3",
|
"version": "0.20.4",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack -p 12341",
|
"dev": "next dev --turbopack -p 12341",
|
||||||
@@ -48,8 +48,8 @@
|
|||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@tauri-apps/api": "~2.10.1",
|
"@tauri-apps/api": "~2.10.1",
|
||||||
"@tauri-apps/plugin-deep-link": "^2.4.7",
|
"@tauri-apps/plugin-deep-link": "^2.4.7",
|
||||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
"@tauri-apps/plugin-dialog": "^2.7.0",
|
||||||
"@tauri-apps/plugin-fs": "~2.4.5",
|
"@tauri-apps/plugin-fs": "~2.5.0",
|
||||||
"@tauri-apps/plugin-log": "^2.8.0",
|
"@tauri-apps/plugin-log": "^2.8.0",
|
||||||
"@tauri-apps/plugin-opener": "^2.5.3",
|
"@tauri-apps/plugin-opener": "^2.5.3",
|
||||||
"ahooks": "^3.9.7",
|
"ahooks": "^3.9.7",
|
||||||
|
|||||||
Generated
+10
-10
@@ -58,11 +58,11 @@ importers:
|
|||||||
specifier: ^2.4.7
|
specifier: ^2.4.7
|
||||||
version: 2.4.7
|
version: 2.4.7
|
||||||
'@tauri-apps/plugin-dialog':
|
'@tauri-apps/plugin-dialog':
|
||||||
specifier: ^2.6.0
|
specifier: ^2.7.0
|
||||||
version: 2.6.0
|
version: 2.7.0
|
||||||
'@tauri-apps/plugin-fs':
|
'@tauri-apps/plugin-fs':
|
||||||
specifier: ~2.4.5
|
specifier: ~2.5.0
|
||||||
version: 2.4.5
|
version: 2.5.0
|
||||||
'@tauri-apps/plugin-log':
|
'@tauri-apps/plugin-log':
|
||||||
specifier: ^2.8.0
|
specifier: ^2.8.0
|
||||||
version: 2.8.0
|
version: 2.8.0
|
||||||
@@ -2755,11 +2755,11 @@ packages:
|
|||||||
'@tauri-apps/plugin-deep-link@2.4.7':
|
'@tauri-apps/plugin-deep-link@2.4.7':
|
||||||
resolution: {integrity: sha512-K0FQlLM6BoV7Ws2xfkh+Tnwi5VZVdkI4Vw/3AGLSf0Xvu2y86AMBzd9w/SpzKhw9ai2B6ES8di/OoGDCExkOzg==}
|
resolution: {integrity: sha512-K0FQlLM6BoV7Ws2xfkh+Tnwi5VZVdkI4Vw/3AGLSf0Xvu2y86AMBzd9w/SpzKhw9ai2B6ES8di/OoGDCExkOzg==}
|
||||||
|
|
||||||
'@tauri-apps/plugin-dialog@2.6.0':
|
'@tauri-apps/plugin-dialog@2.7.0':
|
||||||
resolution: {integrity: sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==}
|
resolution: {integrity: sha512-4nS/hfGMGCXiAS3LtVjH9AgsSAPJeG/7R+q8agTFqytjnMa4Zq95Bq8WzVDkckpanX+yyRHXnRtrKXkANKDHvw==}
|
||||||
|
|
||||||
'@tauri-apps/plugin-fs@2.4.5':
|
'@tauri-apps/plugin-fs@2.5.0':
|
||||||
resolution: {integrity: sha512-dVxWWGE6VrOxC7/jlhyE+ON/Cc2REJlM35R3PJX3UvFw2XwYhLGQVAIyrehenDdKjotipjYEVc4YjOl3qq90fA==}
|
resolution: {integrity: sha512-c83kbz61AK+rKjhS+je9+stIO27nXj7p9cqeg36TwkIUtxpCFTttlHHtqon6h6FN54cXjyAjlMPOJcW3mwE5XQ==}
|
||||||
|
|
||||||
'@tauri-apps/plugin-log@2.8.0':
|
'@tauri-apps/plugin-log@2.8.0':
|
||||||
resolution: {integrity: sha512-a+7rOq3MJwpTOLLKbL8d0qGZ85hgHw5pNOWusA9o3cf7cEgtYHiGY/+O8fj8MvywQIGqFv0da2bYQDlrqLE7rw==}
|
resolution: {integrity: sha512-a+7rOq3MJwpTOLLKbL8d0qGZ85hgHw5pNOWusA9o3cf7cEgtYHiGY/+O8fj8MvywQIGqFv0da2bYQDlrqLE7rw==}
|
||||||
@@ -8397,11 +8397,11 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@tauri-apps/api': 2.10.1
|
'@tauri-apps/api': 2.10.1
|
||||||
|
|
||||||
'@tauri-apps/plugin-dialog@2.6.0':
|
'@tauri-apps/plugin-dialog@2.7.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tauri-apps/api': 2.10.1
|
'@tauri-apps/api': 2.10.1
|
||||||
|
|
||||||
'@tauri-apps/plugin-fs@2.4.5':
|
'@tauri-apps/plugin-fs@2.5.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tauri-apps/api': 2.10.1
|
'@tauri-apps/api': 2.10.1
|
||||||
|
|
||||||
|
|||||||
@@ -716,29 +716,22 @@ impl SyncScheduler {
|
|||||||
match entity_type.as_str() {
|
match entity_type.as_str() {
|
||||||
"profile" => {
|
"profile" => {
|
||||||
let profile_manager = ProfileManager::instance();
|
let profile_manager = ProfileManager::instance();
|
||||||
let profile_to_delete = {
|
let has_profile = {
|
||||||
if let Ok(profiles) = profile_manager.list_profiles() {
|
if let Ok(profiles) = profile_manager.list_profiles() {
|
||||||
let profile_uuid = uuid::Uuid::parse_str(&entity_id).ok();
|
let profile_uuid = uuid::Uuid::parse_str(&entity_id).ok();
|
||||||
profile_uuid.and_then(|uuid| profiles.into_iter().find(|p| p.id == uuid))
|
profile_uuid.is_some_and(|uuid| profiles.iter().any(|p| p.id == uuid))
|
||||||
} else {
|
} else {
|
||||||
None
|
false
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(mut profile) = profile_to_delete {
|
if has_profile {
|
||||||
log::info!(
|
log::info!(
|
||||||
"Profile {} was deleted remotely, disabling sync locally",
|
"Profile {} was deleted remotely, deleting locally",
|
||||||
entity_id
|
entity_id
|
||||||
);
|
);
|
||||||
profile.sync_mode = crate::profile::types::SyncMode::Disabled;
|
if let Err(e) = profile_manager.delete_profile_local_only(&entity_id) {
|
||||||
if let Err(e) = profile_manager.save_profile(&profile) {
|
log::warn!("Failed to delete tombstoned profile {}: {}", entity_id, e);
|
||||||
log::warn!("Failed to disable sync for profile {}: {}", entity_id, e);
|
|
||||||
} else {
|
|
||||||
log::info!(
|
|
||||||
"Profile {} sync disabled due to remote tombstone (local copy kept)",
|
|
||||||
entity_id
|
|
||||||
);
|
|
||||||
let _ = events::emit("profiles-changed", ());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -240,58 +240,77 @@ impl WireGuardSocks5Server {
|
|||||||
socket: &UdpSocket,
|
socket: &UdpSocket,
|
||||||
peer_addr: SocketAddr,
|
peer_addr: SocketAddr,
|
||||||
) -> Result<(), VpnError> {
|
) -> Result<(), VpnError> {
|
||||||
let mut dst = vec![0u8; 2048];
|
|
||||||
let result = tunn.format_handshake_initiation(&mut dst, false);
|
|
||||||
|
|
||||||
match result {
|
|
||||||
TunnResult::WriteToNetwork(packet) => {
|
|
||||||
socket
|
|
||||||
.send_to(packet, peer_addr)
|
|
||||||
.map_err(|e| VpnError::Connection(format!("Failed to send handshake: {e}")))?;
|
|
||||||
}
|
|
||||||
TunnResult::Err(e) => {
|
|
||||||
return Err(VpnError::Tunnel(format!(
|
|
||||||
"Handshake initiation failed: {e:?}"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
|
|
||||||
socket
|
socket
|
||||||
.set_read_timeout(Some(std::time::Duration::from_secs(10)))
|
.set_read_timeout(Some(std::time::Duration::from_secs(5)))
|
||||||
.map_err(|e| VpnError::Connection(format!("Failed to set timeout: {e}")))?;
|
.map_err(|e| VpnError::Connection(format!("Failed to set timeout: {e}")))?;
|
||||||
|
|
||||||
let mut recv_buf = vec![0u8; 2048];
|
// WireGuard handshakes use UDP which can silently lose packets, especially
|
||||||
match socket.recv_from(&mut recv_buf) {
|
// through Docker port-forwarding layers. Retry the handshake initiation up
|
||||||
Ok((len, _)) => {
|
// to 5 times (25s total) before giving up — the protocol is designed for
|
||||||
let result = tunn.decapsulate(None, &recv_buf[..len], &mut dst);
|
// retransmission and peers handle duplicate initiations correctly.
|
||||||
match result {
|
let max_attempts = 5;
|
||||||
TunnResult::WriteToNetwork(response) => {
|
let mut last_error = String::from("no handshake attempt completed");
|
||||||
socket
|
|
||||||
.send_to(response, peer_addr)
|
for attempt in 1..=max_attempts {
|
||||||
.map_err(|e| VpnError::Connection(format!("Failed to send response: {e}")))?;
|
let mut dst = vec![0u8; 2048];
|
||||||
}
|
let result = tunn.format_handshake_initiation(&mut dst, false);
|
||||||
TunnResult::Done => {}
|
|
||||||
TunnResult::Err(e) => {
|
match result {
|
||||||
return Err(VpnError::Tunnel(format!(
|
TunnResult::WriteToNetwork(packet) => {
|
||||||
"Handshake response failed: {e:?}"
|
socket
|
||||||
)));
|
.send_to(packet, peer_addr)
|
||||||
}
|
.map_err(|e| VpnError::Connection(format!("Failed to send handshake: {e}")))?;
|
||||||
_ => {}
|
|
||||||
}
|
}
|
||||||
|
TunnResult::Err(e) => {
|
||||||
|
return Err(VpnError::Tunnel(format!(
|
||||||
|
"Handshake initiation failed: {e:?}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
|
||||||
return Err(VpnError::Connection(format!(
|
let mut recv_buf = vec![0u8; 2048];
|
||||||
"Handshake timeout or error: {e}"
|
match socket.recv_from(&mut recv_buf) {
|
||||||
)));
|
Ok((len, _)) => {
|
||||||
|
let result = tunn.decapsulate(None, &recv_buf[..len], &mut dst);
|
||||||
|
match result {
|
||||||
|
TunnResult::WriteToNetwork(response) => {
|
||||||
|
socket
|
||||||
|
.send_to(response, peer_addr)
|
||||||
|
.map_err(|e| VpnError::Connection(format!("Failed to send response: {e}")))?;
|
||||||
|
}
|
||||||
|
TunnResult::Done => {}
|
||||||
|
TunnResult::Err(e) => {
|
||||||
|
last_error = format!("handshake response error: {e:?}");
|
||||||
|
log::warn!(
|
||||||
|
"[vpn-worker] Handshake attempt {attempt}/{max_attempts} failed: {last_error}"
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
socket
|
||||||
|
.set_read_timeout(None)
|
||||||
|
.map_err(|e| VpnError::Connection(format!("Failed to clear timeout: {e}")))?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Err(e) if attempt < max_attempts => {
|
||||||
|
log::warn!(
|
||||||
|
"[vpn-worker] Handshake attempt {attempt}/{max_attempts} timed out: {e}, retrying"
|
||||||
|
);
|
||||||
|
last_error = format!("timeout: {e}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
last_error = format!("timeout: {e}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
socket
|
Err(VpnError::Connection(format!(
|
||||||
.set_read_timeout(None)
|
"Handshake failed after {max_attempts} attempts: {last_error}"
|
||||||
.map_err(|e| VpnError::Connection(format!("Failed to clear timeout: {e}")))?;
|
)))
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn run(self, config_id: String) -> Result<(), VpnError> {
|
pub async fn run(self, config_id: String) -> Result<(), VpnError> {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use crate::vpn_worker_storage::{
|
|||||||
use std::process::Stdio;
|
use std::process::Stdio;
|
||||||
|
|
||||||
const VPN_WORKER_POLL_INTERVAL_MS: u64 = 100;
|
const VPN_WORKER_POLL_INTERVAL_MS: u64 = 100;
|
||||||
const VPN_WORKER_STARTUP_TIMEOUT_MS: u64 = 10_000;
|
const VPN_WORKER_STARTUP_TIMEOUT_MS: u64 = 30_000;
|
||||||
const OPENVPN_WORKER_STARTUP_TIMEOUT_MS: u64 = 100_000;
|
const OPENVPN_WORKER_STARTUP_TIMEOUT_MS: u64 = 100_000;
|
||||||
|
|
||||||
async fn vpn_worker_accepting_connections(config: &VpnWorkerConfig) -> bool {
|
async fn vpn_worker_accepting_connections(config: &VpnWorkerConfig) -> bool {
|
||||||
|
|||||||
@@ -90,8 +90,40 @@ pub async fn start_wireguard_server() -> Result<WireGuardTestConfig, String> {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for container to be ready and generate configs
|
// Wait for container to generate configs and bring up the WireGuard interface.
|
||||||
sleep(Duration::from_secs(10)).await;
|
// A fixed sleep is flaky — on busy machines the interface takes longer. Instead
|
||||||
|
// we poll `wg show` inside the container until it reports an active interface,
|
||||||
|
// with a generous upper bound.
|
||||||
|
let wg_ready_deadline = tokio::time::Instant::now() + Duration::from_secs(45);
|
||||||
|
loop {
|
||||||
|
sleep(Duration::from_secs(2)).await;
|
||||||
|
|
||||||
|
// Check if peer config file has been generated
|
||||||
|
let config_check = Command::new("docker")
|
||||||
|
.args(["exec", WG_CONTAINER, "cat", "/config/peer1/peer1.conf"])
|
||||||
|
.output();
|
||||||
|
let config_exists = config_check
|
||||||
|
.as_ref()
|
||||||
|
.map(|o| o.status.success())
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
// Check if WireGuard interface is actually up and listening
|
||||||
|
let wg_check = Command::new("docker")
|
||||||
|
.args(["exec", WG_CONTAINER, "wg", "show"])
|
||||||
|
.output();
|
||||||
|
let wg_up = wg_check
|
||||||
|
.as_ref()
|
||||||
|
.map(|o| o.status.success() && String::from_utf8_lossy(&o.stdout).contains("listening port"))
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if config_exists && wg_up {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if tokio::time::Instant::now() >= wg_ready_deadline {
|
||||||
|
return Err("WireGuard container did not become ready within 45s".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Extract client config from container
|
// Extract client config from container
|
||||||
let config_output = Command::new("docker")
|
let config_output = Command::new("docker")
|
||||||
@@ -107,7 +139,30 @@ pub async fn start_wireguard_server() -> Result<WireGuardTestConfig, String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let config_str = String::from_utf8_lossy(&config_output.stdout).to_string();
|
let config_str = String::from_utf8_lossy(&config_output.stdout).to_string();
|
||||||
parse_wireguard_test_config(&config_str)
|
let mut config = parse_wireguard_test_config(&config_str)?;
|
||||||
|
|
||||||
|
// Start a lightweight HTTP server inside the container on the WireGuard
|
||||||
|
// interface so tests can verify traffic flows through the tunnel without
|
||||||
|
// depending on internet access (Docker Desktop for Mac can't reliably NAT
|
||||||
|
// WireGuard tunnel traffic to the internet). The linuxserver/wireguard
|
||||||
|
// image doesn't have python3 or busybox httpd, but it has nc (netcat).
|
||||||
|
let _ = Command::new("docker")
|
||||||
|
.args([
|
||||||
|
"exec",
|
||||||
|
"-d",
|
||||||
|
WG_CONTAINER,
|
||||||
|
"sh",
|
||||||
|
"-c",
|
||||||
|
r#"while true; do printf "HTTP/1.1 200 OK\r\nContent-Length: 13\r\nConnection: close\r\n\r\nWG-TUNNEL-OK\n" | nc -l -p 8080 2>/dev/null; done"#,
|
||||||
|
])
|
||||||
|
.output();
|
||||||
|
// Give the nc loop a moment to start accepting
|
||||||
|
sleep(Duration::from_millis(500)).await;
|
||||||
|
|
||||||
|
// Extract the server's tunnel IP (first octet group from INTERNAL_SUBNET + .1)
|
||||||
|
config.server_tunnel_ip = "10.64.0.1".to_string();
|
||||||
|
|
||||||
|
Ok(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Start an OpenVPN test server and return client config
|
/// Start an OpenVPN test server and return client config
|
||||||
@@ -282,6 +337,10 @@ pub struct WireGuardTestConfig {
|
|||||||
pub peer_endpoint: String,
|
pub peer_endpoint: String,
|
||||||
pub allowed_ips: Vec<String>,
|
pub allowed_ips: Vec<String>,
|
||||||
pub preshared_key: Option<String>,
|
pub preshared_key: Option<String>,
|
||||||
|
/// IP of the WireGuard server on the tunnel interface (e.g. 10.64.0.1).
|
||||||
|
/// Tests use this to reach an HTTP server inside the container without
|
||||||
|
/// needing internet access from Docker.
|
||||||
|
pub server_tunnel_ip: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// OpenVPN test configuration
|
/// OpenVPN test configuration
|
||||||
@@ -355,6 +414,7 @@ fn parse_wireguard_test_config(content: &str) -> Result<WireGuardTestConfig, Str
|
|||||||
peer_endpoint,
|
peer_endpoint,
|
||||||
allowed_ips,
|
allowed_ips,
|
||||||
preshared_key,
|
preshared_key,
|
||||||
|
server_tunnel_ip: String::new(), // filled in by caller
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -382,6 +442,8 @@ fn get_ci_wireguard_config(host: &str, port: &str) -> Result<WireGuardTestConfig
|
|||||||
peer_endpoint: format!("{host}:{port}"),
|
peer_endpoint: format!("{host}:{port}"),
|
||||||
allowed_ips: vec!["0.0.0.0/0".to_string()],
|
allowed_ips: vec!["0.0.0.0/0".to_string()],
|
||||||
preshared_key: std::env::var("VPN_TEST_WG_PRESHARED_KEY").ok(),
|
preshared_key: std::env::var("VPN_TEST_WG_PRESHARED_KEY").ok(),
|
||||||
|
server_tunnel_ip: std::env::var("VPN_TEST_WG_SERVER_IP")
|
||||||
|
.unwrap_or_else(|_| "10.0.0.1".to_string()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -487,7 +487,6 @@ impl Drop for TestEnvGuard {
|
|||||||
struct ProxyProcess {
|
struct ProxyProcess {
|
||||||
id: String,
|
id: String,
|
||||||
local_port: u16,
|
local_port: u16,
|
||||||
local_url: String,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn ensure_donut_proxy_binary() -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
|
async fn ensure_donut_proxy_binary() -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
|
||||||
@@ -664,10 +663,6 @@ async fn start_proxy_with_upstream(
|
|||||||
Ok(ProxyProcess {
|
Ok(ProxyProcess {
|
||||||
id: config["id"].as_str().ok_or("Missing proxy id")?.to_string(),
|
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_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(),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -696,28 +691,23 @@ async fn raw_http_request_via_proxy(
|
|||||||
url: &str,
|
url: &str,
|
||||||
host_header: &str,
|
host_header: &str,
|
||||||
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
|
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
|
||||||
let mut stream = TcpStream::connect(("127.0.0.1", local_port)).await?;
|
let mut stream = tokio::time::timeout(
|
||||||
|
Duration::from_secs(20),
|
||||||
|
TcpStream::connect(("127.0.0.1", local_port)),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|_| "proxy TCP connect timed out after 20s")??;
|
||||||
|
|
||||||
let request = format!("GET {url} HTTP/1.1\r\nHost: {host_header}\r\nConnection: close\r\n\r\n");
|
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?;
|
stream.write_all(request.as_bytes()).await?;
|
||||||
|
|
||||||
let mut response = Vec::new();
|
let mut response = Vec::new();
|
||||||
stream.read_to_end(&mut response).await?;
|
tokio::time::timeout(Duration::from_secs(20), stream.read_to_end(&mut response))
|
||||||
|
.await
|
||||||
|
.map_err(|_| "proxy HTTP response timed out after 20s")??;
|
||||||
Ok(String::from_utf8_lossy(&response).to_string())
|
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() {
|
async fn cleanup_runtime() {
|
||||||
let _ = donutbrowser_lib::proxy_runner::stop_all_proxy_processes().await;
|
let _ = donutbrowser_lib::proxy_runner::stop_all_proxy_processes().await;
|
||||||
let _ = donutbrowser_lib::vpn_worker_runner::stop_all_vpn_workers().await;
|
let _ = donutbrowser_lib::vpn_worker_runner::stop_all_vpn_workers().await;
|
||||||
@@ -744,6 +734,7 @@ async fn wait_for_file(
|
|||||||
async fn run_proxy_feature_suite(
|
async fn run_proxy_feature_suite(
|
||||||
binary_path: &PathBuf,
|
binary_path: &PathBuf,
|
||||||
vpn_id: &str,
|
vpn_id: &str,
|
||||||
|
server_tunnel_ip: &str,
|
||||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
let vpn_worker = donutbrowser_lib::vpn_worker_runner::start_vpn_worker(vpn_id)
|
let vpn_worker = donutbrowser_lib::vpn_worker_runner::start_vpn_worker(vpn_id)
|
||||||
.await
|
.await
|
||||||
@@ -759,20 +750,20 @@ async fn run_proxy_feature_suite(
|
|||||||
|
|
||||||
sleep(Duration::from_millis(500)).await;
|
sleep(Duration::from_millis(500)).await;
|
||||||
|
|
||||||
|
// Test HTTP traffic through the tunnel to the internal HTTP server running
|
||||||
|
// inside the WireGuard container. This avoids depending on internet access
|
||||||
|
// from Docker (macOS Docker Desktop can't reliably NAT WireGuard tunnel
|
||||||
|
// traffic through to the internet).
|
||||||
|
let internal_url = format!("http://{}:8080/", server_tunnel_ip);
|
||||||
|
let internal_host = format!("{}:8080", server_tunnel_ip);
|
||||||
let http_response =
|
let http_response =
|
||||||
raw_http_request_via_proxy(proxy.local_port, "http://example.com/", "example.com").await?;
|
raw_http_request_via_proxy(proxy.local_port, &internal_url, &internal_host).await?;
|
||||||
assert!(
|
assert!(
|
||||||
http_response.contains("Example Domain"),
|
http_response.contains("WG-TUNNEL-OK"),
|
||||||
"HTTP traffic through donut-proxy+VPN should succeed, got: {}",
|
"HTTP traffic through donut-proxy+VPN tunnel should succeed, got: {}",
|
||||||
&http_response[..http_response.len().min(300)]
|
&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()
|
let stats_file = donutbrowser_lib::app_dirs::cache_dir()
|
||||||
.join("traffic_stats")
|
.join("traffic_stats")
|
||||||
.join(format!("{}.json", profile_id));
|
.join(format!("{}.json", profile_id));
|
||||||
@@ -792,14 +783,16 @@ async fn run_proxy_feature_suite(
|
|||||||
.as_object()
|
.as_object()
|
||||||
.ok_or("Traffic stats are missing per-domain data")?;
|
.ok_or("Traffic stats are missing per-domain data")?;
|
||||||
assert!(
|
assert!(
|
||||||
domains.contains_key("example.com"),
|
domains.contains_key(server_tunnel_ip),
|
||||||
"Traffic stats should include example.com domain activity"
|
"Traffic stats should include tunnel server IP activity, got: {:?}",
|
||||||
|
domains.keys().collect::<Vec<_>>()
|
||||||
);
|
);
|
||||||
|
|
||||||
stop_proxy(binary_path, &proxy.id).await?;
|
stop_proxy(binary_path, &proxy.id).await?;
|
||||||
|
|
||||||
|
// DNS blocklist test: blocklist the tunnel server IP so it gets rejected
|
||||||
let blocklist_file = tempfile::NamedTempFile::new()?;
|
let blocklist_file = tempfile::NamedTempFile::new()?;
|
||||||
std::fs::write(blocklist_file.path(), "example.com\n")?;
|
std::fs::write(blocklist_file.path(), format!("{server_tunnel_ip}\n"))?;
|
||||||
let blocked_proxy = start_proxy_with_upstream(
|
let blocked_proxy = start_proxy_with_upstream(
|
||||||
binary_path,
|
binary_path,
|
||||||
&vpn_upstream,
|
&vpn_upstream,
|
||||||
@@ -808,12 +801,8 @@ async fn run_proxy_feature_suite(
|
|||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let blocked_response = raw_http_request_via_proxy(
|
let blocked_response =
|
||||||
blocked_proxy.local_port,
|
raw_http_request_via_proxy(blocked_proxy.local_port, &internal_url, &internal_host).await?;
|
||||||
"http://example.com/",
|
|
||||||
"example.com",
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
assert!(
|
assert!(
|
||||||
blocked_response.contains("403") || blocked_response.contains("Blocked by DNS blocklist"),
|
blocked_response.contains("403") || blocked_response.contains("Blocked by DNS blocklist"),
|
||||||
"DNS blocklist should be enforced before forwarding to the VPN upstream"
|
"DNS blocklist should be enforced before forwarding to the VPN upstream"
|
||||||
@@ -875,8 +864,8 @@ async fn run_proxy_feature_suite(
|
|||||||
async fn test_wireguard_traffic_flows_through_donut_proxy(
|
async fn test_wireguard_traffic_flows_through_donut_proxy(
|
||||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
let _env = TestEnvGuard::new()?;
|
let _env = TestEnvGuard::new()?;
|
||||||
cleanup_runtime().await;
|
|
||||||
|
|
||||||
|
cleanup_runtime().await;
|
||||||
if !test_harness::is_docker_available() {
|
if !test_harness::is_docker_available() {
|
||||||
eprintln!("skipping WireGuard e2e test because Docker is unavailable");
|
eprintln!("skipping WireGuard e2e test because Docker is unavailable");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -901,8 +890,10 @@ async fn test_wireguard_traffic_flows_through_donut_proxy(
|
|||||||
storage.save_config(&vpn_config)?;
|
storage.save_config(&vpn_config)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let result = run_proxy_feature_suite(&binary_path, &vpn_config.id).await;
|
let result =
|
||||||
|
run_proxy_feature_suite(&binary_path, &vpn_config.id, &wg_config.server_tunnel_ip).await;
|
||||||
cleanup_runtime().await;
|
cleanup_runtime().await;
|
||||||
|
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -952,7 +943,9 @@ async fn test_openvpn_traffic_flows_through_donut_proxy(
|
|||||||
storage.save_config(&vpn_config)?;
|
storage.save_config(&vpn_config)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let result = run_proxy_feature_suite(&binary_path, &vpn_config.id).await;
|
// OpenVPN test uses the server's tunnel IP for internal-only traffic.
|
||||||
|
// The OpenVPN server's subnet is 10.9.0.0/24, server at 10.9.0.1.
|
||||||
|
let result = run_proxy_feature_suite(&binary_path, &vpn_config.id, "10.9.0.1").await;
|
||||||
cleanup_runtime().await;
|
cleanup_runtime().await;
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user