mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-10-21 14:51:49 +02:00
feat: support vmess, trojan, and hysteria2
This commit is contained in:
1 parent
c098f82806
commit
4ee54cf9f4
52 files changed
+4325
-1191
No files matched your search
@@ -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 _;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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)?;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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};
|
||||
+790
-115
File diff suppressed because it is too large.
Load diff
+893
-514
File diff suppressed because it is too large.
Load diff
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user