From ce63eccfa4d7353983b23fd93f29bad61db17a37 Mon Sep 17 00:00:00 2001 From: zhom <2717306+zhom@users.noreply.github.com> Date: Sun, 12 Apr 2026 05:24:53 +0400 Subject: [PATCH] feat: shadowsocks --- src-tauri/Cargo.lock | 262 ++++++++++++++++++++- src-tauri/Cargo.toml | 1 + src-tauri/src/bin/proxy_server.rs | 2 +- src-tauri/src/proxy_server.rs | 231 +++++++++++++++--- src-tauri/tests/donut_proxy_integration.rs | 131 +++++++++++ 5 files changed, 582 insertions(+), 45 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 3471c86..6dc0197 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -169,7 +169,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -180,7 +180,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -782,6 +782,12 @@ dependencies = [ "utf8-width", ] +[[package]] +name = "byte_string" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11aade7a05aa8c3a351cedc44c3fc45806430543382fcc4743a9b757a2a0b4ed" + [[package]] name = "bytecheck" version = "0.6.12" @@ -1151,6 +1157,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "const-oid" version = "0.10.2" @@ -1588,6 +1600,16 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "zeroize", +] + [[package]] name = "deranged" version = "0.5.8" @@ -1661,7 +1683,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c" dependencies = [ "block-buffer 0.12.0", - "const-oid", + "const-oid 0.10.2", "crypto-common 0.2.1", ] @@ -1692,7 +1714,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1820,6 +1842,7 @@ dependencies = [ "serde_yaml", "serial_test", "sha2 0.11.0", + "shadowsocks", "smoltcp", "sys-locale", "sysinfo", @@ -1890,6 +1913,36 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "dynosaur" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a12303417f378f29ba12cb12fc78a9df0d8e16ccb1ad94abf04d48d96bdda532" +dependencies = [ + "dynosaur_derive", +] + +[[package]] +name = "dynosaur_derive" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b0713d5c1d52e774c5cd7bb8b043d7c0fc4f921abfb678556140bfbe6ab2364" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + [[package]] name = "either" version = "1.15.0" @@ -2029,7 +2082,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2855,6 +2908,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + [[package]] name = "hmac" version = "0.12.1" @@ -3820,6 +3882,16 @@ dependencies = [ "rayon", ] +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest 0.10.7", +] + [[package]] name = "memchr" version = "2.8.0" @@ -4476,7 +4548,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.45.0", + "windows-sys 0.61.2", ] [[package]] @@ -4789,6 +4861,26 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" +[[package]] +name = "pin-project" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -4806,6 +4898,16 @@ dependencies = [ "futures-io", ] +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.32" @@ -5612,6 +5714,19 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "ring-compat" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccce7bae150b815f0811db41b8312fcb74bffa4cab9cee5429ee00f356dd5bd4" +dependencies = [ + "aead", + "ed25519", + "generic-array", + "pkcs8", + "ring", +] + [[package]] name = "rkyv" version = "0.7.46" @@ -5733,7 +5848,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5895,6 +6010,17 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" +[[package]] +name = "sealed" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f968c5ea23d555e670b449c1c5e7b2fc399fdaec1d304a17cd48e288abc107" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -5965,6 +6091,16 @@ dependencies = [ "serde_core", ] +[[package]] +name = "sendfd" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b183bfd5b1bc64ab0c1ef3ee06b008a9ef1b68a7d3a99ba566fbfe7a7c6d745b" +dependencies = [ + "libc", + "tokio", +] + [[package]] name = "serde" version = "1.0.228" @@ -6227,6 +6363,55 @@ dependencies = [ "digest 0.11.2", ] +[[package]] +name = "shadowsocks" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "482831bf9d55acf3c98e211b6c852c3dfdf1d1b0d23fdf1d887c5a4b2acad4e4" +dependencies = [ + "base64 0.22.1", + "blake3", + "byte_string", + "bytes", + "cfg-if", + "dynosaur", + "futures", + "libc", + "log", + "percent-encoding", + "pin-project", + "sealed", + "sendfd", + "serde", + "serde_json", + "serde_urlencoded", + "shadowsocks-crypto", + "socket2", + "spin", + "thiserror 2.0.18", + "tokio", + "tokio-tfo", + "trait-variant", + "url", + "windows-sys 0.61.2", +] + +[[package]] +name = "shadowsocks-crypto" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d038a3d17586f1c1ab3c1c3b9e4d5ef8fba98fb3890ad740c8487038b2e2ca5" +dependencies = [ + "aes-gcm", + "cfg-if", + "chacha20poly1305", + "hkdf", + "md-5", + "rand 0.9.2", + "ring-compat", + "sha1", +] + [[package]] name = "shared_child" version = "1.1.1" @@ -6275,6 +6460,12 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" + [[package]] name = "simd-adler32" version = "0.3.9" @@ -6359,7 +6550,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6410,6 +6601,25 @@ dependencies = [ "system-deps", ] +[[package]] +name = "spin" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "sqlite-wasm-rs" version = "0.5.2" @@ -7138,7 +7348,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -7369,6 +7579,23 @@ dependencies = [ "tokio-util", ] +[[package]] +name = "tokio-tfo" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6ad2c3b3bb958ad992354a7ebc468fc0f7cdc9af4997bf4d3fd3cb28bad36dc" +dependencies = [ + "cfg-if", + "futures", + "libc", + "log", + "once_cell", + "pin-project", + "socket2", + "tokio", + "windows-sys 0.60.2", +] + [[package]] name = "tokio-tungstenite" version = "0.28.0" @@ -7601,6 +7828,17 @@ dependencies = [ "once_cell", ] +[[package]] +name = "trait-variant" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70977707304198400eb4835a78f6a9f928bf41bba420deb8fdb175cd965d77a7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "tray-icon" version = "0.21.3" @@ -7719,7 +7957,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -8313,7 +8551,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -8842,7 +9080,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10" dependencies = [ "cfg-if", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 841f841..7c31a64 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -85,6 +85,7 @@ aes = "0.9" cbc = "0.2" ring = "0.17" sha2 = "0.11" +shadowsocks = { version = "1.24", default-features = false, features = ["aead-cipher"] } hyper = { version = "1.8", features = ["full"] } hyper-util = { version = "0.1", features = ["full"] } http-body-util = "0.1" diff --git a/src-tauri/src/bin/proxy_server.rs b/src-tauri/src/bin/proxy_server.rs index f325f0e..2ea3677 100644 --- a/src-tauri/src/bin/proxy_server.rs +++ b/src-tauri/src/bin/proxy_server.rs @@ -121,7 +121,7 @@ async fn main() { .arg( Arg::new("type") .long("type") - .help("Proxy type (http, https, socks4, socks5)"), + .help("Proxy type (http, https, socks4, socks5, ss)"), ) .arg(Arg::new("username").long("username").help("Proxy username")) .arg(Arg::new("password").long("password").help("Proxy password")) diff --git a/src-tauri/src/proxy_server.rs b/src-tauri/src/proxy_server.rs index 6cc0f0f..b2cafe4 100644 --- a/src-tauri/src/proxy_server.rs +++ b/src-tauri/src/proxy_server.rs @@ -18,6 +18,13 @@ use std::task::{Context, Poll}; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, ReadBuf}; use tokio::net::TcpListener; use tokio::net::TcpStream; + +/// Combined read+write trait for tunnel target streams, allowing +/// `handle_connect_from_buffer` to handle plain TCP, SOCKS, and +/// Shadowsocks through the same bidirectional-copy path. +trait AsyncStream: AsyncRead + AsyncWrite + Unpin + Send {} +impl AsyncStream for T {} +type BoxedAsyncStream = Box; use url::Url; enum CompiledRule { @@ -770,6 +777,127 @@ async fn handle_http_via_socks4( Ok(hyper_response) } +/// Handle plain HTTP requests through a Shadowsocks upstream. +/// reqwest doesn't support SS natively, so we connect through the SS tunnel +/// manually and forward the HTTP request/response. +async fn handle_http_via_shadowsocks( + req: Request, + upstream: &Url, +) -> Result>, Infallible> { + let domain = req + .uri() + .host() + .map(|h| h.to_string()) + .unwrap_or_else(|| "unknown".to_string()); + let port = req.uri().port_u16().unwrap_or(80); + + let ss_host = upstream.host_str().unwrap_or("127.0.0.1"); + let ss_port = upstream.port().unwrap_or(8388); + let method_str = urlencoding::decode(upstream.username()) + .unwrap_or_default() + .to_string(); + let password = urlencoding::decode(upstream.password().unwrap_or("")) + .unwrap_or_default() + .to_string(); + + let cipher = match method_str.parse::() { + Ok(c) => c, + Err(_) => { + let mut resp = Response::new(Full::new(Bytes::from(format!( + "Bad SS cipher: {method_str}" + )))); + *resp.status_mut() = StatusCode::BAD_GATEWAY; + return Ok(resp); + } + }; + + let context = shadowsocks::context::Context::new_shared(shadowsocks::config::ServerType::Local); + let svr_cfg = match shadowsocks::config::ServerConfig::new( + shadowsocks::config::ServerAddr::from((ss_host.to_string(), ss_port)), + &password, + cipher, + ) { + Ok(c) => c, + Err(e) => { + let mut resp = Response::new(Full::new(Bytes::from(format!("SS config error: {e}")))); + *resp.status_mut() = StatusCode::BAD_GATEWAY; + return Ok(resp); + } + }; + + let target_addr = shadowsocks::relay::Address::DomainNameAddress(domain.clone(), port); + + let mut stream = match shadowsocks::relay::tcprelay::proxy_stream::ProxyClientStream::connect( + context, + &svr_cfg, + target_addr, + ) + .await + { + Ok(s) => s, + Err(e) => { + let mut resp = Response::new(Full::new(Bytes::from(format!("SS connect: {e}")))); + *resp.status_mut() = StatusCode::BAD_GATEWAY; + return Ok(resp); + } + }; + + // Build and send the HTTP request through the SS tunnel + let path = req + .uri() + .path_and_query() + .map(|pq| pq.as_str()) + .unwrap_or("/"); + let method = req.method().as_str(); + let mut raw_req = format!("{method} {path} HTTP/1.1\r\nHost: {domain}\r\nConnection: close\r\n"); + for (name, value) in req.headers() { + if name != "host" && name != "connection" { + raw_req.push_str(&format!("{}: {}\r\n", name, value.to_str().unwrap_or(""))); + } + } + raw_req.push_str("\r\n"); + + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + if let Err(e) = stream.write_all(raw_req.as_bytes()).await { + let mut resp = Response::new(Full::new(Bytes::from(format!("SS write: {e}")))); + *resp.status_mut() = StatusCode::BAD_GATEWAY; + return Ok(resp); + } + + let mut response_buf = Vec::new(); + if let Err(e) = stream.read_to_end(&mut response_buf).await { + log::warn!("SS read error (may be partial): {e}"); + } + + if let Some(tracker) = get_traffic_tracker() { + tracker.record_request(&domain, raw_req.len() as u64, response_buf.len() as u64); + } + + // Parse the raw HTTP response + let response_str = String::from_utf8_lossy(&response_buf); + let header_end = response_str.find("\r\n\r\n").unwrap_or(response_str.len()); + let status_line = response_str + .lines() + .next() + .unwrap_or("HTTP/1.1 502 Bad Gateway"); + let status_code: u16 = status_line + .split_whitespace() + .nth(1) + .and_then(|s| s.parse().ok()) + .unwrap_or(502); + let body = if header_end + 4 < response_buf.len() { + &response_buf[header_end + 4..] + } else { + b"" + }; + + let mut hyper_response = Response::new(Full::new(Bytes::from(body.to_vec()))); + *hyper_response.status_mut() = + StatusCode::from_u16(status_code).unwrap_or(StatusCode::BAD_GATEWAY); + + Ok(hyper_response) +} + async fn handle_http( req: Request, upstream_url: Option, @@ -800,14 +928,19 @@ async fn handle_http( let should_bypass = bypass_matcher.should_bypass(&domain); - // Check if we need to handle SOCKS4 manually (reqwest doesn't support it) + // Handle proxy types that reqwest doesn't support natively if !should_bypass { if let Some(ref upstream) = upstream_url { if upstream != "DIRECT" { if let Ok(url) = Url::parse(upstream) { - if url.scheme() == "socks4" { - // Handle SOCKS4 manually for HTTP requests - return handle_http_via_socks4(req, upstream).await; + match url.scheme() { + "socks4" => { + return handle_http_via_socks4(req, upstream).await; + } + "ss" | "shadowsocks" => { + return handle_http_via_shadowsocks(req, &url).await; + } + _ => {} } } } @@ -1298,37 +1431,24 @@ async fn handle_connect_from_buffer( tracker.record_request(&domain, 0, 0); } - // Connect to target (directly or via upstream proxy) + // Connect to target (directly or via upstream proxy). + // Returns a BoxedAsyncStream so all upstream types (plain TCP, SOCKS, + // Shadowsocks) share the same bidirectional-copy tunnel code below. let should_bypass = bypass_matcher.should_bypass(target_host); - let target_stream = match upstream_url.as_ref() { - None => { - // Direct connection - TcpStream::connect((target_host, target_port)).await? - } - Some(url) if url == "DIRECT" => { - // Direct connection - TcpStream::connect((target_host, target_port)).await? - } - _ if should_bypass => { - // Bypass rule matched - connect directly - TcpStream::connect((target_host, target_port)).await? - } + let target_stream: BoxedAsyncStream = match upstream_url.as_ref() { + None => Box::new(TcpStream::connect((target_host, target_port)).await?), + Some(url) if url == "DIRECT" => Box::new(TcpStream::connect((target_host, target_port)).await?), + _ if should_bypass => Box::new(TcpStream::connect((target_host, target_port)).await?), Some(upstream_url_str) => { - // Connect via upstream proxy let upstream = Url::parse(upstream_url_str)?; let scheme = upstream.scheme(); match scheme { "http" | "https" => { - // Connect via HTTP/HTTPS proxy CONNECT - // Note: HTTPS proxy URLs still use HTTP CONNECT method (CONNECT is always HTTP-based) - // For HTTPS proxies, reqwest handles TLS automatically in handle_http - // For manual CONNECT here, we use plain TCP - HTTPS proxy CONNECT typically works over plain TCP let proxy_host = upstream.host_str().unwrap_or("127.0.0.1"); let proxy_port = upstream.port().unwrap_or(8080); let mut proxy_stream = TcpStream::connect((proxy_host, proxy_port)).await?; - // Add authentication if provided let mut connect_req = format!( "CONNECT {}:{} HTTP/1.1\r\nHost: {}:{}\r\n", target_host, target_port, target_host, target_port @@ -1344,10 +1464,8 @@ async fn handle_connect_from_buffer( connect_req.push_str("\r\n"); - // Send CONNECT request to upstream proxy proxy_stream.write_all(connect_req.as_bytes()).await?; - // Read response let mut buffer = [0u8; 4096]; let n = proxy_stream.read(&mut buffer).await?; let response = String::from_utf8_lossy(&buffer[..n]); @@ -1356,10 +1474,9 @@ async fn handle_connect_from_buffer( return Err(format!("Upstream proxy CONNECT failed: {}", response).into()); } - proxy_stream + Box::new(proxy_stream) } "socks4" | "socks5" => { - // Connect via SOCKS proxy let socks_host = upstream.host_str().unwrap_or("127.0.0.1"); let socks_port = upstream.port().unwrap_or(1080); let socks_addr = format!("{}:{}", socks_host, socks_port); @@ -1367,7 +1484,7 @@ async fn handle_connect_from_buffer( let username = upstream.username(); let password = upstream.password().unwrap_or(""); - connect_via_socks( + let stream = connect_via_socks( &socks_addr, target_host, target_port, @@ -1378,7 +1495,56 @@ async fn handle_connect_from_buffer( None }, ) - .await? + .await?; + Box::new(stream) + } + "ss" | "shadowsocks" => { + // Shadowsocks: URL format is ss://method:password@host:port + // where "method" is the cipher (e.g. aes-256-gcm, chacha20-ietf-poly1305) + // and "password" is the SS server password. + let ss_host = upstream.host_str().unwrap_or("127.0.0.1"); + let ss_port = upstream.port().unwrap_or(8388); + + // The "username" field carries the cipher method + let method_str = urlencoding::decode(upstream.username()) + .unwrap_or_default() + .to_string(); + let password = urlencoding::decode(upstream.password().unwrap_or("")) + .unwrap_or_default() + .to_string(); + + if method_str.is_empty() || password.is_empty() { + return Err( + "Shadowsocks requires method and password (URL: ss://method:password@host:port)" + .into(), + ); + } + + let cipher = method_str.parse::().map_err(|_| { + format!("Unsupported Shadowsocks cipher: {method_str}. Use e.g. aes-256-gcm, chacha20-ietf-poly1305, aes-128-gcm") + })?; + + let context = + shadowsocks::context::Context::new_shared(shadowsocks::config::ServerType::Local); + let svr_cfg = shadowsocks::config::ServerConfig::new( + shadowsocks::config::ServerAddr::from((ss_host.to_string(), ss_port)), + &password, + cipher, + ) + .map_err(|e| format!("Invalid Shadowsocks config: {e}"))?; + + let target_addr = + shadowsocks::relay::Address::DomainNameAddress(target_host.to_string(), target_port); + + let stream = shadowsocks::relay::tcprelay::proxy_stream::ProxyClientStream::connect( + context, + &svr_cfg, + target_addr, + ) + .await + .map_err(|e| format!("Shadowsocks connection failed: {e}"))?; + + Box::new(stream) } _ => { return Err(format!("Unsupported upstream proxy scheme: {}", scheme).into()); @@ -1387,8 +1553,9 @@ async fn handle_connect_from_buffer( } }; - // Enable TCP_NODELAY on target stream for immediate data transfer - let _ = target_stream.set_nodelay(true); + // TCP_NODELAY is set per-stream where applicable (TcpStream paths). + // For encrypted streams (Shadowsocks), the underlying TCP connection + // is managed by the library and nodelay is handled internally. // Send 200 Connection Established response to client // CRITICAL: Must flush after writing to ensure response is sent before tunneling diff --git a/src-tauri/tests/donut_proxy_integration.rs b/src-tauri/tests/donut_proxy_integration.rs index 968efee..ff06dc1 100644 --- a/src-tauri/tests/donut_proxy_integration.rs +++ b/src-tauri/tests/donut_proxy_integration.rs @@ -1298,3 +1298,134 @@ async fn test_local_proxy_with_socks5_upstream( Ok(()) } + +/// Test proxying traffic through a real Shadowsocks server running in Docker. +/// Verifies the full chain: client → donut-proxy → Shadowsocks → internet. +#[tokio::test] +#[serial] +async fn test_local_proxy_with_shadowsocks_upstream( +) -> Result<(), Box> { + let binary_path = setup_test().await?; + let mut tracker = ProxyTestTracker::new(binary_path.clone()); + + // Check Docker availability + let docker_check = std::process::Command::new("docker").arg("version").output(); + if docker_check.map(|o| !o.status.success()).unwrap_or(true) { + eprintln!("skipping Shadowsocks e2e test because Docker is unavailable"); + return Ok(()); + } + + // Start a Shadowsocks server container + let ss_container = "donut-ss-test"; + let ss_port = 18388u16; + let ss_password = "donut-test-password"; + let ss_method = "aes-256-gcm"; + + // Clean up any previous container + let _ = std::process::Command::new("docker") + .args(["rm", "-f", ss_container]) + .output(); + + let docker_start = std::process::Command::new("docker") + .args([ + "run", + "-d", + "--name", + ss_container, + "-p", + &format!("{ss_port}:8388"), + "ghcr.io/shadowsocks/ssserver-rust:latest", + "ssserver", + "-s", + "[::]:8388", + "-k", + ss_password, + "-m", + ss_method, + ]) + .output()?; + + if !docker_start.status.success() { + let stderr = String::from_utf8_lossy(&docker_start.stderr); + eprintln!("skipping Shadowsocks e2e test: Docker run failed: {stderr}"); + return Ok(()); + } + + // Wait for the SS server to be ready + for _ in 0..15 { + sleep(Duration::from_secs(1)).await; + if TcpStream::connect(("127.0.0.1", ss_port)).await.is_ok() { + break; + } + } + + // Start donut-proxy with Shadowsocks upstream + let output = TestUtils::execute_command( + &binary_path, + &[ + "proxy", + "start", + "--host", + "127.0.0.1", + "--proxy-port", + &ss_port.to_string(), + "--type", + "ss", + "--username", + ss_method, + "--password", + ss_password, + ], + ) + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let _ = std::process::Command::new("docker") + .args(["rm", "-f", ss_container]) + .output(); + return Err(format!("Proxy start failed: {stderr}").into()); + } + + let config: Value = serde_json::from_str(&String::from_utf8(output.stdout)?)?; + let proxy_id = config["id"].as_str().unwrap().to_string(); + let local_port = config["localPort"].as_u64().unwrap() as u16; + tracker.track_proxy(proxy_id); + + // Wait for proxy to be fully ready + for _ in 0..20 { + sleep(Duration::from_millis(100)).await; + if TcpStream::connect(("127.0.0.1", local_port)).await.is_ok() { + break; + } + } + sleep(Duration::from_millis(500)).await; + + // Test: HTTP request through donut-proxy → Shadowsocks → example.com + let mut stream = TcpStream::connect(("127.0.0.1", local_port)).await?; + let request = + "GET http://example.com/ HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n"; + stream.write_all(request.as_bytes()).await?; + + let mut response = vec![0u8; 16384]; + let n = tokio::time::timeout(Duration::from_secs(15), stream.read(&mut response)) + .await + .map_err(|_| "HTTP request through Shadowsocks timed out")? + .map_err(|e| format!("Read error: {e}"))?; + let response_str = String::from_utf8_lossy(&response[..n]); + + assert!( + response_str.contains("Example Domain"), + "HTTP traffic through Shadowsocks should reach example.com, got: {}", + &response_str[..response_str.len().min(500)] + ); + println!("Shadowsocks upstream proxy test passed"); + + // Cleanup + tracker.cleanup_all().await; + let _ = std::process::Command::new("docker") + .args(["rm", "-f", ss_container]) + .output(); + + Ok(()) +}