feat: support vmess, trojan, and hysteria2

This commit is contained in:
zhom committed 2026-10-05 20:13:47 +04:00
1 parent c098f82806
commit 4ee54cf9f4
52 files changed
+4325 -1191

No files matched your search

+11 -1
View File
@@ -1338,6 +1338,9 @@ fn manager_error_response(err: impl std::fmt::Display) -> (StatusCode, String) {
// two things that don't go together, not a fault of this machine.
|| lower.contains("is not compatible with")
|| lower.contains("not supported for browser")
// A proxy import body that is not JSON is the caller's input, not a fault
// here; the spec for /v1/proxies/import already promises 400 for it.
|| lower.contains("invalid json format")
{
StatusCode::BAD_REQUEST
} else {
@@ -1587,7 +1590,7 @@ async fn create_profile(
// Apply tags if provided
if let Some(tags) = &request.tags {
if profile_manager
.update_profile_tags(&state.app_handle, &profile.name, tags.clone())
.update_profile_tags(&state.app_handle, &profile.id.to_string(), tags.clone())
.is_err()
{
return Err((
@@ -6103,6 +6106,13 @@ mod tests {
assert_eq!(status, StatusCode::NOT_FOUND);
}
#[test]
fn a_proxy_import_body_that_is_not_json_is_a_bad_request() {
let (status, _) =
manager_error_response("Invalid JSON format: expected value at line 1 column 1");
assert_eq!(status, StatusCode::BAD_REQUEST);
}
#[test]
fn an_extension_write_names_exactly_one_source() {
use base64::Engine as _;
+43
View File
@@ -239,6 +239,25 @@ pub fn settings_dir() -> PathBuf {
data_dir().join("settings")
}
/// Whether a name read from sync or an automation client can become one path
/// part under a data directory.
///
/// Ids and file names such as an extension's `file_name` or a proxy's `id`
/// arrive in JSON from the sync server and from the REST API. `..`, a separator
/// or an absolute path would read, write or delete outside the entity's own
/// directory: a crafted extension `file_name` wrote anywhere the user can
/// write, and an extension id of `..` led a remote tombstone to remove the
/// whole data directory.
pub fn is_plain_path_part(value: &str) -> bool {
!value.is_empty()
&& value != "."
&& value != ".."
&& !value.contains(['/', '\\', '\0'])
// `C:name` carries a drive prefix on Windows; elsewhere `:` is an ordinary
// character, which a linked folder on macOS may well have.
&& !(cfg!(windows) && value.contains(':'))
}
pub fn proxies_dir() -> PathBuf {
data_dir().join("proxies")
}
@@ -363,6 +382,30 @@ pub fn write_owner_only(path: &std::path::Path, content: &[u8]) -> std::io::Resu
mod tests {
use super::*;
#[test]
fn only_a_single_plain_name_is_a_plain_path_part() {
for plain in [
"6d6e21a1-4829-4d2b-bc7f-1b25707b61e4",
"ublock.zip",
"a b.crx",
"..x",
] {
assert!(is_plain_path_part(plain), "{plain}");
}
for unsafe_part in [
"",
".",
"..",
"../x",
"a/b",
"a\\b",
"/etc/passwd",
"nul\0byte",
] {
assert!(!is_plain_path_part(unsafe_part), "{unsafe_part:?}");
}
}
#[test]
fn test_app_name() {
let name = app_name();
+124
View File
@@ -167,6 +167,41 @@ async fn main() {
)
.subcommand(Command::new("list").about("List all proxy servers")),
)
.subcommand(
Command::new("xray")
.about("Manage Xray-core workers for share-link proxies (VLESS, VMess, Trojan, Hysteria2)")
.subcommand(
Command::new("start")
.about(
"Start a worker serving authenticated SOCKS5 on 127.0.0.1. The share link is \
read from DONUT_PROXY_SHARE_LINK, never from the command line",
)
.arg(
Arg::new("owner-pid")
.long("owner-pid")
.value_parser(clap::value_parser!(u32))
.required(true)
.help("The worker exits when this process exits"),
),
)
.subcommand(
Command::new("bind")
.about("Make a worker exit with this process instead of its current owner")
.arg(Arg::new("id").long("id").required(true).help("Worker ID"))
.arg(
Arg::new("pid")
.long("pid")
.value_parser(clap::value_parser!(u32))
.required(true)
.help("New owner process ID"),
),
)
.subcommand(
Command::new("stop")
.about("Stop a worker")
.arg(Arg::new("id").long("id").required(true).help("Worker ID")),
),
)
.subcommand(
Command::new("proxy-worker")
.about("Run a proxy worker process (internal use)")
@@ -350,6 +385,95 @@ async fn main() {
log::error!("Invalid action. Use 'start', 'stop', or 'list'");
process::exit(1);
}
} else if let Some(xray_matches) = matches.subcommand_matches("xray") {
if let Some(start_matches) = xray_matches.subcommand_matches("start") {
let owner_pid = *start_matches
.get_one::<u32>("owner-pid")
.expect("owner-pid is required");
let share_link = std::env::var("DONUT_PROXY_SHARE_LINK")
.ok()
.filter(|link| !link.trim().is_empty());
// The worker processes started below inherit this environment.
std::env::remove_var("DONUT_PROXY_SHARE_LINK");
let Some(share_link) = share_link else {
eprintln!("DONUT_PROXY_SHARE_LINK is not set");
process::exit(2);
};
// The detached worker would otherwise inherit the caller's stdout and
// stderr pipes, and a caller that reads to EOF would wait for the worker
// to exit. Same fix as `proxy start` (proxy_runner.rs).
#[cfg(windows)]
{
use std::os::windows::io::AsRawHandle;
use windows::Win32::Foundation::{SetHandleInformation, HANDLE, HANDLE_FLAGS};
const HANDLE_FLAG_INHERIT: u32 = 0x00000001;
for handle in [
std::io::stdout().as_raw_handle(),
std::io::stderr().as_raw_handle(),
] {
if !handle.is_null() {
unsafe {
let _ = SetHandleInformation(HANDLE(handle), HANDLE_FLAG_INHERIT, HANDLE_FLAGS(0));
}
}
}
}
match donutbrowser_lib::xray_worker_runner::start_xray_worker_for_owner(
None,
share_link.trim(),
owner_pid,
)
.await
{
Ok(worker) => {
// Use println! here because this needs to go to stdout for parsing
println!(
"{}",
serde_json::json!({
"id": worker.id,
"localPort": worker.local_port,
"localUrl": format!("socks5://127.0.0.1:{}", worker.local_port),
"username": worker.username,
"password": worker.password,
})
);
process::exit(0);
}
Err(e) => {
eprintln!("Failed to start Xray-core worker: {e}");
process::exit(1);
}
}
} else if let Some(bind_matches) = xray_matches.subcommand_matches("bind") {
let id = bind_matches
.get_one::<String>("id")
.expect("id is required");
let pid = *bind_matches.get_one::<u32>("pid").expect("pid is required");
let success = donutbrowser_lib::xray_worker_runner::set_browser_pid(id, pid);
// Use println! here because this needs to go to stdout for parsing
println!("{}", serde_json::json!({ "success": success }));
process::exit(0);
} else if let Some(stop_matches) = xray_matches.subcommand_matches("stop") {
let id = stop_matches
.get_one::<String>("id")
.expect("id is required");
match donutbrowser_lib::xray_worker_runner::stop_xray_worker_now(id) {
Ok(success) => {
// Use println! here because this needs to go to stdout for parsing
println!("{}", serde_json::json!({ "success": success }));
process::exit(0);
}
Err(e) => {
eprintln!("Failed to stop Xray-core worker: {e}");
process::exit(1);
}
}
} else {
log::error!("Invalid action. Use 'start', 'bind', or 'stop'");
process::exit(1);
}
} else if let Some(worker_matches) = matches.subcommand_matches("proxy-worker") {
let id = worker_matches
.get_one::<String>("id")
+2
View File
@@ -9,6 +9,8 @@ pub struct ProxySettings {
pub port: u16,
pub username: Option<String>,
pub password: Option<String>,
/// The share link of a `vless`, `vmess`, `trojan` or `hysteria2` proxy. It
/// holds the whole server configuration; the name predates the other three.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vless_uri: Option<String>,
}
+1 -1
View File
@@ -309,7 +309,7 @@ impl BrowserRunner {
if upstream_proxy
.as_ref()
.is_some_and(|proxy| proxy.proxy_type.eq_ignore_ascii_case("vless"))
.is_some_and(|proxy| crate::xray::is_xray_proxy_type(&proxy.proxy_type))
{
let vless_uri = upstream_proxy
.as_ref()
+62 -2
View File
@@ -5,6 +5,7 @@ use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};
use utoipa::ToSchema;
use crate::app_dirs::is_plain_path_part;
use crate::events;
/// Where an extension's payload came from. `archive` is a user-supplied
@@ -636,7 +637,7 @@ impl ExtensionManager {
source_kind: String,
) -> Result<Extension, Box<dyn std::error::Error>> {
let browser_compatibility = determine_browser_compatibility(&file_type);
if browser_compatibility.is_empty() {
if browser_compatibility.is_empty() || !is_plain_path_part(&file_name) {
return Err(err_code("EXTENSION_UNSUPPORTED_FILE_TYPE"));
}
let now = now_secs();
@@ -771,6 +772,9 @@ impl ExtensionManager {
}
pub fn get_extension(&self, id: &str) -> Result<Extension, Box<dyn std::error::Error>> {
if !is_plain_path_part(id) {
return Err(format!("Extension with id '{id}' not found").into());
}
let metadata_path = self.get_metadata_path(id);
if !metadata_path.exists() {
return Err(format!("Extension with id '{id}' not found").into());
@@ -914,7 +918,7 @@ impl ExtensionManager {
explicit_name_provided: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let browser_compatibility = determine_browser_compatibility(&file_type);
if browser_compatibility.is_empty() {
if browser_compatibility.is_empty() || !is_plain_path_part(&file_name) {
return Err(err_code("EXTENSION_UNSUPPORTED_FILE_TYPE"));
}
@@ -1374,6 +1378,9 @@ impl ExtensionManager {
&self,
ext: &Extension,
) -> Result<(), Box<dyn std::error::Error>> {
if !is_plain_path_part(&ext.id) || !is_plain_path_part(&ext.file_name) {
return Err(format!("Extension '{}' has an unsafe id or file name", ext.id).into());
}
let metadata_path = self.get_metadata_path(&ext.id);
if let Some(parent) = metadata_path.parent() {
fs::create_dir_all(parent)?;
@@ -1391,6 +1398,9 @@ impl ExtensionManager {
}
pub fn delete_extension_internal(&self, id: &str) -> Result<(), Box<dyn std::error::Error>> {
if !is_plain_path_part(id) {
return Err(format!("Extension with id '{id}' not found").into());
}
let ext_dir = self.get_extension_dir(id);
if ext_dir.exists() {
fs::remove_dir_all(&ext_dir)?;
@@ -2365,6 +2375,56 @@ mod tests {
assert_eq!(restored.updated_at, ext.updated_at);
}
#[test]
fn ids_and_file_names_from_sync_or_rest_cannot_leave_the_extension_directory() {
let tmp = tempfile::tempdir().unwrap();
let _guard = crate::app_dirs::set_test_data_dir(tmp.path().to_path_buf());
let mgr = ExtensionManager::new();
let ext = mgr
.add_extension(
"fallback".to_string(),
"ublock.zip".to_string(),
localized_extension_zip(),
)
.unwrap();
let canary = tmp.path().join("canary.txt");
std::fs::write(&canary, "still here").unwrap();
// What a crafted sync object would upsert.
for (id, file_name) in [
("..", "ublock.zip"),
("../..", "ublock.zip"),
(ext.id.as_str(), "../../../escaped.zip"),
(ext.id.as_str(), "/tmp/escaped.zip"),
(ext.id.as_str(), ".."),
] {
let mut crafted = ext.clone();
crafted.id = id.to_string();
crafted.file_name = file_name.to_string();
assert!(
mgr.update_extension_internal(&crafted).is_err(),
"{id} / {file_name}"
);
}
// A remote tombstone for `..` must not reach the parent directory.
assert!(mgr.get_extension("..").is_err());
assert!(mgr.delete_extension_internal("..").is_err());
assert!(canary.exists());
// The REST and UI add paths refuse a file name with path parts too.
for file_name in ["../escaped.zip", "nested/ublock.zip"] {
assert!(mgr
.add_extension(
"x".to_string(),
file_name.to_string(),
localized_extension_zip()
)
.is_err());
}
assert!(!tmp.path().join("escaped.zip").exists());
}
#[test]
fn a_stored_placeholder_is_repaired_rather_than_shown_to_the_user() {
let tmp = tempfile::tempdir().unwrap();
+1 -1
View File
@@ -75,7 +75,7 @@ pub fn exit_cache_key(profile: &BrowserProfile) -> Option<ExitCacheKey> {
let settings = PROXY_MANAGER
.resolve_proxy_for_profile(proxy_id, &profile.id.to_string())
.or_else(|| PROXY_MANAGER.get_proxy_settings_by_id(proxy_id))?;
// build_proxy_url returns the VLESS URI verbatim for vless proxies, so one
// build_proxy_url returns the share link verbatim for Xray proxies, so one
// call covers every transport.
return Some(ExitCacheKey {
scope: format!("proxy:{proxy_id}"),
+22 -8
View File
@@ -23,8 +23,9 @@ pub(crate) fn backend_error_with_detail(code: &str, detail: impl std::fmt::Displ
serde_json::json!({ "code": code, "params": { "detail": detail.to_string() } }).to_string()
}
/// A VLESS URI Donut cannot use, carrying which part is unsupported so the UI
/// can say so instead of implying a typo.
/// A share link Donut cannot use, carrying which part is unsupported so the UI
/// can say so instead of implying a typo. The code keeps its VLESS name because
/// REST clients already match on it.
pub(crate) fn vless_config_error(error: &crate::xray::XrayError) -> String {
serde_json::json!({
"code": "VLESS_CONFIG_INVALID",
@@ -383,13 +384,26 @@ async fn create_stored_proxy(
}
}
/// Validate a VLESS URI without touching the network, so the proxy form can
/// tell the user their setup is unsupported while they are still editing it
/// rather than only after they try to save or launch.
/// What the proxy form shows for a share link it cannot read itself: VMess
/// hides its server inside base64.
#[derive(serde::Serialize)]
struct ShareLinkSummary {
proxy_type: &'static str,
host: String,
port: u16,
}
/// Validate an Xray share link without touching the network, so the proxy
/// form can tell the user their setup is unsupported while they are still
/// editing it rather than only after they try to save or launch.
#[tauri::command]
fn validate_vless_uri(uri: String) -> Result<(), String> {
crate::xray::parse_vless_uri(uri.trim())
.map(|_| ())
fn validate_vless_uri(uri: String) -> Result<ShareLinkSummary, String> {
crate::xray::parse_share_link(uri.trim())
.map(|parsed| ShareLinkSummary {
proxy_type: parsed.config.protocol().proxy_type(),
host: parsed.config.address,
port: parsed.config.port,
})
.map_err(|error| vless_config_error(&error))
}
+33 -22
View File
@@ -217,6 +217,20 @@ fn explain_engine_refusal(mut error: McpError, device_refusal: Option<String>) -
const DEFAULT_MCP_PORT: u16 = 51080;
/// The proxy types the proxy tools accept. The last four are Xray share links
/// passed in `vless_uri`.
const MCP_PROXY_TYPES: [&str; 9] = [
"http",
"https",
"httpstls",
"socks4",
"socks5",
"vless",
"vmess",
"trojan",
"hysteria2",
];
/// The event the desktop turns into the "local MCP is being removed" dialog.
///
/// Emitted by the loopback tombstone (below) when anything still tries to reach
@@ -3329,7 +3343,7 @@ impl McpServer {
},
"proxy_type": {
"type": "string",
"enum": ["http", "https", "httpstls", "socks4", "socks5", "vless"],
"enum": MCP_PROXY_TYPES,
"description": "The proxy protocol"
},
"host": {
@@ -3350,7 +3364,7 @@ impl McpServer {
},
"vless_uri": {
"type": "string",
"description": "VLESS + XTLS Vision + REALITY share URI"
"description": "Share link for vless, vmess, trojan and hysteria2 proxies (vless://, vmess://, trojan://, hysteria2:// or hy2://). It holds the whole server configuration, and the link's own scheme decides the stored type."
}
},
"required": ["name", "proxy_type"]
@@ -3372,7 +3386,7 @@ impl McpServer {
},
"proxy_type": {
"type": "string",
"enum": ["http", "https", "httpstls", "socks4", "socks5", "vless"],
"enum": MCP_PROXY_TYPES,
"description": "The proxy protocol"
},
"host": {
@@ -3393,7 +3407,7 @@ impl McpServer {
},
"vless_uri": {
"type": "string",
"description": "VLESS + XTLS Vision + REALITY share URI"
"description": "Share link for vless, vmess, trojan and hysteria2 proxies (vless://, vmess://, trojan://, hysteria2:// or hy2://). It holds the whole server configuration, and the link's own scheme decides the stored type."
}
},
"required": ["proxy_id"]
@@ -5777,8 +5791,11 @@ impl McpServer {
}
if let Some(tags) = tags {
let _ =
ProfileManager::instance().update_profile_tags(&app_handle, &profile.name, tags.clone());
let _ = ProfileManager::instance().update_profile_tags(
&app_handle,
&profile.id.to_string(),
tags.clone(),
);
profile.tags = tags;
if let Ok(profiles) = ProfileManager::instance().list_profiles() {
let _ = crate::tag_manager::TAG_MANAGER
@@ -6580,14 +6597,10 @@ 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" | "httpstls" | "socks4" | "socks5" | "vless"
) {
if !MCP_PROXY_TYPES.contains(&proxy_type) {
return Err(McpError {
code: -32602,
message: "proxy_type must be one of: http, https, httpstls, socks4, socks5, vless"
.to_string(),
message: format!("proxy_type must be one of: {}", MCP_PROXY_TYPES.join(", ")),
data: None,
});
}
@@ -6596,7 +6609,7 @@ impl McpServer {
.get("vless_uri")
.and_then(|value| value.as_str())
.map(str::to_string);
let (host, port) = if proxy_type == "vless" {
let (host, port) = if crate::xray::is_xray_proxy_type(proxy_type) {
if vless_uri.is_none() {
return Err(McpError {
code: -32602,
@@ -6705,14 +6718,10 @@ 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" | "httpstls" | "socks4" | "socks5" | "vless"
) {
if !MCP_PROXY_TYPES.contains(&proxy_type.as_str()) {
return Err(McpError {
code: -32602,
message: "proxy_type must be one of: http, https, httpstls, socks4, socks5, vless"
.to_string(),
message: format!("proxy_type must be one of: {}", MCP_PROXY_TYPES.join(", ")),
data: None,
});
}
@@ -12096,9 +12105,11 @@ mod tests {
.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")));
for xray_type in ["vless", "vmess", "trojan", "hysteria2"] {
assert!(properties["proxy_type"]["enum"]
.as_array()
.is_some_and(|values| values.iter().any(|value| value == xray_type)));
}
assert!(properties["vless_uri"].is_object());
let required = create.input_schema["required"]
+7 -1
View File
@@ -1455,7 +1455,13 @@ impl ProfileManager {
let dest_dir = profiles_dir.join(new_id.to_string());
if source_dir.exists() {
crate::profile_importer::ProfileImporter::copy_directory_recursive(&source_dir, &dest_dir)?;
if let Err(error) =
crate::profile_importer::ProfileImporter::copy_directory_recursive(&source_dir, &dest_dir)
{
// A half-copied folder would otherwise stay behind with no profile.
let _ = fs::remove_dir_all(&dest_dir);
return Err(error);
}
} else {
fs::create_dir_all(&dest_dir)?;
}
+32 -1
View File
@@ -1144,7 +1144,15 @@ impl ProfileImporter {
let source_path = entry.path();
let dest_path = destination.join(entry.file_name());
if source_path.is_dir() {
// Symlinks are not followed, as in `profile_import::copy`: Chromium
// leaves `SingletonLock` pointing at nothing after a crash, which made
// every clone fail, and a link to a folder would copy content from
// outside the profile.
let file_type = entry.file_type()?;
if file_type.is_symlink() {
continue;
}
if file_type.is_dir() {
Self::copy_directory_recursive(&source_path, &dest_path)?;
} else {
fs::copy(&source_path, &dest_path)?;
@@ -1311,6 +1319,29 @@ mod tests {
assert_eq!(content2, "content2", "file2 content should match");
}
#[cfg(unix)]
#[test]
fn a_copy_skips_symlinks_such_as_a_stale_singleton_lock() {
let temp_dir = TempDir::new().expect("Failed to create temp directory");
let source_dir = temp_dir.path().join("source");
let outside = temp_dir.path().join("outside");
fs::create_dir_all(&source_dir).unwrap();
fs::create_dir_all(&outside).unwrap();
fs::write(source_dir.join("Preferences"), "{}").unwrap();
fs::write(outside.join("secret.txt"), "not part of the profile").unwrap();
// What Chromium leaves after a crash, and a link out of the profile.
std::os::unix::fs::symlink("host-12345", source_dir.join("SingletonLock")).unwrap();
std::os::unix::fs::symlink(&outside, source_dir.join("linked")).unwrap();
let dest_dir = temp_dir.path().join("dest");
ProfileImporter::copy_directory_recursive(&source_dir, &dest_dir)
.expect("a dangling lock link must not stop the copy");
assert!(dest_dir.join("Preferences").exists());
assert!(fs::symlink_metadata(dest_dir.join("SingletonLock")).is_err());
assert!(!dest_dir.join("linked").exists());
}
#[test]
fn test_get_default_version_for_browser_no_versions() {
let (importer, _temp_dir) = create_test_profile_importer();
+104 -12
View File
@@ -548,8 +548,11 @@ impl ProxyManager {
}
}
/// Xray-carried proxies keep their whole configuration in the share link
/// (`vless_uri`, named before VMess, Trojan and Hysteria2 joined). The link
/// decides the type, so a VMess link saved under "VLESS" is stored as VMess.
fn normalize_proxy_settings(mut proxy_settings: ProxySettings) -> Result<ProxySettings, String> {
if !proxy_settings.proxy_type.eq_ignore_ascii_case("vless") {
if !crate::xray::is_xray_proxy_type(&proxy_settings.proxy_type) {
proxy_settings.vless_uri = None;
return Ok(proxy_settings);
}
@@ -560,11 +563,11 @@ impl ProxyManager {
.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::vless_config_error(&error))?;
let canonical_uri = crate::xray::export_vless_uri(&parsed.config, parsed.name.as_deref())
crate::xray::parse_share_link(uri).map_err(|error| crate::vless_config_error(&error))?;
let canonical_uri = crate::xray::export_share_link(&parsed.config, parsed.name.as_deref())
.map_err(|error| crate::vless_config_error(&error))?;
proxy_settings.proxy_type = "vless".to_string();
proxy_settings.proxy_type = parsed.config.protocol().proxy_type().to_string();
proxy_settings.host = parsed.config.address;
proxy_settings.port = parsed.config.port;
proxy_settings.username = None;
@@ -1224,7 +1227,7 @@ 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") {
if crate::xray::is_xray_proxy_type(&proxy_settings.proxy_type) {
return proxy_settings.vless_uri.clone().unwrap_or_default();
}
@@ -1317,7 +1320,7 @@ impl ProxyManager {
proxy_settings: &ProxySettings,
) -> Result<ProxyCheckResult, String> {
let mut xray_worker_id = None;
let effective_proxy_settings = if proxy_settings.proxy_type.eq_ignore_ascii_case("vless") {
let effective_proxy_settings = if crate::xray::is_xray_proxy_type(&proxy_settings.proxy_type) {
let uri = proxy_settings
.vless_uri
.as_deref()
@@ -1646,10 +1649,10 @@ 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) {
if crate::xray::XrayProtocol::from_share_link(line).is_some() {
return Some(match crate::xray::parse_share_link(line) {
Ok(parsed) => ProxyParseResult::Parsed(ParsedProxyLine {
proxy_type: "vless".to_string(),
proxy_type: parsed.config.protocol().proxy_type().to_string(),
host: parsed.config.address,
port: parsed.config.port,
username: None,
@@ -1842,9 +1845,25 @@ impl ProxyManager {
let mut skipped = 0;
let mut errors = Vec::new();
let prefix = name_prefix.unwrap_or_else(|| "Imported".to_string());
// Numbered from the first free name, not from 1: a second batch under the
// same prefix otherwise collided with every name of the first and was
// skipped whole as duplicates.
let mut taken: std::collections::HashSet<String> = self
.get_stored_proxies()
.into_iter()
.map(|proxy| proxy.name)
.collect();
let mut number = 0;
for (i, parsed) in parsed_proxies.into_iter().enumerate() {
let proxy_name = format!("{} Proxy {}", prefix, i + 1);
for parsed in parsed_proxies {
let proxy_name = loop {
number += 1;
let candidate = format!("{} Proxy {}", prefix, number);
if !taken.contains(&candidate) {
break candidate;
}
};
taken.insert(proxy_name.clone());
let proxy_settings = ProxySettings {
proxy_type: parsed.proxy_type,
host: parsed.host,
@@ -3920,7 +3939,7 @@ mod tests {
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 invalid = uri.replace("type=tcp", "type=kcp");
let error = ProxyManager::normalize_proxy_settings(ProxySettings {
proxy_type: "vless".to_string(),
host: "127.0.0.1".to_string(),
@@ -3934,6 +3953,79 @@ mod tests {
assert!(!error.contains(&invalid));
}
#[test]
fn every_xray_share_link_is_stored_under_the_type_its_link_names() {
use base64::{engine::general_purpose::STANDARD, Engine as _};
let id = "6d6e21a1-4829-4d2b-bc7f-1b25707b61e4";
let vmess = serde_json::json!({
"v": "2", "ps": "VM", "add": "vm.example.com", "port": "443", "id": id, "aid": "0",
"scy": "auto", "net": "ws", "type": "none", "host": "vm.example.com", "path": "/vm",
"tls": "tls"
});
let cases = [
(
"vless",
format!("vless://{id}@a.example.com:8080?type=ws&path=%2F"),
"vless",
"a.example.com",
8080,
),
// The link decides: a VMess link saved under the VLESS type is VMess.
(
"vless",
format!("vmess://{}", STANDARD.encode(vmess.to_string())),
"vmess",
"vm.example.com",
443,
),
(
"trojan",
"trojan://pw@tj.example.com:443?sni=tj.example.com".to_string(),
"trojan",
"tj.example.com",
443,
),
(
"HY2",
"hy2://pw@hy.example.com:8443".to_string(),
"hysteria2",
"hy.example.com",
8443,
),
];
for (picked_type, link, expected_type, host, port) in cases {
let settings = |uri: String| ProxySettings {
proxy_type: picked_type.to_string(),
host: String::new(),
port: 0,
username: Some("unused".to_string()),
password: None,
vless_uri: Some(uri),
};
let normalized = ProxyManager::normalize_proxy_settings(settings(link.clone())).unwrap();
assert_eq!(normalized.proxy_type, expected_type, "{link}");
assert_eq!(normalized.host, host);
assert_eq!(normalized.port, port);
assert!(normalized.username.is_none());
let canonical = normalized.vless_uri.clone().unwrap();
assert_eq!(
ProxyManager::build_proxy_url(&normalized),
canonical,
"an Xray proxy is exported as its link"
);
let again = ProxyManager::normalize_proxy_settings(settings(canonical.clone())).unwrap();
assert_eq!(again.vless_uri.as_deref(), Some(canonical.as_str()));
assert!(matches!(
ProxyManager::parse_txt_proxies(&canonical).as_slice(),
[ProxyParseResult::Parsed(proxy)]
if proxy.proxy_type == expected_type && proxy.host == host && proxy.port == port
));
}
}
fn history_entry(timestamp: u64, ok: bool) -> ProxyCheckHistoryEntry {
ProxyCheckHistoryEntry {
timestamp,
+86 -16
View File
@@ -129,7 +129,9 @@ impl BlocklistMatcher {
if self.domains.is_empty() {
return false;
}
let host_lower = host.to_lowercase();
// `tracker.com.` is the same name as `tracker.com`; without the trim the
// fully qualified spelling walked past every list entry.
let host_lower = host.trim_end_matches('.').to_lowercase();
let in_set = self.set_contains(&host_lower);
if self.allowlist_mode {
// Allow only listed domains; block everything else.
@@ -443,25 +445,40 @@ async fn connect_via_socks(
}
} else {
let mut stream = stream;
// SOCKS4 - simplified implementation
let ip: std::net::IpAddr = target_host.parse()?;
let mut request = vec![0x04, 0x01]; // SOCKS4, CONNECT
request.extend_from_slice(&target_port.to_be_bytes());
match ip {
std::net::IpAddr::V4(ipv4) => {
match target_host.parse::<std::net::IpAddr>() {
Ok(std::net::IpAddr::V4(ipv4)) => {
request.extend_from_slice(&ipv4.octets());
request.push(0); // empty userid, NULL-terminated
}
std::net::IpAddr::V6(_) => {
Ok(std::net::IpAddr::V6(_)) => {
return Err("SOCKS4 does not support IPv6".into());
}
// The browser sends names, not addresses. Resolving one here would leak
// the destination to this machine's DNS, so it goes as SOCKS4a: the
// marker address 0.0.0.1, the userid, then the name for the proxy to
// resolve. A proxy that speaks only plain SOCKS4 refuses it, as it
// refused every name before.
Err(_) if !target_host.is_empty() && !target_host.contains('\0') => {
request.extend_from_slice(&[0, 0, 0, 1]);
request.push(0); // empty userid, NULL-terminated
request.extend_from_slice(target_host.as_bytes());
request.push(0); // NULL-terminated hostname
}
Err(_) => return Err("SOCKS4 target host is invalid".into()),
}
request.push(0); // NULL terminator for userid
stream.write_all(&request).await?;
let mut response = [0u8; 8];
stream.read_exact(&mut response).await?;
// A proxy that accepts the connection and never answers would otherwise
// hold this slot forever.
let response = tokio::time::timeout(UPSTREAM_DIAL_TIMEOUT, async {
stream.write_all(&request).await?;
let mut response = [0u8; 8];
stream.read_exact(&mut response).await?;
Ok::<_, std::io::Error>(response)
})
.await
.map_err(|_| "SOCKS4 upstream handshake timed out")??;
if response[1] != 0x5A {
return Err("SOCKS4 connection failed".into());
@@ -2195,6 +2212,17 @@ async fn read_upstream_connect_response<S: AsyncRead + Unpin>(
/// only place `Proxy-Authorization` is written, so whether those credentials
/// cross the network in the clear is decided entirely by which stream the
/// caller hands in, nothing here can weaken it.
/// `host:port` for a CONNECT line. An IPv6 literal (a SOCKS5 client sends one
/// for `http://[2001:db8::1]/`) needs brackets, or the proxy reads its last
/// group as the port.
fn connect_authority(host: &str, port: u16) -> String {
if host.parse::<std::net::Ipv6Addr>().is_ok() {
format!("[{host}]:{port}")
} else {
format!("{host}:{port}")
}
}
async fn connect_via_http_proxy<S: AsyncStream + 'static>(
mut proxy_stream: S,
proxy_host: &str,
@@ -2203,10 +2231,8 @@ async fn connect_via_http_proxy<S: AsyncStream + 'static>(
target_port: u16,
upstream: &Url,
) -> Result<BoxedAsyncStream, Box<dyn std::error::Error>> {
let mut connect_req = format!(
"CONNECT {}:{} HTTP/1.1\r\nHost: {}:{}\r\n",
target_host, target_port, target_host, target_port
);
let authority = connect_authority(target_host, target_port);
let mut connect_req = format!("CONNECT {authority} HTTP/1.1\r\nHost: {authority}\r\n");
let (username, password) = upstream_userpass(upstream);
if !username.is_empty() {
@@ -2582,6 +2608,50 @@ mod tests {
assert!(matcher.is_blocked("a.b.c.example.com"));
}
#[tokio::test]
async fn a_socks4_upstream_receives_a_hostname_as_socks4a() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap().to_string();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut request = vec![0_u8; 8 + 1 + "example.com".len() + 1];
socket.read_exact(&mut request).await.unwrap();
socket
.write_all(&[0, 0x5A, 0, 0, 0, 0, 0, 0])
.await
.unwrap();
request
});
connect_via_socks(&address, "example.com", 443, false, None)
.await
.expect("SOCKS4a connect");
let request = server.await.unwrap();
assert_eq!(&request[..8], &[4, 1, 1, 187, 0, 0, 0, 1]);
assert_eq!(&request[8..], b"\0example.com\0");
}
#[test]
fn a_connect_authority_brackets_ipv6_literals_only() {
assert_eq!(connect_authority("2001:db8::1", 443), "[2001:db8::1]:443");
assert_eq!(connect_authority("203.0.113.5", 80), "203.0.113.5:80");
assert_eq!(connect_authority("example.com", 443), "example.com:443");
}
#[test]
fn a_fully_qualified_name_with_a_trailing_dot_is_still_blocked() {
let mut matcher = BlocklistMatcher::new();
let mut domains = HashSet::new();
domains.insert("tracker.com".to_string());
matcher.domains = Arc::new(domains);
assert!(matcher.is_blocked("tracker.com."));
assert!(matcher.is_blocked("ads.TRACKER.com."));
assert!(!matcher.is_blocked("safe.com."));
}
#[test]
fn test_blocklist_no_false_positives() {
let mut matcher = BlocklistMatcher::new();
+64 -54
View File
@@ -18,8 +18,8 @@
//! it did not check.
//!
//! Local proxies are not an exotic case. A local MITM proxy, an SSH tunnel, a
//! locally-run SOCKS client and Donut's own VLESS support all present to the
//! browser as `127.0.0.1:<port>`.
//! locally-run SOCKS client and Donut's own Xray support (VLESS, VMess, Trojan,
//! Hysteria2) all present to the browser as `127.0.0.1:<port>`.
//!
//! This module is the single answer, shared by every caller, and it FAILS
//! CLOSED: anything it cannot parse is reported as unreachable. Refusing a
@@ -50,7 +50,8 @@ pub enum ExitReachability {
/// needs a sidecar that is not available there. That is a permanent refusal
/// wearing a transient one's clothes, and every nightly retry pays for it.
UnsupportedKind {
/// The protocol, as the user would name it: "VLESS".
/// The protocol, as the user would name it: "VLESS", "VMess", "Trojan" or
/// "Hysteria2".
kind: String,
/// Which part of the config it came from: "proxy" or "VPN".
source: &'static str,
@@ -229,43 +230,25 @@ fn normalize_host(raw: &str) -> String {
host.trim().trim_end_matches('.').to_string()
}
/// The host a VLESS URI actually dials.
///
/// Load-bearing because of an asymmetry that is easy to get backwards: a VLESS
/// proxy presents to the browser as `127.0.0.1:<port>` — Donut runs a local xray
/// worker and points the browser at it — but the address that decides whether
/// anyone else could use this config is the SERVER inside the URI. The local
/// port is an implementation detail of this machine; the URI is the exit.
pub fn vless_uri_host(uri: &str) -> Option<String> {
let rest = uri.trim().strip_prefix("vless://")?;
// Cut the fragment (`#label`) and query (`?type=...`) before looking for the
// authority — either may contain '@' or ':'.
let rest = rest.split('#').next()?;
let rest = rest.split('?').next()?;
// `uuid@host:port/...`
let authority = rest.split('/').next()?;
let host_port = authority
.rsplit_once('@')
.map(|(_, h)| h)
.unwrap_or(authority);
let host = normalize_host(host_port);
if host.is_empty() {
None
} else {
Some(host)
}
}
/// The exit host a stored proxy represents, as a remote host would have to dial
/// it.
///
/// Load-bearing because of an asymmetry that is easy to get backwards: an Xray
/// proxy presents to the browser as `127.0.0.1:<port>` — Donut runs a local xray
/// worker and points the browser at it — but the address that decides whether
/// anyone else could use this config is the SERVER inside the share link. The
/// local port is an implementation detail of this machine; the link is the exit.
pub fn proxy_exit_host(settings: &crate::browser::ProxySettings) -> Result<String, String> {
if settings.proxy_type.eq_ignore_ascii_case("vless") {
if crate::xray::is_xray_proxy_type(&settings.proxy_type) {
let uri = settings
.vless_uri
.as_deref()
.filter(|uri| !uri.trim().is_empty())
.ok_or_else(|| "VLESS proxy has no server URI".to_string())?;
return vless_uri_host(uri).ok_or_else(|| "VLESS server URI is malformed".to_string());
.ok_or_else(|| "Xray proxy has no share link".to_string())?;
return crate::xray::share_link_host(uri)
.map(|host| normalize_host(&host))
.filter(|host| !host.is_empty())
.ok_or_else(|| "Xray share link is malformed".to_string());
}
let host = normalize_host(&settings.host);
@@ -277,20 +260,26 @@ pub fn proxy_exit_host(settings: &crate::browser::ProxySettings) -> Result<Strin
/// Whether a stored proxy speaks a protocol no remote host can dial.
///
/// Reads the two facts that both mean VLESS, because either one alone is a real
/// record: the picker writes `proxy_type = "vless"`, and a config pasted as a
/// bare URI carries the protocol in `vless_uri` before the type is normalised.
/// Taking only the first would let the second through to the host that refuses
/// it.
/// Reads the two facts that both mean Xray, because either one alone is a real
/// record: the picker writes `proxy_type = "vless"` (or `vmess`, `trojan`,
/// `hysteria2`), and a config pasted as a bare link carries the protocol in
/// `vless_uri` before the type is normalised. Taking only the first would let
/// the second through to the host that refuses it.
fn unsupported_remote_kind(settings: &crate::browser::ProxySettings) -> Option<String> {
let has_uri = settings
if let Some(protocol) = crate::xray::XrayProtocol::from_scheme(&settings.proxy_type) {
return Some(protocol.display_name().to_string());
}
let uri = settings
.vless_uri
.as_deref()
.is_some_and(|uri| !uri.trim().is_empty());
if settings.proxy_type.eq_ignore_ascii_case("vless") || has_uri {
return Some("VLESS".to_string());
}
None
.filter(|uri| !uri.trim().is_empty())?;
// A link Donut cannot even name still needs the sidecar, so it is refused all
// the same.
Some(
crate::xray::XrayProtocol::from_share_link(uri)
.map_or("Xray", |protocol| protocol.display_name())
.to_string(),
)
}
/// Classify a stored proxy.
@@ -519,16 +508,37 @@ mod tests {
}
#[test]
fn vless_host_parsing_survives_query_and_fragment() {
assert_eq!(
vless_uri_host("vless://uuid@example.com:443?sni=a@b.com&x=1#my@label"),
Some("example.com".to_string())
);
assert_eq!(
vless_uri_host("vless://uuid@[2606:4700::1111]:443?type=ws"),
Some("2606:4700::1111".to_string())
);
assert_eq!(vless_uri_host("not-a-vless-uri"), None);
fn every_xray_protocol_is_refused_under_its_own_name() {
for (proxy_type, uri, kind) in [
("vmess", "vmess://e30=", "VMess"),
("trojan", "trojan://pw@tj.example.com:443", "Trojan"),
(
"hysteria2",
"hysteria2://pw@hy.example.com:443/",
"Hysteria2",
),
// The type field disagrees: the link still names the protocol.
("socks5", "trojan://pw@tj.example.com:443", "Trojan"),
] {
let mut settings = proxy(proxy_type, "127.0.0.1");
settings.vless_uri = Some(uri.into());
assert_eq!(
classify_proxy(&settings),
ExitReachability::UnsupportedKind {
kind: kind.to_string(),
source: "proxy",
}
);
}
}
#[test]
fn an_xray_exit_host_is_the_server_inside_its_link() {
let mut settings = proxy("trojan", "127.0.0.1");
settings.vless_uri = Some("trojan://pw@TJ.example.com.:443?type=ws#x@y".into());
assert_eq!(proxy_exit_host(&settings), Ok("tj.example.com".to_string()));
settings.vless_uri = Some("trojan://".into());
assert!(proxy_exit_host(&settings).is_err());
}
#[test]
+10 -3
View File
@@ -20,7 +20,8 @@
//! unfiltered, unmetered side channel around the TCP filter.
use crate::proxy_server::{
connect_to_target_via_upstream, tunnel_streams, BlocklistMatcher, BypassMatcher,
connect_to_target_via_upstream, redacted_upstream, tunnel_streams, BlocklistMatcher,
BypassMatcher,
};
use crate::traffic_stats::{get_traffic_tracker, LiveTrafficTracker};
use async_socks5::{AddrKind, Auth, SocksDatagram};
@@ -303,6 +304,12 @@ async fn read_request(stream: &mut TcpStream) -> std::io::Result<Socks5Request>
})
}
/// The upstream as a log line may show it: the URL carries the proxy's
/// username and password, and the worker log is a plain file in the temp dir.
fn upstream_label(upstream_url: Option<&str>) -> String {
upstream_url.map_or_else(|| "DIRECT".to_string(), redacted_upstream)
}
/// Read a SOCKS5 address of the given type into a host string (an IP literal or
/// a domain name; `connect_to_target_via_upstream` handles both).
async fn read_addr(stream: &mut TcpStream, atyp: u8) -> std::io::Result<String> {
@@ -377,7 +384,7 @@ async fn handle_connect(
"SOCKS5 CONNECT {}:{} (upstream={})",
host,
port,
upstream_url.as_deref().unwrap_or("DIRECT")
upstream_label(upstream_url.as_deref())
);
// Resolve to the target stream, logging and dropping the (non-Send) dial
@@ -436,7 +443,7 @@ async fn handle_udp_associate(
if mode == UdpMode::Refuse {
log::info!(
"SOCKS5 UDP ASSOCIATE refused: upstream ({}) cannot carry UDP without leaking; Chromium will use proxied TCP",
upstream_url.as_deref().unwrap_or("DIRECT")
upstream_label(upstream_url.as_deref())
);
let _ = send_reply(&mut control, REP_COMMAND_NOT_SUPPORTED, unspecified()).await;
return;
+16
View File
@@ -1908,6 +1908,13 @@ impl SyncEngine {
.as_secs(),
);
// The id names the file below, and the JSON came from the server.
if !crate::app_dirs::is_plain_path_part(&proxy.id) {
return Err(SyncError::InvalidData(
"Remote proxy has an unsafe id".to_string(),
));
}
let proxy_manager = &crate::proxy_manager::PROXY_MANAGER;
let proxy_file = proxy_manager.get_proxy_file_path(&proxy.id);
if let Some(parent) = proxy_file.parent() {
@@ -2479,6 +2486,15 @@ impl SyncEngine {
);
ext.sync_enabled = true;
// Both become path parts below, and the JSON came from the server.
if !crate::app_dirs::is_plain_path_part(&ext.id)
|| !crate::app_dirs::is_plain_path_part(&ext.file_name)
{
return Err(SyncError::InvalidData(
"Remote extension has an unsafe id or file name".to_string(),
));
}
// Download the extension file
let file_remote_key = format!("extensions/{}/file/{}", ext.id, ext.file_name);
let file_stat = self.client.stat(&file_remote_key).await?;
+27 -16
View File
@@ -1210,10 +1210,11 @@ enum ProbeRoute {
/// A temporary local `donut-proxy` worker dials this upstream. Carries the
/// URL the WORKER should dial, which is not always the stored one.
Worker(String),
/// The upstream is VLESS, which no `donut-proxy` worker can speak. An
/// Xray-core sidecar carries the VLESS hop and a `donut-proxy` worker fronts
/// its loopback SOCKS5 endpoint, the same two-stage path `browser_runner`
/// builds for a VLESS launch. Carries the VLESS URI.
/// The upstream is an Xray share link (VLESS, VMess, Trojan, Hysteria2),
/// which no `donut-proxy` worker can speak. An Xray-core sidecar carries the
/// hop and a `donut-proxy` worker fronts its loopback SOCKS5 endpoint, the
/// same two-stage path `browser_runner` builds for such a launch. Carries the
/// share link.
Xray(String),
/// Nothing available here carries this upstream. The probe is skipped and
/// the fingerprint keeps no location at all, which is the only honest
@@ -2214,14 +2215,11 @@ impl WayfernManager {
return ProbeRoute::Worker(upstream);
}
if url
.split_once("://")
.is_some_and(|(scheme, _)| scheme.eq_ignore_ascii_case("vless"))
{
return match crate::xray::parse_vless_uri(url) {
if crate::xray::XrayProtocol::from_share_link(url).is_some() {
return match crate::xray::parse_share_link(url) {
Ok(_) => ProbeRoute::Xray(url.to_string()),
// A `vless://host:port` with no id, flow or security parameters is what
// a stored VLESS proxy collapses to when it is rendered as
// A `vless://host:port` with no id or security parameters is what a
// stored Xray proxy collapses to when it is rendered as
// `type://host:port`. Xray cannot dial that, so there is nothing to
// probe through and the launch-time refresh does the location instead -
// by then the upstream is the loopback SOCKS5 endpoint of a real Xray
@@ -4105,7 +4103,7 @@ mod tests {
));
}
/// A complete VLESS + XTLS Vision + REALITY URI, the only shape Donut takes.
/// A complete VLESS + XTLS Vision + REALITY URI.
fn valid_vless_uri() -> String {
"vless://6d6e21a1-4829-4d2b-bc7f-1b25707b61e4@vpn.example.com:443\
?security=reality&flow=xtls-rprx-vision&encryption=none&type=tcp&sni=a.com\
@@ -4184,6 +4182,17 @@ mod tests {
WayfernManager::probe_route(&uri),
ProbeRoute::Xray(uri.clone())
);
// Every other protocol the sidecar speaks takes the same route.
for link in [
"vless://6d6e21a1-4829-4d2b-bc7f-1b25707b61e4@a.com:443?security=tls&type=ws",
"trojan://pw@tj.example.com:443?sni=tj.example.com",
"hysteria2://pw@hy.example.com:443/",
] {
assert_eq!(
WayfernManager::probe_route(link),
ProbeRoute::Xray(link.to_string())
);
}
// A `vless://host:port` is what a stored VLESS proxy collapses to when it
// is rendered as `type://host:port`: no id, no flow, no REALITY key. Xray
@@ -4193,7 +4202,8 @@ mod tests {
"vless://vpn.example.com:443",
"vless://uuid@vpn.example.com:443",
// Right shape, unsupported transport, still nothing that can carry it.
"vless://6d6e21a1-4829-4d2b-bc7f-1b25707b61e4@a.com:443?security=tls&type=ws",
"vless://6d6e21a1-4829-4d2b-bc7f-1b25707b61e4@a.com:443?security=tls&type=kcp",
"trojan://gw.example.com:443",
] {
assert_eq!(
WayfernManager::probe_route(lossy),
@@ -4256,9 +4266,10 @@ mod tests {
ProbeRoute::Worker("socks5://1.2.3.4:1080".into())
);
// Schemes nothing here speaks. `proxy_type` is free text through the REST
// API and MCP, so these are reachable without a code change, and each one
// must end in a skipped probe rather than a fabricated location.
// Schemes nothing here speaks, and Xray links with no credential to dial.
// `proxy_type` is free text through the REST API and MCP, so these are
// reachable without a code change, and each one must end in a skipped
// probe rather than a fabricated location.
for unroutable in [
"trojan://gw.example.com:443",
"hysteria2://gw.example.com:443",
+307 -47
View File
@@ -1,7 +1,10 @@
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use super::{VlessRealityConfig, XrayError, XrayResult};
use super::{
GrpcMode, Security, StreamSettings, TlsSettings, Transport, XrayConfig, XrayError, XrayOutbound,
XrayResult,
};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
@@ -24,10 +27,7 @@ impl XrayClientRuntime {
}
}
pub fn build_client_config(
config: &VlessRealityConfig,
runtime: &XrayClientRuntime,
) -> XrayResult<Value> {
pub fn build_client_config(config: &XrayConfig, runtime: &XrayClientRuntime) -> XrayResult<Value> {
config.validate()?;
runtime.validate()?;
@@ -50,42 +50,173 @@ pub fn build_client_config(
"ip": "127.0.0.1"
}
}],
"outbounds": [{
"outbounds": [outbound(config)]
}))
}
fn outbound(config: &XrayConfig) -> Value {
match &config.outbound {
XrayOutbound::Vless { id, flow, stream } => {
let mut user = json!({ "id": id, "encryption": "none" });
if let Some(flow) = flow {
user["flow"] = json!(flow.as_str());
}
json!({
"tag": "proxy",
"protocol": "vless",
"settings": {
"vnext": [{ "address": config.address, "port": config.port, "users": [user] }]
},
"streamSettings": stream_settings(stream)
})
}
XrayOutbound::Vmess {
id,
security,
stream,
} => json!({
"tag": "proxy",
"protocol": "vless",
"protocol": "vmess",
"settings": {
"vnext": [{
"address": config.address,
"port": config.port,
"users": [{
"id": config.id,
"encryption": "none",
"flow": config.flow.as_str()
}]
"users": [{ "id": id, "security": security.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
}
"streamSettings": stream_settings(stream)
}),
XrayOutbound::Trojan { password, stream } => json!({
"tag": "proxy",
"protocol": "trojan",
"settings": {
"servers": [{ "address": config.address, "port": config.port, "password": password }]
},
"streamSettings": stream_settings(stream)
}),
XrayOutbound::Hysteria2 {
auth,
tls,
obfs_password,
} => {
// HTTP/3 is the only protocol a Hysteria2 server answers, and Xray-core
// offers h2 and http/1.1 unless told otherwise.
let mut tls_settings = tls_settings(tls);
tls_settings["alpn"] = json!(["h3"]);
let mut stream = json!({
"network": "hysteria",
"hysteriaSettings": { "version": 2, "auth": auth },
"security": "tls",
"tlsSettings": tls_settings
});
if let Some(password) = obfs_password {
stream["finalmask"] = json!({
"udp": [{ "type": "salamander", "settings": { "password": password } }]
});
}
}]
}))
json!({
"tag": "proxy",
"protocol": "hysteria",
"settings": { "version": 2, "address": config.address, "port": config.port },
"streamSettings": stream
})
}
}
}
fn stream_settings(stream: &StreamSettings) -> Value {
let mut settings = json!({
"network": stream.transport.as_str(),
"security": stream.security.as_str(),
"sockopt": {
"tcpKeepAliveIdle": 30,
"tcpKeepAliveInterval": 15
}
});
match &stream.security {
Security::None => {}
Security::Tls(tls) => settings["tlsSettings"] = tls_settings(tls),
Security::Reality(reality) => {
settings["realitySettings"] = json!({
"show": false,
"fingerprint": reality.fingerprint.as_str(),
"serverName": reality.server_name,
"publicKey": reality.public_key,
"shortId": reality.short_id,
"spiderX": reality.spider_x
});
}
}
match &stream.transport {
Transport::Raw { http_header: None } => {}
Transport::Raw {
http_header: Some(header),
} => {
let mut request = json!({ "path": [header.path] });
if !header.hosts.is_empty() {
request["headers"] = json!({ "Host": header.hosts });
}
settings["tcpSettings"] = json!({ "header": { "type": "http", "request": request } });
}
Transport::WebSocket { host, path } => {
settings["wsSettings"] = path_and_host(path, host.as_deref());
}
Transport::HttpUpgrade { host, path } => {
settings["httpupgradeSettings"] = path_and_host(path, host.as_deref());
}
Transport::Xhttp { host, path, mode } => {
let mut xhttp = path_and_host(path, host.as_deref());
xhttp["mode"] = json!(mode.as_str());
settings["xhttpSettings"] = xhttp;
}
Transport::Grpc {
service_name,
authority,
mode,
} => {
let mut grpc = json!({
"serviceName": service_name,
"multiMode": *mode == GrpcMode::Multi
});
if let Some(authority) = authority {
grpc["authority"] = json!(authority);
}
settings["grpcSettings"] = grpc;
}
}
settings
}
fn path_and_host(path: &str, host: Option<&str>) -> Value {
let mut value = json!({ "path": path });
if let Some(host) = host {
value["host"] = json!(host);
}
value
}
fn tls_settings(tls: &TlsSettings) -> Value {
let mut value = json!({});
if let Some(server_name) = &tls.server_name {
value["serverName"] = json!(server_name);
}
if !tls.alpn.is_empty() {
value["alpn"] = json!(tls.alpn);
}
if let Some(fingerprint) = tls.fingerprint {
value["fingerprint"] = json!(fingerprint.as_str());
}
if !tls.pinned_peer_cert_sha256.is_empty() {
value["pinnedPeerCertSha256"] = json!(tls.pinned_peer_cert_sha256.join(","));
}
if !tls.verify_peer_cert_by_name.is_empty() {
value["verifyPeerCertByName"] = json!(tls.verify_peer_cert_by_name.join(","));
}
value
}
pub fn build_client_config_json(
config: &VlessRealityConfig,
config: &XrayConfig,
runtime: &XrayClientRuntime,
) -> XrayResult<String> {
serde_json::to_string_pretty(&build_client_config(config, runtime)?)
@@ -115,23 +246,24 @@ fn validate_socks_credential(field: &'static str, value: &str) -> XrayResult<()>
mod tests {
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use super::super::{RealityFingerprint, RealitySettings, VlessFlow};
use super::super::parse_share_link;
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(),
},
}
const ID: &str = "6d6e21a1-4829-4d2b-bc7f-1b25707b61e4";
fn valid_config() -> XrayConfig {
let key = URL_SAFE_NO_PAD.encode([7_u8; 32]);
parse_share_link(&format!(
"vless://{ID}@vpn.example.com:443?encryption=none&flow=xtls-rprx-vision&security=reality\
&sni=www.example.com&fp=chrome&pbk={key}&sid=0123456789abcdef&spx=%2F&type=tcp&headerType=none"
))
.unwrap()
.config
}
fn outbound_for(link: &str) -> Value {
let config = parse_share_link(link).unwrap().config;
build_client_config(&config, &runtime()).unwrap()["outbounds"][0].clone()
}
fn runtime() -> XrayClientRuntime {
@@ -196,6 +328,124 @@ mod tests {
);
}
#[test]
fn plain_vless_over_websocket_and_tls_has_no_flow() {
let outbound = outbound_for(&format!(
"vless://{ID}@203.0.113.7:443?encryption=none&security=tls&type=ws\
&host=cdn.example.com&path=%2Fray%3Fed%3D2048&alpn=http%2F1.1&fp=firefox"
));
let user = &outbound["settings"]["vnext"][0]["users"][0];
assert_eq!(user["encryption"], "none");
assert!(user.get("flow").is_none());
let stream = &outbound["streamSettings"];
assert_eq!(stream["network"], "ws");
assert_eq!(stream["wsSettings"]["path"], "/ray?ed=2048");
assert_eq!(stream["wsSettings"]["host"], "cdn.example.com");
assert_eq!(stream["security"], "tls");
// The Host header doubles as the server name when the link names none.
assert_eq!(stream["tlsSettings"]["serverName"], "cdn.example.com");
assert_eq!(stream["tlsSettings"]["alpn"], json!(["http/1.1"]));
assert_eq!(stream["tlsSettings"]["fingerprint"], "firefox");
assert!(stream["tlsSettings"].get("allowInsecure").is_none());
}
#[test]
fn plain_vless_without_security_sends_no_tls_settings() {
let outbound = outbound_for(&format!("vless://{ID}@a.example.com:8080?type=tcp"));
assert_eq!(outbound["streamSettings"]["security"], "none");
assert!(outbound["streamSettings"].get("tlsSettings").is_none());
assert!(outbound["streamSettings"].get("realitySettings").is_none());
}
#[test]
fn vmess_carries_its_cipher_and_grpc_service() {
let outbound = outbound_for(&format!(
"vmess://{ID}@a.example.com:443?encryption=chacha20-poly1305&security=tls\
&type=grpc&serviceName=gun-svc&mode=multi&authority=grpc.example.com&sni=a.example.com"
));
assert_eq!(outbound["protocol"], "vmess");
assert_eq!(
outbound["settings"]["vnext"][0]["users"][0]["security"],
"chacha20-poly1305"
);
let grpc = &outbound["streamSettings"]["grpcSettings"];
assert_eq!(grpc["serviceName"], "gun-svc");
assert_eq!(grpc["multiMode"], true);
assert_eq!(grpc["authority"], "grpc.example.com");
}
#[test]
fn trojan_pins_a_certificate_instead_of_skipping_the_check() {
let pin = "AB:".repeat(31) + "AB";
let outbound = outbound_for(&format!(
"trojan://p%40ss@a.example.com:443?sni=a.example.com&pcs={pin}&allowInsecure=1&type=tcp"
));
assert_eq!(outbound["protocol"], "trojan");
assert_eq!(outbound["settings"]["servers"][0]["password"], "p@ss");
let tls = &outbound["streamSettings"]["tlsSettings"];
assert_eq!(tls["pinnedPeerCertSha256"], "ab".repeat(32));
assert!(tls.get("allowInsecure").is_none());
}
#[test]
fn raw_http_header_disguise_is_written_as_a_request() {
let outbound = outbound_for(&format!(
"vmess://{ID}@a.example.com:80?type=tcp&headerType=http&host=a.com,b.com&path=%2Findex"
));
let header = &outbound["streamSettings"]["tcpSettings"]["header"];
assert_eq!(header["type"], "http");
assert_eq!(header["request"]["path"], json!(["/index"]));
assert_eq!(
header["request"]["headers"]["Host"],
json!(["a.com", "b.com"])
);
}
#[test]
fn xhttp_and_httpupgrade_keep_path_host_and_mode() {
let xhttp = outbound_for(&format!(
"vless://{ID}@a.example.com:443?security=tls&type=xhttp&path=%2Fx&host=h.example.com&mode=stream-one"
));
assert_eq!(xhttp["streamSettings"]["network"], "xhttp");
assert_eq!(
xhttp["streamSettings"]["xhttpSettings"]["mode"],
"stream-one"
);
assert_eq!(xhttp["streamSettings"]["xhttpSettings"]["path"], "/x");
let upgrade = outbound_for(&format!(
"vless://{ID}@a.example.com:80?type=httpupgrade&path=%2Fu&host=h.example.com"
));
assert_eq!(
upgrade["streamSettings"]["httpupgradeSettings"],
json!({ "path": "/u", "host": "h.example.com" })
);
}
#[test]
fn hysteria2_speaks_h3_over_quic_with_salamander() {
let pin = "cd".repeat(32);
let outbound = outbound_for(&format!(
"hysteria2://letmein@hy.example.com:8443/?obfs=salamander&obfs-password=salt&pinSHA256={pin}"
));
assert_eq!(outbound["protocol"], "hysteria");
assert_eq!(outbound["settings"]["version"], 2);
assert_eq!(outbound["settings"]["address"], "hy.example.com");
assert_eq!(outbound["settings"]["port"], 8443);
let stream = &outbound["streamSettings"];
assert_eq!(stream["network"], "hysteria");
assert_eq!(stream["hysteriaSettings"]["version"], 2);
assert_eq!(stream["hysteriaSettings"]["auth"], "letmein");
assert_eq!(stream["security"], "tls");
assert_eq!(stream["tlsSettings"]["alpn"], json!(["h3"]));
assert_eq!(stream["tlsSettings"]["serverName"], "hy.example.com");
assert_eq!(
stream["finalmask"]["udp"][0],
json!({ "type": "salamander", "settings": { "password": "salt" } })
);
assert_eq!(stream["tlsSettings"]["pinnedPeerCertSha256"], pin);
assert!(stream.get("sockopt").is_none());
}
#[test]
fn does_not_add_bypass_or_observability_surfaces() {
let value = build_client_config(&valid_config(), &runtime()).unwrap();
@@ -243,12 +493,22 @@ mod tests {
#[test]
fn invalid_model_is_rejected_before_generation() {
let mut config = valid_config();
config.reality.public_key = "secret-invalid-key".to_string();
let secret = "secret-invalid-key";
if let XrayOutbound::Vless {
stream: StreamSettings {
security: Security::Reality(reality),
..
},
..
} = &mut config.outbound
{
reality.public_key = secret.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));
assert!(!error.to_string().contains(secret));
}
}
+11 -10
View File
@@ -4,9 +4,9 @@ pub type XrayResult<T> = Result<T, XrayError>;
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum XrayError {
#[error("invalid VLESS URI")]
#[error("invalid share link")]
InvalidUri,
#[error("URI scheme must be vless")]
#[error("share link scheme must be vless, vmess, trojan, hysteria2 or hy2")]
UnsupportedScheme,
#[error("missing required field: {0}")]
MissingField(&'static str),
@@ -29,26 +29,27 @@ pub enum XrayError {
}
impl XrayError {
/// A stable, translatable identifier for *why* a URI was rejected.
/// A stable, translatable identifier for *why* a share link was rejected.
///
/// Donut supports one VLESS shape — REALITY + XTLS Vision over TCP — so most
/// rejections are "your setup is a kind we do not support", not "you made a
/// typo". The frontend turns these into a sentence naming the unsupported
/// part; without them every rejection reads as a malformed URI and a user
/// with a working WebSocket or plain-TLS server has no idea why it failed.
/// Most rejections are "your setup is a kind we do not support", not "you
/// made a typo". The frontend turns these into a sentence naming the
/// unsupported part; without them every rejection reads as a malformed link
/// and a user with a working mKCP or port-hopping server has no idea why it
/// failed.
pub fn reason_code(&self) -> &'static str {
match self {
Self::UnsupportedScheme => "scheme",
Self::UnsupportedValue { field, .. } | Self::InvalidField { field, .. } => match *field {
"security" => "security",
"flow" => "flow",
"type" => "transport",
"type" | "mode" => "transport",
"encryption" => "encryption",
"headerType" => "headerType",
"fp" => "fingerprint",
"obfs" => "obfs",
// A malformed sni/public key is the same user-facing problem as a
// missing one, so it earns the same specific help rather than the
// generic "invalid URI".
// generic "invalid link".
"sni" | "server_name" => "sni",
"pbk" | "public_key" => "publicKey",
_ => "malformed",
+4 -2
View File
@@ -6,6 +6,8 @@ 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,
is_xray_proxy_type, Fingerprint, GrpcMode, HttpHeader, ParsedShareLink, RealitySettings,
Security, StreamSettings, TlsSettings, Transport, VlessFlow, VmessSecurity, XhttpMode,
XrayConfig, XrayOutbound, XrayProtocol,
};
pub use uri::{export_vless_uri, parse_vless_uri};
pub use uri::{export_share_link, parse_share_link, share_link_host};
File diff suppressed because it is too large. Load diff
+893 -514
View File
File diff suppressed because it is too large. Load diff
+593
View File
@@ -0,0 +1,593 @@
use base64::{
engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD},
Engine as _,
};
use serde_json::json;
use super::*;
const ID: &str = "6d6e21a1-4829-4d2b-bc7f-1b25707b61e4";
const KEY: &str = "mQB9jxUDHO7g49VaNXLEdcNQ_jLhTbLolUsMUNwb6W4";
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")
}
fn vmess(json: serde_json::Value) -> String {
format!("vmess://{}", STANDARD.encode(json.to_string()))
}
fn reason(link: &str) -> &'static str {
parse_share_link(link).unwrap_err().reason_code()
}
/// Parse, export, parse again: the configuration must survive and the
/// exported link must be a fixed point, or every save rewrites the link.
fn assert_round_trip(link: &str) -> ParsedShareLink {
let parsed = parse_share_link(link).unwrap_or_else(|error| panic!("{link}: {error}"));
let exported = export_share_link(&parsed.config, parsed.name.as_deref()).unwrap();
let reparsed = parse_share_link(&exported).unwrap_or_else(|error| panic!("{exported}: {error}"));
assert_eq!(reparsed, parsed, "{link} -> {exported}");
let exported_again = export_share_link(&reparsed.config, reparsed.name.as_deref()).unwrap();
assert_eq!(exported, exported_again);
parsed
}
#[test]
fn parses_supported_reality_vision_uri() {
let parsed = parse_share_link(&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.protocol(), XrayProtocol::Vless);
let XrayOutbound::Vless { id, flow, stream } = parsed.config.outbound else {
panic!("not VLESS");
};
assert_eq!(id, ID);
assert_eq!(flow, Some(VlessFlow::Vision));
assert_eq!(stream.transport, Transport::Raw { http_header: None });
assert_eq!(
stream.security,
Security::Reality(RealitySettings {
server_name: "www.example.com".to_string(),
public_key: public_key(),
short_id: "0123456789abcdef".to_string(),
fingerprint: Fingerprint::Chrome,
spider_x: "/".to_string(),
})
);
}
/// Stored links and the Xray worker reuse check compare the canonical string,
/// so the one shape Donut took before more protocols arrived must still come
/// out byte for byte the same.
#[test]
fn reality_vision_export_keeps_its_original_canonical_form() {
let parsed = parse_share_link(&uri(&[("fp", "firefox")])).unwrap();
let exported = export_share_link(&parsed.config, Some("Home server")).unwrap();
assert_eq!(
exported,
format!(
"vless://{ID}@vpn.example.com:443?encryption=none&flow=xtls-rprx-vision&security=reality\
&sni=www.example.com&fp=firefox&pbk={}&sid=0123456789abcdef&spx=%2F&type=tcp&headerType=none\
#Home%20server",
public_key()
)
);
}
/// The links providers actually hand out, as v2rayN, V2Board/Xboard and the
/// Hysteria client export them.
#[test]
fn real_world_links_parse_and_round_trip() {
let pin = "AB:".repeat(31) + "AB";
let links = [
// Plain VLESS over WebSocket + TLS behind a CDN, no flow.
format!(
"vless://{ID}@104.16.1.1:443?encryption=none&security=tls&sni=cdn.example.com\
&alpn=http%2F1.1&fp=chrome&type=ws&host=cdn.example.com&path=%2Fray%3Fed%3D2048#WS%20node"
),
// VLESS with no security at all, the "just isolation" case.
format!("vless://{ID}@node.example.com:8080?encryption=none&security=none&type=tcp#Plain"),
// VLESS over gRPC and over XHTTP with REALITY.
format!(
"vless://{ID}@grpc.example.com:443?encryption=none&security=reality&type=grpc\
&serviceName=gun&mode=multi&sni=a.com&pbk={KEY}&sid=00&fp=safari"
),
format!(
"vless://{ID}@x.example.com:443?encryption=none&security=reality&type=xhttp&path=%2Fx\
&mode=packet-up&sni=a.com&pbk={KEY}"
),
// VLESS + Vision + TLS over RAW.
format!(
"vless://{ID}@tls.example.com:443?encryption=none&flow=xtls-rprx-vision&security=tls\
&sni=tls.example.com&fp=chrome&type=tcp&headerType=none"
),
// Xboard's Trojan export: legacy `peer`, `allowInsecure` and no security.
"trojan://S3cr3t%2Bpass@tj.example.com:443?allowInsecure=1&peer=tj.example.com\
&sni=tj.example.com#Trojan%20HK"
.to_string(),
format!("trojan://pw@tj.example.com:443?security=tls&type=ws&path=%2Ftj&pcs={pin}"),
"trojan://pw@tj.example.com:443?security=tls&type=grpc&serviceName=tj&sni=tj.example.com"
.to_string(),
// VMess in the URL form of the Xray share-link standard.
format!(
"vmess://{ID}@vm.example.com:443?encryption=auto&security=tls&type=httpupgrade&path=%2Fu"
),
// Hysteria2 in both schemes, with and without obfuscation.
"hysteria2://letmein@hy.example.com:443/?sni=hy.example.com&insecure=1#HY2".to_string(),
format!(
"hy2://user:pass@hy.example.com:8443?obfs=salamander&obfs-password=salt&pinSHA256={pin}"
),
"hysteria2://letmein@[2001:db8::1]:443/".to_string(),
];
for link in links {
assert_round_trip(&link);
}
}
#[test]
fn v2rayn_vmess_json_parses_and_round_trips() {
let ws = vmess(json!({
"v": "2", "ps": "VMess WS", "add": "vm.example.com", "port": "443", "id": ID, "aid": "0",
"scy": "auto", "net": "ws", "type": "none", "host": "cdn.example.com", "path": "/vm",
"tls": "tls", "sni": "", "alpn": "", "fp": "chrome"
}));
let parsed = assert_round_trip(&ws);
assert_eq!(parsed.name.as_deref(), Some("VMess WS"));
assert_eq!(parsed.config.protocol(), XrayProtocol::Vmess);
let XrayOutbound::Vmess {
id,
security,
stream,
} = parsed.config.outbound
else {
panic!("not VMess");
};
assert_eq!(id, ID);
assert_eq!(security, VmessSecurity::Auto);
assert_eq!(
stream.transport,
Transport::WebSocket {
host: Some("cdn.example.com".to_string()),
path: "/vm".to_string(),
}
);
let Security::Tls(tls) = stream.security else {
panic!("not TLS");
};
assert_eq!(tls.server_name.as_deref(), Some("cdn.example.com"));
assert_eq!(tls.fingerprint, Some(Fingerprint::Chrome));
// Numbers instead of strings, an old alterId, gRPC with its service in
// `path` and its mode in `type`, and a TCP HTTP disguise.
for link in [
vmess(json!({
"v": 2, "ps": "", "add": "203.0.113.9", "port": 8443, "id": ID, "aid": 64,
"scy": "aes-128-gcm", "net": "grpc", "type": "multi", "host": "", "path": "svc", "tls": "tls"
})),
vmess(json!({
"v": "2", "ps": "tcp", "add": "vm.example.com", "port": "80", "id": ID, "aid": "0",
"scy": "none", "net": "tcp", "type": "http", "host": "a.com", "path": "/", "tls": ""
})),
] {
assert_round_trip(&link);
}
}
#[test]
fn vmess_json_reads_padding_free_url_safe_base64_and_a_fragment_name() {
let body = json!({ "add": "vm.example.com", "port": "443", "id": ID, "net": "tcp" }).to_string();
let link = format!("vmess://{}#From%20fragment", URL_SAFE_NO_PAD.encode(body));
let parsed = parse_share_link(&link).unwrap();
assert_eq!(parsed.name.as_deref(), Some("From fragment"));
assert_eq!(parsed.config.port, 443);
}
#[test]
fn hysteria2_defaults_port_and_server_name_and_joins_userpass_auth() {
let parsed = parse_share_link("hy2://user:p%40ss@hy.example.com").unwrap();
assert_eq!(parsed.config.port, 443);
let XrayOutbound::Hysteria2 { auth, tls, .. } = parsed.config.outbound else {
panic!("not Hysteria2");
};
assert_eq!(auth, "user:p@ss");
assert_eq!(tls.server_name.as_deref(), Some("hy.example.com"));
}
#[test]
fn trojan_defaults_to_tls_and_takes_peer_as_server_name() {
let parsed = parse_share_link("trojan://pw@tj.example.com:443?peer=front.example.com").unwrap();
let XrayOutbound::Trojan { password, stream } = parsed.config.outbound else {
panic!("not Trojan");
};
assert_eq!(password, "pw");
let Security::Tls(tls) = stream.security else {
panic!("Trojan must default to TLS");
};
assert_eq!(tls.server_name.as_deref(), Some("front.example.com"));
}
#[test]
fn a_percent_escape_in_a_secret_survives_saving() {
// `pa%2541ss` is the password `pa%41ss`; written back unescaped it read as
// `paAss` on the next load and authentication failed.
for link in [
"trojan://pa%2541ss@tj.example.com:443?sni=tj.example.com",
"hysteria2://u%25s:p%2541@hy.example.com:443/",
] {
let parsed = assert_round_trip(link);
match parsed.config.outbound {
XrayOutbound::Trojan { password, .. } => assert_eq!(password, "pa%41ss"),
XrayOutbound::Hysteria2 { auth, .. } => assert_eq!(auth, "u%s:p%41"),
other => panic!("unexpected {other:?}"),
}
}
}
#[test]
fn a_salamander_key_xray_would_refuse_is_refused_on_import() {
let short = "hy2://pw@hy.example.com?obfs=salamander&obfs-password=abc";
assert_eq!(reason(short), "malformed");
assert!(parse_share_link(&short.replace("=abc", "=abcd")).is_ok());
}
#[test]
fn an_internationalized_server_name_is_read_as_punycode() {
// Links stored before more protocols arrived kept such names verbatim; they
// must still load.
let parsed = parse_share_link(&uri(&[("sni", "café.example.com")])).unwrap();
let XrayOutbound::Vless {
stream: StreamSettings {
security: Security::Reality(reality),
..
},
..
} = parsed.config.outbound
else {
panic!("not VLESS REALITY");
};
assert_eq!(reality.server_name, "xn--caf-dma.example.com");
let tls = assert_round_trip(
"trojan://pw@tj.example.com:443?sni=caf%C3%A9.example.com&type=ws&host=caf%C3%A9.example.com",
);
let XrayOutbound::Trojan { stream, .. } = tls.config.outbound else {
panic!("not Trojan");
};
assert_eq!(stream.transport.host(), Some("xn--caf-dma.example.com"));
let Security::Tls(settings) = stream.security else {
panic!("not TLS");
};
assert_eq!(
settings.server_name.as_deref(),
Some("xn--caf-dma.example.com")
);
}
#[test]
fn insecure_flags_are_dropped_and_the_certificate_is_still_checked() {
for link in [
"trojan://pw@tj.example.com:443?allowInsecure=1&sni=tj.example.com",
"hysteria2://pw@hy.example.com:443/?insecure=1",
] {
let parsed = parse_share_link(link).unwrap();
let exported = export_share_link(&parsed.config, None).unwrap();
assert!(!exported.contains("nsecure"), "{exported}");
}
}
/// Most rejections mean "your server is a kind we do not support" rather than
/// "you mistyped". These pin the reason each rejection reports, because the
/// UI turns it into the one sentence that tells the user which part of their
/// setup Donut cannot use.
#[test]
fn unsupported_setups_report_which_part_is_unsupported() {
let good = format!(
"vless://{ID}@example.com:443?security=reality&flow=xtls-rprx-vision\
&encryption=none&type=tcp&sni=a.com&pbk={KEY}&sid=00&fp=chrome"
);
assert!(parse_share_link(&good).is_ok(), "baseline URI must parse");
let cases = [
// Transports Xray-core dropped or Donut does not speak. mKCP brings its own
// `seed` option, which must not hide the real problem.
(good.replace("type=tcp", "type=kcp&seed=x"), "transport"),
(good.replace("type=tcp", "type=h2"), "transport"),
(good.replace("type=tcp", "type=quic"), "transport"),
(good.replace("type=tcp", "type=grpc&mode=tun"), "transport"),
(
good.replace("security=reality", "security=xtls"),
"security",
),
(
good
.replace("type=tcp", "type=ws")
.replace("&flow=xtls-rprx-vision", ""),
"security",
),
(
good
.replace("type=tcp", "type=ws")
.replace("security=reality", "security=tls"),
"flow",
),
(
good.replace("flow=xtls-rprx-vision", "flow=xtls-rprx-direct"),
"flow",
),
(
good.replace(
"encryption=none",
"encryption=mlkem768x25519plus.native.0rtt.abc",
),
"encryption",
),
(
good.replace("type=tcp", "type=tcp&headerType=srtp"),
"headerType",
),
(good.replace("fp=chrome", "fp=unsafe"), "fingerprint"),
(good.replace("&sni=a.com", ""), "sni"),
(good.replace(&format!("&pbk={KEY}"), ""), "publicKey"),
(good.replace("vless://", "ss://"), "scheme"),
(good.replace("vless://", "https://"), "scheme"),
(format!("{good}&madeUpKey=1"), "parameter"),
("not a uri".to_string(), "malformed"),
(
"trojan://pw@tj.example.com:443?flow=xtls-rprx-vision".to_string(),
"flow",
),
(
format!("vmess://{ID}@vm.example.com:443?encryption=rc4"),
"encryption",
),
(
"hysteria2://pw@hy.example.com:443/?obfs=gost".to_string(),
"obfs",
),
(
"hysteria2://pw@hy.example.com:443/?mport=20000-30000".to_string(),
"parameter",
),
(
vmess(
json!({ "add": "vm.example.com", "port": "443", "id": ID, "net": "kcp", "type": "wechat-video" }),
),
"transport",
),
(
vmess(
json!({ "add": "vm.example.com", "port": "443", "id": ID, "net": "tcp", "tls": "xtls" }),
),
"security",
),
("vmess://bm90IGpzb24=".to_string(), "malformed"),
];
for (link, expected) in cases {
assert_eq!(reason(&link), expected, "{link}");
}
}
#[test]
fn an_empty_unknown_option_is_not_worth_refusing_a_link_over() {
assert!(parse_share_link(&format!("{}&ech=&pqv=", uri(&[]).replace("#Primary", ""))).is_ok());
}
#[test]
fn a_display_name_survives_an_export_parse_round_trip() {
// Percent signs are legal in a fragment, so they used to pass through
// unencoded and then get decoded on the way back in — "50% off" became
// "50 off"-ish and drifted further on every canonicalizing save.
for name in ["50% off", "a#b", "spaced name", "100%25", "üñî"] {
let parsed = parse_share_link(&uri(&[])).expect("baseline parses");
let exported = export_share_link(&parsed.config, Some(name)).expect("exports");
let reparsed = parse_share_link(&exported).expect("re-parses");
assert_eq!(
reparsed.name.as_deref(),
Some(name),
"display name mutated across a round trip: {exported}"
);
let exported_again =
export_share_link(&reparsed.config, reparsed.name.as_deref()).expect("re-exports");
assert_eq!(exported, exported_again);
}
}
#[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_share_link(&input).unwrap();
assert_eq!(parsed.config.address, "2001:db8::1");
assert_eq!(parsed.name.as_deref(), Some("Home server"));
let XrayOutbound::Vless {
stream: StreamSettings {
security: Security::Reality(reality),
..
},
..
} = parsed.config.outbound
else {
panic!("not VLESS REALITY");
};
assert_eq!(reality.spider_x, "/search?q=hello world");
}
#[test]
fn an_internationalized_host_is_stored_as_punycode() {
let input = uri(&[]).replace("vpn.example.com", "café.example.com");
let parsed = parse_share_link(&input).unwrap();
assert_eq!(parsed.config.address, "xn--caf-dma.example.com");
// And the canonical form survives an export/import round trip.
let exported = export_share_link(&parsed.config, None).unwrap();
assert_eq!(
parse_share_link(&exported).unwrap().config.address,
"xn--caf-dma.example.com"
);
}
#[test]
fn rejects_a_host_that_percent_decodes_into_something_undialable() {
let input = uri(&[]).replace("vpn.example.com", "vpn%2Fexample.com");
assert!(matches!(
parse_share_link(&input),
Err(XrayError::InvalidField {
field: "address",
..
})
));
}
#[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_share_link(&input).unwrap();
let XrayOutbound::Vless {
stream: StreamSettings {
security: Security::Reality(reality),
..
},
..
} = parsed.config.outbound
else {
panic!("not VLESS REALITY");
};
assert_eq!(reality.fingerprint, Fingerprint::Chrome);
assert_eq!(reality.short_id, "");
assert_eq!(reality.spider_x, "/");
}
#[test]
fn accepts_raw_as_tcp_alias() {
assert!(parse_share_link(&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),
format!(" {valid}"),
];
for input in cases {
assert!(parse_share_link(&input).is_err(), "{input}");
}
}
#[test]
fn rejects_missing_required_reality_values() {
let key = public_key();
let cases = [
// No security means none, and Vision needs TLS or REALITY.
format!("vless://{ID}@vpn.example.com:443?flow=xtls-rprx-vision&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_share_link(&input).is_err(), "{input}");
}
}
#[test]
fn rejects_unknown_and_duplicate_parameters() {
assert_eq!(
parse_share_link(&uri(&[("madeUp", "unsupported")])),
Err(XrayError::UnsupportedParameter("madeUp".to_string()))
);
let input = uri(&[]).replace("#Primary", "&sni=duplicate.example.com#Primary");
assert_eq!(
parse_share_link(&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_share_link(&input).unwrap_err();
assert!(!error.to_string().contains(invalid_key));
assert!(!error.to_string().contains(ID));
}
let error = parse_share_link("trojan://hunter2@tj.example.com:443?type=kcp").unwrap_err();
assert!(!error.to_string().contains("hunter2"));
}
#[test]
fn export_handles_ipv6_host() {
let mut parsed = parse_share_link(&uri(&[])).unwrap();
parsed.config.address = "2001:db8::1".to_string();
let exported = export_share_link(&parsed.config, None).unwrap();
assert!(exported.starts_with(&format!("vless://{ID}@[2001:db8::1]:443?")));
assert_eq!(
parse_share_link(&exported).unwrap().config.address,
"2001:db8::1"
);
}
#[test]
fn share_link_host_names_the_server_even_for_links_donut_refuses() {
assert_eq!(
share_link_host("vless://uuid@example.com:443?sni=a@b.com&x=1#my@label").as_deref(),
Some("example.com")
);
assert_eq!(
share_link_host("vless://uuid@[2606:4700::1111]:443?type=ws").as_deref(),
Some("2606:4700::1111")
);
assert_eq!(
share_link_host(&vmess(json!({ "add": "VM.example.com", "net": "kcp" }))).as_deref(),
Some("VM.example.com")
);
assert_eq!(
share_link_host("hysteria2://pw@hy.example.com:20000-30000/").as_deref(),
Some("hy.example.com")
);
assert_eq!(share_link_host("not-a-share-link"), None);
}
+19 -9
View File
@@ -2,7 +2,7 @@ use crate::proxy_runner::find_sidecar_executable;
#[cfg(unix)]
use crate::proxy_storage::is_process_running;
use crate::proxy_storage::{process_identity_matches, resolve_process_start_time};
use crate::xray::{build_client_config_json, parse_vless_uri, XrayClientRuntime};
use crate::xray::{build_client_config_json, parse_share_link, 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,
@@ -162,13 +162,24 @@ async fn wait_until_ready(
pub async fn start_xray_worker(
profile_id: Option<&str>,
vless_uri: &str,
) -> Result<XrayWorkerConfig, Box<dyn std::error::Error>> {
start_xray_worker_for_owner(profile_id, vless_uri, std::process::id()).await
}
/// Starts a worker that lives until `owner_pid` exits, or until it is stopped
/// or re-bound with `set_browser_pid`. The `donut-proxy xray start` command
/// passes the pid of the process that called it, because its own pid is gone
/// the moment it prints the result.
pub async fn start_xray_worker_for_owner(
profile_id: Option<&str>,
vless_uri: &str,
owner_pid: u32,
) -> Result<XrayWorkerConfig, Box<dyn std::error::Error>> {
let _start_guard = XRAY_START_LOCK.lock().await;
parse_vless_uri(vless_uri)
parse_share_link(vless_uri)
.map_err(|error| -> Box<dyn std::error::Error> { crate::vless_config_error(&error).into() })?;
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"))?;
@@ -212,7 +223,7 @@ pub async fn start_xray_worker(
let mut last_error = None;
for attempt in 1..=3 {
match spawn_xray_worker(profile_id, vless_uri).await {
match spawn_xray_worker(profile_id, vless_uri, owner_pid, owner_start_time).await {
Ok(worker) => return Ok(worker),
Err(error) => {
if attempt < 3 {
@@ -234,6 +245,8 @@ pub async fn start_xray_worker(
async fn spawn_xray_worker(
profile_id: Option<&str>,
vless_uri: &str,
owner_pid: u32,
owner_start_time: u64,
) -> 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))?;
@@ -260,11 +273,8 @@ async fn spawn_xray_worker(
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"))?,
);
config.browser_pid_start_time = Some(owner_start_time);
save_xray_worker_config(&config)
.map_err(|error| structured_error_with_detail("XRAY_START_FAILED", error))?;
@@ -520,7 +530,7 @@ pub async fn run_xray_worker(config_path: &Path) -> Result<(), Box<dyn std::erro
);
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)
let parsed = parse_share_link(&config.vless_uri)
.map_err(|error| -> Box<dyn std::error::Error> { crate::vless_config_error(&error).into() })?;
let runtime = XrayClientRuntime {
listen_port: config.local_port,