From ce76c1381f97e13b0b0508bff63027fbc6e3cf13 Mon Sep 17 00:00:00 2001 From: zhom <2717306+zhom@users.noreply.github.com> Date: Sun, 26 Apr 2026 23:28:54 +0400 Subject: [PATCH] refactor: vpn refresh and remove openvpn support --- .vscode/settings.json | 1 - AGENTS.md | 2 +- README.md | 2 +- _typos.toml | 1 - package.json | 3 +- scripts/openvpn-test-harness.mjs | 161 ---- src-tauri/src/api_server.rs | 253 +++++++ src-tauri/src/bin/proxy_server.rs | 21 +- src-tauri/src/lib.rs | 37 - src-tauri/src/mcp_server.rs | 6 +- src-tauri/src/vpn/config.rs | 210 +----- src-tauri/src/vpn/mod.rs | 13 +- src-tauri/src/vpn/openvpn.rs | 349 --------- src-tauri/src/vpn/openvpn_socks5.rs | 811 --------------------- src-tauri/src/vpn/socks5_server.rs | 59 +- src-tauri/src/vpn/storage.rs | 25 +- src-tauri/src/vpn_worker_runner.rs | 17 +- src-tauri/src/vpn_worker_storage.rs | 27 +- src-tauri/tests/fixtures/test.ovpn | 39 - src-tauri/tests/test_harness/mod.rs | 209 +----- src-tauri/tests/vpn_integration.rs | 259 ++----- src/components/create-profile-dialog.tsx | 12 +- src/components/profile-data-table.tsx | 8 +- src/components/proxy-assignment-dialog.tsx | 6 +- src/components/proxy-management-dialog.tsx | 6 +- src/components/ui/command.tsx | 2 +- src/components/vpn-form-dialog.tsx | 543 ++++---------- src/components/vpn-import-dialog.tsx | 29 +- src/i18n/locales/en.json | 10 - src/i18n/locales/es.json | 10 - src/i18n/locales/fr.json | 10 - src/i18n/locales/ja.json | 10 - src/i18n/locales/pt.json | 10 - src/i18n/locales/ru.json | 10 - src/i18n/locales/zh.json | 10 - src/types.ts | 2 +- 36 files changed, 613 insertions(+), 2570 deletions(-) delete mode 100644 scripts/openvpn-test-harness.mjs delete mode 100644 src-tauri/src/vpn/openvpn.rs delete mode 100644 src-tauri/src/vpn/openvpn_socks5.rs delete mode 100644 src-tauri/tests/fixtures/test.ovpn diff --git a/.vscode/settings.json b/.vscode/settings.json index 25ad2ed..30d7888 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -191,7 +191,6 @@ "osascript", "oscpu", "outpath", - "OVPN", "pango", "passout", "patchelf", diff --git a/AGENTS.md b/AGENTS.md index 0cc5718..d83c008 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,7 +26,7 @@ donutbrowser/ │ │ ├── api_server.rs # REST API (utoipa + axum) │ │ ├── mcp_server.rs # MCP protocol server │ │ ├── sync/ # Cloud sync (engine, encryption, manifest, scheduler) -│ │ ├── vpn/ # WireGuard & OpenVPN tunnels +│ │ ├── vpn/ # WireGuard tunnels │ │ ├── camoufox/ # Camoufox fingerprint engine (Bayesian network) │ │ ├── wayfern_manager.rs # Wayfern (Chromium) browser management │ │ ├── camoufox_manager.rs # Camoufox (Firefox) browser management diff --git a/README.md b/README.md index afd1f84..d4a25e5 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ - **Unlimited browser profiles** — each fully isolated with its own fingerprint, cookies, extensions, and data - **Chromium & Firefox engines** — Chromium powered by [Wayfern](https://wayfern.com), Firefox powered by [Camoufox](https://camoufox.com), both with advanced fingerprint spoofing - **Proxy support** — HTTP, HTTPS, SOCKS4, SOCKS5 per profile, with dynamic proxy URLs -- **VPN support** — WireGuard and OpenVPN configs per profile +- **VPN support** — WireGuard configs per profile - **Local API & MCP** — REST API and [Model Context Protocol](https://modelcontextprotocol.io) server for integration with Claude, automation tools, and custom workflows - **Profile groups** — organize profiles and apply bulk settings - **Import profiles** — migrate from Chrome, Firefox, Edge, Brave, or other Chromium browsers diff --git a/_typos.toml b/_typos.toml index 0286547..dafceb5 100644 --- a/_typos.toml +++ b/_typos.toml @@ -4,7 +4,6 @@ extend-exclude = [ "src-tauri/src/camoufox/data/*.xml", "src/i18n/locales/*.json", "src-tauri/build.rs", - "src-tauri/tests/fixtures/test.ovpn", ] [default.extend-words] diff --git a/package.json b/package.json index b225c6c..995464e 100644 --- a/package.json +++ b/package.json @@ -8,8 +8,7 @@ "dev": "next dev --turbopack -p 12341", "build": "next build", "start": "next start", - "test": "pnpm test:rust:unit && pnpm test:openvpn-e2e && pnpm test:sync-e2e", - "test:openvpn-e2e": "node scripts/openvpn-test-harness.mjs", + "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 && cargo test --test vpn_integration", "test:sync-e2e": "node scripts/sync-test-harness.mjs", diff --git a/scripts/openvpn-test-harness.mjs b/scripts/openvpn-test-harness.mjs deleted file mode 100644 index 45d8549..0000000 --- a/scripts/openvpn-test-harness.mjs +++ /dev/null @@ -1,161 +0,0 @@ -#!/usr/bin/env node -/** - * OpenVPN E2E Test Harness - * - * This script: - * 1. Skips unless explicitly enabled via DONUTBROWSER_RUN_OPENVPN_E2E=1 - * 2. Builds the Rust vpn_integration test binary without running it - * 3. Runs the OpenVPN e2e test binary under sudo - * - * Usage: DONUTBROWSER_RUN_OPENVPN_E2E=1 node scripts/openvpn-test-harness.mjs - */ - -import { spawn } from "child_process"; -import path from "path"; -import { fileURLToPath } from "url"; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const ROOT_DIR = path.resolve(__dirname, ".."); -const SRC_TAURI_DIR = path.join(ROOT_DIR, "src-tauri"); -const TEST_NAME = "test_openvpn_traffic_flows_through_donut_proxy"; - -function log(message) { - console.log(`[openvpn-harness] ${message}`); -} - -function error(message) { - console.error(`[openvpn-harness] ERROR: ${message}`); -} - -function shouldRun() { - if (process.env.DONUTBROWSER_RUN_OPENVPN_E2E !== "1") { - log("Skipping OpenVPN e2e test because DONUTBROWSER_RUN_OPENVPN_E2E is not set"); - return false; - } - - if (process.platform !== "linux") { - log(`Skipping OpenVPN e2e test on unsupported platform: ${process.platform}`); - return false; - } - - return true; -} - -async function buildTestBinary() { - log("Building OpenVPN e2e test binary..."); - - return new Promise((resolve, reject) => { - let executablePath = ""; - let stdoutBuffer = ""; - - const proc = spawn( - "cargo", - [ - "test", - "--test", - "vpn_integration", - TEST_NAME, - "--no-run", - "--message-format=json", - ], - { - cwd: SRC_TAURI_DIR, - env: process.env, - stdio: ["ignore", "pipe", "pipe"], - } - ); - - const parseBuffer = (flush = false) => { - const lines = stdoutBuffer.split("\n"); - const completeLines = flush ? lines : lines.slice(0, -1); - stdoutBuffer = flush ? "" : lines.at(-1) ?? ""; - - for (const line of completeLines.filter(Boolean)) { - try { - const message = JSON.parse(line); - if (message.reason === "compiler-artifact" && message.executable) { - executablePath = message.executable; - } - } catch { - // Ignore non-JSON lines. - } - } - }; - - proc.stdout.on("data", (data) => { - stdoutBuffer += data.toString(); - parseBuffer(); - }); - - proc.stderr.on("data", (data) => { - process.stderr.write(data); - }); - - proc.on("error", (err) => { - reject(err); - }); - - proc.on("close", (code) => { - parseBuffer(true); - - if (code !== 0) { - reject(new Error(`cargo test --no-run exited with code ${code}`)); - return; - } - - if (!executablePath) { - reject(new Error("Could not determine the vpn_integration test binary path")); - return; - } - - resolve(path.isAbsolute(executablePath) ? executablePath : path.resolve(SRC_TAURI_DIR, executablePath)); - }); - }); -} - -async function runOpenVpnE2e(executablePath) { - log("Running OpenVPN e2e test under sudo..."); - - return new Promise((resolve, reject) => { - const proc = spawn( - "sudo", - [ - "--preserve-env=CI,GITHUB_ACTIONS,VPN_TEST_OVPN_HOST,VPN_TEST_OVPN_PORT,DONUTBROWSER_RUN_OPENVPN_E2E", - executablePath, - TEST_NAME, - "--exact", - "--nocapture", - ], - { - cwd: SRC_TAURI_DIR, - env: process.env, - stdio: "inherit", - } - ); - - proc.on("error", (err) => { - reject(err); - }); - - proc.on("close", (code) => { - resolve(code ?? 1); - }); - }); -} - -async function main() { - if (!shouldRun()) { - process.exit(0); - } - - try { - const executablePath = await buildTestBinary(); - const exitCode = await runOpenVpnE2e(executablePath); - process.exit(exitCode); - } catch (err) { - error(err instanceof Error ? err.message : String(err)); - process.exit(1); - } -} - -main(); diff --git a/src-tauri/src/api_server.rs b/src-tauri/src/api_server.rs index ae903df..abf2e5f 100644 --- a/src-tauri/src/api_server.rs +++ b/src-tauri/src/api_server.rs @@ -130,6 +130,39 @@ struct UpdateProxyRequest { proxy_settings: Option, } +#[derive(Debug, Serialize, Deserialize, ToSchema)] +struct ApiVpnResponse { + id: String, + name: String, + /// Always "WireGuard" + vpn_type: String, + created_at: i64, + last_used: Option, +} + +#[derive(Debug, Deserialize, ToSchema)] +struct ImportVpnRequest { + /// Raw WireGuard `.conf` file content + content: String, + /// Original filename + filename: String, + /// Optional display name; defaults to filename-based name + name: Option, +} + +#[derive(Debug, Deserialize, ToSchema)] +struct CreateVpnRequest { + name: String, + /// Must be "WireGuard" + vpn_type: String, + config_data: String, +} + +#[derive(Debug, Deserialize, ToSchema)] +struct UpdateVpnRequest { + name: String, +} + #[derive(Debug, Deserialize, ToSchema)] struct DownloadBrowserRequest { browser: String, @@ -191,6 +224,12 @@ struct OpenUrlRequest { create_proxy, update_proxy, delete_proxy, + get_vpns, + get_vpn, + import_vpn, + create_vpn, + update_vpn, + delete_vpn, download_browser_api, get_browser_versions, check_browser_downloaded, @@ -207,6 +246,10 @@ struct OpenUrlRequest { ApiProxyResponse, CreateProxyRequest, UpdateProxyRequest, + ApiVpnResponse, + ImportVpnRequest, + CreateVpnRequest, + UpdateVpnRequest, DownloadBrowserRequest, DownloadBrowserResponse, RunProfileResponse, @@ -219,6 +262,7 @@ struct OpenUrlRequest { (name = "groups", description = "Group management endpoints"), (name = "tags", description = "Tag management endpoints"), (name = "proxies", description = "Proxy management endpoints"), + (name = "vpns", description = "VPN management endpoints"), (name = "browsers", description = "Browser management endpoints"), ), modifiers(&SecurityAddon), @@ -311,6 +355,9 @@ impl ApiServer { .routes(routes!(get_tags)) .routes(routes!(get_proxies, create_proxy)) .routes(routes!(get_proxy, update_proxy, delete_proxy)) + .routes(routes!(get_vpns, create_vpn)) + .routes(routes!(import_vpn)) + .routes(routes!(get_vpn, update_vpn, delete_vpn)) .routes(routes!(get_extensions)) .routes(routes!(delete_extension_api)) .routes(routes!(get_extension_groups)) @@ -1189,6 +1236,212 @@ async fn delete_proxy( } } +// API Handlers - VPNs + +fn vpn_to_api_response(c: &crate::vpn::VpnConfig) -> ApiVpnResponse { + ApiVpnResponse { + id: c.id.clone(), + name: c.name.clone(), + vpn_type: c.vpn_type.to_string(), + created_at: c.created_at, + last_used: c.last_used, + } +} + +fn parse_vpn_type(s: &str) -> Option { + match s.to_ascii_lowercase().as_str() { + "wireguard" | "wg" => Some(crate::vpn::VpnType::WireGuard), + _ => None, + } +} + +#[utoipa::path( + get, + path = "/v1/vpns", + responses( + (status = 200, description = "List of all VPN configurations", body = Vec), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error") + ), + security(("bearer_auth" = [])), + tag = "vpns" +)] +async fn get_vpns( + State(_state): State, +) -> Result>, StatusCode> { + let storage = crate::vpn::VPN_STORAGE + .lock() + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let configs = storage + .list_configs() + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + Ok(Json(configs.iter().map(vpn_to_api_response).collect())) +} + +#[utoipa::path( + get, + path = "/v1/vpns/{id}", + params(("id" = String, Path, description = "VPN configuration ID")), + responses( + (status = 200, description = "VPN configuration details", body = ApiVpnResponse), + (status = 401, description = "Unauthorized"), + (status = 404, description = "VPN configuration not found"), + (status = 500, description = "Internal server error") + ), + security(("bearer_auth" = [])), + tag = "vpns" +)] +async fn get_vpn( + Path(id): Path, + State(_state): State, +) -> Result, StatusCode> { + let storage = crate::vpn::VPN_STORAGE + .lock() + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let configs = storage + .list_configs() + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + configs + .iter() + .find(|c| c.id == id) + .map(|c| Json(vpn_to_api_response(c))) + .ok_or(StatusCode::NOT_FOUND) +} + +#[utoipa::path( + post, + path = "/v1/vpns/import", + request_body = ImportVpnRequest, + responses( + (status = 200, description = "VPN configuration imported successfully", body = ApiVpnResponse), + (status = 400, description = "Invalid or unrecognized VPN config"), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error") + ), + security(("bearer_auth" = [])), + tag = "vpns" +)] +async fn import_vpn( + State(_state): State, + Json(request): Json, +) -> Result, StatusCode> { + let result = { + let storage = crate::vpn::VPN_STORAGE + .lock() + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + storage.import_config(&request.content, &request.filename, request.name) + }; + match result { + Ok(config) => { + let _ = events::emit("vpn-configs-changed", ()); + Ok(Json(vpn_to_api_response(&config))) + } + Err(_) => Err(StatusCode::BAD_REQUEST), + } +} + +#[utoipa::path( + post, + path = "/v1/vpns", + request_body = CreateVpnRequest, + responses( + (status = 200, description = "VPN configuration created successfully", body = ApiVpnResponse), + (status = 400, description = "Invalid VPN config or unknown vpn_type"), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error") + ), + security(("bearer_auth" = [])), + tag = "vpns" +)] +async fn create_vpn( + State(_state): State, + Json(request): Json, +) -> Result, StatusCode> { + let vpn_type = parse_vpn_type(&request.vpn_type).ok_or(StatusCode::BAD_REQUEST)?; + let result = { + let storage = crate::vpn::VPN_STORAGE + .lock() + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + storage.create_config_manual(&request.name, vpn_type, &request.config_data) + }; + match result { + Ok(config) => { + let _ = events::emit("vpn-configs-changed", ()); + Ok(Json(vpn_to_api_response(&config))) + } + Err(_) => Err(StatusCode::BAD_REQUEST), + } +} + +#[utoipa::path( + put, + path = "/v1/vpns/{id}", + params(("id" = String, Path, description = "VPN configuration ID")), + request_body = UpdateVpnRequest, + responses( + (status = 200, description = "VPN configuration updated successfully", body = ApiVpnResponse), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "VPN configuration not found"), + (status = 500, description = "Internal server error") + ), + security(("bearer_auth" = [])), + tag = "vpns" +)] +async fn update_vpn( + Path(id): Path, + State(_state): State, + Json(request): Json, +) -> Result, StatusCode> { + let result = { + let storage = crate::vpn::VPN_STORAGE + .lock() + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + storage.update_config_name(&id, &request.name) + }; + match result { + Ok(config) => { + let _ = events::emit("vpn-configs-changed", ()); + Ok(Json(vpn_to_api_response(&config))) + } + Err(_) => Err(StatusCode::NOT_FOUND), + } +} + +#[utoipa::path( + delete, + path = "/v1/vpns/{id}", + params(("id" = String, Path, description = "VPN configuration ID")), + responses( + (status = 204, description = "VPN configuration deleted successfully"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "VPN configuration not found"), + (status = 500, description = "Internal server error") + ), + security(("bearer_auth" = [])), + tag = "vpns" +)] +async fn delete_vpn( + Path(id): Path, + State(_state): State, +) -> Result { + let _ = crate::vpn_worker_runner::stop_vpn_worker_by_vpn_id(&id).await; + + let result = { + let storage = crate::vpn::VPN_STORAGE + .lock() + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + storage.delete_config(&id) + }; + match result { + Ok(_) => { + let _ = events::emit("vpn-configs-changed", ()); + Ok(StatusCode::NO_CONTENT) + } + Err(_) => Err(StatusCode::NOT_FOUND), + } +} + // Extension API endpoints #[utoipa::path( diff --git a/src-tauri/src/bin/proxy_server.rs b/src-tauri/src/bin/proxy_server.rs index bfcb1ea..26651bc 100644 --- a/src-tauri/src/bin/proxy_server.rs +++ b/src-tauri/src/bin/proxy_server.rs @@ -490,23 +490,10 @@ async fn main() { let server = donutbrowser_lib::vpn::socks5_server::WireGuardSocks5Server::new(wg_config, port); - if let Err(e) = server.run(id.clone()).await { - log::error!("VPN worker failed: {}", e); - process::exit(1); - } - } - "openvpn" => { - let ovpn_config = match donutbrowser_lib::vpn::parse_openvpn_config(&vpn_config_data) { - Ok(c) => c, - Err(e) => { - log::error!("Failed to parse OpenVPN config: {}", e); - process::exit(1); - } - }; - - let server = - donutbrowser_lib::vpn::openvpn_socks5::OpenVpnSocks5Server::new(ovpn_config, port); - if let Err(e) = server.run(id.clone()).await { + if let Err(e) = server + .run(id.clone(), config_path.map(std::path::PathBuf::from)) + .await + { log::error!("VPN worker failed: {}", e); process::exit(1); } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index fae88e6..98d4675 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -769,42 +769,6 @@ 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 { - 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, @@ -2075,7 +2039,6 @@ 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, diff --git a/src-tauri/src/mcp_server.rs b/src-tauri/src/mcp_server.rs index 45b91ce..2d0b1b4 100644 --- a/src-tauri/src/mcp_server.rs +++ b/src-tauri/src/mcp_server.rs @@ -848,17 +848,17 @@ impl McpServer { // VPN management tools McpTool { name: "import_vpn".to_string(), - description: "Import a WireGuard (.conf) or OpenVPN (.ovpn) configuration".to_string(), + description: "Import a WireGuard (.conf) configuration".to_string(), input_schema: serde_json::json!({ "type": "object", "properties": { "content": { "type": "string", - "description": "Raw VPN config file content" + "description": "Raw WireGuard config file content" }, "filename": { "type": "string", - "description": "Original filename (.conf or .ovpn) for type detection" + "description": "Original filename (.conf)" }, "name": { "type": "string", diff --git a/src-tauri/src/vpn/config.rs b/src-tauri/src/vpn/config.rs index 2a36830..10cd68f 100644 --- a/src-tauri/src/vpn/config.rs +++ b/src-tauri/src/vpn/config.rs @@ -11,8 +11,6 @@ pub enum VpnError { UnknownFormat, #[error("Invalid WireGuard config: {0}")] InvalidWireGuard(String), - #[error("Invalid OpenVPN config: {0}")] - InvalidOpenVpn(String), #[error("Storage error: {0}")] Storage(String), #[error("Connection error: {0}")] @@ -31,14 +29,12 @@ pub enum VpnError { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum VpnType { WireGuard, - OpenVPN, } impl std::fmt::Display for VpnType { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { VpnType::WireGuard => write!(f, "WireGuard"), - VpnType::OpenVPN => write!(f, "OpenVPN"), } } } @@ -72,19 +68,6 @@ pub struct WireGuardConfig { pub preshared_key: Option, } -/// Parsed OpenVPN configuration -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct OpenVpnConfig { - pub raw_config: String, - pub remote_host: String, - pub remote_port: u16, - pub protocol: String, // "udp" or "tcp" - pub dev_type: String, // "tun" or "tap" - pub has_inline_ca: bool, - pub has_inline_cert: bool, - pub has_inline_key: bool, -} - /// Result of importing a VPN configuration #[derive(Debug, Clone, Serialize, Deserialize)] pub struct VpnImportResult { @@ -110,26 +93,16 @@ pub struct VpnStatus { pub fn detect_vpn_type(content: &str, filename: &str) -> Result { let filename_lower = filename.to_lowercase(); - // Check file extension first - if filename_lower.ends_with(".conf") { - // .conf could be WireGuard - check content - if content.contains("[Interface]") && content.contains("[Peer]") { - return Ok(VpnType::WireGuard); - } - } - - if filename_lower.ends_with(".ovpn") { - return Ok(VpnType::OpenVPN); - } - - // Check content patterns - if content.contains("[Interface]") && content.contains("PrivateKey") && content.contains("[Peer]") + if filename_lower.ends_with(".conf") + && content.contains("[Interface]") + && content.contains("[Peer]") { return Ok(VpnType::WireGuard); } - if content.contains("remote ") && (content.contains("client") || content.contains("dev tun")) { - return Ok(VpnType::OpenVPN); + if content.contains("[Interface]") && content.contains("PrivateKey") && content.contains("[Peer]") + { + return Ok(VpnType::WireGuard); } Err(VpnError::UnknownFormat) @@ -254,75 +227,6 @@ fn validate_wireguard_key(key: &str, field: &str) -> Result<(), VpnError> { Ok(()) } -/// Parse an OpenVPN configuration file -pub fn parse_openvpn_config(content: &str) -> Result { - let mut remote_host = String::new(); - let mut remote_port: u16 = 1194; // Default OpenVPN port - let mut protocol = "udp".to_string(); - let mut dev_type = "tun".to_string(); - - let has_inline_ca = content.contains("") && content.contains(""); - let has_inline_cert = content.contains("") && content.contains(""); - let has_inline_key = content.contains("") && content.contains(""); - - for line in content.lines() { - let line = line.trim(); - - // Skip empty lines and comments - if line.is_empty() || line.starts_with('#') || line.starts_with(';') { - continue; - } - - let parts: Vec<&str> = line.split_whitespace().collect(); - if parts.is_empty() { - continue; - } - - match parts[0] { - "remote" => { - if parts.len() >= 2 { - remote_host = parts[1].to_string(); - } - if let Some(port) = parts.get(2).and_then(|p| p.parse().ok()) { - remote_port = port; - } - if parts.len() >= 4 { - protocol = parts[3].to_string(); - } - } - "proto" if parts.len() >= 2 => { - protocol = parts[1].to_string(); - } - "port" => { - if let Some(port) = parts.get(1).and_then(|p| p.parse().ok()) { - remote_port = port; - } - } - "dev" if parts.len() >= 2 => { - dev_type = parts[1].to_string(); - } - _ => {} - } - } - - if remote_host.is_empty() { - return Err(VpnError::InvalidOpenVpn( - "Missing 'remote' directive".to_string(), - )); - } - - Ok(OpenVpnConfig { - raw_config: content.to_string(), - remote_host, - remote_port, - protocol, - dev_type, - has_inline_ca, - has_inline_cert, - has_inline_key, - }) -} - #[cfg(test)] mod tests { use super::*; @@ -336,15 +240,6 @@ mod tests { ); } - #[test] - fn test_detect_openvpn_by_extension() { - let content = "client\nremote vpn.example.com 1194"; - assert_eq!( - detect_vpn_type(content, "test.ovpn").unwrap(), - VpnType::OpenVPN - ); - } - #[test] fn test_detect_wireguard_by_content() { let content = "[Interface]\nPrivateKey = testkey123\nAddress = 10.0.0.2/24\n\n[Peer]\nPublicKey = peerkey456\nEndpoint = vpn.example.com:51820"; @@ -354,21 +249,19 @@ mod tests { ); } - #[test] - fn test_detect_openvpn_by_content() { - let content = "client\ndev tun\nproto udp\nremote vpn.example.com 1194"; - assert_eq!( - detect_vpn_type(content, "config").unwrap(), - VpnType::OpenVPN - ); - } - #[test] fn test_detect_unknown_format() { let content = "random text that is not a vpn config"; assert!(detect_vpn_type(content, "random.txt").is_err()); } + #[test] + fn test_reject_openvpn_content() { + let content = "client\ndev tun\nproto udp\nremote vpn.example.com 1194"; + assert!(detect_vpn_type(content, "test.ovpn").is_err()); + assert!(detect_vpn_type(content, "config").is_err()); + } + #[test] fn test_parse_wireguard_config() { let content = r#" @@ -444,81 +337,4 @@ Endpoint = 1.2.3.4:51820 assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("PrivateKey")); } - - #[test] - fn test_parse_openvpn_config() { - let content = r#" -client -dev tun -proto udp -remote vpn.example.com 1194 -resolv-retry infinite -nobind -persist-key -persist-tun - ------BEGIN CERTIFICATE----- -...certificate data... ------END CERTIFICATE----- - - ------BEGIN CERTIFICATE----- -...cert data... ------END CERTIFICATE----- - - ------BEGIN PRIVATE KEY----- -...key data... ------END PRIVATE KEY----- - -"#; - - let config = parse_openvpn_config(content).unwrap(); - assert_eq!(config.remote_host, "vpn.example.com"); - assert_eq!(config.remote_port, 1194); - assert_eq!(config.protocol, "udp"); - assert_eq!(config.dev_type, "tun"); - assert!(config.has_inline_ca); - assert!(config.has_inline_cert); - assert!(config.has_inline_key); - } - - #[test] - fn test_parse_openvpn_config_minimal() { - let content = r#" -client -remote vpn.example.com -"#; - - let config = parse_openvpn_config(content).unwrap(); - assert_eq!(config.remote_host, "vpn.example.com"); - assert_eq!(config.remote_port, 1194); // Default - assert_eq!(config.protocol, "udp"); // Default - } - - #[test] - fn test_parse_openvpn_config_with_port_and_proto() { - let content = r#" -client -remote vpn.example.com 443 tcp -"#; - - let config = parse_openvpn_config(content).unwrap(); - assert_eq!(config.remote_host, "vpn.example.com"); - assert_eq!(config.remote_port, 443); - assert_eq!(config.protocol, "tcp"); - } - - #[test] - fn test_parse_openvpn_missing_remote() { - let content = r#" -client -dev tun -proto udp -"#; - - let result = parse_openvpn_config(content); - assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("remote")); - } } diff --git a/src-tauri/src/vpn/mod.rs b/src-tauri/src/vpn/mod.rs index 571b286..710adb2 100644 --- a/src-tauri/src/vpn/mod.rs +++ b/src-tauri/src/vpn/mod.rs @@ -1,23 +1,20 @@ -//! VPN support module for WireGuard and OpenVPN configurations. +//! VPN support module for WireGuard configurations. //! //! This module provides: -//! - VPN config parsing (WireGuard .conf and OpenVPN .ovpn files) +//! - WireGuard config parsing (`.conf` files) //! - Encrypted storage for VPN configurations -//! - Tunnel management with userspace WireGuard (boringtun) and OpenVPN process management +//! - Tunnel management with userspace WireGuard (boringtun) routed through smoltcp mod config; -mod openvpn; -pub mod openvpn_socks5; pub mod socks5_server; mod storage; mod tunnel; mod wireguard; pub use config::{ - detect_vpn_type, parse_openvpn_config, parse_wireguard_config, OpenVpnConfig, VpnConfig, - VpnError, VpnImportResult, VpnStatus, VpnType, WireGuardConfig, + detect_vpn_type, parse_wireguard_config, VpnConfig, VpnError, VpnImportResult, VpnStatus, + VpnType, WireGuardConfig, }; -pub use openvpn::OpenVpnTunnel; pub use storage::VpnStorage; pub use tunnel::{TunnelManager, VpnTunnel}; pub use wireguard::WireGuardTunnel; diff --git a/src-tauri/src/vpn/openvpn.rs b/src-tauri/src/vpn/openvpn.rs deleted file mode 100644 index 0b249cd..0000000 --- a/src-tauri/src/vpn/openvpn.rs +++ /dev/null @@ -1,349 +0,0 @@ -//! OpenVPN tunnel implementation using system openvpn binary. - -use super::config::{OpenVpnConfig, VpnError, VpnStatus}; -use super::tunnel::VpnTunnel; -use async_trait::async_trait; -use chrono::Utc; -use std::io::{BufRead, BufReader}; -use std::path::PathBuf; -use std::process::{Child, Command, Stdio}; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use std::sync::Arc; -use tempfile::NamedTempFile; -use tokio::sync::Mutex; - -/// OpenVPN tunnel implementation -pub struct OpenVpnTunnel { - vpn_id: String, - config: OpenVpnConfig, - process: Arc>>, - config_file: Option, - connected: AtomicBool, - connected_at: Option, - bytes_sent: AtomicU64, - bytes_received: AtomicU64, -} - -impl OpenVpnTunnel { - /// Create a new OpenVPN tunnel - pub fn new(vpn_id: String, config: OpenVpnConfig) -> Self { - Self { - vpn_id, - config, - process: Arc::new(Mutex::new(None)), - config_file: None, - connected: AtomicBool::new(false), - connected_at: None, - bytes_sent: AtomicU64::new(0), - bytes_received: AtomicU64::new(0), - } - } - - /// Find the openvpn binary - fn find_openvpn_binary() -> Result { - // Check common locations - let locations = [ - "/usr/sbin/openvpn", - "/usr/local/sbin/openvpn", - "/opt/homebrew/bin/openvpn", - "/usr/bin/openvpn", - "C:\\Program Files\\OpenVPN\\bin\\openvpn.exe", - "C:\\Program Files (x86)\\OpenVPN\\bin\\openvpn.exe", - ]; - - for loc in &locations { - let path = PathBuf::from(loc); - if path.exists() { - return Ok(path); - } - } - - // Try to find via which/where command - #[cfg(unix)] - { - if let Ok(output) = Command::new("which").arg("openvpn").output() { - if output.status.success() { - let path = String::from_utf8_lossy(&output.stdout).trim().to_string(); - if !path.is_empty() { - return Ok(PathBuf::from(path)); - } - } - } - } - - #[cfg(windows)] - { - use std::os::windows::process::CommandExt; - const CREATE_NO_WINDOW: u32 = 0x08000000; - if let Ok(output) = Command::new("where") - .arg("openvpn") - .creation_flags(CREATE_NO_WINDOW) - .output() - { - if output.status.success() { - let path = String::from_utf8_lossy(&output.stdout) - .lines() - .next() - .unwrap_or("") - .trim() - .to_string(); - if !path.is_empty() { - return Ok(PathBuf::from(path)); - } - } - } - } - - Err(VpnError::Connection( - "OpenVPN binary not found. Please install OpenVPN.".to_string(), - )) - } - - /// Write config to temporary file - fn write_config_file(&mut self) -> Result { - let temp_file = - NamedTempFile::new().map_err(|e| VpnError::Io(std::io::Error::other(e.to_string())))?; - - std::fs::write(temp_file.path(), &self.config.raw_config).map_err(VpnError::Io)?; - - let path = temp_file.path().to_path_buf(); - self.config_file = Some(temp_file); - - Ok(path) - } - - /// Start the OpenVPN process - async fn start_process(&mut self) -> Result<(), VpnError> { - let openvpn_bin = Self::find_openvpn_binary()?; - let config_path = self.write_config_file()?; - - log::info!( - "[vpn] Starting OpenVPN with config: {}", - config_path.display() - ); - - // Build command with common options - let mut cmd = Command::new(&openvpn_bin); - cmd - .arg("--config") - .arg(&config_path) - .arg("--verb") - .arg("3") // Verbosity level - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - - // On Unix, try to avoid requiring root if possible - #[cfg(unix)] - { - cmd.arg("--script-security").arg("2"); - } - - let child = cmd - .spawn() - .map_err(|e| VpnError::Connection(format!("Failed to start OpenVPN: {e}")))?; - - *self.process.lock().await = Some(child); - - // Wait a bit and check if process is still running - tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; - - let mut process_guard = self.process.lock().await; - if let Some(ref mut child) = *process_guard { - match child.try_wait() { - Ok(Some(status)) => { - // Process exited early - let mut error_msg = format!("OpenVPN exited with status: {status}"); - - // Try to get stderr output - if let Some(stderr) = child.stderr.take() { - let reader = BufReader::new(stderr); - let lines: Vec = reader.lines().map_while(Result::ok).take(5).collect(); - if !lines.is_empty() { - error_msg.push_str(&format!("\nError: {}", lines.join("\n"))); - } - } - - return Err(VpnError::Connection(error_msg)); - } - Ok(None) => { - // Still running, good - } - Err(e) => { - return Err(VpnError::Connection(format!( - "Failed to check process status: {e}" - ))); - } - } - } - - Ok(()) - } - - /// Kill the OpenVPN process - async fn kill_process(&mut self) -> Result<(), VpnError> { - let mut process_guard = self.process.lock().await; - - if let Some(mut child) = process_guard.take() { - // Try graceful shutdown first - #[cfg(unix)] - { - use nix::sys::signal::{kill, Signal}; - use nix::unistd::Pid; - - if let Ok(pid) = child.id().try_into() { - let _ = kill(Pid::from_raw(pid), Signal::SIGTERM); - // Wait a bit for graceful shutdown - tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; - } - } - - // Force kill if still running - let _ = child.kill(); - let _ = child.wait(); - } - - // Clean up config file - self.config_file = None; - - Ok(()) - } -} - -#[async_trait] -impl VpnTunnel for OpenVpnTunnel { - async fn connect(&mut self) -> Result<(), VpnError> { - if self.connected.load(Ordering::Relaxed) { - return Ok(()); - } - - // Start OpenVPN process - self.start_process().await?; - - // Wait for connection to be established - // Note: In a real implementation, we'd monitor the OpenVPN management interface - // For now, we assume success if the process starts and runs for a bit - tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; - - // Check if process is still running - let process_guard = self.process.lock().await; - if let Some(ref child) = *process_guard { - let id = child.id(); - if id > 0 { - self.connected.store(true, Ordering::Release); - self.connected_at = Some(Utc::now().timestamp()); - log::info!("[vpn] OpenVPN tunnel {} connected (PID: {id})", self.vpn_id); - return Ok(()); - } - } - - Err(VpnError::Connection( - "Failed to establish OpenVPN connection".to_string(), - )) - } - - async fn disconnect(&mut self) -> Result<(), VpnError> { - if !self.connected.load(Ordering::Relaxed) { - return Ok(()); - } - - self.kill_process().await?; - - self.connected.store(false, Ordering::Release); - self.connected_at = None; - - log::info!("[vpn] OpenVPN tunnel {} disconnected", self.vpn_id); - - Ok(()) - } - - fn is_connected(&self) -> bool { - self.connected.load(Ordering::Acquire) - } - - fn vpn_id(&self) -> &str { - &self.vpn_id - } - - fn get_status(&self) -> VpnStatus { - VpnStatus { - connected: self.is_connected(), - vpn_id: self.vpn_id.clone(), - connected_at: self.connected_at, - bytes_sent: Some(self.bytes_sent.load(Ordering::Relaxed)), - bytes_received: Some(self.bytes_received.load(Ordering::Relaxed)), - last_handshake: None, - } - } - - fn bytes_sent(&self) -> u64 { - self.bytes_sent.load(Ordering::Relaxed) - } - - fn bytes_received(&self) -> u64 { - self.bytes_received.load(Ordering::Relaxed) - } -} - -impl Drop for OpenVpnTunnel { - fn drop(&mut self) { - // Clean up process on drop (synchronously) - if let Ok(mut guard) = self.process.try_lock() { - if let Some(mut child) = guard.take() { - let _ = child.kill(); - let _ = child.wait(); - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn create_test_config() -> OpenVpnConfig { - OpenVpnConfig { - raw_config: "client\nremote test.example.com 1194\ndev tun".to_string(), - remote_host: "test.example.com".to_string(), - remote_port: 1194, - protocol: "udp".to_string(), - dev_type: "tun".to_string(), - has_inline_ca: false, - has_inline_cert: false, - has_inline_key: false, - } - } - - #[test] - fn test_openvpn_tunnel_creation() { - let config = create_test_config(); - let tunnel = OpenVpnTunnel::new("test-ovpn-1".to_string(), config); - - assert_eq!(tunnel.vpn_id(), "test-ovpn-1"); - assert!(!tunnel.is_connected()); - assert_eq!(tunnel.bytes_sent(), 0); - assert_eq!(tunnel.bytes_received(), 0); - } - - #[test] - fn test_openvpn_status() { - let config = create_test_config(); - let tunnel = OpenVpnTunnel::new("test-ovpn-2".to_string(), config); - - let status = tunnel.get_status(); - assert!(!status.connected); - assert_eq!(status.vpn_id, "test-ovpn-2"); - assert!(status.connected_at.is_none()); - } - - #[test] - fn test_find_openvpn_binary_format() { - // This test just checks that the function doesn't panic - // It may or may not find openvpn depending on the system - let result = OpenVpnTunnel::find_openvpn_binary(); - // Just check that it returns a valid Result - match result { - Ok(path) => assert!(!path.as_os_str().is_empty()), - Err(e) => assert!(e.to_string().contains("not found")), - } - } -} diff --git a/src-tauri/src/vpn/openvpn_socks5.rs b/src-tauri/src/vpn/openvpn_socks5.rs deleted file mode 100644 index 7657ed7..0000000 --- a/src-tauri/src/vpn/openvpn_socks5.rs +++ /dev/null @@ -1,811 +0,0 @@ -use super::config::{OpenVpnConfig, VpnError}; -use std::net::{IpAddr, Ipv4Addr, SocketAddr}; -use std::path::{Path, PathBuf}; -use std::process::{Command, Stdio}; -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, - port: u16, -} - -impl OpenVpnSocks5Server { - pub fn new(config: OpenVpnConfig, port: u16) -> Self { - Self { config, port } - } - - fn read_log_tail(path: &Path, lines: usize) -> String { - std::fs::read_to_string(path) - .unwrap_or_default() - .lines() - .rev() - .take(lines) - .collect::>() - .into_iter() - .rev() - .collect::>() - .join("\n") - } - - fn extract_vpn_ip(line: &str) -> Option { - for field in line.split(',') { - let trimmed = field.trim(); - if let Ok(ip) = trimmed.parse::() { - 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::>() - .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::>() - .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))] - { - let _ = openvpn_bin; - OpenVpnDependencyStatus { - binary_found: true, - missing_windows_adapter: false, - dependency_check_failed: false, - } - } - } - - pub(crate) fn find_openvpn_binary() -> Result { - 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", - "/opt/homebrew/bin/openvpn", - "/usr/bin/openvpn", - "C:\\Program Files\\OpenVPN\\bin\\openvpn.exe", - "C:\\Program Files (x86)\\OpenVPN\\bin\\openvpn.exe", - ]; - - for loc in &locations { - let path = PathBuf::from(loc); - if path.exists() { - return Ok(path); - } - } - - #[cfg(unix)] - { - if let Ok(output) = Command::new("which").arg("openvpn").output() { - if output.status.success() { - let path = String::from_utf8_lossy(&output.stdout).trim().to_string(); - if !path.is_empty() { - return Ok(PathBuf::from(path)); - } - } - } - } - - #[cfg(windows)] - { - use std::os::windows::process::CommandExt; - const CREATE_NO_WINDOW: u32 = 0x08000000; - if let Ok(output) = Command::new("where") - .arg("openvpn") - .creation_flags(CREATE_NO_WINDOW) - .output() - { - if output.status.success() { - let path = String::from_utf8_lossy(&output.stdout) - .lines() - .next() - .unwrap_or("") - .trim() - .to_string(); - if !path.is_empty() { - return Ok(PathBuf::from(path)); - } - } - } - } - - Err(VpnError::Connection( - "OpenVPN binary not found. Please install OpenVPN.".to_string(), - )) - } - - 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 { - 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 { - 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::() { - 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, 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, 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> { - 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::>(); - 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(), - )); - } - - let config_path = std::env::temp_dir().join(format!("openvpn_{}.ovpn", config_id)); - std::fs::write(&config_path, self.build_runtime_config()).map_err(VpnError::Io)?; - - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let _ = std::fs::set_permissions(&config_path, std::fs::Permissions::from_mode(0o600)); - } - - 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)?; - - 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("--verb") - .arg("3") - .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}")))?; - - 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 immediately (status: {}). Last output:\n{}", - status, - Self::read_log_tail(&openvpn_log_path, 20) - ))); - } - Ok(None) => {} - Err(e) => { - let _ = std::fs::remove_file(&config_path); - return Err(VpnError::Connection(format!( - "Failed to check OpenVPN status: {e}" - ))); - } - } - - 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}")))?; - - let actual_port = listener - .local_addr() - .map_err(|e| VpnError::Connection(format!("Failed to get local addr: {e}")))? - .port(); - - 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!( - "[vpn-worker] OpenVPN SOCKS5 server listening on 127.0.0.1:{}", - actual_port - ); - - loop { - match listener.accept().await { - Ok((client, _)) => { - let bind_ip = vpn_bind_ip.clone(); - tokio::spawn(async move { - let _ = Self::handle_socks5_client(client, bind_ip).await; - }); - } - Err(error) => { - log::warn!("[vpn-worker] Accept error: {error}"); - } - } - } - } - - async fn handle_socks5_client( - mut client: TcpStream, - vpn_bind_ip: Arc, - ) -> Result<(), Box> { - 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?; - - 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(()); - } - - 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 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 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(()); - } - }; - - 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 client_read, mut client_write) = client.into_split(); - let (mut upstream_read, mut upstream_write) = upstream.into_split(); - - 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(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?; - } - } - - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_find_openvpn_binary_format() { - let result = OpenVpnSocks5Server::find_openvpn_binary(); - match result { - Ok(path) => assert!(!path.as_os_str().is_empty()), - Err(e) => assert!(e.to_string().contains("not found")), - } - } -} diff --git a/src-tauri/src/vpn/socks5_server.rs b/src-tauri/src/vpn/socks5_server.rs index 79eb91a..d8a8e63 100644 --- a/src-tauri/src/vpn/socks5_server.rs +++ b/src-tauri/src/vpn/socks5_server.rs @@ -52,8 +52,25 @@ impl WgDevice { let mut dst = vec![0u8; ip_packet.len() + 256]; let mut tunn = self.tunn.lock().unwrap(); let result = tunn.encapsulate(&ip_packet, &mut dst); - if let TunnResult::WriteToNetwork(packet) = result { - let _ = self.udp_socket.send_to(packet, self.peer_addr); + match result { + TunnResult::WriteToNetwork(packet) => { + if let Err(e) = self.udp_socket.send_to(packet, self.peer_addr) { + log::error!("[wg] udp send_to failed: {e}"); + } + } + TunnResult::Done => { + // boringtun has nothing to send right now (e.g. handshake not yet + // complete); silently drop. smoltcp will retransmit. + } + TunnResult::Err(e) => { + log::error!( + "[wg] encapsulate error for {}B IP packet: {e:?}", + ip_packet.len() + ); + } + TunnResult::WriteToTunnelV4(_, _) | TunnResult::WriteToTunnelV6(_, _) => { + log::error!("[wg] encapsulate returned unexpected WriteToTunnel — bug?"); + } } } } @@ -313,7 +330,11 @@ impl WireGuardSocks5Server { ))) } - pub async fn run(self, config_id: String) -> Result<(), VpnError> { + pub async fn run( + self, + config_id: String, + config_path: Option, + ) -> Result<(), VpnError> { let peer_addr = self.resolve_endpoint()?; let mut tunn = self.create_tunnel()?; @@ -371,11 +392,37 @@ impl WireGuardSocks5Server { .map_err(|e| VpnError::Connection(format!("Failed to get local addr: {e}")))? .port(); - // Update config with actual port and local_url - if let Some(mut wc) = crate::vpn_worker_storage::get_vpn_worker_config(&config_id) { + // Update config with actual port and local_url. Prefer the explicit + // config path the worker was started with — see issue #287, where + // get_storage_dir() in the worker process resolved to a different + // directory than in the parent (Qubes/sandboxed Linux), causing the + // write-back to land in the wrong place and the parent to time out. + let updated = match &config_path { + Some(path) => crate::vpn_worker_storage::get_vpn_worker_config_from_path(path) + .or_else(|| crate::vpn_worker_storage::get_vpn_worker_config(&config_id)), + None => crate::vpn_worker_storage::get_vpn_worker_config(&config_id), + }; + if let Some(mut wc) = updated { 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); + let result = match &config_path { + Some(path) => crate::vpn_worker_storage::save_vpn_worker_config_to_path(&wc, path) + .map_err(|e| e.to_string()), + None => crate::vpn_worker_storage::save_vpn_worker_config(&wc).map_err(|e| e.to_string()), + }; + if let Err(e) = result { + log::error!( + "[vpn-worker] Failed to write back local_url to config: {} (path={:?})", + e, + config_path + ); + } + } else { + log::error!( + "[vpn-worker] Could not load worker config for write-back (id={}, path={:?})", + config_id, + config_path + ); } log::info!( diff --git a/src-tauri/src/vpn/storage.rs b/src-tauri/src/vpn/storage.rs index 2cffb31..67d8fd7 100644 --- a/src-tauri/src/vpn/storage.rs +++ b/src-tauri/src/vpn/storage.rs @@ -161,7 +161,17 @@ impl VpnStorage { let content = fs::read_to_string(&self.storage_path) .map_err(|e| VpnError::Storage(format!("Failed to read storage file: {e}")))?; - serde_json::from_str(&content) + // Drop entries whose vpn_type isn't recognized by the current build (e.g. + // legacy "OpenVPN" entries after support was removed). Filtering at JSON + // level keeps the rest of the file deserializable instead of the whole + // load failing on a single unknown variant. + let mut value: serde_json::Value = serde_json::from_str(&content) + .map_err(|e| VpnError::Storage(format!("Failed to parse storage file: {e}")))?; + if let Some(arr) = value.get_mut("configs").and_then(|v| v.as_array_mut()) { + arr.retain(|c| c.get("vpn_type").and_then(|t| t.as_str()) == Some("WireGuard")); + } + + serde_json::from_value(value) .map_err(|e| VpnError::Storage(format!("Failed to parse storage file: {e}"))) } @@ -328,14 +338,10 @@ impl VpnStorage { vpn_type: VpnType, config_data: &str, ) -> Result { - // Validate the config by parsing it match vpn_type { VpnType::WireGuard => { super::parse_wireguard_config(config_data)?; } - VpnType::OpenVPN => { - super::parse_openvpn_config(config_data)?; - } } let id = Uuid::new_v4().to_string(); @@ -392,20 +398,15 @@ impl VpnStorage { ) -> Result { let vpn_type = super::detect_vpn_type(content, filename)?; - // Validate the config by parsing it match vpn_type { VpnType::WireGuard => { super::parse_wireguard_config(content)?; } - VpnType::OpenVPN => { - super::parse_openvpn_config(content)?; - } } let id = Uuid::new_v4().to_string(); let display_name = name.unwrap_or_else(|| { - // Generate name from filename - let base = filename.trim_end_matches(".conf").trim_end_matches(".ovpn"); + let base = filename.trim_end_matches(".conf"); format!("{} ({})", base, vpn_type) }); let sync_enabled = crate::sync::is_sync_configured(); @@ -491,7 +492,7 @@ mod tests { let config2 = VpnConfig { id: "id-2".to_string(), name: "VPN 2".to_string(), - vpn_type: VpnType::OpenVPN, + vpn_type: VpnType::WireGuard, config_data: "secret2".to_string(), created_at: 2000, last_used: Some(3000), diff --git a/src-tauri/src/vpn_worker_runner.rs b/src-tauri/src/vpn_worker_runner.rs index c8d662d..ce7f8db 100644 --- a/src-tauri/src/vpn_worker_runner.rs +++ b/src-tauri/src/vpn_worker_runner.rs @@ -9,7 +9,6 @@ use std::process::Stdio; const VPN_WORKER_POLL_INTERVAL_MS: u64 = 100; const VPN_WORKER_STARTUP_TIMEOUT_MS: u64 = 30_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 { @@ -44,13 +43,8 @@ fn read_worker_log(id: &str) -> String { async fn wait_for_vpn_worker_ready( id: &str, - vpn_type: &str, ) -> Result> { - 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_timeout = 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( @@ -124,7 +118,7 @@ pub async fn start_vpn_worker(vpn_id: &str) -> Result Result "wireguard", - crate::vpn::VpnType::OpenVPN => "openvpn", - }; + let vpn_type_str = "wireguard"; // Write decrypted config to a temp file let config_file_path = std::env::temp_dir() @@ -270,7 +261,7 @@ pub async fn start_vpn_worker(vpn_id: &str) -> Result Result> { diff --git a/src-tauri/src/vpn_worker_storage.rs b/src-tauri/src/vpn_worker_storage.rs index 48ca687..c37b525 100644 --- a/src-tauri/src/vpn_worker_storage.rs +++ b/src-tauri/src/vpn_worker_storage.rs @@ -1,6 +1,7 @@ use crate::proxy_storage::get_storage_dir; use serde::{Deserialize, Serialize}; use std::fs; +use std::path::Path; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct VpnWorkerConfig { @@ -36,12 +37,34 @@ pub fn save_vpn_worker_config(config: &VpnWorkerConfig) -> Result<(), Box Result<(), Box> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let content = serde_json::to_string_pretty(config)?; + fs::write(path, content)?; Ok(()) } +/// Read a worker config from a specific path. Counterpart to +/// `save_vpn_worker_config_to_path`. +pub fn get_vpn_worker_config_from_path(path: &Path) -> Option { + let content = fs::read_to_string(path).ok()?; + serde_json::from_str(&content).ok() +} + pub fn get_vpn_worker_config(id: &str) -> Option { let storage_dir = get_storage_dir(); let file_path = storage_dir.join(format!("vpn_worker_{}.json", id)); diff --git a/src-tauri/tests/fixtures/test.ovpn b/src-tauri/tests/fixtures/test.ovpn deleted file mode 100644 index 691dfcf..0000000 --- a/src-tauri/tests/fixtures/test.ovpn +++ /dev/null @@ -1,39 +0,0 @@ -# Sample OpenVPN configuration for testing -# This is NOT a real configuration - for unit test purposes only - -client -dev tun -proto udp -remote vpn.example.com 1194 -resolv-retry infinite -nobind -persist-key -persist-tun -verb 3 - - ------BEGIN CERTIFICATE----- -MIIBojCCAUigAwIBAgIJAKPGF0Tc8XJaMAoGCCqGSM49BAMCMBkxFzAVBgNVBAMM -DnRlc3QtY2EtZXhhbXBsZTAeFw0yMzAxMDEwMDAwMDBaFw0yNTAxMDEwMDAwMDBa -MBkxFzAVBgNVBAMMDnRlc3QtY2EtZXhhbXBsZTBZMBMGByqGSM49AgEGCCqGSM49 -AwEHA0IABHfakeZYe3R6uCZoL5DqbZkW8mBVKnIYMrIIKV4FPYO9V1YL8V3Z9QC -TEST_CERTIFICATE_DATA_NOT_REAL_EXAMPLE_ONLY ------END CERTIFICATE----- - - - ------BEGIN CERTIFICATE----- -MIIBojCCAUigAwIBAgIJAKPGF0Tc8XJbMAoGCCqGSM49BAMCMBkxFzAVBgNVBAMM -DnRlc3QtY2xpZW50LWV4YW1wbGUwHhcNMjMwMTAxMDAwMDAwWhcNMjUwMTAxMDAw -MDAwWjAZMRcwFQYDVQQDDA50ZXN0LWNsaWVudC1leGFtcGxlMFkwEwYHKoZIzj0C -AQYIKoZIzj0DAQcDQgAE -TEST_CLIENT_CERT_DATA_NOT_REAL_EXAMPLE_ONLY ------END CERTIFICATE----- - - - ------BEGIN PRIVATE KEY----- -MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgZFG/NKjHmTJBNcuH -TEST_PRIVATE_KEY_DATA_NOT_REAL_EXAMPLE_ONLY ------END PRIVATE KEY----- - diff --git a/src-tauri/tests/test_harness/mod.rs b/src-tauri/tests/test_harness/mod.rs index dfb18ff..be3300d 100644 --- a/src-tauri/tests/test_harness/mod.rs +++ b/src-tauri/tests/test_harness/mod.rs @@ -1,6 +1,6 @@ //! Test harness for VPN integration tests. //! -//! This module provides Docker-based test infrastructure for WireGuard and OpenVPN tests. +//! This module provides Docker-based test infrastructure for WireGuard tests. //! In CI environments, it uses pre-configured service containers. //! In local development, it spawns Docker containers on demand. //! @@ -13,10 +13,7 @@ use std::time::Duration; use tokio::time::sleep; 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 { @@ -27,10 +24,6 @@ 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") @@ -165,166 +158,10 @@ pub async fn start_wireguard_server() -> Result { Ok(config) } -/// Start an OpenVPN test server and return client config -pub async fn start_openvpn_server() -> Result { - 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()); - - return get_ci_openvpn_config(&host, &port); - } - - if !is_docker_available() { - return Err("Docker is not available for local testing".to_string()); - } - - // Stop any existing container - let _ = Command::new("docker") - .args(["rm", "-f", OVPN_CONTAINER]) - .output(); - - 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 pub async fn stop_vpn_servers() { let _ = Command::new("docker") - .args(["rm", "-f", WG_CONTAINER, OVPN_CONTAINER]) - .output(); - let _ = Command::new("docker") - .args(["volume", "rm", "-f", OVPN_VOLUME]) + .args(["rm", "-f", WG_CONTAINER]) .output(); } @@ -343,14 +180,6 @@ pub struct WireGuardTestConfig { pub server_tunnel_ip: String, } -/// OpenVPN test configuration -pub struct OpenVpnTestConfig { - pub raw_config: String, - pub remote_host: String, - pub remote_port: u16, - pub protocol: String, -} - /// Parse WireGuard test config from INI content fn parse_wireguard_test_config(content: &str) -> Result { let mut private_key = String::new(); @@ -436,7 +265,7 @@ fn get_ci_wireguard_config(host: &str, port: &str) -> Result Result Result { - 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 -dev tun -proto udp -remote {host} {port} -resolv-retry infinite -nobind -persist-key -persist-tun -"# - ); - - Ok(OpenVpnTestConfig { - raw_config, - remote_host: host.to_string(), - remote_port: port.parse().unwrap_or(1194), - protocol: "udp".to_string(), - }) -} diff --git a/src-tauri/tests/vpn_integration.rs b/src-tauri/tests/vpn_integration.rs index 0b472f4..edcd23c 100644 --- a/src-tauri/tests/vpn_integration.rs +++ b/src-tauri/tests/vpn_integration.rs @@ -8,8 +8,7 @@ mod test_harness; use common::TestUtils; use donutbrowser_lib::vpn::{ - detect_vpn_type, parse_openvpn_config, parse_wireguard_config, OpenVpnConfig, VpnConfig, - VpnStorage, VpnType, WireGuardConfig, + detect_vpn_type, parse_wireguard_config, VpnConfig, VpnStorage, VpnType, WireGuardConfig, }; use serde_json::Value; use serial_test::serial; @@ -45,27 +44,6 @@ fn test_wireguard_config_import() { assert_eq!(wg.persistent_keepalive, Some(25)); } -#[test] -fn test_openvpn_config_import() { - let config = include_str!("fixtures/test.ovpn"); - let result = parse_openvpn_config(config); - - assert!( - result.is_ok(), - "Failed to parse OpenVPN config: {:?}", - result.err() - ); - - let ovpn = result.unwrap(); - assert_eq!(ovpn.remote_host, "vpn.example.com"); - assert_eq!(ovpn.remote_port, 1194); - assert_eq!(ovpn.protocol, "udp"); - assert_eq!(ovpn.dev_type, "tun"); - assert!(ovpn.has_inline_ca); - assert!(ovpn.has_inline_cert); - assert!(ovpn.has_inline_key); -} - #[test] fn test_detect_vpn_type_wireguard_by_extension() { let content = "[Interface]\nPrivateKey = test\n[Peer]\nPublicKey = peer"; @@ -75,15 +53,6 @@ fn test_detect_vpn_type_wireguard_by_extension() { assert_eq!(result.unwrap(), VpnType::WireGuard); } -#[test] -fn test_detect_vpn_type_openvpn_by_extension() { - let content = "client\nremote vpn.example.com 1194"; - let result = detect_vpn_type(content, "my-vpn.ovpn"); - - assert!(result.is_ok()); - assert_eq!(result.unwrap(), VpnType::OpenVPN); -} - #[test] fn test_detect_vpn_type_wireguard_by_content() { let content = r#" @@ -101,20 +70,6 @@ Endpoint = 1.2.3.4:51820 assert_eq!(result.unwrap(), VpnType::WireGuard); } -#[test] -fn test_detect_vpn_type_openvpn_by_content() { - let content = r#" -client -dev tun -proto udp -remote vpn.server.com 443 -"#; - let result = detect_vpn_type(content, "config.txt"); - - assert!(result.is_ok()); - assert_eq!(result.unwrap(), VpnType::OpenVPN); -} - #[test] fn test_detect_vpn_type_unknown() { let content = "this is just some random text that is not a vpn config"; @@ -123,6 +78,13 @@ fn test_detect_vpn_type_unknown() { assert!(result.is_err()); } +#[test] +fn test_reject_openvpn_content() { + let content = "client\ndev tun\nproto udp\nremote vpn.example.com 1194"; + assert!(detect_vpn_type(content, "old.ovpn").is_err()); + assert!(detect_vpn_type(content, "config.txt").is_err()); +} + #[test] fn test_wireguard_config_missing_private_key() { let config = r#" @@ -154,32 +116,6 @@ Address = 10.0.0.2/24 assert!(err.contains("PublicKey") || err.contains("Peer")); } -#[test] -fn test_openvpn_config_missing_remote() { - let config = r#" -client -dev tun -proto udp -"#; - let result = parse_openvpn_config(config); - - assert!(result.is_err()); - let err = result.unwrap_err().to_string(); - assert!(err.contains("remote")); -} - -#[test] -fn test_openvpn_config_with_port_in_remote() { - let config = "client\nremote server.example.com 443 tcp"; - let result = parse_openvpn_config(config); - - assert!(result.is_ok()); - let ovpn = result.unwrap(); - assert_eq!(ovpn.remote_host, "server.example.com"); - assert_eq!(ovpn.remote_port, 443); - assert_eq!(ovpn.protocol, "tcp"); -} - // ============================================================================ // Storage Tests // ============================================================================ @@ -228,16 +164,11 @@ fn test_vpn_storage_list() { let temp_dir = tempfile::TempDir::new().unwrap(); let storage = create_test_storage(&temp_dir); - // Save two configs for i in 1..=2 { let config = VpnConfig { id: format!("list-test-{i}"), name: format!("VPN {i}"), - vpn_type: if i == 1 { - VpnType::WireGuard - } else { - VpnType::OpenVPN - }, + vpn_type: VpnType::WireGuard, config_data: "secret data".to_string(), created_at: 1000 * i as i64, last_used: None, @@ -250,7 +181,6 @@ fn test_vpn_storage_list() { let list = storage.list_configs().unwrap(); assert_eq!(list.len(), 2); - // Config data should be empty in listing for cfg in &list { assert!(cfg.config_data.is_empty()); } @@ -297,6 +227,52 @@ fn test_vpn_storage_import() { assert!(!imported.id.is_empty()); } +/// Existing OpenVPN entries on disk should be silently dropped at load time +/// after support was removed. Stored configs are encrypted at rest, so we +/// build the on-disk JSON by hand instead of going through `save_config`. +#[test] +#[serial] +fn test_vpn_storage_drops_legacy_openvpn_entries() { + let temp_dir = tempfile::TempDir::new().unwrap(); + let storage_path = temp_dir.path().join("vpn_configs.json"); + std::fs::write( + &storage_path, + r#"{ + "version": 1, + "configs": [ + { + "id": "wg-keep", + "name": "Keep me", + "vpn_type": "WireGuard", + "encrypted_data": "", + "nonce": "", + "created_at": 1, + "last_used": null, + "sync_enabled": false, + "last_sync": null + }, + { + "id": "ovpn-drop", + "name": "Drop me", + "vpn_type": "OpenVPN", + "encrypted_data": "", + "nonce": "", + "created_at": 2, + "last_used": null, + "sync_enabled": false, + "last_sync": null + } + ] + }"#, + ) + .unwrap(); + + let storage = create_test_storage(&temp_dir); + let configs = storage.list_configs().unwrap(); + let ids: Vec<_> = configs.iter().map(|c| c.id.as_str()).collect(); + assert_eq!(ids, vec!["wg-keep"]); +} + // ============================================================================ // Helper Functions // ============================================================================ @@ -309,13 +285,9 @@ fn create_test_storage(temp_dir: &tempfile::TempDir) -> VpnStorage { // Connection Tests (require Docker) // ============================================================================ -/// These tests require Docker to be available. -/// They are automatically skipped if Docker is not installed. - #[tokio::test] #[serial] async fn test_wireguard_tunnel_init() { - // This test only verifies tunnel creation, not actual connection let config = WireGuardConfig { private_key: "YEocP0e2o1WT5GlvBvQzVF7EeR6z9aCk+ZdZ5NKEuXA=".to_string(), address: "10.0.0.2/24".to_string(), @@ -337,30 +309,6 @@ async fn test_wireguard_tunnel_init() { assert_eq!(tunnel.bytes_received(), 0); } -#[tokio::test] -#[serial] -async fn test_openvpn_tunnel_init() { - // This test only verifies tunnel creation, not actual connection - let config = OpenVpnConfig { - raw_config: "client\nremote localhost 1194".to_string(), - remote_host: "localhost".to_string(), - remote_port: 1194, - protocol: "udp".to_string(), - dev_type: "tun".to_string(), - has_inline_ca: false, - has_inline_cert: false, - has_inline_key: false, - }; - - use donutbrowser_lib::vpn::{OpenVpnTunnel, VpnTunnel}; - - let tunnel = OpenVpnTunnel::new("test-ovpn".to_string(), config); - assert_eq!(tunnel.vpn_id(), "test-ovpn"); - assert!(!tunnel.is_connected()); - assert_eq!(tunnel.bytes_sent(), 0); - assert_eq!(tunnel.bytes_received(), 0); -} - #[tokio::test] #[serial] async fn test_tunnel_manager() { @@ -565,45 +513,6 @@ fn build_wireguard_config(config: &test_harness::WireGuardTestConfig) -> String ) } -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, @@ -750,10 +659,6 @@ async fn run_proxy_feature_suite( 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 = @@ -790,7 +695,6 @@ async fn run_proxy_feature_suite( 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()?; std::fs::write(blocklist_file.path(), format!("{server_tunnel_ip}\n"))?; let blocked_proxy = start_proxy_with_upstream( @@ -896,56 +800,3 @@ async fn test_wireguard_traffic_flows_through_donut_proxy( result } - -#[tokio::test] -#[serial] -async fn test_openvpn_traffic_flows_through_donut_proxy( -) -> Result<(), Box> { - 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)?; - } - - // 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; - result -} diff --git a/src/components/create-profile-dialog.tsx b/src/components/create-profile-dialog.tsx index afc45d2..380ee7e 100644 --- a/src/components/create-profile-dialog.tsx +++ b/src/components/create-profile-dialog.tsx @@ -1068,7 +1068,7 @@ export function CreateProfileDialog({ v.id === selectedProxyId.slice(4), ); return vpn - ? `${vpn.vpn_type === "WireGuard" ? "WG" : "OVPN"} — ${vpn.name}` + ? `WG — ${vpn.name}` : "No proxy / VPN"; } const proxy = storedProxies.find( @@ -1154,9 +1154,7 @@ export function CreateProfileDialog({ variant="outline" className="text-[10px] px-1 py-0 leading-tight mr-1" > - {vpn.vpn_type === "WireGuard" - ? "WG" - : "OVPN"} + WG {vpn.name} @@ -1417,7 +1415,7 @@ export function CreateProfileDialog({ v.id === selectedProxyId.slice(4), ); return vpn - ? `${vpn.vpn_type === "WireGuard" ? "WG" : "OVPN"} — ${vpn.name}` + ? `WG — ${vpn.name}` : "No proxy / VPN"; } const proxy = storedProxies.find( @@ -1503,9 +1501,7 @@ export function CreateProfileDialog({ variant="outline" className="text-[10px] px-1 py-0 leading-tight mr-1" > - {vpn.vpn_type === "WireGuard" - ? "WG" - : "OVPN"} + WG {vpn.name} diff --git a/src/components/profile-data-table.tsx b/src/components/profile-data-table.tsx index 30476aa..b86fb63 100644 --- a/src/components/profile-data-table.tsx +++ b/src/components/profile-data-table.tsx @@ -2219,11 +2219,7 @@ export function ProfilesDataTable({ : effectiveProxy ? effectiveProxy.name : "Not Selected"; - const vpnBadge = effectiveVpn - ? effectiveVpn.vpn_type === "WireGuard" - ? "WG" - : "OVPN" - : null; + const vpnBadge = effectiveVpn ? "WG" : null; const tooltipText = hasAssignment ? displayName : null; const isSelectorOpen = meta.openProxySelectorFor === profile.id; const selectedId = effectiveVpnId ?? effectiveProxyId ?? null; @@ -2385,7 +2381,7 @@ export function ProfilesDataTable({ variant="outline" className="text-[10px] px-1 py-0 leading-tight mr-1" > - {vpn.vpn_type === "WireGuard" ? "WG" : "OVPN"} + WG {vpn.name} diff --git a/src/components/proxy-assignment-dialog.tsx b/src/components/proxy-assignment-dialog.tsx index 5dc9d1d..d114113 100644 --- a/src/components/proxy-assignment-dialog.tsx +++ b/src/components/proxy-assignment-dialog.tsx @@ -179,9 +179,7 @@ export function ProxyAssignmentDialog({ if (selectionType === "none") return "None"; if (selectionType === "vpn") { const vpn = vpnConfigs.find((v) => v.id === selectedId); - return vpn - ? `${vpn.vpn_type === "WireGuard" ? "WG" : "OVPN"} — ${vpn.name}` - : "None"; + return vpn ? `WG — ${vpn.name}` : "None"; } const proxy = storedProxies.find( (p) => p.id === selectedId, @@ -264,7 +262,7 @@ export function ProxyAssignmentDialog({ variant="outline" className="text-[10px] px-1 py-0 leading-tight mr-1" > - {vpn.vpn_type === "WireGuard" ? "WG" : "OVPN"} + WG {vpn.name} diff --git a/src/components/proxy-management-dialog.tsx b/src/components/proxy-management-dialog.tsx index 1b895a9..416da7e 100644 --- a/src/components/proxy-management-dialog.tsx +++ b/src/components/proxy-management-dialog.tsx @@ -670,11 +670,7 @@ export function ProxyManagementDialog({ - - {vpn.vpn_type === "WireGuard" - ? "WG" - : "OVPN"} - + WG diff --git a/src/components/ui/command.tsx b/src/components/ui/command.tsx index 7c18248..5f0c781 100644 --- a/src/components/ui/command.tsx +++ b/src/components/ui/command.tsx @@ -111,7 +111,7 @@ function CommandGroup({ ("WireGuard"); const [wireGuardForm, setWireGuardForm] = useState(defaultWireGuardForm); - const [openVpnForm, setOpenVpnForm] = - useState(defaultOpenVpnForm); - const [vpnDependencyStatus, setVpnDependencyStatus] = - useState(null); const resetForms = useCallback(() => { - setVpnType("WireGuard"); setWireGuardForm(defaultWireGuardForm); - setOpenVpnForm(defaultOpenVpnForm); }, []); useEffect(() => { if (isOpen) { if (editingVpn) { - setVpnType(editingVpn.vpn_type); - if (editingVpn.vpn_type === "WireGuard") { - setWireGuardForm({ ...defaultWireGuardForm, name: editingVpn.name }); - } else { - setOpenVpnForm({ name: editingVpn.name, rawConfig: "" }); - } + setWireGuardForm({ ...defaultWireGuardForm, name: editingVpn.name }); } else { resetForms(); } } }, [isOpen, editingVpn, resetForms]); - useEffect(() => { - if (!isOpen) { - setVpnDependencyStatus(null); - return; - } - - let cancelled = false; - - void invoke("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(); @@ -167,10 +100,7 @@ export function VpnFormDialog({ const handleSubmit = useCallback(async () => { if (editingVpn) { - const name = - vpnType === "WireGuard" - ? wireGuardForm.name.trim() - : openVpnForm.name.trim(); + const name = wireGuardForm.name.trim(); if (!name) { toast.error("VPN name is required"); @@ -196,80 +126,49 @@ export function VpnFormDialog({ return; } - if (vpnType === "WireGuard") { - const { name, privateKey, address, peerPublicKey, peerEndpoint } = - wireGuardForm; + const { name, privateKey, address, peerPublicKey, peerEndpoint } = + wireGuardForm; - if (!name.trim()) { - toast.error("VPN name is required"); - return; - } - if (!privateKey.trim()) { - toast.error("Private key is required"); - return; - } - if (!address.trim()) { - toast.error("Address is required"); - return; - } - if (!peerPublicKey.trim()) { - toast.error("Peer public key is required"); - return; - } - if (!peerEndpoint.trim()) { - toast.error("Peer endpoint is required"); - return; - } - - setIsSubmitting(true); - try { - const configData = buildWireGuardConfig(wireGuardForm); - await invoke("create_vpn_config_manual", { - name: name.trim(), - vpnType: "WireGuard", - configData, - }); - await emit("vpn-configs-changed"); - toast.success("WireGuard VPN created successfully"); - onClose(); - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - toast.error(`Failed to create VPN: ${errorMessage}`); - } finally { - setIsSubmitting(false); - } - } else { - const { name, rawConfig } = openVpnForm; - - if (!name.trim()) { - toast.error("VPN name is required"); - return; - } - if (!rawConfig.trim()) { - toast.error("OpenVPN config content is required"); - return; - } - - setIsSubmitting(true); - try { - await invoke("create_vpn_config_manual", { - name: name.trim(), - vpnType: "OpenVPN", - configData: rawConfig, - }); - await emit("vpn-configs-changed"); - toast.success("OpenVPN configuration created successfully"); - onClose(); - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - toast.error(`Failed to create VPN: ${errorMessage}`); - } finally { - setIsSubmitting(false); - } + if (!name.trim()) { + toast.error("VPN name is required"); + return; } - }, [editingVpn, vpnType, wireGuardForm, openVpnForm, onClose]); + if (!privateKey.trim()) { + toast.error("Private key is required"); + return; + } + if (!address.trim()) { + toast.error("Address is required"); + return; + } + if (!peerPublicKey.trim()) { + toast.error("Peer public key is required"); + return; + } + if (!peerEndpoint.trim()) { + toast.error("Peer endpoint is required"); + return; + } + + setIsSubmitting(true); + try { + const configData = buildWireGuardConfig(wireGuardForm); + await invoke("create_vpn_config_manual", { + name: name.trim(), + vpnType: "WireGuard", + configData, + }); + await emit("vpn-configs-changed"); + toast.success("WireGuard VPN created successfully"); + onClose(); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + toast.error(`Failed to create VPN: ${errorMessage}`); + } finally { + setIsSubmitting(false); + } + }, [editingVpn, wireGuardForm, onClose]); const updateWireGuard = useCallback( (field: keyof WireGuardFormData, value: string) => { @@ -278,54 +177,10 @@ export function VpnFormDialog({ [], ); - const updateOpenVpn = useCallback( - (field: keyof OpenVpnFormData, value: string) => { - setOpenVpnForm((prev) => ({ ...prev, [field]: value })); - }, - [], - ); - - const dialogTitle = editingVpn - ? "Edit VPN" - : vpnType === "WireGuard" - ? "Create WireGuard VPN" - : "Create OpenVPN Configuration"; - + const dialogTitle = editingVpn ? "Edit VPN" : "Create WireGuard VPN"; const dialogDescription = editingVpn ? "Update the name of your VPN configuration." - : vpnType === "WireGuard" - ? "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", - ); - } - } + : "Enter your WireGuard interface and peer details."; return ( @@ -337,221 +192,147 @@ export function VpnFormDialog({
- {dependencyWarningTitle && dependencyWarningDescription && ( - - - {dependencyWarningTitle} - - - {dependencyWarningDescription} - - - )} +
+ + { + updateWireGuard("name", e.target.value); + }} + placeholder="e.g. Home WireGuard" + disabled={isSubmitting} + /> +
{!editingVpn && ( -
- - -
- )} - - {vpnType === "WireGuard" && ( <>
- + { - updateWireGuard("name", e.target.value); + updateWireGuard("privateKey", e.target.value); }} - placeholder="e.g. Home WireGuard" + placeholder="Base64-encoded private key" disabled={isSubmitting} />
- {!editingVpn && ( - <> -
- - { - updateWireGuard("privateKey", e.target.value); - }} - placeholder="Base64-encoded private key" - disabled={isSubmitting} - /> -
- -
- - { - updateWireGuard("address", e.target.value); - }} - placeholder="e.g. 10.0.0.2/24" - disabled={isSubmitting} - /> -
- -
-
- - { - updateWireGuard("dns", e.target.value); - }} - placeholder="e.g. 1.1.1.1" - disabled={isSubmitting} - /> -
- -
- - { - updateWireGuard("mtu", e.target.value); - }} - placeholder="e.g. 1420" - disabled={isSubmitting} - /> -
-
- -
- - { - updateWireGuard("peerPublicKey", e.target.value); - }} - placeholder="Base64-encoded peer public key" - disabled={isSubmitting} - /> -
- -
- - { - updateWireGuard("peerEndpoint", e.target.value); - }} - placeholder="e.g. vpn.example.com:51820" - disabled={isSubmitting} - /> -
- -
- - { - updateWireGuard("allowedIps", e.target.value); - }} - placeholder="e.g. 0.0.0.0/0, ::/0" - disabled={isSubmitting} - /> -
- -
-
- - { - updateWireGuard( - "persistentKeepalive", - e.target.value, - ); - }} - placeholder="e.g. 25" - disabled={isSubmitting} - /> -
- -
- - { - updateWireGuard("presharedKey", e.target.value); - }} - placeholder="Base64-encoded preshared key" - disabled={isSubmitting} - /> -
-
- - )} - - )} - - {vpnType === "OpenVPN" && ( - <>
- + { - updateOpenVpn("name", e.target.value); + updateWireGuard("address", e.target.value); }} - placeholder="e.g. Work OpenVPN" + placeholder="e.g. 10.0.0.2/24" disabled={isSubmitting} />
- {!editingVpn && ( +
- -