refactor: vpn refresh and remove openvpn support

This commit is contained in:
zhom
2026-04-26 23:28:54 +04:00
parent 91218e08f9
commit ce76c1381f
36 changed files with 613 additions and 2570 deletions
+253
View File
@@ -130,6 +130,39 @@ struct UpdateProxyRequest {
proxy_settings: Option<ProxySettings>,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
struct ApiVpnResponse {
id: String,
name: String,
/// Always "WireGuard"
vpn_type: String,
created_at: i64,
last_used: Option<i64>,
}
#[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<String>,
}
#[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<crate::vpn::VpnType> {
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<ApiVpnResponse>),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error")
),
security(("bearer_auth" = [])),
tag = "vpns"
)]
async fn get_vpns(
State(_state): State<ApiServerState>,
) -> Result<Json<Vec<ApiVpnResponse>>, 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<String>,
State(_state): State<ApiServerState>,
) -> Result<Json<ApiVpnResponse>, 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<ApiServerState>,
Json(request): Json<ImportVpnRequest>,
) -> Result<Json<ApiVpnResponse>, 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<ApiServerState>,
Json(request): Json<CreateVpnRequest>,
) -> Result<Json<ApiVpnResponse>, 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<String>,
State(_state): State<ApiServerState>,
Json(request): Json<UpdateVpnRequest>,
) -> Result<Json<ApiVpnResponse>, 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<String>,
State(_state): State<ApiServerState>,
) -> Result<StatusCode, StatusCode> {
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(
+4 -17
View File
@@ -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);
}
-37
View File
@@ -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<VpnDependencyStatus, String> {
match vpn_type {
vpn::VpnType::WireGuard => Ok(VpnDependencyStatus {
is_available: true,
requires_external_install: false,
missing_binary: false,
missing_windows_adapter: false,
dependency_check_failed: false,
}),
vpn::VpnType::OpenVPN => {
let status = crate::vpn::openvpn_socks5::OpenVpnSocks5Server::dependency_status();
let is_available =
status.binary_found && !status.missing_windows_adapter && !status.dependency_check_failed;
Ok(VpnDependencyStatus {
is_available,
requires_external_install: true,
missing_binary: !status.binary_found,
missing_windows_adapter: status.missing_windows_adapter,
dependency_check_failed: status.dependency_check_failed,
})
}
}
}
#[tauri::command]
async fn import_vpn_config(
content: String,
@@ -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,
+3 -3
View File
@@ -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",
+13 -197
View File
@@ -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<String>,
}
/// 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<VpnType, VpnError> {
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<OpenVpnConfig, VpnError> {
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("<ca>") && content.contains("</ca>");
let has_inline_cert = content.contains("<cert>") && content.contains("</cert>");
let has_inline_key = content.contains("<key>") && content.contains("</key>");
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
<ca>
-----BEGIN CERTIFICATE-----
...certificate data...
-----END CERTIFICATE-----
</ca>
<cert>
-----BEGIN CERTIFICATE-----
...cert data...
-----END CERTIFICATE-----
</cert>
<key>
-----BEGIN PRIVATE KEY-----
...key data...
-----END PRIVATE KEY-----
</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"));
}
}
+5 -8
View File
@@ -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;
-349
View File
@@ -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<Mutex<Option<Child>>>,
config_file: Option<NamedTempFile>,
connected: AtomicBool,
connected_at: Option<i64>,
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<PathBuf, VpnError> {
// 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<PathBuf, VpnError> {
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<String> = 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")),
}
}
}
-811
View File
@@ -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::<Vec<_>>()
.into_iter()
.rev()
.collect::<Vec<_>>()
.join("\n")
}
fn extract_vpn_ip(line: &str) -> Option<Ipv4Addr> {
for field in line.split(',') {
let trimmed = field.trim();
if let Ok(ip) = trimmed.parse::<Ipv4Addr>() {
if ip.is_private() && !ip.is_loopback() {
return Some(ip);
}
}
}
None
}
fn log_indicates_connected(log_content: &str) -> bool {
log_content.contains("Initialization Sequence Completed")
}
fn log_indicates_failure(log_content: &str) -> bool {
log_content.contains("AUTH_FAILED")
|| log_content.contains("Exiting due to fatal error")
|| log_content.contains("Fatal error")
|| log_content.contains("Options error")
|| log_content.contains("Exiting")
}
fn has_config_directive(config: &str, directive: &str) -> bool {
config.lines().any(|line| {
let trimmed = line.trim();
!trimmed.is_empty()
&& !trimmed.starts_with('#')
&& !trimmed.starts_with(';')
&& trimmed.starts_with(directive)
})
}
fn strip_config_directive(config: &str, directive: &str) -> String {
config
.lines()
.filter(|line| {
let trimmed = line.trim();
trimmed.is_empty()
|| trimmed.starts_with('#')
|| trimmed.starts_with(';')
|| !trimmed.starts_with(directive)
})
.collect::<Vec<_>>()
.join("\n")
}
fn build_runtime_config(&self) -> String {
let mut runtime_config = self.config.raw_config.clone();
runtime_config = Self::strip_config_directive(&runtime_config, "redirect-gateway");
runtime_config = Self::strip_config_directive(&runtime_config, "block-outside-dns");
runtime_config = Self::strip_config_directive(&runtime_config, "dhcp-option");
if !runtime_config.contains("pull-filter ignore \"redirect-gateway\"") {
runtime_config.push_str("\npull-filter ignore \"redirect-gateway\"\n");
}
if !runtime_config.contains("pull-filter ignore \"block-outside-dns\"") {
runtime_config.push_str("pull-filter ignore \"block-outside-dns\"\n");
}
if !runtime_config.contains("pull-filter ignore \"dhcp-option\"") {
runtime_config.push_str("pull-filter ignore \"dhcp-option\"\n");
}
if !Self::has_config_directive(&runtime_config, "route 0.0.0.0") {
runtime_config.push_str("\nroute 0.0.0.0 0.0.0.0 vpn_gateway 9999\n");
}
#[cfg(windows)]
{
if Self::has_config_directive(&runtime_config, "dev-node") {
runtime_config = runtime_config
.lines()
.filter(|line| {
let trimmed = line.trim();
trimmed.is_empty()
|| trimmed.starts_with('#')
|| trimmed.starts_with(';')
|| !trimmed.starts_with("dev-node")
})
.collect::<Vec<_>>()
.join("\n");
}
if !Self::has_config_directive(&runtime_config, "disable-dco") {
runtime_config.push_str("\ndisable-dco\n");
}
if self.config.dev_type.starts_with("tun")
&& !Self::has_config_directive(&runtime_config, "windows-driver")
{
runtime_config.push_str("\nwindows-driver wintun\n");
}
}
runtime_config
}
pub(crate) fn dependency_status() -> OpenVpnDependencyStatus {
let Ok(openvpn_bin) = Self::find_openvpn_binary() else {
return OpenVpnDependencyStatus {
binary_found: false,
missing_windows_adapter: false,
dependency_check_failed: false,
};
};
#[cfg(windows)]
{
match Self::windows_openvpn_has_adapter(&openvpn_bin) {
Ok(has_adapter) => OpenVpnDependencyStatus {
binary_found: true,
missing_windows_adapter: !has_adapter,
dependency_check_failed: false,
},
Err(_) => OpenVpnDependencyStatus {
binary_found: true,
missing_windows_adapter: false,
dependency_check_failed: true,
},
}
}
#[cfg(not(windows))]
{
let _ = openvpn_bin;
OpenVpnDependencyStatus {
binary_found: true,
missing_windows_adapter: false,
dependency_check_failed: false,
}
}
}
pub(crate) fn find_openvpn_binary() -> Result<PathBuf, VpnError> {
if let Ok(path) = std::env::var("DONUTBROWSER_OPENVPN_BIN") {
let path = PathBuf::from(path);
if path.exists() {
return Ok(path);
}
return Err(VpnError::Connection(format!(
"Configured OpenVPN binary does not exist: {}",
path.display()
)));
}
let locations = [
"/usr/sbin/openvpn",
"/usr/local/sbin/openvpn",
"/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<bool, VpnError> {
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
let output = Command::new(openvpn_bin)
.arg("--show-adapters")
.creation_flags(CREATE_NO_WINDOW)
.output()
.map_err(|e| VpnError::Connection(format!("Failed to inspect OpenVPN adapters: {e}")))?;
let text = format!(
"{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
Ok(
text
.lines()
.map(str::trim)
.any(|line| !line.is_empty() && !line.starts_with("Available adapters")),
)
}
fn extract_vpn_ip_from_log(log_content: &str) -> Option<Ipv4Addr> {
for line in log_content.lines() {
if let Some(ip) = Self::extract_vpn_ip(line) {
return Some(ip);
}
if let Some(position) = line.find("ifconfig ") {
let after = &line[position + "ifconfig ".len()..];
if let Some(ip_str) = after
.split_whitespace()
.next()
.or_else(|| after.split(',').next())
{
if let Ok(ip) = ip_str.parse::<Ipv4Addr>() {
if ip.is_private() && !ip.is_loopback() {
return Some(ip);
}
}
}
}
}
None
}
async fn wait_for_openvpn_ready_via_management(
child: &mut std::process::Child,
mgmt_port: u16,
log_path: &Path,
) -> Result<Option<Ipv4Addr>, VpnError> {
let deadline =
tokio::time::Instant::now() + tokio::time::Duration::from_secs(OPENVPN_CONNECT_TIMEOUT_SECS);
let mgmt_stream = loop {
if tokio::time::Instant::now() >= deadline {
return Err(VpnError::Connection(format!(
"Timed out connecting to OpenVPN management interface. Last OpenVPN output:\n{}",
Self::read_log_tail(log_path, 20)
)));
}
if let Ok(Some(status)) = child.try_wait() {
return Err(VpnError::Connection(format!(
"OpenVPN exited (status: {}) before the tunnel was established. Last output:\n{}",
status,
Self::read_log_tail(log_path, 20)
)));
}
match TcpStream::connect(("127.0.0.1", mgmt_port)).await {
Ok(stream) => break stream,
Err(_) => tokio::time::sleep(tokio::time::Duration::from_millis(500)).await,
}
};
let (mgmt_reader, mut mgmt_writer) = mgmt_stream.into_split();
let _ = mgmt_writer.write_all(b"state on\nstate\n").await;
let mut lines = BufReader::new(mgmt_reader).lines();
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(1));
interval.tick().await;
let mut vpn_ip = None;
loop {
if tokio::time::Instant::now() >= deadline {
return Err(VpnError::Connection(format!(
"Timed out waiting for OpenVPN to reach CONNECTED state. Last OpenVPN output:\n{}",
Self::read_log_tail(log_path, 20)
)));
}
if let Ok(Some(status)) = child.try_wait() {
return Err(VpnError::Connection(format!(
"OpenVPN exited (status: {}) before connecting. Last output:\n{}",
status,
Self::read_log_tail(log_path, 20)
)));
}
tokio::select! {
line_result = lines.next_line() => {
match line_result {
Ok(Some(line)) => {
if let Some(ip) = Self::extract_vpn_ip(&line) {
vpn_ip = Some(ip);
}
if line.contains(",CONNECTED,") {
break;
}
if line.contains("AUTH_FAILED") {
return Err(VpnError::Connection(format!(
"OpenVPN authentication failed. Last output:\n{}",
Self::read_log_tail(log_path, 20)
)));
}
if line.contains(",EXITING,") || line.contains(">FATAL:") {
return Err(VpnError::Connection(format!(
"OpenVPN is exiting. Last output:\n{}",
Self::read_log_tail(log_path, 20)
)));
}
}
Ok(None) => {
return Err(VpnError::Connection(format!(
"OpenVPN management connection closed before CONNECTED state. Last output:\n{}",
Self::read_log_tail(log_path, 20)
)));
}
Err(_) => {}
}
}
_ = interval.tick() => {
let _ = mgmt_writer.write_all(b"state\n").await;
let log_path = log_path.to_path_buf();
let log_content = tokio::task::spawn_blocking(move || std::fs::read_to_string(log_path))
.await
.ok()
.and_then(Result::ok);
if let Some(content) = log_content {
if Self::log_indicates_connected(&content) {
break;
}
}
}
}
}
if vpn_ip.is_none() {
if let Ok(log_content) = std::fs::read_to_string(log_path) {
vpn_ip = Self::extract_vpn_ip_from_log(&log_content);
}
}
Ok(vpn_ip)
}
async fn wait_for_openvpn_ready_via_log(
child: &mut std::process::Child,
log_path: &Path,
) -> Result<Option<Ipv4Addr>, VpnError> {
let deadline =
tokio::time::Instant::now() + tokio::time::Duration::from_secs(OPENVPN_CONNECT_TIMEOUT_SECS);
loop {
if tokio::time::Instant::now() >= deadline {
return Err(VpnError::Connection(format!(
"Timed out waiting for OpenVPN to connect. Last OpenVPN output:\n{}",
Self::read_log_tail(log_path, 40)
)));
}
if let Ok(Some(status)) = child.try_wait() {
return Err(VpnError::Connection(format!(
"OpenVPN exited (status: {}) before connecting. Last output:\n{}",
status,
Self::read_log_tail(log_path, 40)
)));
}
let log_path_buf = log_path.to_path_buf();
let log_content = tokio::task::spawn_blocking(move || std::fs::read_to_string(log_path_buf))
.await
.ok()
.and_then(Result::ok)
.unwrap_or_default();
if Self::log_indicates_connected(&log_content) {
return Ok(Self::extract_vpn_ip_from_log(&log_content));
}
if Self::log_indicates_failure(&log_content) {
return Err(VpnError::Connection(format!(
"OpenVPN reported a fatal error while connecting. Last output:\n{}",
Self::read_log_tail(log_path, 40)
)));
}
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
}
}
async fn connect_target(
target: SocksTarget,
vpn_bind_ip: Ipv4Addr,
) -> Result<(TcpStream, SocketAddr), Box<dyn std::error::Error + Send + Sync>> {
let mut addresses = match target {
SocksTarget::Address(addr) => vec![addr],
SocksTarget::Domain(host, port) => {
let mut resolved = lookup_host((host.as_str(), port))
.await?
.collect::<Vec<_>>();
resolved.sort_by_key(|addr| if addr.is_ipv4() { 0 } else { 1 });
resolved
}
};
if addresses.is_empty() {
return Err("No addresses resolved for SOCKS5 target".into());
}
let mut last_error = None;
for address in addresses.drain(..) {
let socket = if address.is_ipv4() {
let socket = TcpSocket::new_v4()?;
if !vpn_bind_ip.is_unspecified() {
socket.bind(SocketAddr::new(IpAddr::V4(vpn_bind_ip), 0))?;
}
socket
} else {
TcpSocket::new_v6()?
};
match socket.connect(address).await {
Ok(stream) => return Ok((stream, address)),
Err(error) => last_error = Some(error),
}
}
Err(
last_error
.map(|error| error.into())
.unwrap_or_else(|| "Failed to connect to any resolved SOCKS5 target".into()),
)
}
pub async fn run(self, config_id: String) -> Result<(), VpnError> {
let openvpn_bin = Self::find_openvpn_binary()?;
let supports_management = Self::openvpn_supports_management(&openvpn_bin);
#[cfg(windows)]
if !Self::windows_openvpn_has_adapter(&openvpn_bin)? {
return Err(VpnError::Connection(
"OpenVPN requires a TAP/Wintun/ovpn-dco adapter on Windows, but none were found. Install or provision an adapter before connecting.".to_string(),
));
}
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<Ipv4Addr>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
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")),
}
}
}
+53 -6
View File
@@ -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<std::path::PathBuf>,
) -> 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!(
+13 -12
View File
@@ -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<VpnConfig, VpnError> {
// 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<VpnConfig, VpnError> {
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),
+4 -13
View File
@@ -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<VpnWorkerConfig, Box<dyn std::error::Error>> {
let startup_timeout = if vpn_type == "openvpn" {
tokio::time::Duration::from_millis(OPENVPN_WORKER_STARTUP_TIMEOUT_MS)
} else {
tokio::time::Duration::from_millis(VPN_WORKER_STARTUP_TIMEOUT_MS)
};
let startup_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<VpnWorkerConfig, Box<dyn s
return Ok(existing);
}
return wait_for_vpn_worker_ready(&existing.id, &existing.vpn_type).await;
return wait_for_vpn_worker_ready(&existing.id).await;
}
}
// Worker config exists but process is dead, clean up
@@ -141,10 +135,7 @@ pub async fn start_vpn_worker(vpn_id: &str) -> Result<VpnWorkerConfig, Box<dyn s
.map_err(|e| format!("Failed to load VPN config: {e}"))?
};
let vpn_type_str = match vpn_config.vpn_type {
crate::vpn::VpnType::WireGuard => "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<VpnWorkerConfig, Box<dyn s
drop(child);
}
wait_for_vpn_worker_ready(&id, vpn_type_str).await
wait_for_vpn_worker_ready(&id).await
}
pub async fn stop_vpn_worker(id: &str) -> Result<bool, Box<dyn std::error::Error>> {
+25 -2
View File
@@ -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<dyn st
fs::create_dir_all(&storage_dir)?;
let file_path = storage_dir.join(format!("vpn_worker_{}.json", config.id));
let content = serde_json::to_string_pretty(config)?;
fs::write(&file_path, content)?;
save_vpn_worker_config_to_path(config, &file_path)
}
/// Write a worker config to a specific path. Used by detached worker
/// processes that already know their config file path (passed via
/// `--config-path`) and must write back to the same location regardless of
/// how `get_storage_dir()` resolves in the worker process — which can
/// differ from the parent on Linux distros that sandbox the GUI (Qubes,
/// flatpak, etc.) and is the cause of issue #287.
pub fn save_vpn_worker_config_to_path(
config: &VpnWorkerConfig,
path: &Path,
) -> Result<(), Box<dyn std::error::Error>> {
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<VpnWorkerConfig> {
let content = fs::read_to_string(path).ok()?;
serde_json::from_str(&content).ok()
}
pub fn get_vpn_worker_config(id: &str) -> Option<VpnWorkerConfig> {
let storage_dir = get_storage_dir();
let file_path = storage_dir.join(format!("vpn_worker_{}.json", id));