feat: xray support

This commit is contained in:
zhom
2026-07-31 01:04:58 +04:00
parent 064bf297dd
commit 0a7d7803f2
112 changed files with 10291 additions and 772 deletions
+12
View File
@@ -2772,6 +2772,18 @@ mod tests {
"proxy_settings must be optional on update, required list: {update_proxy:?}"
);
let proxy_settings = schema_required(&spec, "ProxySettings");
for field in ["username", "password", "vless_uri"] {
assert!(
!proxy_settings.iter().any(|candidate| candidate == field),
"{field} must be optional in proxy settings, required list: {proxy_settings:?}"
);
assert!(
spec["components"]["schemas"]["ProxySettings"]["properties"][field].is_object(),
"{field} must be present in the served ProxySettings schema"
);
}
let import_profiles = schema_required(&spec, "ImportProfilesRequest");
for field in ["group_id", "duplicate_strategy", "wayfern_config"] {
assert!(
+30
View File
@@ -195,6 +195,17 @@ async fn main() {
.help("Direct path to the VPN worker config JSON file"),
),
)
.subcommand(
Command::new("xray-worker")
.about("Run an Xray-core worker process (internal use)")
.arg(Arg::new("action").required(true).help("Action (start)"))
.arg(
Arg::new("config-path")
.long("config-path")
.required(true)
.help("Direct path to the Xray worker config JSON file"),
),
)
.subcommand(
Command::new("mcp-bridge")
.about("Bridge stdio MCP to a local HTTP MCP server")
@@ -509,6 +520,25 @@ async fn main() {
log::error!("Invalid action for vpn-worker. Use 'start'");
process::exit(1);
}
} else if let Some(xray_matches) = matches.subcommand_matches("xray-worker") {
let action = xray_matches
.get_one::<String>("action")
.expect("action is required");
let config_path = xray_matches
.get_one::<String>("config-path")
.expect("config-path is required");
if action != "start" {
log::error!("Invalid action for xray-worker. Use 'start'");
process::exit(1);
}
set_high_priority();
if let Err(error) =
donutbrowser_lib::xray_worker_runner::run_xray_worker(std::path::Path::new(config_path)).await
{
log::error!("Xray worker failed: {error}");
process::exit(1);
}
} else if let Some(bridge_matches) = matches.subcommand_matches("mcp-bridge") {
let url = bridge_matches
.get_one::<String>("url")
+5 -2
View File
@@ -2,13 +2,15 @@ use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use utoipa::ToSchema;
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub struct ProxySettings {
pub proxy_type: String, // "http", "https", "socks4", "socks5", or "ss" (Shadowsocks)
pub proxy_type: String,
pub host: String,
pub port: u16,
pub username: Option<String>,
pub password: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vless_uri: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@@ -581,6 +583,7 @@ mod tests {
port: 8080,
username: None,
password: None,
vless_uri: None,
};
// Test that it can be serialized (implements Serialize)
+179 -75
View File
@@ -6,9 +6,26 @@ use crate::profile::{BrowserProfile, ProfileManager};
use crate::proxy_manager::PROXY_MANAGER;
use crate::wayfern_manager::{WayfernConfig, WayfernManager};
use serde::Serialize;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::{Arc, LazyLock};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
static PROFILE_LAUNCH_LOCKS: LazyLock<
tokio::sync::Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
> = LazyLock::new(|| tokio::sync::Mutex::new(HashMap::new()));
async fn lock_profile_launch(profile_id: &str) -> tokio::sync::OwnedMutexGuard<()> {
let lock = {
let mut locks = PROFILE_LAUNCH_LOCKS.lock().await;
locks
.entry(profile_id.to_string())
.or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
.clone()
};
lock.lock_owned().await
}
pub struct BrowserRunner {
pub profile_manager: &'static ProfileManager,
pub downloaded_browsers_registry: &'static DownloadedBrowsersRegistry,
@@ -181,18 +198,6 @@ impl BrowserRunner {
.map_err(|e| format!("Failed to get executable path for {}: {e}", profile.browser).into())
}
pub async fn launch_browser(
&self,
app_handle: tauri::AppHandle,
profile: &BrowserProfile,
url: Option<String>,
local_proxy_settings: Option<&ProxySettings>,
) -> Result<BrowserProfile, Box<dyn std::error::Error + Send + Sync>> {
self
.launch_browser_internal(app_handle, profile, url, local_proxy_settings, None, false)
.await
}
async fn launch_browser_internal(
&self,
app_handle: tauri::AppHandle,
@@ -218,6 +223,52 @@ impl BrowserRunner {
.resolve_launch_proxy(profile)
.await
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.into() })?;
let geo_proxy_signature_settings = upstream_proxy.clone();
struct XrayLaunchGuard {
worker_id: Option<String>,
profile_name: String,
}
impl Drop for XrayLaunchGuard {
fn drop(&mut self) {
let Some(worker_id) = self.worker_id.take() else {
return;
};
log::warn!(
"Launch failed after Xray-core start for profile {}; stopping worker",
self.profile_name
);
if let Err(error) = crate::xray_worker_runner::stop_xray_worker_now(&worker_id) {
log::warn!("Failed to stop Xray-core worker after failed launch: {error}");
}
}
}
let mut xray_launch_guard = XrayLaunchGuard {
worker_id: None,
profile_name: profile.name.clone(),
};
if upstream_proxy
.as_ref()
.is_some_and(|proxy| proxy.proxy_type.eq_ignore_ascii_case("vless"))
{
let vless_uri = upstream_proxy
.as_ref()
.and_then(|proxy| proxy.vless_uri.as_deref())
.ok_or_else(|| crate::backend_error("VLESS_CONFIG_INVALID"))?;
let worker =
crate::xray_worker_runner::start_xray_worker(Some(&profile.id.to_string()), vless_uri)
.await
.map_err(|error| -> Box<dyn std::error::Error + Send + Sync> {
error.to_string().into()
})?;
log::info!(
"Xray-core worker started for Wayfern profile on port {}",
worker.local_port
);
xray_launch_guard.worker_id = Some(worker.id.clone());
upstream_proxy = Some(worker.local_proxy_settings());
}
// If profile has a VPN instead of proxy, start VPN worker and use it as upstream
if upstream_proxy.is_none() {
@@ -231,6 +282,7 @@ impl BrowserRunner {
port,
username: None,
password: None,
vless_uri: None,
});
log::info!("VPN worker started for Wayfern profile on port {}", port);
}
@@ -287,7 +339,7 @@ impl BrowserRunner {
// would survive until machine reboot.
struct ProxyLaunchGuard {
app_handle: tauri::AppHandle,
placeholder_pid: u32,
routing_pid: u32,
profile_name: String,
armed: bool,
}
@@ -299,7 +351,7 @@ impl BrowserRunner {
self.profile_name
);
let app_handle = self.app_handle.clone();
let pid = self.placeholder_pid;
let pid = self.routing_pid;
tauri::async_runtime::spawn(async move {
if let Err(e) = PROXY_MANAGER.stop_proxy(app_handle, pid).await {
log::warn!("Failed to stop proxy worker after failed launch: {e}");
@@ -310,7 +362,7 @@ impl BrowserRunner {
}
let mut proxy_launch_guard = ProxyLaunchGuard {
app_handle: app_handle.clone(),
placeholder_pid: launch_placeholder_pid,
routing_pid: launch_placeholder_pid,
profile_name: profile.name.clone(),
armed: true,
};
@@ -369,7 +421,7 @@ impl BrowserRunner {
// later on-demand match can tell the location was never resolved.
updated_wayfern_config.geo_proxy_signature = if geolocation_applied {
Some(crate::wayfern_manager::WayfernManager::geo_signature(
upstream_proxy.as_ref(),
geo_proxy_signature_settings.as_ref(),
profile.vpn_id.as_deref(),
wayfern_config.geoip.as_ref(),
))
@@ -454,14 +506,52 @@ impl BrowserRunner {
format!("Failed to launch Wayfern: {e}").into()
})?;
// Browser is up and using the worker — failures past this point must
// not stop it.
proxy_launch_guard.armed = false;
// Get the process ID from launch result
let process_id = wayfern_result.processId.unwrap_or(0);
let Some(process_id) = wayfern_result.processId.filter(|pid| *pid != 0) else {
if let Err(error) = self.wayfern_manager.stop_wayfern(&wayfern_result.id).await {
log::warn!("Failed to stop Wayfern after it omitted its process ID: {error}");
}
return Err(
crate::backend_error_with_detail(
"INTERNAL_ERROR",
"Wayfern did not report a process identifier",
)
.into(),
);
};
log::info!("Wayfern launched successfully with PID: {process_id}");
if let Err(error) = PROXY_MANAGER.update_proxy_pid(launch_placeholder_pid, process_id) {
if let Err(stop_error) = self.wayfern_manager.stop_wayfern(&wayfern_result.id).await {
log::warn!("Failed to stop Wayfern after proxy PID mapping failed: {stop_error}");
}
return Err(crate::backend_error_with_detail("INTERNAL_ERROR", error).into());
}
proxy_launch_guard.routing_pid = process_id;
log::info!(
"Updated proxy PID mapping from launch placeholder {launch_placeholder_pid} to actual PID: {process_id}"
);
if !PROXY_MANAGER.set_browser_pid_for_profile(&updated_profile.id.to_string(), process_id) {
if let Err(error) = self.wayfern_manager.stop_wayfern(&wayfern_result.id).await {
log::warn!("Failed to stop Wayfern after proxy worker reassignment failed: {error}");
}
return Err(crate::backend_error("INTERNAL_ERROR").into());
}
if let Some(worker_id) = xray_launch_guard.worker_id.as_deref() {
if !crate::xray_worker_runner::set_browser_pid(worker_id, process_id) {
if let Err(error) = self.wayfern_manager.stop_wayfern(&wayfern_result.id).await {
log::warn!("Failed to stop Wayfern after Xray worker reassignment failed: {error}");
}
return Err(crate::backend_error("XRAY_START_FAILED").into());
}
}
// The browser and both detached routing workers now share one verified
// process identity, so later profile-persistence failures must not tear
// down a live route.
proxy_launch_guard.armed = false;
xray_launch_guard.worker_id = None;
// Wayfern.setFingerprint echoes back the fingerprint the browser actually
// applied, which may be UPGRADED from the stored one (e.g. when the
// stored fingerprint targets an older browser version). Persist it so the
@@ -484,24 +574,6 @@ impl BrowserRunner {
updated_profile.process_id = Some(process_id);
updated_profile.last_launch = Some(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs());
// Update the proxy manager with the correct PID. When the browser
// reported no PID, keep the entry keyed by its unique placeholder (which
// the cleanup sweep skips) rather than remapping to a shared 0 key that
// concurrent launches could collide on.
if process_id != 0 {
if let Err(e) = PROXY_MANAGER.update_proxy_pid(launch_placeholder_pid, process_id) {
log::warn!("Warning: Failed to update proxy PID mapping: {e}");
} else {
log::info!(
"Updated proxy PID mapping from launch placeholder {launch_placeholder_pid} to actual PID: {process_id}"
);
}
}
// Persist the real browser PID so the detached proxy worker self-reaps
// when this browser dies, even after the GUI exits/restarts.
PROXY_MANAGER.set_browser_pid_for_profile(&updated_profile.id.to_string(), process_id);
// Save the updated profile
log::info!(
"Saving profile {} with wayfern_config fingerprint length: {}",
@@ -682,8 +754,7 @@ impl BrowserRunner {
final_profile.process_id.is_some()
);
if is_running && url.is_some() {
// Browser is running and we have a URL to open
if is_running {
if let Some(url_ref) = url.as_ref() {
log::info!(
"Opening {} in existing browser",
@@ -708,44 +779,15 @@ impl BrowserRunner {
"Failed to open URL in existing browser: {}",
crate::log_redaction::text(&e.to_string())
);
// Fall back to launching a new instance
log::info!(
"Falling back to new instance for browser: {}",
final_profile.browser
);
// Fallback to launching a new instance for other browsers
self
.launch_browser_internal(
app_handle.clone(),
&final_profile,
url,
internal_proxy_settings,
None,
false,
)
.await
Err(e)
}
}
} else {
// This case shouldn't happen since we checked is_some() above, but handle it gracefully
log::info!("URL was unexpectedly None, launching new browser instance");
self
.launch_browser(
app_handle.clone(),
&final_profile,
url,
internal_proxy_settings,
)
.await
log::info!("Browser is already running and no URL was requested");
Ok(final_profile)
}
} else {
// Browser is not running or no URL provided, launch new instance
if !is_running {
log::info!("Launching new browser instance - browser not running");
} else {
log::info!("Launching new browser instance - no URL provided");
}
log::info!("Launching new browser instance - browser not running");
self
.launch_browser_internal(
app_handle.clone(),
@@ -785,6 +827,17 @@ impl BrowserRunner {
&self,
app_handle: tauri::AppHandle,
profile: &BrowserProfile,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let _profile_launch_guard = lock_profile_launch(&profile.id.to_string()).await;
self
.kill_browser_process_unlocked(app_handle, profile)
.await
}
async fn kill_browser_process_unlocked(
&self,
app_handle: tauri::AppHandle,
profile: &BrowserProfile,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// Handle Wayfern profiles using WayfernManager
if profile.browser == "wayfern" {
@@ -810,6 +863,14 @@ impl BrowserRunner {
profile_id_str
);
}
if let Err(error) =
crate::xray_worker_runner::stop_xray_worker_by_profile_id(&profile_id_str).await
{
log::warn!(
"Warning: Failed to stop Xray-core worker for profile {}: {error}",
profile_id_str
);
}
let mut process_actually_stopped = false;
match self
@@ -1159,6 +1220,7 @@ impl BrowserRunner {
.into_iter()
.find(|p| p.id.to_string() == profile_id)
.ok_or_else(|| format!("Profile '{profile_id}' not found"))?;
let _profile_launch_guard = lock_profile_launch(&profile.id.to_string()).await;
if profile.is_cross_os() {
return Err(format!(
@@ -1209,6 +1271,7 @@ pub async fn launch_browser_profile_impl(
profile.name,
profile.id
);
let _profile_launch_guard = lock_profile_launch(&profile.id.to_string()).await;
if profile.is_cross_os() {
return Err(format!(
@@ -1259,6 +1322,17 @@ pub async fn launch_browser_profile_impl(
profile_for_launch.id
);
if force_new
&& browser_runner
.check_browser_status(app_handle.clone(), &profile_for_launch)
.await
.map_err(|error| {
crate::wrap_backend_error(error, "Failed to check browser status before launch")
})?
{
return Err(crate::backend_error("PROFILE_RUNNING"));
}
// Launch browser or open URL in existing instance. Wayfern starts its
// own local proxy inside `launch_browser_internal`; other browser types
// are rejected there, so no proxy needs to be staged here.
@@ -1339,7 +1413,6 @@ pub async fn kill_browser_profile(
profile.name,
profile.id
);
let browser_runner = BrowserRunner::instance();
match browser_runner
@@ -1447,6 +1520,37 @@ pub async fn open_url_with_profile(
.await
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn profile_launch_lock_serializes_only_the_same_profile() {
let profile = format!("launch-lock-{}", uuid::Uuid::new_v4());
let other_profile = format!("launch-lock-{}", uuid::Uuid::new_v4());
let first = lock_profile_launch(&profile).await;
assert!(tokio::time::timeout(
Duration::from_millis(100),
lock_profile_launch(&other_profile)
)
.await
.is_ok());
assert!(
tokio::time::timeout(Duration::from_millis(100), lock_profile_launch(&profile))
.await
.is_err()
);
drop(first);
assert!(
tokio::time::timeout(Duration::from_millis(100), lock_profile_launch(&profile))
.await
.is_ok()
);
}
}
// Global singleton instance
lazy_static::lazy_static! {
static ref BROWSER_RUNNER: BrowserRunner = BrowserRunner::new();
+1
View File
@@ -973,6 +973,7 @@ impl CloudAuthManager {
port: config.port,
username: config.username,
password: config.password,
vless_uri: None,
};
match PROXY_MANAGER.upsert_cloud_proxy(settings) {
Ok(_) => {
+27 -10
View File
@@ -68,13 +68,18 @@ impl ConsistencyResult {
}
}
/// URL for handing this proxy to reqwest, or None for upstreams reqwest can't
/// drive (Shadowsocks).
fn proxy_url(settings: &crate::browser::ProxySettings) -> Option<String> {
/// URL for handing this proxy to reqwest. VLESS is reached through the
/// authenticated loopback Xray-core worker already serving the profile.
fn proxy_url(settings: &crate::browser::ProxySettings, profile_id: Option<&str>) -> Option<String> {
match settings.proxy_type.to_lowercase().as_str() {
"http" | "https" | "socks4" | "socks5" => Some(
crate::proxy_manager::ProxyManager::build_proxy_url(settings),
),
"vless" => profile_id
.and_then(crate::xray_worker_storage::find_xray_worker_by_profile_id)
.map(|worker| {
crate::proxy_manager::ProxyManager::build_proxy_url(&worker.local_proxy_settings())
}),
_ => None,
}
}
@@ -125,9 +130,15 @@ pub async fn check_profile_consistency(
let Some(settings) = PROXY_MANAGER.get_proxy_settings_by_id(proxy_id) else {
return Ok(ConsistencyResult::skip());
};
let Some(url) = proxy_url(&settings) else {
let profile_id = profile.id.to_string();
let Some(url) = proxy_url(&settings, Some(&profile_id)) else {
return Ok(ConsistencyResult::skip());
};
let cache_identity = if settings.proxy_type.eq_ignore_ascii_case("vless") {
settings.vless_uri.clone().unwrap_or_else(|| url.clone())
} else {
url.clone()
};
let now = crate::proxy_manager::now_secs();
@@ -137,7 +148,9 @@ pub async fn check_profile_consistency(
let cache = EXIT_CACHE.lock().unwrap();
cache
.get(proxy_id)
.filter(|c| c.proxy_url == url && now.saturating_sub(c.fetched_at) < EXIT_CACHE_TTL_SECS)
.filter(|c| {
c.proxy_url == cache_identity && now.saturating_sub(c.fetched_at) < EXIT_CACHE_TTL_SECS
})
.cloned()
};
@@ -163,7 +176,7 @@ pub async fn check_profile_consistency(
proxy_id.clone(),
CachedExit {
fetched_at: now,
proxy_url: url.clone(),
proxy_url: cache_identity,
timezone: tz.clone(),
country_code: cc.clone(),
ip: ip.clone(),
@@ -339,8 +352,9 @@ mod tests {
port: 8080,
username: Some("u".into()),
password: Some("p".into()),
vless_uri: None,
};
assert_eq!(proxy_url(&http).as_deref(), Some("http://u:p@h:8080"));
assert_eq!(proxy_url(&http, None).as_deref(), Some("http://u:p@h:8080"));
// A password with URL-reserved characters must not break the authority —
// unencoded, the `/` truncates the host and reqwest targets `u` instead.
@@ -350,9 +364,10 @@ mod tests {
port: 8080,
username: Some("user".into()),
password: Some("ab/cd@ef".into()),
vless_uri: None,
};
assert_eq!(
proxy_url(&reserved).as_deref(),
proxy_url(&reserved, None).as_deref(),
Some("http://user:ab%2Fcd%40ef@gw.provider.io:8080")
);
@@ -363,9 +378,10 @@ mod tests {
port: 1080,
username: Some("justuser".into()),
password: None,
vless_uri: None,
};
assert_eq!(
proxy_url(&user_only).as_deref(),
proxy_url(&user_only, None).as_deref(),
Some("socks5://justuser@h:1080")
);
@@ -375,7 +391,8 @@ mod tests {
port: 8080,
username: None,
password: None,
vless_uri: None,
};
assert_eq!(proxy_url(&ss), None);
assert_eq!(proxy_url(&ss, None), None);
}
}
+3
View File
@@ -92,6 +92,9 @@ mod version_updater;
pub mod vpn;
pub mod vpn_worker_runner;
pub mod vpn_worker_storage;
pub mod xray;
pub mod xray_worker_runner;
pub mod xray_worker_storage;
use browser_runner::{
check_browser_exists, kill_browser_profile, launch_browser_profile, open_url_with_profile,
+102 -29
View File
@@ -937,8 +937,8 @@ impl McpServer {
},
"proxy_type": {
"type": "string",
"enum": ["http", "https", "socks4", "socks5"],
"description": "The type of proxy (for regular proxies)"
"enum": ["http", "https", "socks4", "socks5", "vless"],
"description": "The proxy protocol"
},
"host": {
"type": "string",
@@ -955,9 +955,13 @@ impl McpServer {
"password": {
"type": "string",
"description": "Optional password for authentication (for regular proxies)"
},
"vless_uri": {
"type": "string",
"description": "VLESS + XTLS Vision + REALITY share URI"
}
},
"required": ["name", "proxy_type", "host", "port"]
"required": ["name", "proxy_type"]
}),
},
McpTool {
@@ -976,8 +980,8 @@ impl McpServer {
},
"proxy_type": {
"type": "string",
"enum": ["http", "https", "socks4", "socks5"],
"description": "The type of proxy (for regular proxies)"
"enum": ["http", "https", "socks4", "socks5", "vless"],
"description": "The proxy protocol"
},
"host": {
"type": "string",
@@ -994,6 +998,10 @@ impl McpServer {
"password": {
"type": "string",
"description": "Optional password for authentication (for regular proxies)"
},
"vless_uri": {
"type": "string",
"description": "VLESS + XTLS Vision + REALITY share URI"
}
},
"required": ["proxy_id"]
@@ -3000,28 +3008,45 @@ impl McpServer {
// The tool schema declares an enum, but JSON-Schema enums are advisory only;
// enforce it here so a bad value can't produce a non-functional proxy.
if !matches!(proxy_type, "http" | "https" | "socks4" | "socks5") {
if !matches!(proxy_type, "http" | "https" | "socks4" | "socks5" | "vless") {
return Err(McpError {
code: -32602,
message: "proxy_type must be one of: http, https, socks4, socks5".to_string(),
message: "proxy_type must be one of: http, https, socks4, socks5, vless".to_string(),
});
}
let host = arguments
.get("host")
.and_then(|v| v.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing host".to_string(),
})?;
let port = arguments
.get("port")
.and_then(|v| v.as_u64())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing port".to_string(),
})? as u16;
let vless_uri = arguments
.get("vless_uri")
.and_then(|value| value.as_str())
.map(str::to_string);
let (host, port) = if proxy_type == "vless" {
if vless_uri.is_none() {
return Err(McpError {
code: -32602,
message: "Missing vless_uri".to_string(),
});
}
(String::new(), 1)
} else {
let host = arguments
.get("host")
.and_then(|value| value.as_str())
.ok_or_else(|| McpError {
code: -32602,
message: "Missing host".to_string(),
})?
.to_string();
let port = arguments
.get("port")
.and_then(|value| value.as_u64())
.and_then(|port| u16::try_from(port).ok())
.filter(|port| *port != 0)
.ok_or_else(|| McpError {
code: -32602,
message: "Missing or invalid port".to_string(),
})?;
(host, port)
};
let username = arguments
.get("username")
@@ -3034,10 +3059,11 @@ impl McpServer {
let proxy_settings = ProxySettings {
proxy_type: proxy_type.to_string(),
host: host.to_string(),
host,
port,
username,
password,
vless_uri,
};
let proxy = PROXY_MANAGER
@@ -3075,7 +3101,10 @@ impl McpServer {
// Build proxy_settings if any settings fields are provided
let has_settings = arguments.get("proxy_type").is_some()
|| arguments.get("host").is_some()
|| arguments.get("port").is_some();
|| arguments.get("port").is_some()
|| arguments.get("username").is_some()
|| arguments.get("password").is_some()
|| arguments.get("vless_uri").is_some();
let proxy_settings = if has_settings {
// Get existing proxy to use as defaults
@@ -3093,6 +3122,15 @@ impl McpServer {
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| existing.proxy_settings.proxy_type.clone());
if !matches!(
proxy_type.as_str(),
"http" | "https" | "socks4" | "socks5" | "vless"
) {
return Err(McpError {
code: -32602,
message: "proxy_type must be one of: http, https, socks4, socks5, vless".to_string(),
});
}
let host = arguments
.get("host")
@@ -3100,11 +3138,17 @@ impl McpServer {
.map(|s| s.to_string())
.unwrap_or_else(|| existing.proxy_settings.host.clone());
let port = arguments
.get("port")
.and_then(|v| v.as_u64())
.map(|p| p as u16)
.unwrap_or(existing.proxy_settings.port);
let port = match arguments.get("port") {
Some(value) => value
.as_u64()
.and_then(|port| u16::try_from(port).ok())
.filter(|port| *port != 0)
.ok_or_else(|| McpError {
code: -32602,
message: "Invalid port".to_string(),
})?,
None => existing.proxy_settings.port,
};
let username = arguments
.get("username")
@@ -3117,6 +3161,11 @@ impl McpServer {
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.or_else(|| existing.proxy_settings.password.clone());
let vless_uri = arguments
.get("vless_uri")
.and_then(|value| value.as_str())
.map(str::to_string)
.or_else(|| existing.proxy_settings.vless_uri.clone());
Some(ProxySettings {
proxy_type,
@@ -3124,6 +3173,7 @@ impl McpServer {
port,
username,
password,
vless_uri,
})
} else {
None
@@ -5560,6 +5610,29 @@ mod tests {
assert!(!server.is_running());
}
#[test]
fn proxy_tool_schema_exposes_vless_reality_without_requiring_regular_endpoint_fields() {
let server = McpServer::new();
let tools = server.get_tools();
let create = tools
.iter()
.find(|tool| tool.name == "create_proxy")
.expect("create_proxy tool");
let properties = &create.input_schema["properties"];
assert!(properties["proxy_type"]["enum"]
.as_array()
.is_some_and(|values| values.iter().any(|value| value == "vless")));
assert!(properties["vless_uri"].is_object());
let required = create.input_schema["required"]
.as_array()
.expect("required fields");
assert!(required.iter().any(|field| field == "name"));
assert!(required.iter().any(|field| field == "proxy_type"));
assert!(!required.iter().any(|field| field == "host"));
assert!(!required.iter().any(|field| field == "port"));
}
#[test]
fn rate_limit_only_classifies_browser_automation_tools() {
let request = |method: &str, name: Option<&str>| McpRequest {
+227 -21
View File
@@ -32,6 +32,8 @@ pub struct ExportedProxy {
pub username: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub password: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vless_uri: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -49,6 +51,8 @@ pub struct ParsedProxyLine {
pub port: u16,
pub username: Option<String>,
pub password: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vless_uri: Option<String>,
pub original_line: String,
}
@@ -418,6 +422,31 @@ impl ProxyManager {
Ok(())
}
fn normalize_proxy_settings(mut proxy_settings: ProxySettings) -> Result<ProxySettings, String> {
if !proxy_settings.proxy_type.eq_ignore_ascii_case("vless") {
proxy_settings.vless_uri = None;
return Ok(proxy_settings);
}
let uri = proxy_settings
.vless_uri
.as_deref()
.filter(|uri| !uri.is_empty())
.ok_or_else(|| crate::backend_error("VLESS_CONFIG_INVALID"))?;
let parsed = crate::xray::parse_vless_uri(uri)
.map_err(|error| crate::backend_error_with_detail("VLESS_CONFIG_INVALID", error))?;
let canonical_uri = crate::xray::export_vless_uri(&parsed.config, parsed.name.as_deref())
.map_err(|error| crate::backend_error_with_detail("VLESS_CONFIG_INVALID", error))?;
proxy_settings.proxy_type = "vless".to_string();
proxy_settings.host = parsed.config.address;
proxy_settings.port = parsed.config.port;
proxy_settings.username = None;
proxy_settings.password = None;
proxy_settings.vless_uri = Some(canonical_uri);
Ok(proxy_settings)
}
// Create a new stored proxy
pub fn create_stored_proxy(
&self,
@@ -437,6 +466,7 @@ impl ProxyManager {
}
}
let proxy_settings = Self::normalize_proxy_settings(proxy_settings)?;
let stored_proxy = StoredProxy::new(name, proxy_settings);
{
@@ -670,6 +700,7 @@ impl ProxyManager {
port: base_proxy.proxy_settings.port,
username: Some(geo_username),
password: base_proxy.proxy_settings.password.clone(),
vless_uri: None,
};
// Check if name already exists
@@ -822,6 +853,10 @@ impl ProxyManager {
return Err(serde_json::json!({ "code": "NAME_CANNOT_BE_EMPTY" }).to_string());
}
let proxy_settings = proxy_settings
.map(Self::normalize_proxy_settings)
.transpose()?;
// First, check for conflicts without holding a mutable reference
{
let stored_proxies = self.stored_proxies.lock().unwrap();
@@ -1063,6 +1098,10 @@ impl ProxyManager {
// a password containing `/`, `#`, `?` or `@` otherwise breaks the URL
// authority and silently retargets the request at the wrong host.
pub fn build_proxy_url(proxy_settings: &ProxySettings) -> String {
if proxy_settings.proxy_type.eq_ignore_ascii_case("vless") {
return proxy_settings.vless_uri.clone().unwrap_or_default();
}
let mut url = format!("{}://", proxy_settings.proxy_type);
if let (Some(username), Some(password)) = (&proxy_settings.username, &proxy_settings.password) {
@@ -1090,9 +1129,22 @@ impl ProxyManager {
proxy_id: &str,
proxy_settings: &ProxySettings,
) -> Result<ProxyCheckResult, String> {
let upstream_url = Self::build_proxy_url(proxy_settings);
let mut xray_worker_id = None;
let effective_proxy_settings = if proxy_settings.proxy_type.eq_ignore_ascii_case("vless") {
let uri = proxy_settings
.vless_uri
.as_deref()
.ok_or_else(|| crate::backend_error("VLESS_CONFIG_INVALID"))?;
let worker = crate::xray_worker_runner::start_xray_worker(None, uri)
.await
.map_err(|error| error.to_string())?;
xray_worker_id = Some(worker.id.clone());
worker.local_proxy_settings()
} else {
proxy_settings.clone()
};
let upstream_url = Self::build_proxy_url(&effective_proxy_settings);
// Try process-based check first (identical to browser launch path)
// Try process-based check first (identical to browser launch path).
// If the proxy worker fails to start (e.g. Gatekeeper, antivirus, signing
// restrictions), fall back to a direct reqwest check.
@@ -1130,13 +1182,21 @@ impl ProxyManager {
result
}
Err(err_msg) => {
log::warn!(
"Proxy worker failed to start ({}), falling back to direct check",
err_msg
);
ip_utils::fetch_public_ip(Some(&upstream_url)).await
if xray_worker_id.is_some() {
log::warn!("Local proxy worker failed to start in front of Xray-core: {err_msg}");
Err(ip_utils::IpError::Network(err_msg))
} else {
log::warn!(
"Proxy worker failed to start ({}), falling back to direct check",
err_msg
);
ip_utils::fetch_public_ip(Some(&upstream_url)).await
}
}
};
if let Some(worker_id) = xray_worker_id {
let _ = crate::xray_worker_runner::stop_xray_worker(&worker_id).await;
}
let ip = match ip_result {
Ok(ip) => ip,
@@ -1195,6 +1255,7 @@ impl ProxyManager {
port: p.proxy_settings.port,
username: p.proxy_settings.username.clone(),
password: p.proxy_settings.password.clone(),
vless_uri: p.proxy_settings.vless_uri.clone(),
})
.collect();
@@ -1248,6 +1309,7 @@ impl ProxyManager {
port,
username: None,
password: None,
vless_uri: None,
original_line: line.to_string(),
});
}
@@ -1283,6 +1345,7 @@ impl ProxyManager {
port,
username: Some(parts[2].to_string()),
password: Some(parts[3].to_string()),
vless_uri: None,
original_line: line.to_string(),
})
}
@@ -1295,6 +1358,7 @@ impl ProxyManager {
port,
username: Some(parts[0].to_string()),
password: Some(parts[1].to_string()),
vless_uri: None,
original_line: line.to_string(),
})
}
@@ -1322,6 +1386,24 @@ impl ProxyManager {
// Try to parse URL format: protocol://username:password@host:port
fn try_parse_url_format(line: &str) -> Option<ProxyParseResult> {
if line.starts_with("vless://") {
return Some(match crate::xray::parse_vless_uri(line) {
Ok(parsed) => ProxyParseResult::Parsed(ParsedProxyLine {
proxy_type: "vless".to_string(),
host: parsed.config.address,
port: parsed.config.port,
username: None,
password: None,
vless_uri: Some(line.to_string()),
original_line: line.to_string(),
}),
Err(error) => ProxyParseResult::Invalid {
line: line.to_string(),
reason: error.to_string(),
},
});
}
// Check for protocol prefix using strip_prefix
let (protocol, rest) = if let Some(rest) = line.strip_prefix("http://") {
("http", rest)
@@ -1367,6 +1449,7 @@ impl ProxyManager {
port,
username,
password,
vless_uri: None,
original_line: line.to_string(),
}));
}
@@ -1382,6 +1465,7 @@ impl ProxyManager {
port,
username: None,
password: None,
vless_uri: None,
original_line: line.to_string(),
}));
}
@@ -1419,6 +1503,7 @@ impl ProxyManager {
port,
username,
password,
vless_uri: None,
original_line: line.to_string(),
}));
}
@@ -1447,6 +1532,7 @@ impl ProxyManager {
port: exported.port,
username: exported.username,
password: exported.password,
vless_uri: exported.vless_uri,
};
match self.create_stored_proxy(app_handle, exported.name.clone(), proxy_settings) {
@@ -1489,6 +1575,7 @@ impl ProxyManager {
port: parsed.port,
username: parsed.username,
password: parsed.password,
vless_uri: parsed.vless_uri,
};
match self.create_stored_proxy(app_handle, proxy_name.clone(), proxy_settings) {
@@ -1564,6 +1651,7 @@ impl ProxyManager {
port: existing.local_port,
username: None,
password: None,
vless_uri: None,
});
}
// Need to add this PID to the mapping - we'll do that after starting
@@ -1604,6 +1692,7 @@ impl ProxyManager {
port: existing.local_port,
username: None,
password: None,
vless_uri: None,
});
}
// Profile ID changed - we'll create a new proxy but don't stop the old one
@@ -1769,6 +1858,7 @@ impl ProxyManager {
port: proxy_info.local_port,
username: None,
password: None,
vless_uri: None,
})
}
@@ -1914,28 +2004,29 @@ impl ProxyManager {
/// placeholder until `update_proxy_pid` runs, so it is not a reliable way to
/// find the worker for a profile mid-launch. Safe on the reuse
/// path — it simply rewrites `browser_pid` to the new live PID. A `browser_pid`
/// of 0 (launch failed to report a PID) is ignored so the worker never
/// self-exits against a bogus PID.
pub fn set_browser_pid_for_profile(&self, profile_id: &str, browser_pid: u32) {
/// of 0 (launch failed to report a PID) is rejected so the caller can abort
/// the launch instead of leaving a worker without a verified browser owner.
pub fn set_browser_pid_for_profile(&self, profile_id: &str, browser_pid: u32) -> bool {
if browser_pid == 0 {
return;
return false;
}
let proxy_id = {
let map = self.profile_active_proxy_ids.lock().unwrap();
match map.get(profile_id) {
Some(id) => id.clone(),
None => return, // No local worker for this profile — nothing to tag.
None => return false,
}
};
if let Some(mut cfg) = crate::proxy_storage::get_proxy_config(&proxy_id) {
cfg.browser_pid = Some(browser_pid);
if crate::proxy_storage::update_proxy_config(&cfg) {
log::info!(
"Recorded browser PID {browser_pid} on proxy config {proxy_id} for self-reaping"
);
} else {
log::warn!("Failed to persist browser_pid {browser_pid} to proxy config {proxy_id}");
}
let Some(mut cfg) = crate::proxy_storage::get_proxy_config(&proxy_id) else {
return false;
};
cfg.browser_pid = Some(browser_pid);
if crate::proxy_storage::update_proxy_config(&cfg) {
log::info!("Recorded browser PID {browser_pid} on proxy config {proxy_id} for self-reaping");
true
} else {
log::warn!("Failed to persist browser_pid {browser_pid} to proxy config {proxy_id}");
false
}
}
@@ -2220,6 +2311,24 @@ impl ProxyManager {
}
}
{
use crate::proxy_storage::process_identity_matches;
use crate::xray_worker_storage::{list_xray_worker_configs, unstarted_worker_is_stale};
for worker in list_xray_worker_configs() {
let dead = worker
.pid
.is_some_and(|pid| !process_identity_matches(pid, worker.pid_start_time));
if dead || unstarted_worker_is_stale(&worker) {
log::info!(
"Cleaning up orphaned Xray-core worker config: {}",
worker.id
);
let _ = crate::xray_worker_runner::stop_xray_worker(&worker.id).await;
}
}
}
// Emit event for reactive UI updates
if let Err(e) = events::emit_empty("proxies-changed") {
log::error!("Failed to emit proxies-changed event: {e}");
@@ -2355,6 +2464,7 @@ mod tests {
port: 8080,
username: Some("user".to_string()),
password: Some("pass".to_string()),
vless_uri: None,
};
assert!(
@@ -2382,6 +2492,7 @@ mod tests {
port: 0,
username: None,
password: None,
vless_uri: None,
};
assert!(
@@ -2536,6 +2647,7 @@ mod tests {
port: 8080,
username: Some("user".to_string()),
password: Some("pass".to_string()),
vless_uri: None,
};
// Test command arguments match expected format
@@ -3175,6 +3287,7 @@ mod tests {
port: 8080,
username: None,
password: None,
vless_uri: None,
});
assert_eq!(url, "http://1.2.3.4:8080");
@@ -3185,6 +3298,7 @@ mod tests {
port: 1080,
username: Some("user".to_string()),
password: Some("p@ss".to_string()),
vless_uri: None,
});
assert_eq!(url, "socks5://user:p%40ss@proxy.example.com:1080");
@@ -3195,10 +3309,101 @@ mod tests {
port: 3128,
username: Some("justuser".to_string()),
password: None,
vless_uri: None,
});
assert_eq!(url, "http://justuser@host.io:3128");
}
fn valid_vless_uri() -> String {
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
let public_key = URL_SAFE_NO_PAD.encode([7_u8; 32]);
format!(
"vless://6d6e21a1-4829-4d2b-bc7f-1b25707b61e4@127.0.0.1:443?\
encryption=none&flow=xtls-rprx-vision&security=reality&\
sni=www.example.com&fp=chrome&pbk={public_key}&\
sid=0123456789abcdef&spx=%2F&type=tcp&headerType=none#Local"
)
}
#[test]
fn vless_settings_are_validated_canonicalized_and_stripped_of_unused_credentials() {
let uri = valid_vless_uri();
let normalized = ProxyManager::normalize_proxy_settings(ProxySettings {
proxy_type: "VLESS".to_string(),
host: "ignored.invalid".to_string(),
port: 1,
username: Some("unused-user".to_string()),
password: Some("unused-password".to_string()),
vless_uri: Some(uri.clone()),
})
.unwrap();
assert_eq!(normalized.proxy_type, "vless");
assert_eq!(normalized.host, "127.0.0.1");
assert_eq!(normalized.port, 443);
assert!(normalized.username.is_none());
assert!(normalized.password.is_none());
assert_eq!(normalized.vless_uri.as_deref(), Some(uri.as_str()));
let invalid = uri.replace("security=reality", "security=tls");
let error = ProxyManager::normalize_proxy_settings(ProxySettings {
proxy_type: "vless".to_string(),
host: "127.0.0.1".to_string(),
port: 443,
username: None,
password: None,
vless_uri: Some(invalid.clone()),
})
.unwrap_err();
assert!(error.contains("VLESS_CONFIG_INVALID"));
assert!(!error.contains(&invalid));
}
#[test]
fn vless_stored_proxy_persistence_and_exports_preserve_the_canonical_uri() {
let temp = tempfile::tempdir().unwrap();
let _data_guard = crate::app_dirs::set_test_data_dir(temp.path().to_path_buf());
let manager = ProxyManager::new();
let settings = ProxyManager::normalize_proxy_settings(ProxySettings {
proxy_type: "vless".to_string(),
host: "ignored.invalid".to_string(),
port: 1,
username: Some("unused".to_string()),
password: Some("unused".to_string()),
vless_uri: Some(valid_vless_uri()),
})
.unwrap();
let stored = StoredProxy::new("Local Reality".to_string(), settings);
manager.save_proxy(&stored).unwrap();
manager.upsert_stored_proxy(stored.clone());
let json: ProxyExportData =
serde_json::from_str(&manager.export_proxies_json().unwrap()).unwrap();
assert_eq!(json.proxies.len(), 1);
assert_eq!(json.proxies[0].proxy_type, "vless");
assert_eq!(json.proxies[0].vless_uri, stored.proxy_settings.vless_uri);
assert_eq!(
manager.export_proxies_txt(),
stored.proxy_settings.vless_uri.clone().unwrap()
);
let reloaded = ProxyManager::new()
.get_stored_proxies()
.into_iter()
.find(|candidate| candidate.id == stored.id)
.expect("persisted VLESS proxy should reload");
assert_eq!(reloaded.proxy_settings, stored.proxy_settings);
let parsed = ProxyManager::parse_txt_proxies(&manager.export_proxies_txt());
assert!(matches!(
parsed.as_slice(),
[ProxyParseResult::Parsed(proxy)]
if proxy.proxy_type == "vless"
&& proxy.vless_uri == stored.proxy_settings.vless_uri
));
}
#[test]
fn test_geo_username_construction() {
// Country only
@@ -3270,6 +3475,7 @@ mod tests {
port: 80,
username: None,
password: None,
vless_uri: None,
},
sync_enabled: false,
last_sync: None,
+48 -33
View File
@@ -50,27 +50,23 @@ fn prune_stale_proxy_logs(temp_dir: &Path, retain: usize) {
}
}
fn target_binary_name(base_name: &str) -> Option<String> {
let target = std::env::var("TARGET").ok()?;
fn target_binary_name(base_name: &str) -> String {
let target = env!("DONUT_BUILD_TARGET");
#[cfg(windows)]
{
Some(format!("{base_name}-{target}.exe"))
format!("{base_name}-{target}.exe")
}
#[cfg(not(windows))]
{
Some(format!("{base_name}-{target}"))
format!("{base_name}-{target}")
}
}
fn unsuffixed_binary_name(base_name: &str) -> String {
#[cfg(windows)]
{
match base_name {
"donut-proxy" => "donut-proxy.exe".to_string(),
_ => String::new(),
}
format!("{base_name}.exe")
}
#[cfg(not(windows))]
@@ -79,19 +75,24 @@ fn unsuffixed_binary_name(base_name: &str) -> String {
}
}
fn binary_matches_prefix(path: &Path, base_name: &str) -> bool {
let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else {
fn is_executable_file(path: &Path) -> bool {
let Ok(metadata) = path.metadata() else {
return false;
};
if !metadata.is_file() {
return false;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
metadata.permissions().mode() & 0o111 != 0
}
#[cfg(windows)]
{
file_name.starts_with(&format!("{base_name}-")) && file_name.ends_with(".exe")
}
#[cfg(not(windows))]
{
file_name.starts_with(&format!("{base_name}-"))
path
.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| extension.eq_ignore_ascii_case("exe"))
}
}
@@ -156,10 +157,10 @@ pub(crate) fn find_sidecar_executable(
Some(manifest_dir.join("target").join("release")),
);
let mut exact_names = vec![unsuffixed_binary_name(base_name)];
if let Some(target_name) = target_binary_name(base_name) {
exact_names.push(target_name);
}
let exact_names = [
unsuffixed_binary_name(base_name),
target_binary_name(base_name),
];
for dir in &search_dirs {
for name in &exact_names {
@@ -168,19 +169,10 @@ pub(crate) fn find_sidecar_executable(
}
let candidate = dir.join(name);
if candidate.exists() {
if is_executable_file(&candidate) {
return Ok(candidate);
}
}
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_file() && binary_matches_prefix(&path, base_name) {
return Ok(path);
}
}
}
}
Err(
@@ -565,7 +557,9 @@ pub async fn stop_all_proxy_processes() -> Result<(), Box<dyn std::error::Error>
#[cfg(test)]
mod tests {
use super::{parse_sidecar_version, prune_stale_proxy_logs};
use super::{
is_executable_file, parse_sidecar_version, prune_stale_proxy_logs, target_binary_name,
};
use std::fs;
use std::time::Duration;
@@ -592,6 +586,27 @@ mod tests {
);
}
#[test]
fn sidecar_target_name_is_exact_and_metadata_files_are_not_executables() {
let target_name = target_binary_name("xray");
assert!(target_name.starts_with("xray-"));
assert!(target_name.contains(env!("DONUT_BUILD_TARGET")));
let temp = tempfile::tempdir().unwrap();
let marker = temp.path().join(format!("{target_name}.source.json"));
fs::write(&marker, "{}").unwrap();
assert!(!is_executable_file(&marker));
let executable = temp.path().join(target_name);
fs::write(&executable, "binary").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&executable, fs::Permissions::from_mode(0o755)).unwrap();
}
assert!(is_executable_file(&executable));
}
#[test]
fn prunes_only_old_proxy_logs() {
let temp = tempfile::tempdir().unwrap();
+139 -28
View File
@@ -139,22 +139,28 @@ impl BlocklistMatcher {
}
}
/// Wrapper stream that counts bytes read and written
#[derive(Clone, Copy)]
enum TrafficDirection {
Sent,
Received,
}
/// Wrapper stream that counts bytes successfully relayed to its destination.
struct CountingStream<S> {
inner: S,
bytes_read: Arc<AtomicU64>,
bytes_written: Arc<AtomicU64>,
write_direction: TrafficDirection,
// Resolved once per stream: the global tracker is fixed after init, so the
// hot poll paths avoid taking the global RwLock on every packet
tracker: Option<Arc<LiveTrafficTracker>>,
}
impl<S> CountingStream<S> {
fn new(inner: S) -> Self {
fn new(inner: S, write_direction: TrafficDirection) -> Self {
Self {
inner,
bytes_read: Arc::new(AtomicU64::new(0)),
bytes_written: Arc::new(AtomicU64::new(0)),
write_direction,
tracker: get_traffic_tracker(),
}
}
@@ -166,21 +172,7 @@ impl<S: AsyncRead + Unpin> AsyncRead for CountingStream<S> {
cx: &mut Context<'_>,
buf: &mut ReadBuf<'_>,
) -> Poll<io::Result<()>> {
let filled_before = buf.filled().len();
let result = Pin::new(&mut self.inner).poll_read(cx, buf);
if let Poll::Ready(Ok(())) = &result {
let bytes_read = buf.filled().len() - filled_before;
if bytes_read > 0 {
self
.bytes_read
.fetch_add(bytes_read as u64, Ordering::Relaxed);
// Update global tracker - count as received (data coming into proxy)
if let Some(tracker) = &self.tracker {
tracker.add_bytes_received(bytes_read as u64);
}
}
}
result
Pin::new(&mut self.inner).poll_read(cx, buf)
}
}
@@ -193,9 +185,11 @@ impl<S: AsyncWrite + Unpin> AsyncWrite for CountingStream<S> {
let result = Pin::new(&mut self.inner).poll_write(cx, buf);
if let Poll::Ready(Ok(n)) = &result {
self.bytes_written.fetch_add(*n as u64, Ordering::Relaxed);
// Update global tracker - count as sent (data going out of proxy)
if let Some(tracker) = &self.tracker {
tracker.add_bytes_sent(*n as u64);
match self.write_direction {
TrafficDirection::Sent => tracker.add_bytes_sent(*n as u64),
TrafficDirection::Received => tracker.add_bytes_received(*n as u64),
}
}
}
result
@@ -2145,9 +2139,11 @@ pub(crate) async fn tunnel_streams(
target_stream: BoxedAsyncStream,
domain: String,
) {
// Wrap streams to count bytes transferred
let mut counting_client = CountingStream::new(client_stream);
let mut counting_target = CountingStream::new(target_stream);
// Count each payload byte once, when it is successfully written to its
// destination. Writes to the target are uploads; writes to the client are
// downloads.
let mut counting_client = CountingStream::new(client_stream, TrafficDirection::Received);
let mut counting_target = CountingStream::new(target_stream, TrafficDirection::Sent);
log::trace!("Starting bidirectional tunnel");
@@ -2165,10 +2161,8 @@ pub(crate) async fn tunnel_streams(
}
// Log final byte counts and update domain stats
let final_sent = counting_client.bytes_read.load(Ordering::Relaxed)
+ counting_target.bytes_written.load(Ordering::Relaxed);
let final_recv = counting_target.bytes_read.load(Ordering::Relaxed)
+ counting_client.bytes_written.load(Ordering::Relaxed);
let final_sent = counting_target.bytes_written.load(Ordering::Relaxed);
let final_recv = counting_client.bytes_written.load(Ordering::Relaxed);
log::trace!("Tunnel closed - sent: {final_sent} bytes, received: {final_recv} bytes");
// Update domain-specific byte counts now that tunnel is complete
@@ -2514,6 +2508,123 @@ mod tests {
feeder.abort();
}
#[tokio::test]
#[serial_test::serial]
async fn tunnel_traffic_counts_chunked_duplex_bytes_once_per_direction() {
let temp_dir = tempfile::tempdir().unwrap();
let _cache_guard = crate::app_dirs::set_test_cache_dir(temp_dir.path().to_path_buf());
let profile_id = "traffic-counting-profile";
let domain = "counting.example";
init_traffic_tracker("traffic-counting-proxy".into(), Some(profile_id.into()));
let tracker = get_traffic_tracker().unwrap();
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let (browser_result, accepted_result) =
tokio::join!(TcpStream::connect(addr), listener.accept());
let browser_stream = browser_result.unwrap();
let (proxy_client_stream, _) = accepted_result.unwrap();
// Keep the duplex buffer deliberately small so client-to-target writes
// must make partial progress while both directions remain active.
let (proxy_target_stream, target_stream) = tokio::io::duplex(11);
let tunnel = tokio::spawn(tunnel_streams(
proxy_client_stream,
Box::new(proxy_target_stream),
domain.into(),
));
let upload_chunks = vec![vec![0x11; 3], vec![0x22; 31], vec![0x33; 8_193]];
let download_chunks = vec![vec![0x44; 5], vec![0x55; 47], vec![0x66; 5_003]];
let expected_upload = upload_chunks.concat();
let expected_download = download_chunks.concat();
let upload_len = expected_upload.len();
let download_len = expected_download.len();
let (browser_reader, mut browser_writer) = browser_stream.into_split();
let (target_reader, mut target_writer) = tokio::io::split(target_stream);
let transfer = async move {
let send_upload = async move {
for chunk in upload_chunks {
browser_writer.write_all(&chunk).await.unwrap();
tokio::task::yield_now().await;
}
browser_writer.flush().await.unwrap();
browser_writer
};
let send_download = async move {
for chunk in download_chunks {
target_writer.write_all(&chunk).await.unwrap();
tokio::task::yield_now().await;
}
target_writer.flush().await.unwrap();
target_writer
};
let receive_upload = async move {
let mut target_reader = target_reader;
let mut bytes = vec![0; upload_len];
target_reader.read_exact(&mut bytes).await.unwrap();
(target_reader, bytes)
};
let receive_download = async move {
let mut browser_reader = browser_reader;
let mut bytes = vec![0; download_len];
browser_reader.read_exact(&mut bytes).await.unwrap();
(browser_reader, bytes)
};
tokio::join!(send_upload, send_download, receive_upload, receive_download)
};
let (
mut browser_writer,
mut target_writer,
(target_reader, actual_upload),
(browser_reader, actual_download),
) = tokio::time::timeout(std::time::Duration::from_secs(5), transfer)
.await
.expect("duplex transfer timed out");
assert_eq!(actual_upload, expected_upload);
assert_eq!(actual_download, expected_download);
assert!(
!tunnel.is_finished(),
"the tunnel should remain live until its peers close"
);
assert_eq!(
tracker.get_snapshot(),
(upload_len as u64, download_len as u64, 0),
"global counters must update in real time without double-counting"
);
let (browser_shutdown, target_shutdown) =
tokio::join!(browser_writer.shutdown(), target_writer.shutdown());
browser_shutdown.unwrap();
target_shutdown.unwrap();
drop((browser_writer, target_writer, browser_reader, target_reader));
tokio::time::timeout(std::time::Duration::from_secs(5), tunnel)
.await
.expect("tunnel did not close")
.expect("tunnel task panicked");
assert_eq!(
tracker.get_snapshot(),
(upload_len as u64, download_len as u64, 0),
"closing the tunnel must not add another copy of its traffic"
);
assert_eq!(
tracker.flush_to_disk().unwrap(),
Some((upload_len as u64, download_len as u64))
);
let stats = crate::traffic_stats::load_traffic_stats(profile_id).unwrap();
assert_eq!(stats.total_bytes_sent, upload_len as u64);
assert_eq!(stats.total_bytes_received, download_len as u64);
let domain_stats = stats.domains.get(domain).unwrap();
assert_eq!(domain_stats.bytes_sent, upload_len as u64);
assert_eq!(domain_stats.bytes_received, download_len as u64);
}
#[test]
fn test_blocklist_comments_skipped() {
let mut tmpfile = tempfile::NamedTempFile::new().unwrap();
+22 -5
View File
@@ -202,19 +202,24 @@ pub fn generate_proxy_id() -> String {
)
}
pub fn is_process_running(pid: u32) -> bool {
pub fn process_start_time(pid: u32) -> Option<u64> {
use sysinfo::{ProcessRefreshKind, ProcessesToUpdate, System};
let pid = sysinfo::Pid::from_u32(pid);
// Refresh only the queried PID with the minimal refresh kind: this is a
// pure existence check, and callers (worker supervisors every 15s, GUI
// cleanup loops) must not pay for a full system process-table scan.
let mut system = System::new();
system.refresh_processes_specifics(
ProcessesToUpdate::Some(&[pid]),
true,
ProcessRefreshKind::nothing(),
);
system.process(pid).is_some()
system.process(pid).map(sysinfo::Process::start_time)
}
pub fn is_process_running(pid: u32) -> bool {
process_start_time(pid).is_some()
}
pub fn process_identity_matches(pid: u32, expected_start_time: Option<u64>) -> bool {
expected_start_time.is_some_and(|expected| process_start_time(pid) == Some(expected))
}
#[cfg(test)]
@@ -236,6 +241,18 @@ mod tests {
);
}
#[test]
fn process_identity_requires_the_observed_start_time() {
let pid = std::process::id();
let start_time = process_start_time(pid).expect("current process should be visible");
assert!(process_identity_matches(pid, Some(start_time)));
assert!(!process_identity_matches(
pid,
Some(start_time.saturating_add(1))
));
assert!(!process_identity_matches(pid, None));
}
#[test]
fn test_is_process_running_detects_current_process() {
let pid = std::process::id();
+254
View File
@@ -0,0 +1,254 @@
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use super::{VlessRealityConfig, XrayError, XrayResult};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct XrayClientRuntime {
pub listen_port: u16,
pub username: String,
pub password: String,
}
impl XrayClientRuntime {
pub fn validate(&self) -> XrayResult<()> {
if self.listen_port == 0 {
return Err(XrayError::InvalidField {
field: "listen_port",
reason: "must be between 1 and 65535",
});
}
validate_socks_credential("username", &self.username)?;
validate_socks_credential("password", &self.password)
}
}
pub fn build_client_config(
config: &VlessRealityConfig,
runtime: &XrayClientRuntime,
) -> XrayResult<Value> {
config.validate()?;
runtime.validate()?;
Ok(json!({
"log": {
"loglevel": "warning"
},
"inbounds": [{
"tag": "local-socks",
"listen": "127.0.0.1",
"port": runtime.listen_port,
"protocol": "socks",
"settings": {
"auth": "password",
"accounts": [{
"user": runtime.username,
"pass": runtime.password
}],
"udp": true,
"ip": "127.0.0.1"
}
}],
"outbounds": [{
"tag": "proxy",
"protocol": "vless",
"settings": {
"vnext": [{
"address": config.address,
"port": config.port,
"users": [{
"id": config.id,
"encryption": "none",
"flow": config.flow.as_str()
}]
}]
},
"streamSettings": {
"network": "tcp",
"security": "reality",
"realitySettings": {
"show": false,
"fingerprint": config.reality.fingerprint.as_str(),
"serverName": config.reality.server_name,
"publicKey": config.reality.public_key,
"shortId": config.reality.short_id,
"spiderX": config.reality.spider_x
},
"sockopt": {
"tcpKeepAliveIdle": 30,
"tcpKeepAliveInterval": 15
}
}
}]
}))
}
pub fn build_client_config_json(
config: &VlessRealityConfig,
runtime: &XrayClientRuntime,
) -> XrayResult<String> {
serde_json::to_string_pretty(&build_client_config(config, runtime)?)
.map_err(|_| XrayError::Serialization)
}
fn validate_socks_credential(field: &'static str, value: &str) -> XrayResult<()> {
if value.is_empty() || value.len() > 255 {
return Err(XrayError::InvalidField {
field,
reason: "must contain between 1 and 255 URL-safe ASCII characters",
});
}
if !value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~'))
{
return Err(XrayError::InvalidField {
field,
reason: "must contain only URL-safe ASCII characters",
});
}
Ok(())
}
#[cfg(test)]
mod tests {
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use super::super::{RealityFingerprint, RealitySettings, VlessFlow};
use super::*;
fn valid_config() -> VlessRealityConfig {
VlessRealityConfig {
address: "vpn.example.com".to_string(),
port: 443,
id: "6d6e21a1-4829-4d2b-bc7f-1b25707b61e4".to_string(),
flow: VlessFlow::Vision,
reality: RealitySettings {
server_name: "www.example.com".to_string(),
public_key: URL_SAFE_NO_PAD.encode([7_u8; 32]),
short_id: "0123456789abcdef".to_string(),
fingerprint: RealityFingerprint::Chrome,
spider_x: "/".to_string(),
},
}
}
fn runtime() -> XrayClientRuntime {
XrayClientRuntime {
listen_port: 41_321,
username: "local_user-1".to_string(),
password: "local_password-1".to_string(),
}
}
#[test]
fn generates_minimal_authenticated_tcp_reality_config() {
let value = build_client_config(&valid_config(), &runtime()).unwrap();
assert_eq!(value["log"]["loglevel"], "warning");
assert_eq!(value["inbounds"].as_array().unwrap().len(), 1);
assert_eq!(value["inbounds"][0]["listen"], "127.0.0.1");
assert_eq!(value["inbounds"][0]["port"], 41_321);
assert_eq!(value["inbounds"][0]["protocol"], "socks");
assert_eq!(value["inbounds"][0]["settings"]["auth"], "password");
assert_eq!(
value["inbounds"][0]["settings"]["accounts"][0]["user"],
"local_user-1"
);
assert_eq!(
value["inbounds"][0]["settings"]["accounts"][0]["pass"],
"local_password-1"
);
assert_eq!(value["inbounds"][0]["settings"]["udp"], true);
assert_eq!(value["inbounds"][0]["settings"]["ip"], "127.0.0.1");
assert_eq!(value["outbounds"].as_array().unwrap().len(), 1);
assert_eq!(value["outbounds"][0]["protocol"], "vless");
assert_eq!(
value["outbounds"][0]["settings"]["vnext"][0]["address"],
"vpn.example.com"
);
assert_eq!(
value["outbounds"][0]["settings"]["vnext"][0]["users"][0]["encryption"],
"none"
);
assert_eq!(
value["outbounds"][0]["settings"]["vnext"][0]["users"][0]["flow"],
"xtls-rprx-vision"
);
assert_eq!(value["outbounds"][0]["streamSettings"]["network"], "tcp");
assert_eq!(
value["outbounds"][0]["streamSettings"]["security"],
"reality"
);
assert_eq!(
value["outbounds"][0]["streamSettings"]["realitySettings"]["fingerprint"],
"chrome"
);
assert_eq!(
value["outbounds"][0]["streamSettings"]["realitySettings"]["serverName"],
"www.example.com"
);
assert_eq!(
value["outbounds"][0]["streamSettings"]["realitySettings"]["shortId"],
"0123456789abcdef"
);
}
#[test]
fn does_not_add_bypass_or_observability_surfaces() {
let value = build_client_config(&valid_config(), &runtime()).unwrap();
for absent in ["api", "dns", "policy", "routing", "stats"] {
assert!(value.get(absent).is_none(), "{absent}");
}
assert!(value["outbounds"][0].get("mux").is_none());
assert!(!value.to_string().contains("freedom"));
assert!(!value.to_string().contains("blackhole"));
}
#[test]
fn json_output_round_trips_without_shape_changes() {
let value = build_client_config(&valid_config(), &runtime()).unwrap();
let json = build_client_config_json(&valid_config(), &runtime()).unwrap();
let reparsed: Value = serde_json::from_str(&json).unwrap();
assert_eq!(reparsed, value);
}
#[test]
fn runtime_requires_nonzero_port_and_url_safe_credentials() {
let cases = [
XrayClientRuntime {
listen_port: 0,
..runtime()
},
XrayClientRuntime {
username: String::new(),
..runtime()
},
XrayClientRuntime {
password: "contains:@".to_string(),
..runtime()
},
XrayClientRuntime {
username: "a".repeat(256),
..runtime()
},
];
for runtime in cases {
assert!(runtime.validate().is_err());
}
}
#[test]
fn invalid_model_is_rejected_before_generation() {
let mut config = valid_config();
config.reality.public_key = "secret-invalid-key".to_string();
let error = build_client_config(&config, &runtime()).unwrap_err();
assert!(matches!(
error,
XrayError::InvalidField { field: "pbk", .. }
));
assert!(!error.to_string().contains(&config.reality.public_key));
}
}
+29
View File
@@ -0,0 +1,29 @@
use thiserror::Error;
pub type XrayResult<T> = Result<T, XrayError>;
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum XrayError {
#[error("invalid VLESS URI")]
InvalidUri,
#[error("URI scheme must be vless")]
UnsupportedScheme,
#[error("missing required field: {0}")]
MissingField(&'static str),
#[error("invalid field: {field} ({reason})")]
InvalidField {
field: &'static str,
reason: &'static str,
},
#[error("unsupported query parameter: {0}")]
UnsupportedParameter(String),
#[error("duplicate query parameter: {0}")]
DuplicateParameter(String),
#[error("unsupported value for {field}; expected {expected}")]
UnsupportedValue {
field: &'static str,
expected: &'static str,
},
#[error("failed to serialize Xray client configuration")]
Serialization,
}
+11
View File
@@ -0,0 +1,11 @@
mod client;
mod error;
mod model;
mod uri;
pub use client::{build_client_config, build_client_config_json, XrayClientRuntime};
pub use error::{XrayError, XrayResult};
pub use model::{
ParsedVlessUri, RealityFingerprint, RealitySettings, VlessFlow, VlessRealityConfig,
};
pub use uri::{export_vless_uri, parse_vless_uri};
+412
View File
@@ -0,0 +1,412 @@
use std::net::IpAddr;
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use serde::{Deserialize, Serialize};
use url::Host;
use uuid::Uuid;
use super::{XrayError, XrayResult};
const MAX_SPIDER_X_BYTES: usize = 2048;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum VlessFlow {
#[default]
#[serde(rename = "xtls-rprx-vision")]
Vision,
}
impl VlessFlow {
pub const fn as_str(self) -> &'static str {
match self {
Self::Vision => "xtls-rprx-vision",
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum RealityFingerprint {
#[default]
Chrome,
Firefox,
Safari,
Edge,
Ios,
Android,
}
impl RealityFingerprint {
pub const fn as_str(self) -> &'static str {
match self {
Self::Chrome => "chrome",
Self::Firefox => "firefox",
Self::Safari => "safari",
Self::Edge => "edge",
Self::Ios => "ios",
Self::Android => "android",
}
}
pub(crate) fn parse(value: &str) -> XrayResult<Self> {
match value {
"chrome" => Ok(Self::Chrome),
"firefox" => Ok(Self::Firefox),
"safari" => Ok(Self::Safari),
"edge" => Ok(Self::Edge),
"ios" => Ok(Self::Ios),
"android" => Ok(Self::Android),
_ => Err(XrayError::UnsupportedValue {
field: "fp",
expected: "chrome, firefox, safari, edge, ios, or android",
}),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RealitySettings {
pub server_name: String,
pub public_key: String,
#[serde(default)]
pub short_id: String,
#[serde(default)]
pub fingerprint: RealityFingerprint,
#[serde(default = "default_spider_x")]
pub spider_x: String,
}
impl RealitySettings {
pub fn validate(&self) -> XrayResult<()> {
validate_server_name(&self.server_name)?;
validate_public_key(&self.public_key)?;
validate_short_id(&self.short_id)?;
validate_spider_x(&self.spider_x)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct VlessRealityConfig {
pub address: String,
pub port: u16,
pub id: String,
#[serde(default)]
pub flow: VlessFlow,
pub reality: RealitySettings,
}
impl VlessRealityConfig {
pub fn validate(&self) -> XrayResult<()> {
validate_endpoint_address(&self.address)?;
if self.port == 0 {
return Err(XrayError::InvalidField {
field: "port",
reason: "must be between 1 and 65535",
});
}
Uuid::parse_str(&self.id).map_err(|_| XrayError::InvalidField {
field: "id",
reason: "must be a UUID",
})?;
self.reality.validate()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ParsedVlessUri {
pub name: Option<String>,
pub config: VlessRealityConfig,
}
impl ParsedVlessUri {
pub fn validate(&self) -> XrayResult<()> {
if let Some(name) = &self.name {
validate_display_name(name)?;
}
self.config.validate()
}
}
pub(crate) fn default_spider_x() -> String {
"/".to_string()
}
pub(crate) fn validate_display_name(name: &str) -> XrayResult<()> {
if name.is_empty() {
return Err(XrayError::InvalidField {
field: "name",
reason: "must not be empty",
});
}
if name.chars().count() > 200 {
return Err(XrayError::InvalidField {
field: "name",
reason: "must not exceed 200 characters",
});
}
if name.chars().any(char::is_control) {
return Err(XrayError::InvalidField {
field: "name",
reason: "must not contain control characters",
});
}
Ok(())
}
fn validate_endpoint_address(address: &str) -> XrayResult<()> {
if address.is_empty()
|| address.trim() != address
|| address.starts_with('[')
|| address.ends_with(']')
{
return Err(XrayError::InvalidField {
field: "address",
reason: "must be a valid hostname or IP address",
});
}
if address.parse::<IpAddr>().is_ok() {
return Ok(());
}
Host::parse(address).map_err(|_| XrayError::InvalidField {
field: "address",
reason: "must be a valid hostname or IP address",
})?;
Ok(())
}
fn validate_server_name(server_name: &str) -> XrayResult<()> {
if server_name.is_empty() || server_name.trim() != server_name {
return Err(XrayError::InvalidField {
field: "sni",
reason: "must be a valid DNS name",
});
}
match Host::parse(server_name) {
Ok(Host::Domain(_)) => Ok(()),
_ => Err(XrayError::InvalidField {
field: "sni",
reason: "must be a valid DNS name",
}),
}
}
fn validate_public_key(public_key: &str) -> XrayResult<()> {
let decoded = URL_SAFE_NO_PAD
.decode(public_key)
.map_err(|_| XrayError::InvalidField {
field: "pbk",
reason: "must be an unpadded base64url-encoded 32-byte key",
})?;
if decoded.len() != 32 || URL_SAFE_NO_PAD.encode(decoded) != public_key {
return Err(XrayError::InvalidField {
field: "pbk",
reason: "must be an unpadded base64url-encoded 32-byte key",
});
}
Ok(())
}
fn validate_short_id(short_id: &str) -> XrayResult<()> {
if short_id.len() > 16 || !short_id.len().is_multiple_of(2) {
return Err(XrayError::InvalidField {
field: "sid",
reason: "must be empty or contain up to 16 even-length hexadecimal characters",
});
}
if !short_id.bytes().all(|byte| byte.is_ascii_hexdigit()) {
return Err(XrayError::InvalidField {
field: "sid",
reason: "must be empty or contain up to 16 even-length hexadecimal characters",
});
}
Ok(())
}
fn validate_spider_x(spider_x: &str) -> XrayResult<()> {
if !spider_x.starts_with('/') {
return Err(XrayError::InvalidField {
field: "spx",
reason: "must start with /",
});
}
if spider_x.len() > MAX_SPIDER_X_BYTES || spider_x.chars().any(char::is_control) {
return Err(XrayError::InvalidField {
field: "spx",
reason: "must be a valid relative path no longer than 2048 bytes",
});
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn public_key() -> String {
URL_SAFE_NO_PAD.encode([7_u8; 32])
}
fn valid_config() -> VlessRealityConfig {
VlessRealityConfig {
address: "vpn.example.com".to_string(),
port: 443,
id: "6d6e21a1-4829-4d2b-bc7f-1b25707b61e4".to_string(),
flow: VlessFlow::Vision,
reality: RealitySettings {
server_name: "www.example.com".to_string(),
public_key: public_key(),
short_id: "0123456789abcdef".to_string(),
fingerprint: RealityFingerprint::Chrome,
spider_x: "/".to_string(),
},
}
}
#[test]
fn valid_model_passes_validation() {
assert_eq!(valid_config().validate(), Ok(()));
}
#[test]
fn endpoint_accepts_ipv4_ipv6_and_dns() {
for address in ["198.51.100.4", "2001:db8::1", "vpn.example.com"] {
let mut config = valid_config();
config.address = address.to_string();
assert_eq!(config.validate(), Ok(()), "{address}");
}
}
#[test]
fn endpoint_rejects_empty_whitespace_and_invalid_hosts() {
for address in [
"",
" vpn.example.com",
"vpn example.com",
"vpn.example.com:443",
"[2001:db8::1]",
] {
let mut config = valid_config();
config.address = address.to_string();
assert!(matches!(
config.validate(),
Err(XrayError::InvalidField {
field: "address",
..
})
));
}
}
#[test]
fn id_must_be_a_uuid() {
let mut config = valid_config();
config.id = "not-a-uuid".to_string();
assert_eq!(
config.validate(),
Err(XrayError::InvalidField {
field: "id",
reason: "must be a UUID",
})
);
}
#[test]
fn server_name_must_be_dns_name() {
for server_name in ["", "203.0.113.5", "bad server"] {
let mut config = valid_config();
config.reality.server_name = server_name.to_string();
assert!(matches!(
config.validate(),
Err(XrayError::InvalidField { field: "sni", .. })
));
}
}
#[test]
fn public_key_must_be_canonical_base64url_and_32_bytes() {
let short_key = URL_SAFE_NO_PAD.encode([1_u8; 31]);
for public_key in [
"not-base64!",
short_key.as_str(),
"BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc=",
] {
let mut config = valid_config();
config.reality.public_key = public_key.to_string();
let error = config.validate().unwrap_err();
assert!(matches!(
error,
XrayError::InvalidField { field: "pbk", .. }
));
assert!(!error.to_string().contains(public_key));
}
}
#[test]
fn short_id_accepts_empty_or_even_hex_up_to_sixteen_chars() {
for short_id in ["", "ab", "0123456789abcdef", "ABCDEF"] {
let mut config = valid_config();
config.reality.short_id = short_id.to_string();
assert_eq!(config.validate(), Ok(()), "{short_id}");
}
}
#[test]
fn short_id_rejects_odd_non_hex_and_overlong_values() {
for short_id in ["a", "xz", "0123456789abcdef00"] {
let mut config = valid_config();
config.reality.short_id = short_id.to_string();
assert!(matches!(
config.validate(),
Err(XrayError::InvalidField { field: "sid", .. })
));
}
}
#[test]
fn spider_x_must_be_safe_relative_path() {
for spider_x in ["relative", "/line\nbreak"] {
let mut config = valid_config();
config.reality.spider_x = spider_x.to_string();
assert!(matches!(
config.validate(),
Err(XrayError::InvalidField { field: "spx", .. })
));
}
}
#[test]
fn serde_defaults_preserve_the_supported_profile() {
let value = serde_json::json!({
"address": "vpn.example.com",
"port": 443,
"id": "6d6e21a1-4829-4d2b-bc7f-1b25707b61e4",
"reality": {
"server_name": "www.example.com",
"public_key": public_key()
}
});
let config: VlessRealityConfig = serde_json::from_value(value).unwrap();
assert_eq!(config.flow, VlessFlow::Vision);
assert_eq!(config.reality.fingerprint, RealityFingerprint::Chrome);
assert_eq!(config.reality.short_id, "");
assert_eq!(config.reality.spider_x, "/");
}
#[test]
fn serde_rejects_unknown_configuration_fields() {
let value = serde_json::json!({
"address": "vpn.example.com",
"port": 443,
"id": "6d6e21a1-4829-4d2b-bc7f-1b25707b61e4",
"transport": "websocket",
"reality": {
"server_name": "www.example.com",
"public_key": public_key()
}
});
assert!(serde_json::from_value::<VlessRealityConfig>(value).is_err());
}
}
+422
View File
@@ -0,0 +1,422 @@
use std::collections::HashMap;
use std::net::IpAddr;
use url::{Host, Url};
use uuid::Uuid;
use super::{
model::validate_display_name, ParsedVlessUri, RealityFingerprint, RealitySettings, VlessFlow,
VlessRealityConfig, XrayError, XrayResult,
};
const SUPPORTED_PARAMETERS: &[&str] = &[
"encryption",
"flow",
"security",
"sni",
"fp",
"pbk",
"sid",
"spx",
"type",
"headerType",
];
pub fn parse_vless_uri(input: &str) -> XrayResult<ParsedVlessUri> {
if input.trim() != input {
return Err(XrayError::InvalidUri);
}
let url = Url::parse(input).map_err(|_| XrayError::InvalidUri)?;
if url.scheme() != "vless" {
return Err(XrayError::UnsupportedScheme);
}
if url.password().is_some() {
return Err(XrayError::InvalidField {
field: "id",
reason: "password-style user information is not supported",
});
}
if !matches!(url.path(), "" | "/") {
return Err(XrayError::InvalidField {
field: "path",
reason: "VLESS TCP URIs must not contain a path",
});
}
let raw_id = url.username();
if raw_id.is_empty() {
return Err(XrayError::MissingField("id"));
}
let id = Uuid::parse_str(raw_id)
.map_err(|_| XrayError::InvalidField {
field: "id",
reason: "must be a UUID",
})?
.to_string();
let address = match url.host().ok_or(XrayError::MissingField("address"))? {
Host::Domain(value) => value.to_string(),
Host::Ipv4(value) => value.to_string(),
Host::Ipv6(value) => value.to_string(),
};
let port = url.port().ok_or(XrayError::MissingField("port"))?;
let parameters = parse_parameters(&url)?;
require_value(&parameters, "security", "reality")?;
require_value(&parameters, "flow", VlessFlow::Vision.as_str())?;
optional_value(&parameters, "encryption", "none")?;
match parameters.get("type").map(String::as_str) {
None | Some("tcp" | "raw") => {}
Some(_) => {
return Err(XrayError::UnsupportedValue {
field: "type",
expected: "tcp",
});
}
}
optional_value(&parameters, "headerType", "none")?;
let server_name = required_parameter(&parameters, "sni")?.to_string();
let public_key = required_parameter(&parameters, "pbk")?.to_string();
let short_id = parameters.get("sid").cloned().unwrap_or_default();
let spider_x = parameters
.get("spx")
.cloned()
.unwrap_or_else(|| "/".to_string());
let fingerprint = parameters
.get("fp")
.map(|value| RealityFingerprint::parse(value))
.transpose()?
.unwrap_or_default();
let name = url
.fragment()
.filter(|fragment| !fragment.is_empty())
.map(|fragment| {
urlencoding::decode(fragment)
.map(|value| value.into_owned())
.map_err(|_| XrayError::InvalidField {
field: "name",
reason: "must use valid percent encoding",
})
})
.transpose()?;
let parsed = ParsedVlessUri {
name,
config: VlessRealityConfig {
address,
port,
id,
flow: VlessFlow::Vision,
reality: RealitySettings {
server_name,
public_key,
short_id,
fingerprint,
spider_x,
},
},
};
parsed.validate()?;
Ok(parsed)
}
pub fn export_vless_uri(config: &VlessRealityConfig, name: Option<&str>) -> XrayResult<String> {
config.validate()?;
if let Some(name) = name {
validate_display_name(name)?;
}
let mut url = Url::parse("vless://placeholder@127.0.0.1").expect("static VLESS URL is valid");
url
.set_username(&config.id)
.map_err(|_| XrayError::InvalidUri)?;
let uri_host = match config.address.parse::<IpAddr>() {
Ok(IpAddr::V6(address)) => format!("[{address}]"),
_ => config.address.clone(),
};
url
.set_host(Some(&uri_host))
.map_err(|_| XrayError::InvalidField {
field: "address",
reason: "must be a valid hostname or IP address",
})?;
url
.set_port(Some(config.port))
.map_err(|_| XrayError::InvalidField {
field: "port",
reason: "must be between 1 and 65535",
})?;
{
let mut query = url.query_pairs_mut();
query.append_pair("encryption", "none");
query.append_pair("flow", config.flow.as_str());
query.append_pair("security", "reality");
query.append_pair("sni", &config.reality.server_name);
query.append_pair("fp", config.reality.fingerprint.as_str());
query.append_pair("pbk", &config.reality.public_key);
query.append_pair("sid", &config.reality.short_id);
query.append_pair("spx", &config.reality.spider_x);
query.append_pair("type", "tcp");
query.append_pair("headerType", "none");
}
url.set_fragment(name);
Ok(url.into())
}
fn parse_parameters(url: &Url) -> XrayResult<HashMap<String, String>> {
let mut parameters = HashMap::new();
for (name, value) in url.query_pairs() {
if !SUPPORTED_PARAMETERS.contains(&name.as_ref()) {
return Err(XrayError::UnsupportedParameter(name.into_owned()));
}
if parameters
.insert(name.to_string(), value.into_owned())
.is_some()
{
return Err(XrayError::DuplicateParameter(name.into_owned()));
}
}
Ok(parameters)
}
fn required_parameter<'a>(
parameters: &'a HashMap<String, String>,
name: &'static str,
) -> XrayResult<&'a str> {
parameters
.get(name)
.filter(|value| !value.is_empty())
.map(String::as_str)
.ok_or(XrayError::MissingField(name))
}
fn require_value(
parameters: &HashMap<String, String>,
name: &'static str,
expected: &'static str,
) -> XrayResult<()> {
let value = required_parameter(parameters, name)?;
if value != expected {
return Err(XrayError::UnsupportedValue {
field: name,
expected,
});
}
Ok(())
}
fn optional_value(
parameters: &HashMap<String, String>,
name: &'static str,
expected: &'static str,
) -> XrayResult<()> {
if parameters.get(name).is_some_and(|value| value != expected) {
return Err(XrayError::UnsupportedValue {
field: name,
expected,
});
}
Ok(())
}
#[cfg(test)]
mod tests {
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use super::*;
const ID: &str = "6d6e21a1-4829-4d2b-bc7f-1b25707b61e4";
fn public_key() -> String {
URL_SAFE_NO_PAD.encode([7_u8; 32])
}
fn uri(overrides: &[(&str, &str)]) -> String {
let key = public_key();
let mut parameters = vec![
("encryption", "none"),
("flow", "xtls-rprx-vision"),
("security", "reality"),
("sni", "www.example.com"),
("fp", "chrome"),
("pbk", key.as_str()),
("sid", "0123456789abcdef"),
("spx", "/"),
("type", "tcp"),
("headerType", "none"),
];
for (name, value) in overrides {
if let Some(parameter) = parameters.iter_mut().find(|(key, _)| key == name) {
parameter.1 = value;
} else {
parameters.push((name, value));
}
}
let query = parameters
.into_iter()
.map(|(name, value)| format!("{name}={}", urlencoding::encode(value)))
.collect::<Vec<_>>()
.join("&");
format!("vless://{ID}@vpn.example.com:443?{query}#Primary")
}
#[test]
fn parses_supported_reality_vision_uri() {
let parsed = parse_vless_uri(&uri(&[])).unwrap();
assert_eq!(parsed.name.as_deref(), Some("Primary"));
assert_eq!(parsed.config.address, "vpn.example.com");
assert_eq!(parsed.config.port, 443);
assert_eq!(parsed.config.id, ID);
assert_eq!(parsed.config.flow, VlessFlow::Vision);
assert_eq!(parsed.config.reality.server_name, "www.example.com");
assert_eq!(parsed.config.reality.public_key, public_key());
assert_eq!(parsed.config.reality.short_id, "0123456789abcdef");
assert_eq!(
parsed.config.reality.fingerprint,
RealityFingerprint::Chrome
);
assert_eq!(parsed.config.reality.spider_x, "/");
}
#[test]
fn parses_ipv6_and_percent_encoded_metadata() {
let input = uri(&[("spx", "/search?q=hello world")])
.replace("vpn.example.com", "[2001:db8::1]")
.replace("#Primary", "#Home%20server");
let parsed = parse_vless_uri(&input).unwrap();
assert_eq!(parsed.config.address, "2001:db8::1");
assert_eq!(parsed.config.reality.spider_x, "/search?q=hello world");
assert_eq!(parsed.name.as_deref(), Some("Home server"));
}
#[test]
fn applies_only_safe_optional_defaults() {
let key = public_key();
let input = format!(
"vless://{ID}@vpn.example.com:443?flow=xtls-rprx-vision&security=reality&sni=www.example.com&pbk={key}"
);
let parsed = parse_vless_uri(&input).unwrap();
assert_eq!(
parsed.config.reality.fingerprint,
RealityFingerprint::Chrome
);
assert_eq!(parsed.config.reality.short_id, "");
assert_eq!(parsed.config.reality.spider_x, "/");
}
#[test]
fn accepts_raw_as_tcp_alias() {
assert!(parse_vless_uri(&uri(&[("type", "raw")])).is_ok());
}
#[test]
fn rejects_wrong_scheme_credentials_path_and_missing_port() {
let valid = uri(&[]);
let cases = [
valid.replacen("vless://", "https://", 1),
valid.replacen(ID, &format!("{ID}:password"), 1),
valid.replacen(":443?", ":443/path?", 1),
valid.replacen(":443?", "?", 1),
];
for input in cases {
assert!(parse_vless_uri(&input).is_err(), "{input}");
}
}
#[test]
fn rejects_missing_required_reality_values() {
let key = public_key();
let cases = [
format!("vless://{ID}@vpn.example.com:443?flow=xtls-rprx-vision&sni=www.example.com&pbk={key}"),
format!("vless://{ID}@vpn.example.com:443?security=reality&sni=www.example.com&pbk={key}"),
format!("vless://{ID}@vpn.example.com:443?flow=xtls-rprx-vision&security=reality&pbk={key}"),
format!("vless://{ID}@vpn.example.com:443?flow=xtls-rprx-vision&security=reality&sni=www.example.com"),
];
for input in cases {
assert!(parse_vless_uri(&input).is_err(), "{input}");
}
}
#[test]
fn rejects_unsupported_security_transport_flow_and_encryption() {
for (name, value) in [
("security", "tls"),
("type", "ws"),
("flow", ""),
("encryption", "auto"),
("headerType", "http"),
("fp", "unsafe"),
] {
assert!(
matches!(
parse_vless_uri(&uri(&[(name, value)])),
Err(XrayError::UnsupportedValue { .. } | XrayError::MissingField(_))
),
"{name}={value}"
);
}
}
#[test]
fn rejects_unknown_and_duplicate_parameters() {
assert_eq!(
parse_vless_uri(&uri(&[("serviceName", "unsupported")])),
Err(XrayError::UnsupportedParameter("serviceName".to_string()))
);
let input = uri(&[]).replace("#Primary", "&sni=duplicate.example.com#Primary");
assert_eq!(
parse_vless_uri(&input),
Err(XrayError::DuplicateParameter("sni".to_string()))
);
}
#[test]
fn rejects_invalid_uuid_key_short_id_and_spider_x_without_echoing_secrets() {
let invalid_key = "private-value-that-must-not-be-echoed";
let cases = [
uri(&[]).replacen(ID, "not-a-uuid", 1),
uri(&[("pbk", invalid_key)]),
uri(&[("sid", "xyz")]),
uri(&[("spx", "relative")]),
];
for input in cases {
let error = parse_vless_uri(&input).unwrap_err();
assert!(!error.to_string().contains(invalid_key));
assert!(!error.to_string().contains(ID));
}
}
#[test]
fn export_is_canonical_and_round_trips() {
let parsed = parse_vless_uri(&uri(&[("fp", "firefox")])).unwrap();
let exported = export_vless_uri(&parsed.config, Some("Home server")).unwrap();
assert!(exported.starts_with(&format!("vless://{ID}@vpn.example.com:443?")));
assert!(exported.contains("type=tcp"));
assert!(exported.contains("flow=xtls-rprx-vision"));
assert!(exported.ends_with("#Home%20server"));
let reparsed = parse_vless_uri(&exported).unwrap();
assert_eq!(
reparsed,
ParsedVlessUri {
name: Some("Home server".to_string()),
config: parsed.config,
}
);
}
#[test]
fn export_handles_ipv6_host() {
let mut parsed = parse_vless_uri(&uri(&[])).unwrap();
parsed.config.address = "2001:db8::1".to_string();
let exported = export_vless_uri(&parsed.config, None).unwrap();
assert!(exported.starts_with(&format!("vless://{ID}@[2001:db8::1]:443?")));
assert_eq!(
parse_vless_uri(&exported).unwrap().config.address,
"2001:db8::1"
);
}
}
+817
View File
@@ -0,0 +1,817 @@
use crate::proxy_runner::find_sidecar_executable;
use crate::proxy_storage::{is_process_running, process_identity_matches, process_start_time};
use crate::xray::{build_client_config_json, parse_vless_uri, XrayClientRuntime};
use crate::xray_worker_storage::{
create_xray_worker_log, delete_xray_worker_config, generate_xray_worker_id,
get_xray_worker_config, get_xray_worker_config_from_path, list_xray_worker_configs,
save_xray_worker_config, save_xray_worker_config_to_path, unstarted_worker_is_stale,
write_xray_runtime_config, xray_worker_config_path, xray_worker_log_path, XrayWorkerConfig,
};
use std::path::Path;
use std::process::{Child, Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
const STARTUP_TIMEOUT: Duration = Duration::from_secs(15);
const POLL_INTERVAL: Duration = Duration::from_millis(100);
const READY_CHECK_TIMEOUT: Duration = Duration::from_millis(750);
static XRAY_BINARY_VERIFIED: AtomicBool = AtomicBool::new(false);
static XRAY_START_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
fn resolve_process_start_time(pid: u32) -> Option<u64> {
for _ in 0..25 {
if let Some(start_time) = process_start_time(pid) {
return Some(start_time);
}
std::thread::sleep(Duration::from_millis(10));
}
None
}
fn structured_error(code: &str) -> Box<dyn std::error::Error> {
serde_json::json!({ "code": code }).to_string().into()
}
fn structured_error_with_detail(
code: &str,
detail: impl std::fmt::Display,
) -> Box<dyn std::error::Error> {
serde_json::json!({ "code": code, "params": { "detail": detail.to_string() } })
.to_string()
.into()
}
#[cfg(any(target_os = "macos", test))]
fn parse_macos_major_version(version: &str) -> Option<u64> {
version.trim().split('.').next()?.parse().ok()
}
#[cfg(target_os = "macos")]
fn ensure_supported_macos_version() -> Result<(), Box<dyn std::error::Error>> {
let output = Command::new("/usr/bin/sw_vers")
.arg("-productVersion")
.output()
.map_err(|_| structured_error("XRAY_UNAVAILABLE"))?;
let version = String::from_utf8_lossy(&output.stdout);
if !output.status.success() {
return Err(structured_error("XRAY_UNAVAILABLE"));
}
if parse_macos_major_version(&version).is_some_and(|major| major < 12) {
return Err(structured_error("XRAY_UNSUPPORTED_OS"));
}
Ok(())
}
fn ensure_xray_binary() -> Result<std::path::PathBuf, Box<dyn std::error::Error>> {
#[cfg(target_os = "macos")]
ensure_supported_macos_version()?;
let executable =
find_sidecar_executable("xray").map_err(|_| structured_error("XRAY_UNAVAILABLE"))?;
if XRAY_BINARY_VERIFIED.load(Ordering::Acquire) {
return Ok(executable);
}
let mut command = Command::new(&executable);
command.arg("version");
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
command.creation_flags(CREATE_NO_WINDOW);
}
let output = command
.output()
.map_err(|_| structured_error("XRAY_UNAVAILABLE"))?;
let version_output = String::from_utf8_lossy(&output.stdout);
if !output.status.success() || !version_output.trim_start().starts_with("Xray ") {
return Err(structured_error("XRAY_UNAVAILABLE"));
}
XRAY_BINARY_VERIFIED.store(true, Ordering::Release);
Ok(executable)
}
async fn authenticated_socks_ready(config: &XrayWorkerConfig) -> bool {
if !config
.xray_pid
.is_some_and(|pid| process_identity_matches(pid, config.xray_pid_start_time))
{
return false;
}
matches!(
tokio::time::timeout(READY_CHECK_TIMEOUT, async {
let mut stream = tokio::net::TcpStream::connect(("127.0.0.1", config.local_port)).await?;
stream.write_all(&[5, 1, 2]).await?;
let mut method = [0_u8; 2];
stream.read_exact(&mut method).await?;
if method != [5, 2] {
return Err(std::io::Error::other(
"Xray SOCKS endpoint did not request password authentication",
));
}
let username = config.username.as_bytes();
let password = config.password.as_bytes();
let mut auth = Vec::with_capacity(username.len() + password.len() + 3);
auth.extend_from_slice(&[1, username.len() as u8]);
auth.extend_from_slice(username);
auth.push(password.len() as u8);
auth.extend_from_slice(password);
stream.write_all(&auth).await?;
let mut response = [0_u8; 2];
stream.read_exact(&mut response).await?;
if response != [1, 0] {
return Err(std::io::Error::other(
"Xray SOCKS endpoint rejected local authentication",
));
}
Ok::<(), std::io::Error>(())
})
.await,
Ok(Ok(()))
)
}
async fn wait_until_ready(
id: &str,
supervisor_pid: u32,
supervisor_start_time: u64,
) -> Result<XrayWorkerConfig, Box<dyn std::error::Error>> {
let deadline = tokio::time::Instant::now() + STARTUP_TIMEOUT;
loop {
if let Some(config) = get_xray_worker_config(id) {
if !process_identity_matches(supervisor_pid, Some(supervisor_start_time)) {
let log = std::fs::read_to_string(xray_worker_log_path(id)).unwrap_or_default();
if !log.is_empty() {
log::error!("Xray worker {id} exited during startup: {log}");
}
return Err(structured_error("XRAY_START_FAILED"));
}
if config.pid == Some(supervisor_pid)
&& config.pid_start_time == Some(supervisor_start_time)
&& config.ready
&& authenticated_socks_ready(&config).await
{
return Ok(config);
}
}
if tokio::time::Instant::now() >= deadline {
return Err(structured_error("XRAY_START_FAILED"));
}
tokio::time::sleep(POLL_INTERVAL).await;
}
}
pub async fn start_xray_worker(
profile_id: Option<&str>,
vless_uri: &str,
) -> Result<XrayWorkerConfig, Box<dyn std::error::Error>> {
let _start_guard = XRAY_START_LOCK.lock().await;
parse_vless_uri(vless_uri)
.map_err(|error| structured_error_with_detail("VLESS_CONFIG_INVALID", error))?;
crate::proxy_runner::ensure_sidecar_version().await?;
ensure_xray_binary()?;
let owner_pid = std::process::id();
let owner_start_time =
resolve_process_start_time(owner_pid).ok_or_else(|| structured_error("XRAY_START_FAILED"))?;
for config in list_xray_worker_configs() {
let dead = config
.pid
.is_some_and(|pid| !process_identity_matches(pid, config.pid_start_time));
if dead || unstarted_worker_is_stale(&config) {
let _ = stop_xray_worker(&config.id).await;
}
}
if let Some(profile_id) = profile_id {
let mut workers = list_xray_worker_configs()
.into_iter()
.filter(|config| config.profile_id.as_deref() == Some(profile_id))
.collect::<Vec<_>>();
workers.sort_unstable_by_key(|config| std::cmp::Reverse(config.created_at));
let mut reusable = None;
for mut existing in workers {
let candidate = reusable.is_none()
&& existing.vless_uri == vless_uri
&& existing
.pid
.is_some_and(|pid| process_identity_matches(pid, existing.pid_start_time))
&& existing.ready
&& worker_is_leased_to(&existing, owner_pid, owner_start_time);
if candidate
&& persist_browser_identity(&mut existing, owner_pid, owner_start_time)
&& authenticated_socks_ready(&existing).await
{
reusable = Some(existing);
} else {
let _ = stop_xray_worker(&existing.id).await;
}
}
if let Some(existing) = reusable {
return Ok(existing);
}
}
let mut last_error = None;
for attempt in 1..=3 {
match spawn_xray_worker(profile_id, vless_uri).await {
Ok(worker) => return Ok(worker),
Err(error) => {
if attempt < 3 {
log::warn!(
"Xray worker startup attempt {attempt} failed; retrying with a new local port"
);
}
last_error = Some(error.to_string());
}
}
}
Err(
last_error
.map(Into::into)
.unwrap_or_else(|| structured_error("XRAY_START_FAILED")),
)
}
async fn spawn_xray_worker(
profile_id: Option<&str>,
vless_uri: &str,
) -> Result<XrayWorkerConfig, Box<dyn std::error::Error>> {
let listener = std::net::TcpListener::bind(("127.0.0.1", 0))
.map_err(|error| structured_error_with_detail("XRAY_START_FAILED", error))?;
let local_port = listener
.local_addr()
.map_err(|error| structured_error_with_detail("XRAY_START_FAILED", error))?
.port();
drop(listener);
let id = generate_xray_worker_id();
let mut pending = PendingSupervisor {
id: id.clone(),
pid: None,
pid_start_time: None,
armed: true,
};
let username = format!("donut_{}", uuid::Uuid::new_v4().simple());
let password = uuid::Uuid::new_v4().simple().to_string();
let mut config = XrayWorkerConfig::new(
id.clone(),
profile_id.map(str::to_string),
vless_uri.to_string(),
local_port,
username,
password,
);
let owner_pid = std::process::id();
config.browser_pid = Some(owner_pid);
config.browser_pid_start_time = Some(
resolve_process_start_time(owner_pid).ok_or_else(|| structured_error("XRAY_START_FAILED"))?,
);
save_xray_worker_config(&config)
.map_err(|error| structured_error_with_detail("XRAY_START_FAILED", error))?;
let supervisor = find_sidecar_executable("donut-proxy")
.map_err(|_| structured_error("PROXY_SIDECAR_VERSION_MISMATCH"))?;
let config_path = xray_worker_config_path(&id);
let log_file = create_xray_worker_log(&id)
.map_err(|error| structured_error_with_detail("XRAY_START_FAILED", error))?;
let mut command = Command::new(supervisor);
command
.arg("xray-worker")
.arg("start")
.arg("--config-path")
.arg(&config_path)
.stdin(Stdio::null())
.stdout(Stdio::from(log_file.try_clone().map_err(|error| {
structured_error_with_detail("XRAY_START_FAILED", error)
})?))
.stderr(Stdio::from(log_file));
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
unsafe {
command.pre_exec(|| {
if libc::setsid() == -1 {
return Err(std::io::Error::last_os_error());
}
Ok(())
});
}
}
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const DETACHED_PROCESS: u32 = 0x00000008;
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
const CREATE_NO_WINDOW: u32 = 0x08000000;
command.creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW);
}
let mut child = command
.spawn()
.map_err(|error| structured_error_with_detail("XRAY_START_FAILED", error))?;
let supervisor_pid = child.id();
let Some(supervisor_start_time) = resolve_process_start_time(supervisor_pid) else {
terminate_supervisor_unchecked(supervisor_pid);
let _ = child.wait();
return Err(structured_error("XRAY_START_FAILED"));
};
pending.pid = Some(supervisor_pid);
pending.pid_start_time = Some(supervisor_start_time);
drop(spawn_supervisor_reaper(child));
let ready = wait_until_ready(&id, supervisor_pid, supervisor_start_time).await?;
pending.armed = false;
Ok(ready)
}
pub fn set_browser_pid(worker_id: &str, browser_pid: u32) -> bool {
if browser_pid == 0 {
return false;
}
let Some(browser_pid_start_time) = resolve_process_start_time(browser_pid) else {
log::warn!("Failed to resolve browser PID {browser_pid} identity for Xray worker {worker_id}");
return false;
};
let Some(mut config) = get_xray_worker_config(worker_id) else {
return false;
};
persist_browser_identity(&mut config, browser_pid, browser_pid_start_time)
}
fn persist_browser_identity(
config: &mut XrayWorkerConfig,
browser_pid: u32,
browser_pid_start_time: u64,
) -> bool {
config.browser_pid = Some(browser_pid);
config.browser_pid_start_time = Some(browser_pid_start_time);
if !crate::xray_worker_storage::update_xray_worker_config(config) {
log::warn!(
"Failed to persist browser PID {browser_pid} on Xray worker {}",
config.id
);
return false;
}
true
}
fn worker_is_leased_to(config: &XrayWorkerConfig, pid: u32, start_time: u64) -> bool {
config.browser_pid == Some(pid) && config.browser_pid_start_time == Some(start_time)
}
struct PendingSupervisor {
id: String,
pid: Option<u32>,
pid_start_time: Option<u64>,
armed: bool,
}
fn spawn_supervisor_reaper(mut child: Child) -> std::thread::JoinHandle<()> {
std::thread::spawn(move || {
let _ = child.wait();
})
}
impl Drop for PendingSupervisor {
fn drop(&mut self) {
if self.armed {
let xray_process = get_xray_worker_config(&self.id)
.and_then(|config| config.xray_pid.zip(config.xray_pid_start_time));
delete_xray_worker_config(&self.id);
if let Some(pid) = self.pid {
if self.pid_start_time.is_none() || process_identity_matches(pid, self.pid_start_time) {
terminate_supervisor_unchecked(pid);
}
}
if let Some((pid, start_time)) = xray_process {
if process_identity_matches(pid, Some(start_time)) {
terminate_process_unchecked(pid);
}
}
delete_xray_worker_config(&self.id);
}
}
}
#[cfg(unix)]
fn terminate_supervisor_unchecked(pid: u32) {
let group = -(pid as i32);
unsafe {
libc::kill(group, libc::SIGTERM);
}
std::thread::sleep(Duration::from_millis(250));
if is_process_running(pid) {
unsafe {
libc::kill(group, libc::SIGKILL);
}
}
}
#[cfg(windows)]
fn terminate_supervisor_unchecked(pid: u32) {
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
let _ = Command::new("taskkill")
.args(["/T", "/F", "/PID", &pid.to_string()])
.creation_flags(CREATE_NO_WINDOW)
.output();
}
#[cfg(unix)]
fn terminate_process_unchecked(pid: u32) {
unsafe {
libc::kill(pid as i32, libc::SIGTERM);
}
std::thread::sleep(Duration::from_millis(250));
if is_process_running(pid) {
unsafe {
libc::kill(pid as i32, libc::SIGKILL);
}
}
}
#[cfg(windows)]
fn terminate_process_unchecked(pid: u32) {
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
let _ = Command::new("taskkill")
.args(["/F", "/PID", &pid.to_string()])
.creation_flags(CREATE_NO_WINDOW)
.output();
}
pub fn stop_xray_worker_now(id: &str) -> Result<bool, Box<dyn std::error::Error>> {
let Some(config) = get_xray_worker_config(id) else {
return Ok(false);
};
delete_xray_worker_config(id);
if let Some(pid) = config
.pid
.filter(|pid| process_identity_matches(*pid, config.pid_start_time))
{
terminate_supervisor_unchecked(pid);
}
if let Some(pid) = config
.xray_pid
.filter(|pid| process_identity_matches(*pid, config.xray_pid_start_time))
{
terminate_process_unchecked(pid);
}
delete_xray_worker_config(id);
Ok(true)
}
pub async fn stop_xray_worker(id: &str) -> Result<bool, Box<dyn std::error::Error>> {
stop_xray_worker_now(id)
}
pub async fn stop_xray_worker_by_profile_id(
profile_id: &str,
) -> Result<bool, Box<dyn std::error::Error>> {
let workers = list_xray_worker_configs()
.into_iter()
.filter(|config| config.profile_id.as_deref() == Some(profile_id))
.collect::<Vec<_>>();
let mut stopped = false;
for worker in workers {
stopped |= stop_xray_worker(&worker.id).await?;
}
Ok(stopped)
}
struct ManagedChild {
child: Child,
}
impl ManagedChild {
fn new(child: Child) -> Self {
Self { child }
}
fn id(&self) -> u32 {
self.child.id()
}
fn try_wait(&mut self) -> std::io::Result<Option<std::process::ExitStatus>> {
self.child.try_wait()
}
}
impl Drop for ManagedChild {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
pub async fn run_xray_worker(config_path: &Path) -> Result<(), Box<dyn std::error::Error>> {
let mut config = get_xray_worker_config_from_path(config_path)
.ok_or_else(|| structured_error("XRAY_START_FAILED"))?;
let supervisor_pid = std::process::id();
config.ready = false;
config.xray_pid = None;
config.xray_pid_start_time = None;
config.pid = Some(supervisor_pid);
config.pid_start_time = Some(
resolve_process_start_time(supervisor_pid)
.ok_or_else(|| structured_error("XRAY_START_FAILED"))?,
);
save_xray_worker_config_to_path(&config, config_path)
.map_err(|error| structured_error_with_detail("XRAY_START_FAILED", error))?;
let parsed = parse_vless_uri(&config.vless_uri)
.map_err(|error| structured_error_with_detail("VLESS_CONFIG_INVALID", error))?;
let runtime = XrayClientRuntime {
listen_port: config.local_port,
username: config.username.clone(),
password: config.password.clone(),
};
let runtime_json = build_client_config_json(&parsed.config, &runtime)
.map_err(|error| structured_error_with_detail("VLESS_CONFIG_INVALID", error))?;
write_xray_runtime_config(&config.id, runtime_json.as_bytes())
.map_err(|error| structured_error_with_detail("XRAY_START_FAILED", error))?;
let runtime_path = crate::xray_worker_storage::xray_runtime_config_path(&config.id);
let executable = ensure_xray_binary()?;
let mut command = Command::new(executable);
command
.arg("run")
.arg("-c")
.arg(&runtime_path)
.stdin(Stdio::null())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit());
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
command.creation_flags(CREATE_NO_WINDOW);
}
let child = command
.spawn()
.map_err(|error| structured_error_with_detail("XRAY_START_FAILED", error))?;
let mut child = ManagedChild::new(child);
let xray_pid = child.id();
config.xray_pid = Some(xray_pid);
config.xray_pid_start_time = Some(
resolve_process_start_time(xray_pid).ok_or_else(|| structured_error("XRAY_START_FAILED"))?,
);
save_xray_worker_config_to_path(&config, config_path)
.map_err(|error| structured_error_with_detail("XRAY_START_FAILED", error))?;
let readiness_result: Result<(), Box<dyn std::error::Error>> = async {
let deadline = tokio::time::Instant::now() + STARTUP_TIMEOUT;
loop {
match child.try_wait() {
Ok(Some(_)) => return Err(structured_error("XRAY_START_FAILED")),
Ok(None) => {}
Err(error) => {
return Err(structured_error_with_detail("XRAY_START_FAILED", error));
}
}
if authenticated_socks_ready(&config).await {
tokio::time::sleep(POLL_INTERVAL).await;
match child.try_wait() {
Ok(Some(_)) => return Err(structured_error("XRAY_START_FAILED")),
Ok(None) => {}
Err(error) => {
return Err(structured_error_with_detail("XRAY_START_FAILED", error));
}
}
if authenticated_socks_ready(&config).await {
return Ok(());
}
}
if tokio::time::Instant::now() >= deadline {
return Err(structured_error("XRAY_START_FAILED"));
}
tokio::time::sleep(POLL_INTERVAL).await;
}
}
.await;
if let Err(error) = readiness_result {
drop(child);
delete_xray_worker_config(&config.id);
return Err(error);
}
config.ready = true;
if let Err(error) = save_xray_worker_config_to_path(&config, config_path) {
drop(child);
delete_xray_worker_config(&config.id);
return Err(structured_error_with_detail("XRAY_START_FAILED", error));
}
let mut state_misses = 0_u8;
let mut browser_misses = 0_u8;
let result = loop {
match child.try_wait() {
Ok(Some(_)) => break Err(structured_error("XRAY_START_FAILED")),
Ok(None) => {}
Err(error) => break Err(structured_error_with_detail("XRAY_START_FAILED", error)),
}
match get_xray_worker_config_from_path(config_path) {
Some(latest) => {
state_misses = 0;
if let Some(browser_pid) = latest.browser_pid {
if process_identity_matches(browser_pid, latest.browser_pid_start_time) {
browser_misses = 0;
} else {
browser_misses += 1;
if browser_misses >= 2 {
break Ok(());
}
}
} else {
browser_misses = 0;
}
}
None => {
state_misses += 1;
if state_misses >= 2 {
break Ok(());
}
}
}
tokio::time::sleep(Duration::from_millis(500)).await;
};
drop(child);
delete_xray_worker_config(&config.id);
result
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
fn short_lived_child() -> Child {
Command::new("sh")
.args(["-c", "sleep 0.2"])
.spawn()
.unwrap()
}
#[cfg(windows)]
fn short_lived_child() -> Child {
Command::new("cmd")
.args(["/C", "ping -n 2 127.0.0.1 >NUL"])
.spawn()
.unwrap()
}
#[test]
fn supervisor_child_is_reaped_after_exit() {
let child = short_lived_child();
let pid = child.id();
let start_time = resolve_process_start_time(pid).unwrap();
spawn_supervisor_reaper(child).join().unwrap();
assert!(!process_identity_matches(pid, Some(start_time)));
}
fn readiness_config(port: u16) -> XrayWorkerConfig {
let mut config = XrayWorkerConfig::new(
"readiness".to_string(),
None,
"vless://unused".to_string(),
port,
"local-user".to_string(),
"local-password".to_string(),
);
let pid = std::process::id();
config.xray_pid = Some(pid);
config.xray_pid_start_time = process_start_time(pid);
config
}
#[test]
fn parses_macos_major_versions_for_sidecar_compatibility() {
assert_eq!(parse_macos_major_version("11.7.10\n"), Some(11));
assert_eq!(parse_macos_major_version("12.0"), Some(12));
assert_eq!(parse_macos_major_version("15.5.1"), Some(15));
assert_eq!(parse_macos_major_version("unknown"), None);
}
#[tokio::test]
async fn readiness_requires_the_expected_authenticated_socks_endpoint() {
let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0))
.await
.unwrap();
let config = readiness_config(listener.local_addr().unwrap().port());
let expected_username = config.username.clone();
let expected_password = config.password.clone();
let server = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let mut greeting = [0_u8; 3];
stream.read_exact(&mut greeting).await.unwrap();
assert_eq!(greeting, [5, 1, 2]);
stream.write_all(&[5, 2]).await.unwrap();
let mut header = [0_u8; 2];
stream.read_exact(&mut header).await.unwrap();
assert_eq!(header[0], 1);
let mut username = vec![0_u8; header[1] as usize];
stream.read_exact(&mut username).await.unwrap();
let password_len = stream.read_u8().await.unwrap();
let mut password = vec![0_u8; password_len as usize];
stream.read_exact(&mut password).await.unwrap();
assert_eq!(username, expected_username.as_bytes());
assert_eq!(password, expected_password.as_bytes());
stream.write_all(&[1, 0]).await.unwrap();
});
assert!(authenticated_socks_ready(&config).await);
server.await.unwrap();
}
#[tokio::test]
async fn readiness_rejects_an_unrelated_listener_on_the_reserved_port() {
let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0))
.await
.unwrap();
let config = readiness_config(listener.local_addr().unwrap().port());
let server = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let mut greeting = [0_u8; 3];
stream.read_exact(&mut greeting).await.unwrap();
stream.write_all(&[5, 0]).await.unwrap();
});
assert!(!authenticated_socks_ready(&config).await);
server.await.unwrap();
}
#[test]
fn browser_identity_is_persisted_on_the_exact_worker() {
let temp = tempfile::tempdir().unwrap();
let _cache_guard = crate::app_dirs::set_test_cache_dir(temp.path().to_path_buf());
let id = format!("xray-browser-owner-{}", uuid::Uuid::new_v4());
let config = XrayWorkerConfig::new(
id.clone(),
Some("profile".to_string()),
"vless://unused".to_string(),
1080,
"local-user".to_string(),
"local-password".to_string(),
);
save_xray_worker_config(&config).unwrap();
let browser_pid = std::process::id();
assert!(set_browser_pid(&id, browser_pid));
let saved = get_xray_worker_config(&id).unwrap();
assert_eq!(saved.browser_pid, Some(browser_pid));
assert_eq!(
saved.browser_pid_start_time,
process_start_time(browser_pid)
);
assert!(delete_xray_worker_config(&id));
}
#[test]
fn reusable_worker_requires_and_persists_the_exact_live_owner() {
let temp = tempfile::tempdir().unwrap();
let _cache_guard = crate::app_dirs::set_test_cache_dir(temp.path().to_path_buf());
let id = format!("xray-worker-lease-{}", uuid::Uuid::new_v4());
let mut config = XrayWorkerConfig::new(
id.clone(),
Some("profile".to_string()),
"vless://unused".to_string(),
1080,
"local-user".to_string(),
"local-password".to_string(),
);
config.browser_pid = Some(u32::MAX);
config.browser_pid_start_time = Some(1);
save_xray_worker_config(&config).unwrap();
let owner_pid = std::process::id();
let owner_start_time = process_start_time(owner_pid).unwrap();
assert!(!worker_is_leased_to(&config, owner_pid, owner_start_time));
assert!(persist_browser_identity(
&mut config,
owner_pid,
owner_start_time
));
assert!(worker_is_leased_to(&config, owner_pid, owner_start_time));
let saved = get_xray_worker_config(&id).unwrap();
assert_eq!(saved.browser_pid, Some(owner_pid));
assert_eq!(saved.browser_pid_start_time, Some(owner_start_time));
assert!(delete_xray_worker_config(&id));
}
}
+443
View File
@@ -0,0 +1,443 @@
use serde::{Deserialize, Serialize};
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
const UNSTARTED_WORKER_GRACE_SECS: u64 = 60;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct XrayWorkerConfig {
pub id: String,
pub profile_id: Option<String>,
pub vless_uri: String,
pub local_port: u16,
pub username: String,
pub password: String,
#[serde(default)]
pub created_at: u64,
pub pid: Option<u32>,
#[serde(default)]
pub pid_start_time: Option<u64>,
pub xray_pid: Option<u32>,
#[serde(default)]
pub xray_pid_start_time: Option<u64>,
#[serde(default)]
pub ready: bool,
#[serde(default)]
pub browser_pid: Option<u32>,
#[serde(default)]
pub browser_pid_start_time: Option<u64>,
}
impl XrayWorkerConfig {
pub fn new(
id: String,
profile_id: Option<String>,
vless_uri: String,
local_port: u16,
username: String,
password: String,
) -> Self {
Self {
id,
profile_id,
vless_uri,
local_port,
username,
password,
created_at: now_secs(),
pid: None,
pid_start_time: None,
xray_pid: None,
xray_pid_start_time: None,
ready: false,
browser_pid: None,
browser_pid_start_time: None,
}
}
pub fn local_proxy_settings(&self) -> crate::browser::ProxySettings {
crate::browser::ProxySettings {
proxy_type: "socks5".to_string(),
host: "127.0.0.1".to_string(),
port: self.local_port,
username: Some(self.username.clone()),
password: Some(self.password.clone()),
vless_uri: None,
}
}
}
fn now_secs() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
pub fn unstarted_worker_is_stale(config: &XrayWorkerConfig) -> bool {
config.pid.is_none()
&& (config.created_at == 0
|| now_secs().saturating_sub(config.created_at) > UNSTARTED_WORKER_GRACE_SECS)
}
fn ensure_private_storage_dir() -> std::io::Result<PathBuf> {
let directory = crate::proxy_storage::get_storage_dir();
fs::create_dir_all(&directory)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&directory, fs::Permissions::from_mode(0o700))?;
}
Ok(directory)
}
fn atomic_write_owner_only(path: &Path, content: &[u8]) -> std::io::Result<()> {
let parent = path
.parent()
.ok_or_else(|| std::io::Error::other("worker path has no parent"))?;
fs::create_dir_all(parent)?;
let mut temporary = tempfile::Builder::new()
.prefix(".xray-state-")
.tempfile_in(parent)?;
crate::app_dirs::restrict_to_owner(temporary.path());
temporary.write_all(content)?;
temporary.flush()?;
temporary.as_file().sync_all()?;
temporary.persist(path).map_err(|error| error.error)?;
crate::app_dirs::restrict_to_owner(path);
Ok(())
}
pub fn xray_worker_config_path(id: &str) -> PathBuf {
crate::proxy_storage::get_storage_dir().join(format!("xray_worker_{id}.json"))
}
pub fn xray_runtime_config_path(id: &str) -> PathBuf {
crate::proxy_storage::get_storage_dir().join(format!("xray_runtime_{id}.json"))
}
pub fn xray_worker_log_path(id: &str) -> PathBuf {
crate::proxy_storage::get_storage_dir().join(format!("xray_worker_{id}.log"))
}
fn xray_worker_tombstone_path(id: &str) -> PathBuf {
crate::proxy_storage::get_storage_dir().join(format!("xray_worker_{id}.stopped"))
}
fn worker_is_tombstoned(id: &str) -> bool {
xray_worker_tombstone_path(id).exists()
}
pub fn create_xray_worker_log(id: &str) -> std::io::Result<std::fs::File> {
ensure_private_storage_dir()?;
if worker_is_tombstoned(id) {
return Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
"Xray worker has stopped",
));
}
let path = xray_worker_log_path(id);
let file = crate::app_dirs::create_owner_only(&path)?;
if worker_is_tombstoned(id) {
drop(file);
let _ = fs::remove_file(path);
return Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
"Xray worker has stopped",
));
}
Ok(file)
}
pub fn write_xray_runtime_config(id: &str, content: &[u8]) -> std::io::Result<()> {
ensure_private_storage_dir()?;
if worker_is_tombstoned(id) {
return Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
"Xray worker has stopped",
));
}
let path = xray_runtime_config_path(id);
atomic_write_owner_only(&path, content)?;
if worker_is_tombstoned(id) {
let _ = fs::remove_file(path);
return Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
"Xray worker has stopped",
));
}
Ok(())
}
pub fn save_xray_worker_config(
config: &XrayWorkerConfig,
) -> Result<(), Box<dyn std::error::Error>> {
save_xray_worker_config_to_path(config, &xray_worker_config_path(&config.id))
}
pub fn save_xray_worker_config_to_path(
config: &XrayWorkerConfig,
path: &Path,
) -> Result<(), Box<dyn std::error::Error>> {
ensure_private_storage_dir()?;
if worker_is_tombstoned(&config.id) {
return Err(
std::io::Error::new(std::io::ErrorKind::NotFound, "Xray worker has stopped").into(),
);
}
let content = serde_json::to_vec_pretty(config)?;
atomic_write_owner_only(path, &content)?;
if worker_is_tombstoned(&config.id) {
let _ = fs::remove_file(path);
return Err(
std::io::Error::new(std::io::ErrorKind::NotFound, "Xray worker has stopped").into(),
);
}
Ok(())
}
pub fn get_xray_worker_config(id: &str) -> Option<XrayWorkerConfig> {
get_xray_worker_config_from_path(&xray_worker_config_path(id))
}
pub fn get_xray_worker_config_from_path(path: &Path) -> Option<XrayWorkerConfig> {
let content = fs::read(path).ok()?;
serde_json::from_slice(&content).ok()
}
pub fn update_xray_worker_config(config: &XrayWorkerConfig) -> bool {
let path = xray_worker_config_path(&config.id);
path.exists() && save_xray_worker_config_to_path(config, &path).is_ok()
}
pub fn delete_xray_worker_config(id: &str) -> bool {
if ensure_private_storage_dir().is_ok() {
let _ = atomic_write_owner_only(&xray_worker_tombstone_path(id), b"");
}
let path = xray_worker_config_path(id);
let deleted = !path.exists() || fs::remove_file(path).is_ok();
let _ = fs::remove_file(xray_runtime_config_path(id));
let _ = fs::remove_file(xray_worker_log_path(id));
deleted
}
pub fn list_xray_worker_configs() -> Vec<XrayWorkerConfig> {
let storage_dir = crate::proxy_storage::get_storage_dir();
let Ok(entries) = fs::read_dir(storage_dir) else {
return Vec::new();
};
entries
.flatten()
.filter_map(|entry| {
let path = entry.path();
let name = path.file_name()?.to_str()?;
if !name.starts_with("xray_worker_") || !name.ends_with(".json") {
return None;
}
get_xray_worker_config_from_path(&path)
})
.collect()
}
pub fn find_xray_worker_by_profile_id(profile_id: &str) -> Option<XrayWorkerConfig> {
list_xray_worker_configs()
.into_iter()
.filter(|config| config.profile_id.as_deref() == Some(profile_id))
.max_by_key(|config| config.created_at)
}
pub fn generate_xray_worker_id() -> String {
format!(
"xrayw_{}_{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs(),
rand::random::<u32>()
)
}
#[cfg(test)]
mod tests {
use super::*;
fn test_config(id: &str) -> XrayWorkerConfig {
XrayWorkerConfig::new(
id.to_string(),
Some("profile".to_string()),
"vless://example".to_string(),
1080,
"local-user".to_string(),
"local-password".to_string(),
)
}
#[test]
fn local_proxy_settings_use_authenticated_loopback_socks() {
let config = test_config("id");
let proxy = config.local_proxy_settings();
assert_eq!(proxy.proxy_type, "socks5");
assert_eq!(proxy.host, "127.0.0.1");
assert_eq!(proxy.port, 1080);
assert_eq!(proxy.username.as_deref(), Some("local-user"));
assert_eq!(proxy.password.as_deref(), Some("local-password"));
assert!(proxy.vless_uri.is_none());
}
#[test]
fn worker_storage_round_trips_updates_lists_and_securely_cleans_runtime_files() {
let temp = tempfile::tempdir().unwrap();
let _cache_guard = crate::app_dirs::set_test_cache_dir(temp.path().to_path_buf());
let id = format!("xray-storage-test-{}", uuid::Uuid::new_v4());
let mut config = test_config(&id);
save_xray_worker_config(&config).unwrap();
assert_eq!(get_xray_worker_config(&id).unwrap().username, "local-user");
assert_eq!(
find_xray_worker_by_profile_id("profile").unwrap().id,
config.id
);
assert!(list_xray_worker_configs()
.iter()
.any(|candidate| candidate.id == id));
config.pid = Some(41);
config.xray_pid = Some(42);
config.browser_pid = Some(43);
assert!(update_xray_worker_config(&config));
let updated = get_xray_worker_config(&id).unwrap();
assert_eq!(updated.pid, Some(41));
assert_eq!(updated.xray_pid, Some(42));
assert_eq!(updated.browser_pid, Some(43));
let runtime_path = xray_runtime_config_path(&id);
write_xray_runtime_config(&id, b"{\"runtime\":true}").unwrap();
let log_path = xray_worker_log_path(&id);
drop(create_xray_worker_log(&id).unwrap());
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(
std::fs::metadata(crate::proxy_storage::get_storage_dir())
.unwrap()
.permissions()
.mode()
& 0o777,
0o700
);
assert_eq!(
std::fs::metadata(xray_worker_config_path(&id))
.unwrap()
.permissions()
.mode()
& 0o777,
0o600
);
assert_eq!(
std::fs::metadata(&runtime_path)
.unwrap()
.permissions()
.mode()
& 0o777,
0o600
);
assert_eq!(
std::fs::metadata(&log_path).unwrap().permissions().mode() & 0o777,
0o600
);
}
assert!(delete_xray_worker_config(&id));
assert!(get_xray_worker_config(&id).is_none());
assert!(!runtime_path.exists());
assert!(!log_path.exists());
assert!(!update_xray_worker_config(&config));
assert!(write_xray_runtime_config(&id, b"{}").is_err());
assert!(create_xray_worker_log(&id).is_err());
}
#[test]
fn fresh_unstarted_workers_have_a_grace_period_but_legacy_entries_are_stale() {
let fresh = test_config("fresh");
assert!(!unstarted_worker_is_stale(&fresh));
let mut legacy = test_config("legacy");
legacy.created_at = 0;
assert!(unstarted_worker_is_stale(&legacy));
legacy.pid = Some(1);
assert!(!unstarted_worker_is_stale(&legacy));
}
#[test]
fn atomic_state_updates_never_expose_partial_json() {
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("state.json");
atomic_write_owner_only(&path, br#"{"value":0}"#).unwrap();
let complete = Arc::new(AtomicBool::new(false));
let writer_complete = complete.clone();
let writer_path = path.clone();
let writer = std::thread::spawn(move || {
for value in 1..=500 {
let content = serde_json::to_vec(&serde_json::json!({ "value": value })).unwrap();
atomic_write_owner_only(&writer_path, &content).unwrap();
}
writer_complete.store(true, Ordering::Release);
});
while !complete.load(Ordering::Acquire) {
let content = std::fs::read(&path).unwrap();
let value: serde_json::Value = serde_json::from_slice(&content).unwrap();
assert!(value["value"].is_number());
}
writer.join().unwrap();
}
#[cfg(unix)]
#[test]
fn atomic_state_write_replaces_a_symlink_without_touching_its_target() {
use std::os::unix::fs::symlink;
let temp = tempfile::tempdir().unwrap();
let victim = temp.path().join("victim");
let state = temp.path().join("state.json");
std::fs::write(&victim, "untouched").unwrap();
symlink(&victim, &state).unwrap();
atomic_write_owner_only(&state, br#"{"safe":true}"#).unwrap();
assert_eq!(std::fs::read_to_string(victim).unwrap(), "untouched");
assert_eq!(
serde_json::from_slice::<serde_json::Value>(&std::fs::read(state).unwrap()).unwrap()["safe"],
true
);
}
#[test]
fn legacy_worker_config_defaults_missing_browser_pid() {
let value = serde_json::json!({
"id": "legacy",
"profile_id": "profile",
"vless_uri": "vless://example",
"local_port": 1080,
"username": "user",
"password": "password",
"pid": 1,
"xray_pid": 2
});
let config: XrayWorkerConfig = serde_json::from_value(value).unwrap();
assert_eq!(config.created_at, 0);
assert_eq!(config.pid_start_time, None);
assert_eq!(config.xray_pid_start_time, None);
assert!(!config.ready);
assert_eq!(config.browser_pid, None);
assert_eq!(config.browser_pid_start_time, None);
}
}