mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-08-02 01:08:41 +02:00
feat: xray support
This commit is contained in:
+8
-1
@@ -1,5 +1,7 @@
|
||||
fn main() {
|
||||
println!("cargo::rustc-check-cfg=cfg(mobile)");
|
||||
let build_target = std::env::var("TARGET").expect("Cargo must provide TARGET");
|
||||
println!("cargo:rustc-env=DONUT_BUILD_TARGET={build_target}");
|
||||
|
||||
// Ensure dist folder exists for tauri::generate_context!() macro
|
||||
// This allows running cargo test without building the frontend first
|
||||
@@ -98,8 +100,13 @@ fn external_binaries_exist() -> bool {
|
||||
} else {
|
||||
format!("donut-proxy-{}", target)
|
||||
};
|
||||
let xray_name = if target.contains("windows") {
|
||||
format!("xray-{}.exe", target)
|
||||
} else {
|
||||
format!("xray-{}", target)
|
||||
};
|
||||
|
||||
binaries_dir.join(&donut_proxy_name).exists()
|
||||
binaries_dir.join(&donut_proxy_name).exists() && binaries_dir.join(&xray_name).exists()
|
||||
}
|
||||
|
||||
fn ensure_dist_folder_exists() {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { execSync, execFileSync } from "node:child_process";
|
||||
import { execFileSync, execSync } from "node:child_process";
|
||||
import { copyFileSync, existsSync, mkdirSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { downloadXray } from "./download-xray.mjs";
|
||||
|
||||
const MANIFEST_DIR = dirname(fileURLToPath(import.meta.url));
|
||||
const PROFILE =
|
||||
@@ -35,9 +36,18 @@ const isWindows = TARGET.includes("windows");
|
||||
// Determine source directory
|
||||
let srcDir;
|
||||
if (TARGET === HOST_TARGET || TARGET === "unknown") {
|
||||
srcDir = join(MANIFEST_DIR, "target", PROFILE === "release" ? "release" : "debug");
|
||||
srcDir = join(
|
||||
MANIFEST_DIR,
|
||||
"target",
|
||||
PROFILE === "release" ? "release" : "debug",
|
||||
);
|
||||
} else {
|
||||
srcDir = join(MANIFEST_DIR, "target", TARGET, PROFILE === "release" ? "release" : "debug");
|
||||
srcDir = join(
|
||||
MANIFEST_DIR,
|
||||
"target",
|
||||
TARGET,
|
||||
PROFILE === "release" ? "release" : "debug",
|
||||
);
|
||||
}
|
||||
|
||||
const destDir = join(MANIFEST_DIR, "binaries");
|
||||
@@ -70,3 +80,4 @@ function copyBinary(baseName) {
|
||||
}
|
||||
|
||||
copyBinary("donut-proxy");
|
||||
await downloadXray(TARGET);
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
chmodSync,
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { basename, dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
export const XRAY_VERSION = "v26.3.27";
|
||||
export const XRAY_SOURCE_URL = `https://github.com/XTLS/Xray-core/tree/${XRAY_VERSION}`;
|
||||
export const XRAY_LICENSE_FILE = "xray-LICENSE.txt";
|
||||
|
||||
export const XRAY_ASSETS = {
|
||||
"aarch64-apple-darwin": {
|
||||
name: "Xray-macos-arm64-v8a.zip",
|
||||
sha256: "2e93a67e8aa1936ecefb307e120830fcbd4c643ab9b1c46a2d0838d5f8409eaf",
|
||||
},
|
||||
"x86_64-apple-darwin": {
|
||||
name: "Xray-macos-64.zip",
|
||||
sha256: "f5b0471d3459eff1b82e48af0aeac186abcc3298210070afbbbd8437a4e8b203",
|
||||
},
|
||||
"x86_64-unknown-linux-gnu": {
|
||||
name: "Xray-linux-64.zip",
|
||||
sha256: "23cd9af937744d97776ee35ecad4972cf4b2109d1e0fe6be9930467608f7c8ae",
|
||||
},
|
||||
"aarch64-unknown-linux-gnu": {
|
||||
name: "Xray-linux-arm64-v8a.zip",
|
||||
sha256: "4d30283ae614e3057f730f67cd088a42be6fdf91f8639d82cb69e48cde80413c",
|
||||
},
|
||||
"x86_64-pc-windows-msvc": {
|
||||
name: "Xray-windows-64.zip",
|
||||
sha256: "d004c39288ce9ada487c6f398c7c545f7d749e44bdfdd59dbc9f865afba4e1ad",
|
||||
},
|
||||
};
|
||||
|
||||
const MANIFEST_DIR = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export function requestedTarget() {
|
||||
const targetIndex = process.argv.indexOf("--target");
|
||||
if (targetIndex !== -1 && process.argv[targetIndex + 1]) {
|
||||
return process.argv[targetIndex + 1];
|
||||
}
|
||||
if (process.env.TARGET) {
|
||||
return process.env.TARGET;
|
||||
}
|
||||
|
||||
const result = spawnSync("rustc", ["-vV"], { encoding: "utf8" });
|
||||
const match = result.stdout?.match(/^host:\s*(.+)$/m);
|
||||
if (!match) {
|
||||
throw new Error("Unable to determine the Rust target");
|
||||
}
|
||||
return match[1].trim();
|
||||
}
|
||||
|
||||
function sha256(path) {
|
||||
return createHash("sha256").update(readFileSync(path)).digest("hex");
|
||||
}
|
||||
|
||||
export function xrayBinaryName(target) {
|
||||
return `xray-${target}${target.includes("windows") ? ".exe" : ""}`;
|
||||
}
|
||||
|
||||
export function xrayDownloadUrl(assetName) {
|
||||
return `https://github.com/XTLS/Xray-core/releases/download/${XRAY_VERSION}/${assetName}`;
|
||||
}
|
||||
|
||||
function extractArchive(archive, destinationDir, windowsTarget) {
|
||||
if (windowsTarget) {
|
||||
const result = spawnSync(
|
||||
"powershell",
|
||||
[
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-Command",
|
||||
"Expand-Archive -LiteralPath $args[0] -DestinationPath $args[1] -Force",
|
||||
archive,
|
||||
destinationDir,
|
||||
],
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
if (result.status !== 0) {
|
||||
throw new Error("Failed to extract the Xray-core archive");
|
||||
}
|
||||
return {
|
||||
binary: join(destinationDir, "Xray.exe"),
|
||||
license: join(destinationDir, "LICENSE"),
|
||||
};
|
||||
}
|
||||
|
||||
const result = spawnSync(
|
||||
"unzip",
|
||||
["-qq", "-j", archive, "xray", "LICENSE", "-d", destinationDir],
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
if (result.status !== 0) {
|
||||
throw new Error("Failed to extract the Xray-core archive");
|
||||
}
|
||||
|
||||
return {
|
||||
binary: join(destinationDir, "xray"),
|
||||
license: join(destinationDir, "LICENSE"),
|
||||
};
|
||||
}
|
||||
|
||||
export async function downloadXray(target = requestedTarget()) {
|
||||
const asset = XRAY_ASSETS[target];
|
||||
if (!asset) {
|
||||
throw new Error(`Xray-core is not packaged for Rust target '${target}'`);
|
||||
}
|
||||
|
||||
const windowsTarget = target.includes("windows");
|
||||
const destinationDir = join(MANIFEST_DIR, "binaries");
|
||||
const destination = join(destinationDir, xrayBinaryName(target));
|
||||
const licenseDestination = join(destinationDir, XRAY_LICENSE_FILE);
|
||||
const marker = `${destination}.source.json`;
|
||||
|
||||
if (
|
||||
existsSync(destination) &&
|
||||
existsSync(licenseDestination) &&
|
||||
existsSync(marker)
|
||||
) {
|
||||
try {
|
||||
const source = JSON.parse(readFileSync(marker, "utf8"));
|
||||
if (
|
||||
source.version === XRAY_VERSION &&
|
||||
source.archiveSha256 === asset.sha256 &&
|
||||
source.binarySha256 === sha256(destination) &&
|
||||
source.licenseSha256 === sha256(licenseDestination)
|
||||
) {
|
||||
return destination;
|
||||
}
|
||||
} catch {
|
||||
// A partial or older cache entry is replaced from the verified archive.
|
||||
}
|
||||
}
|
||||
|
||||
mkdirSync(destinationDir, { recursive: true });
|
||||
const scratch = mkdtempSync(join(tmpdir(), "donut-xray-"));
|
||||
try {
|
||||
const archive = join(scratch, basename(asset.name));
|
||||
const response = await fetch(xrayDownloadUrl(asset.name));
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to download Xray-core (${response.status} ${response.statusText})`,
|
||||
);
|
||||
}
|
||||
writeFileSync(archive, Buffer.from(await response.arrayBuffer()));
|
||||
|
||||
const actual = sha256(archive);
|
||||
if (actual !== asset.sha256) {
|
||||
throw new Error(
|
||||
`Xray-core checksum mismatch: expected ${asset.sha256}, got ${actual}`,
|
||||
);
|
||||
}
|
||||
|
||||
const extracted = extractArchive(archive, scratch, windowsTarget);
|
||||
if (!existsSync(extracted.binary) || !existsSync(extracted.license)) {
|
||||
throw new Error(
|
||||
"The Xray-core archive did not contain its executable and license",
|
||||
);
|
||||
}
|
||||
copyFileSync(extracted.binary, destination);
|
||||
copyFileSync(extracted.license, licenseDestination);
|
||||
if (!windowsTarget) {
|
||||
chmodSync(destination, 0o755);
|
||||
}
|
||||
writeFileSync(
|
||||
marker,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
version: XRAY_VERSION,
|
||||
archiveSha256: asset.sha256,
|
||||
binarySha256: sha256(destination),
|
||||
licenseSha256: sha256(licenseDestination),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
console.log(`Downloaded Xray-core ${XRAY_VERSION} to ${destination}`);
|
||||
return destination;
|
||||
} finally {
|
||||
rmSync(scratch, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
||||
downloadXray().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
import {
|
||||
downloadXray,
|
||||
XRAY_ASSETS,
|
||||
XRAY_LICENSE_FILE,
|
||||
XRAY_SOURCE_URL,
|
||||
XRAY_VERSION,
|
||||
xrayBinaryName,
|
||||
xrayDownloadUrl,
|
||||
} from "./download-xray.mjs";
|
||||
|
||||
const EXPECTED_ASSETS = {
|
||||
"aarch64-apple-darwin": [
|
||||
"Xray-macos-arm64-v8a.zip",
|
||||
"2e93a67e8aa1936ecefb307e120830fcbd4c643ab9b1c46a2d0838d5f8409eaf",
|
||||
],
|
||||
"x86_64-apple-darwin": [
|
||||
"Xray-macos-64.zip",
|
||||
"f5b0471d3459eff1b82e48af0aeac186abcc3298210070afbbbd8437a4e8b203",
|
||||
],
|
||||
"x86_64-unknown-linux-gnu": [
|
||||
"Xray-linux-64.zip",
|
||||
"23cd9af937744d97776ee35ecad4972cf4b2109d1e0fe6be9930467608f7c8ae",
|
||||
],
|
||||
"aarch64-unknown-linux-gnu": [
|
||||
"Xray-linux-arm64-v8a.zip",
|
||||
"4d30283ae614e3057f730f67cd088a42be6fdf91f8639d82cb69e48cde80413c",
|
||||
],
|
||||
"x86_64-pc-windows-msvc": [
|
||||
"Xray-windows-64.zip",
|
||||
"d004c39288ce9ada487c6f398c7c545f7d749e44bdfdd59dbc9f865afba4e1ad",
|
||||
],
|
||||
};
|
||||
|
||||
test("pins the official Xray-core release and supported assets", () => {
|
||||
assert.equal(XRAY_VERSION, "v26.3.27");
|
||||
assert.equal(
|
||||
XRAY_SOURCE_URL,
|
||||
"https://github.com/XTLS/Xray-core/tree/v26.3.27",
|
||||
);
|
||||
assert.deepEqual(
|
||||
Object.fromEntries(
|
||||
Object.entries(XRAY_ASSETS).map(([target, asset]) => [
|
||||
target,
|
||||
[asset.name, asset.sha256],
|
||||
]),
|
||||
),
|
||||
EXPECTED_ASSETS,
|
||||
);
|
||||
|
||||
for (const asset of Object.values(XRAY_ASSETS)) {
|
||||
assert.match(asset.sha256, /^[a-f0-9]{64}$/);
|
||||
assert.equal(
|
||||
xrayDownloadUrl(asset.name),
|
||||
`https://github.com/XTLS/Xray-core/releases/download/v26.3.27/${asset.name}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("uses the sidecar filenames expected by Tauri", () => {
|
||||
assert.equal(XRAY_LICENSE_FILE, "xray-LICENSE.txt");
|
||||
assert.equal(
|
||||
xrayBinaryName("aarch64-apple-darwin"),
|
||||
"xray-aarch64-apple-darwin",
|
||||
);
|
||||
assert.equal(
|
||||
xrayBinaryName("x86_64-pc-windows-msvc"),
|
||||
"xray-x86_64-pc-windows-msvc.exe",
|
||||
);
|
||||
});
|
||||
|
||||
test("bundles the upstream license in Tauri and portable releases", async () => {
|
||||
const tauriConfig = JSON.parse(
|
||||
await readFile(new URL("./tauri.conf.json", import.meta.url), "utf8"),
|
||||
);
|
||||
assert.equal(
|
||||
tauriConfig.bundle.resources[`binaries/${XRAY_LICENSE_FILE}`],
|
||||
"licenses/Xray-core-LICENSE.txt",
|
||||
);
|
||||
|
||||
for (const workflow of ["release.yml", "rolling-release.yml"]) {
|
||||
const contents = await readFile(
|
||||
new URL(`../.github/workflows/${workflow}`, import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(
|
||||
contents,
|
||||
/cp "src-tauri\/binaries\/xray-LICENSE\.txt" "\$PORTABLE_DIR\/licenses\/Xray-core-LICENSE\.txt"/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects an unsupported target before downloading", async () => {
|
||||
await assert.rejects(
|
||||
downloadXray("riscv64gc-unknown-linux-gnu"),
|
||||
/not packaged for Rust target/,
|
||||
);
|
||||
});
|
||||
@@ -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!(
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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(_) => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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(¶meters, "security", "reality")?;
|
||||
require_value(¶meters, "flow", VlessFlow::Vision.as_str())?;
|
||||
optional_value(¶meters, "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(¶meters, "headerType", "none")?;
|
||||
|
||||
let server_name = required_parameter(¶meters, "sni")?.to_string();
|
||||
let public_key = required_parameter(¶meters, "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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm copy-proxy-binary && pnpm dev",
|
||||
"devUrl": "http://localhost:12341",
|
||||
"beforeBuildCommand": "pnpm copy-proxy-binary:release && (test -d ../dist || pnpm build)",
|
||||
"beforeBuildCommand": "pnpm copy-proxy-binary:release && pnpm build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
@@ -30,7 +30,10 @@
|
||||
"active": true,
|
||||
"targets": ["app", "dmg", "nsis", "deb", "rpm", "appimage"],
|
||||
"category": "Productivity",
|
||||
"externalBin": ["binaries/donut-proxy"],
|
||||
"externalBin": ["binaries/donut-proxy", "binaries/xray"],
|
||||
"resources": {
|
||||
"binaries/xray-LICENSE.txt": "licenses/Xray-core-LICENSE.txt"
|
||||
},
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
|
||||
@@ -156,6 +156,281 @@ async fn test_sidecar_reports_build_version() -> Result<(), Box<dyn std::error::
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct XrayChainCleanup {
|
||||
outer_id: Option<String>,
|
||||
xray_id: Option<String>,
|
||||
}
|
||||
|
||||
impl XrayChainCleanup {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
outer_id: None,
|
||||
xray_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn cleanup(&mut self) {
|
||||
if let Some(id) = self.outer_id.take() {
|
||||
let _ = donutbrowser_lib::proxy_runner::stop_proxy_process(&id).await;
|
||||
}
|
||||
if let Some(id) = self.xray_id.take() {
|
||||
let _ = donutbrowser_lib::xray_worker_runner::stop_xray_worker(&id).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for XrayChainCleanup {
|
||||
fn drop(&mut self) {
|
||||
fn terminate_process_tree(pid: u32) {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let _ = std::process::Command::new("kill")
|
||||
.args(["-TERM", &format!("-{pid}")])
|
||||
.output();
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
let _ = std::process::Command::new("taskkill")
|
||||
.args(["/T", "/F", "/PID", &pid.to_string()])
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.output();
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(id) = self.outer_id.take() {
|
||||
if let Some(config) = donutbrowser_lib::proxy_storage::get_proxy_config(&id) {
|
||||
if let Some(pid) = config.pid {
|
||||
terminate_process_tree(pid);
|
||||
}
|
||||
}
|
||||
donutbrowser_lib::proxy_storage::delete_proxy_config(&id);
|
||||
}
|
||||
if let Some(id) = self.xray_id.take() {
|
||||
if let Some(config) = donutbrowser_lib::xray_worker_storage::get_xray_worker_config(&id) {
|
||||
if let Some(pid) = config.pid {
|
||||
terminate_process_tree(pid);
|
||||
}
|
||||
}
|
||||
donutbrowser_lib::xray_worker_storage::delete_xray_worker_config(&id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct EnvironmentRestore {
|
||||
name: &'static str,
|
||||
previous: Option<std::ffi::OsString>,
|
||||
}
|
||||
|
||||
impl EnvironmentRestore {
|
||||
fn set_path(name: &'static str, value: &std::path::Path) -> Self {
|
||||
let previous = std::env::var_os(name);
|
||||
std::env::set_var(name, value);
|
||||
Self { name, previous }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EnvironmentRestore {
|
||||
fn drop(&mut self) {
|
||||
if let Some(previous) = &self.previous {
|
||||
std::env::set_var(self.name, previous);
|
||||
} else {
|
||||
std::env::remove_var(self.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// End-to-end VLESS + XTLS Vision + REALITY test.
|
||||
///
|
||||
/// This is intentionally opt-in because it requires a live official Xray-core
|
||||
/// server. Set `DONUT_E2E_VLESS_URI`; optionally set
|
||||
/// `DONUT_E2E_XRAY_TARGET_URL` to a controlled plain-HTTP endpoint. When the
|
||||
/// VLESS endpoint itself is loopback, the test supplies an isolated local HTTP
|
||||
/// target automatically.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_xray_reality_chain_and_local_traffic_monitoring(
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let Some(vless_uri) = std::env::var("DONUT_E2E_VLESS_URI")
|
||||
.ok()
|
||||
.filter(|value| !value.is_empty())
|
||||
else {
|
||||
println!("skipping Xray-core integration test: DONUT_E2E_VLESS_URI is not set");
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let parsed = donutbrowser_lib::xray::parse_vless_uri(&vless_uri)?;
|
||||
let endpoint_is_loopback = parsed.config.address == "localhost"
|
||||
|| parsed
|
||||
.config
|
||||
.address
|
||||
.parse::<std::net::IpAddr>()
|
||||
.is_ok_and(|address| address.is_loopback());
|
||||
let mut local_target = None;
|
||||
let target_url = if let Ok(url) = std::env::var("DONUT_E2E_XRAY_TARGET_URL") {
|
||||
url
|
||||
} else if endpoint_is_loopback {
|
||||
let (port, server) = start_mock_http_server("XRAY-REALITY-INTEGRATION-OK").await;
|
||||
local_target = Some(server);
|
||||
format!("http://127.0.0.1:{port}/xray-reality")
|
||||
} else {
|
||||
"http://httpbin.org/anything".to_string()
|
||||
};
|
||||
let target = url::Url::parse(&target_url)?;
|
||||
if target.scheme() != "http" {
|
||||
return Err(
|
||||
"DONUT_E2E_XRAY_TARGET_URL must use plain HTTP for exact payload accounting".into(),
|
||||
);
|
||||
}
|
||||
let target_host = target.host_str().ok_or("Xray target URL has no host")?;
|
||||
let target_port = target
|
||||
.port_or_known_default()
|
||||
.ok_or("Xray target URL has no port")?;
|
||||
let host_header = if target_port == 80 {
|
||||
target_host.to_string()
|
||||
} else {
|
||||
format!("{target_host}:{target_port}")
|
||||
};
|
||||
|
||||
let isolated_root = tempfile::tempdir()?;
|
||||
let _data_root = EnvironmentRestore::set_path("DONUTBROWSER_DATA_ROOT", isolated_root.path());
|
||||
let _binary_path = setup_test().await.map_err(|error| error.to_string())?;
|
||||
let profile_id = format!("xray-integration-{}", uuid::Uuid::new_v4());
|
||||
let payload = b"donut-xray-monitor-payload";
|
||||
let mut cleanup = XrayChainCleanup::new();
|
||||
|
||||
let (first_worker, concurrent_worker) = tokio::join!(
|
||||
donutbrowser_lib::xray_worker_runner::start_xray_worker(Some(&profile_id), &vless_uri),
|
||||
donutbrowser_lib::xray_worker_runner::start_xray_worker(Some(&profile_id), &vless_uri),
|
||||
);
|
||||
let xray_worker = first_worker?;
|
||||
let concurrent_worker = concurrent_worker?;
|
||||
assert_eq!(
|
||||
concurrent_worker.id, xray_worker.id,
|
||||
"concurrent starts for one profile must reuse one Xray worker"
|
||||
);
|
||||
cleanup.xray_id = Some(xray_worker.id.clone());
|
||||
|
||||
let upstream_url = format!(
|
||||
"socks5://{}:{}@127.0.0.1:{}",
|
||||
urlencoding::encode(&xray_worker.username),
|
||||
urlencoding::encode(&xray_worker.password),
|
||||
xray_worker.local_port
|
||||
);
|
||||
let mut outer = donutbrowser_lib::proxy_runner::start_proxy_process_with_profile(
|
||||
Some(upstream_url),
|
||||
None,
|
||||
Some(profile_id.clone()),
|
||||
Vec::new(),
|
||||
None,
|
||||
false,
|
||||
Some("http".to_string()),
|
||||
)
|
||||
.await?;
|
||||
cleanup.outer_id = Some(outer.id.clone());
|
||||
outer.browser_pid = Some(std::process::id());
|
||||
assert!(
|
||||
donutbrowser_lib::proxy_storage::update_proxy_config(&outer),
|
||||
"outer traffic-monitor config should remain writable"
|
||||
);
|
||||
|
||||
let local_port = outer.local_port.ok_or("outer proxy has no local port")?;
|
||||
let request = format!(
|
||||
"POST {target_url} HTTP/1.1\r\nHost: {host_header}\r\nContent-Type: \
|
||||
application/octet-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
payload.len()
|
||||
);
|
||||
let mut stream = TcpStream::connect(("127.0.0.1", local_port)).await?;
|
||||
stream.write_all(request.as_bytes()).await?;
|
||||
stream.write_all(payload).await?;
|
||||
let mut response = Vec::new();
|
||||
tokio::time::timeout(Duration::from_secs(30), stream.read_to_end(&mut response))
|
||||
.await
|
||||
.map_err(|_| "HTTP request through Xray-core timed out")??;
|
||||
|
||||
let header_end = response
|
||||
.windows(4)
|
||||
.position(|window| window == b"\r\n\r\n")
|
||||
.map(|position| position + 4)
|
||||
.ok_or("proxy returned an invalid HTTP response")?;
|
||||
let status_line = String::from_utf8_lossy(&response[..header_end])
|
||||
.lines()
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
if !status_line.contains(" 200 ") {
|
||||
return Err(
|
||||
format!(
|
||||
"HTTP request through Xray-core failed ({status_line}): {}",
|
||||
String::from_utf8_lossy(&response[header_end..])
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
let response_body = &response[header_end..];
|
||||
if endpoint_is_loopback && std::env::var_os("DONUT_E2E_XRAY_TARGET_URL").is_none() {
|
||||
assert_eq!(response_body, b"XRAY-REALITY-INTEGRATION-OK");
|
||||
} else {
|
||||
assert!(
|
||||
!response_body.is_empty(),
|
||||
"controlled Xray target returned an empty body"
|
||||
);
|
||||
}
|
||||
|
||||
let stats = tokio::time::timeout(Duration::from_secs(10), async {
|
||||
loop {
|
||||
if let Some(stats) = donutbrowser_lib::traffic_stats::load_traffic_stats(&profile_id) {
|
||||
if stats.total_requests > 0 {
|
||||
break stats;
|
||||
}
|
||||
}
|
||||
sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|_| "traffic monitor did not flush Xray session statistics")?;
|
||||
|
||||
assert_eq!(stats.total_requests, 1);
|
||||
assert_eq!(stats.total_bytes_sent, payload.len() as u64);
|
||||
assert_eq!(stats.total_bytes_received, response_body.len() as u64);
|
||||
let domain = stats
|
||||
.domains
|
||||
.get(target_host)
|
||||
.ok_or("traffic monitor did not attribute the target domain")?;
|
||||
assert_eq!(domain.request_count, 1);
|
||||
assert_eq!(domain.bytes_sent, payload.len() as u64);
|
||||
assert_eq!(domain.bytes_received, response_body.len() as u64);
|
||||
|
||||
let outer_id = outer.id.clone();
|
||||
let outer_pid = outer.pid;
|
||||
let xray_id = xray_worker.id.clone();
|
||||
let supervisor_pid = xray_worker.pid;
|
||||
let xray_pid = xray_worker.xray_pid;
|
||||
cleanup.cleanup().await;
|
||||
if let Some(server) = local_target.take() {
|
||||
server.abort();
|
||||
}
|
||||
|
||||
assert!(donutbrowser_lib::proxy_storage::get_proxy_config(&outer_id).is_none());
|
||||
assert!(donutbrowser_lib::xray_worker_storage::get_xray_worker_config(&xray_id).is_none());
|
||||
assert!(!donutbrowser_lib::xray_worker_storage::xray_runtime_config_path(&xray_id).exists());
|
||||
for pid in [outer_pid, supervisor_pid, xray_pid].into_iter().flatten() {
|
||||
for _ in 0..20 {
|
||||
if !donutbrowser_lib::proxy_storage::is_process_running(pid) {
|
||||
break;
|
||||
}
|
||||
sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
assert!(
|
||||
!donutbrowser_lib::proxy_storage::is_process_running(pid),
|
||||
"worker process {pid} survived cleanup"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test starting a local proxy without upstream proxy (DIRECT)
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
|
||||
Reference in New Issue
Block a user