Skip to content

Commit 48883dd

Browse files
committed
refactor: more robust vpn handling
1 parent ac5d975 commit 48883dd

19 files changed

Lines changed: 1936 additions & 203 deletions

File tree

.github/workflows/lint-rs.yml

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ jobs:
6767
if: matrix.os == 'ubuntu-22.04'
6868
run: |
6969
sudo apt-get update
70-
sudo apt install libwebkit2gtk-4.1-dev build-essential curl wget file libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev
70+
sudo apt install libwebkit2gtk-4.1-dev build-essential curl wget file libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev openvpn
7171
7272
- name: Install frontend dependencies
7373
run: pnpm install --frozen-lockfile
@@ -117,6 +117,16 @@ jobs:
117117
run: cargo test --lib && cargo test --test donut_proxy_integration && cargo test --test vpn_integration
118118
working-directory: src-tauri
119119

120+
- name: Run OpenVPN e2e test (Ubuntu only)
121+
if: matrix.os == 'ubuntu-22.04'
122+
shell: bash
123+
working-directory: src-tauri
124+
env:
125+
DONUTBROWSER_RUN_OPENVPN_E2E: "1"
126+
run: |
127+
sudo --preserve-env=PATH,CARGO_HOME,RUSTUP_HOME,DONUTBROWSER_RUN_OPENVPN_E2E \
128+
cargo test --test vpn_integration test_openvpn_traffic_flows_through_donut_proxy -- --nocapture
129+
120130
- name: Run Rust sync e2e tests
121131
run: node scripts/sync-test-harness.mjs
122132

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
"start": "next start",
1111
"test": "pnpm test:rust:unit && pnpm test:sync-e2e",
1212
"test:rust": "cd src-tauri && cargo test",
13-
"test:rust:unit": "cd src-tauri && cargo test --lib && cargo test --test donut_proxy_integration",
13+
"test:rust:unit": "cd src-tauri && cargo test --lib && cargo test --test donut_proxy_integration && cargo test --test vpn_integration",
1414
"test:sync-e2e": "node scripts/sync-test-harness.mjs",
1515
"lint": "pnpm lint:js && pnpm lint:rust && pnpm lint:spell",
1616
"lint:js": "biome check src/ && tsc --noEmit && cd donut-sync && biome check src/ && tsc --noEmit",

src-tauri/src/ip_utils.rs

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ pub async fn fetch_public_ip(proxy: Option<&str>) -> Result<String, IpError> {
5555
let proxy = reqwest::Proxy::all(proxy_url)
5656
.map_err(|e| IpError::Network(format!("Invalid proxy: {}", e)))?;
5757
client_builder
58+
.no_proxy()
5859
.proxy(proxy)
5960
.build()
6061
.map_err(|e| IpError::Network(e.to_string()))?
@@ -64,7 +65,7 @@ pub async fn fetch_public_ip(proxy: Option<&str>) -> Result<String, IpError> {
6465
.map_err(|e| IpError::Network(e.to_string()))?
6566
};
6667

67-
let mut last_error = None;
68+
let mut errors = Vec::new();
6869

6970
for url in &urls {
7071
match client.get(*url).send().await {
@@ -76,21 +77,29 @@ pub async fn fetch_public_ip(proxy: Option<&str>) -> Result<String, IpError> {
7677
}
7778
}
7879
Err(e) => {
79-
last_error = Some(format!("Failed to read response from {}: {}", url, e));
80+
errors.push(format!("{}: {}", url, e));
8081
}
8182
},
8283
Ok(response) => {
83-
last_error = Some(format!("HTTP {} from {}", response.status(), url));
84+
errors.push(format!("{}: HTTP {}", url, response.status()));
8485
}
8586
Err(e) => {
86-
last_error = Some(format!("Request to {} failed: {}", url, e));
87+
errors.push(format!("{}: {}", url, e));
8788
}
8889
}
8990
}
9091

91-
Err(IpError::Network(last_error.unwrap_or_else(|| {
92-
"Failed to fetch public IP from any endpoint".to_string()
93-
})))
92+
if errors.is_empty() {
93+
Err(IpError::Network(
94+
"Failed to fetch public IP from any endpoint".to_string(),
95+
))
96+
} else {
97+
Err(IpError::Network(format!(
98+
"All {} endpoints failed: {}",
99+
errors.len(),
100+
errors.join("; ")
101+
)))
102+
}
94103
}
95104

96105
#[cfg(test)]

src-tauri/src/lib.rs

Lines changed: 103 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -813,6 +813,42 @@ async fn download_geoip_database(app_handle: tauri::AppHandle) -> Result<(), Str
813813
}
814814

815815
// VPN commands
816+
#[derive(serde::Serialize)]
817+
#[serde(rename_all = "camelCase")]
818+
struct VpnDependencyStatus {
819+
is_available: bool,
820+
requires_external_install: bool,
821+
missing_binary: bool,
822+
missing_windows_adapter: bool,
823+
dependency_check_failed: bool,
824+
}
825+
826+
#[tauri::command]
827+
async fn get_vpn_dependency_status(vpn_type: vpn::VpnType) -> Result<VpnDependencyStatus, String> {
828+
match vpn_type {
829+
vpn::VpnType::WireGuard => Ok(VpnDependencyStatus {
830+
is_available: true,
831+
requires_external_install: false,
832+
missing_binary: false,
833+
missing_windows_adapter: false,
834+
dependency_check_failed: false,
835+
}),
836+
vpn::VpnType::OpenVPN => {
837+
let status = crate::vpn::openvpn_socks5::OpenVpnSocks5Server::dependency_status();
838+
let is_available =
839+
status.binary_found && !status.missing_windows_adapter && !status.dependency_check_failed;
840+
841+
Ok(VpnDependencyStatus {
842+
is_available,
843+
requires_external_install: true,
844+
missing_binary: !status.binary_found,
845+
missing_windows_adapter: status.missing_windows_adapter,
846+
dependency_check_failed: status.dependency_check_failed,
847+
})
848+
}
849+
}
850+
}
851+
816852
#[tauri::command]
817853
async fn import_vpn_config(
818854
content: String,
@@ -986,45 +1022,81 @@ async fn check_vpn_validity(
9861022
.unwrap_or_default()
9871023
.as_secs();
9881024

989-
// Start a temporary VPN worker to send real traffic
1025+
let had_existing_worker = vpn_worker_storage::find_vpn_worker_by_vpn_id(&vpn_id).is_some();
1026+
9901027
let vpn_worker = vpn_worker_runner::start_vpn_worker(&vpn_id)
9911028
.await
9921029
.map_err(|e| format!("Failed to start VPN worker: {e}"))?;
9931030

994-
let socks_url = format!("socks5://127.0.0.1:{}", vpn_worker.local_port.unwrap_or(0));
995-
996-
// Fetch public IP through the VPN SOCKS5 proxy
997-
let result = match ip_utils::fetch_public_ip(Some(&socks_url)).await {
998-
Ok(ip) => {
999-
let (city, country, country_code) =
1000-
crate::proxy_manager::ProxyManager::get_ip_geolocation(&ip)
1001-
.await
1002-
.unwrap_or_default();
1003-
1004-
crate::proxy_manager::ProxyCheckResult {
1005-
ip,
1006-
city,
1007-
country,
1008-
country_code,
1009-
timestamp: now,
1010-
is_valid: true,
1031+
let socks_url = format!(
1032+
"socks5://127.0.0.1:{}",
1033+
vpn_worker.local_port.unwrap_or_default()
1034+
);
1035+
1036+
let local_proxy = crate::proxy_runner::start_proxy_process(Some(socks_url), None)
1037+
.await
1038+
.map_err(|error| error.to_string());
1039+
let local_proxy = match local_proxy {
1040+
Ok(proxy) => proxy,
1041+
Err(error_message) => {
1042+
if !had_existing_worker {
1043+
let _ = vpn_worker_runner::stop_vpn_worker(&vpn_worker.id).await;
10111044
}
1045+
return Err(format!("Failed to start validation proxy: {error_message}"));
10121046
}
1013-
Err(e) => {
1014-
log::warn!("VPN check failed to fetch public IP: {e}");
1015-
crate::proxy_manager::ProxyCheckResult {
1016-
ip: String::new(),
1017-
city: None,
1018-
country: None,
1019-
country_code: None,
1020-
timestamp: now,
1021-
is_valid: false,
1047+
};
1048+
1049+
let local_proxy_url = format!(
1050+
"http://127.0.0.1:{}",
1051+
local_proxy.local_port.unwrap_or_default()
1052+
);
1053+
1054+
let mut result = None;
1055+
for attempt in 0..3 {
1056+
if attempt > 0 {
1057+
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
1058+
}
1059+
1060+
match ip_utils::fetch_public_ip(Some(&local_proxy_url)).await {
1061+
Ok(ip) => {
1062+
let (city, country, country_code) =
1063+
crate::proxy_manager::ProxyManager::get_ip_geolocation(&ip)
1064+
.await
1065+
.unwrap_or_default();
1066+
1067+
result = Some(crate::proxy_manager::ProxyCheckResult {
1068+
ip,
1069+
city,
1070+
country,
1071+
country_code,
1072+
timestamp: now,
1073+
is_valid: true,
1074+
});
1075+
break;
1076+
}
1077+
Err(error) => {
1078+
log::warn!(
1079+
"VPN validation attempt {} failed to fetch public IP through donut-proxy: {}",
1080+
attempt + 1,
1081+
error
1082+
);
10221083
}
10231084
}
1024-
};
1085+
}
10251086

1026-
// Stop the temporary VPN worker
1027-
let _ = vpn_worker_runner::stop_vpn_worker(&vpn_worker.id).await;
1087+
let _ = crate::proxy_runner::stop_proxy_process(&local_proxy.id).await;
1088+
if !had_existing_worker {
1089+
let _ = vpn_worker_runner::stop_vpn_worker(&vpn_worker.id).await;
1090+
}
1091+
1092+
let result = result.unwrap_or(crate::proxy_manager::ProxyCheckResult {
1093+
ip: String::new(),
1094+
city: None,
1095+
country: None,
1096+
country_code: None,
1097+
timestamp: now,
1098+
is_valid: false,
1099+
});
10281100

10291101
Ok(result)
10301102
}
@@ -1932,6 +2004,7 @@ pub fn run() {
19322004
add_mcp_to_claude_code,
19332005
remove_mcp_from_claude_code,
19342006
// VPN commands
2007+
get_vpn_dependency_status,
19352008
import_vpn_config,
19362009
list_vpn_configs,
19372010
get_vpn_config,

0 commit comments

Comments
 (0)