From 6d641075e600b0d2813b88e970cb09fa0287d87a Mon Sep 17 00:00:00 2001 From: litcc Date: Sun, 1 Mar 2026 23:52:18 +0800 Subject: [PATCH 01/25] fix(test): fix all unit tests for macOS Apple Silicon This commit fixes all unit tests to pass on macOS with M-series chips. Key changes: - Enhanced DockerTestRunner with container IP retrieval for macOS bridge mode - Replaced socks5 image with v2fly for cross-platform compatibility - Fixed cross-container communication using host.docker.internal - Fixed WireGuard UDP checksum verification on macOS virtualization - Added missing test initialize() calls - Fixed test config paths and added proper feature gates --- clash-bin/tests/data/config/socks5-auth.json | 28 ++++++ .../tests/data/config/socks5-noauth.json | 22 +++++ .../proxy_provider/proxy_set_provider.rs | 8 +- clash-lib/src/proxy/group/relay/mod.rs | 22 +++-- clash-lib/src/proxy/group/smart/mod.rs | 4 +- .../src/proxy/shadowsocks/outbound/mod.rs | 10 +- clash-lib/src/proxy/socks/outbound/mod.rs | 95 ++++++++----------- clash-lib/src/proxy/trojan/mod.rs | 23 +++-- .../utils/test_utils/docker_utils/consts.rs | 2 +- .../test_utils/docker_utils/docker_runner.rs | 72 ++++++++++++-- clash-lib/src/proxy/vless/mod.rs | 1 + clash-lib/src/proxy/wg/device.rs | 40 ++++++-- clash-lib/src/proxy/wg/mod.rs | 12 ++- clash-lib/tests/api_tests.rs | 2 + clash-lib/tests/data/config/client/rules.yaml | 12 +-- clash-lib/tests/smoke_tests.rs | 1 + 16 files changed, 246 insertions(+), 108 deletions(-) create mode 100644 clash-bin/tests/data/config/socks5-auth.json create mode 100644 clash-bin/tests/data/config/socks5-noauth.json diff --git a/clash-bin/tests/data/config/socks5-auth.json b/clash-bin/tests/data/config/socks5-auth.json new file mode 100644 index 000000000..1f8177d31 --- /dev/null +++ b/clash-bin/tests/data/config/socks5-auth.json @@ -0,0 +1,28 @@ +{ + "log": { + "loglevel": "debug" + }, + "inbounds": [ + { + "port": 10002, + "listen": "0.0.0.0", + "protocol": "socks", + "settings": { + "auth": "password", + "accounts": [ + { + "user": "user", + "pass": "password" + } + ], + "udp": true, + "ip": "0.0.0.0" + } + } + ], + "outbounds": [ + { + "protocol": "freedom" + } + ] +} diff --git a/clash-bin/tests/data/config/socks5-noauth.json b/clash-bin/tests/data/config/socks5-noauth.json new file mode 100644 index 000000000..ebec78d36 --- /dev/null +++ b/clash-bin/tests/data/config/socks5-noauth.json @@ -0,0 +1,22 @@ +{ + "log": { + "loglevel": "debug" + }, + "inbounds": [ + { + "port": 10002, + "listen": "0.0.0.0", + "protocol": "socks", + "settings": { + "auth": "noauth", + "udp": true, + "ip": "0.0.0.0" + } + } + ], + "outbounds": [ + { + "protocol": "freedom" + } + ] +} diff --git a/clash-lib/src/app/remote_content_manager/providers/proxy_provider/proxy_set_provider.rs b/clash-lib/src/app/remote_content_manager/providers/proxy_provider/proxy_set_provider.rs index 741e7801e..d6409e387 100644 --- a/clash-lib/src/app/remote_content_manager/providers/proxy_provider/proxy_set_provider.rs +++ b/clash-lib/src/app/remote_content_manager/providers/proxy_provider/proxy_set_provider.rs @@ -291,12 +291,10 @@ mod tests { mock_vehicle.expect_read().returning(|| { Ok(r#" proxies: - - name: "ss" - type: ss + - name: "socks5" + type: socks5 server: localhost - port: 8388 - cipher: aes-256-gcm - password: "password" + port: 1080 udp: true "# .as_bytes() diff --git a/clash-lib/src/proxy/group/relay/mod.rs b/clash-lib/src/proxy/group/relay/mod.rs index 7660209ea..69339d39f 100644 --- a/clash-lib/src/proxy/group/relay/mod.rs +++ b/clash-lib/src/proxy/group/relay/mod.rs @@ -208,18 +208,20 @@ mod tests { use tokio::sync::RwLock; - use crate::proxy::{ - mocks::MockDummyProxyProvider, - utils::test_utils::{ - Suite, - consts::*, - docker_runner::{DockerTestRunner, DockerTestRunnerBuilder}, - run_test_suites_and_cleanup, + use super::*; + use crate::{ + proxy::{ + mocks::MockDummyProxyProvider, + utils::test_utils::{ + Suite, + consts::*, + docker_runner::{DockerTestRunner, DockerTestRunnerBuilder}, + run_test_suites_and_cleanup, + }, }, + tests::initialize, }; - use super::*; - const PASSWORD: &str = "FzcLbKs2dY9mhL"; const CIPHER: &str = "aes-256-gcm"; @@ -236,6 +238,7 @@ mod tests { #[tokio::test] #[serial_test::serial] async fn test_relay_1() -> anyhow::Result<()> { + initialize(); let ss_opts = crate::proxy::shadowsocks::outbound::HandlerOptions { name: "test-ss".to_owned(), common_opts: Default::default(), @@ -273,6 +276,7 @@ mod tests { #[tokio::test] #[serial_test::serial] async fn test_relay_2() -> anyhow::Result<()> { + initialize(); let ss_opts = crate::proxy::shadowsocks::outbound::HandlerOptions { name: "test-ss".to_owned(), common_opts: Default::default(), diff --git a/clash-lib/src/proxy/group/smart/mod.rs b/clash-lib/src/proxy/group/smart/mod.rs index aba8a6971..b6b52d439 100644 --- a/clash-lib/src/proxy/group/smart/mod.rs +++ b/clash-lib/src/proxy/group/smart/mod.rs @@ -761,6 +761,7 @@ mod tests { run_test_suites_and_cleanup, }, }, + tests::initialize, }; use tempfile::tempdir; use tokio::sync::RwLock; @@ -783,7 +784,8 @@ mod tests { #[tokio::test] #[serial_test::serial] async fn test_smart_group_smoke() -> anyhow::Result<()> { - let ss_port = 10003; + initialize(); + let ss_port = 10002; let ss_opts = crate::proxy::shadowsocks::outbound::HandlerOptions { name: "test-ss-for-smart".to_owned(), common_opts: Default::default(), diff --git a/clash-lib/src/proxy/shadowsocks/outbound/mod.rs b/clash-lib/src/proxy/shadowsocks/outbound/mod.rs index 35a163429..ced88fc38 100644 --- a/clash-lib/src/proxy/shadowsocks/outbound/mod.rs +++ b/clash-lib/src/proxy/shadowsocks/outbound/mod.rs @@ -265,6 +265,7 @@ mod tests { let host = format!("0.0.0.0:{}", port); DockerTestRunnerBuilder::new() .image(IMAGE_SS_RUST) + .port(port) .entrypoint(&["ssserver"]) .cmd(&["-s", &host, "-m", CIPHER, "-k", PASSWORD, "-U", "-vvv"]) .build() @@ -280,6 +281,7 @@ mod tests { let host = format!("0.0.0.0:{}", port); DockerTestRunnerBuilder::new() .image(IMAGE_SS_RUST) + .port(port) .entrypoint(&["ssserver"]) .cmd(&[ "-s", @@ -340,7 +342,9 @@ mod tests { ss_port: u16, stls_port: u16, ) -> anyhow::Result { - let ss_server_env = format!("SERVER=127.0.0.1:{}", ss_port); + // Use host.docker.internal to access SS server running in another + // container via host port mapping + let ss_server_env = format!("SERVER=host.docker.internal:{}", ss_port); let listen_env = format!("LISTEN=0.0.0.0:{}", stls_port); let password = format!("PASSWORD={}", SHADOW_TLS_PASSWORD); DockerTestRunnerBuilder::new() @@ -363,6 +367,7 @@ mod tests { #[tokio::test] #[serial_test::serial] async fn test_shadowtls() -> anyhow::Result<()> { + initialize(); // the real port that used for communication let shadow_tls_port = 10002; // not important, you can assign any port that is not conflict with @@ -398,7 +403,7 @@ mod tests { obfs_port: u16, mode: SimpleOBFSMode, ) -> anyhow::Result { - let ss_server_env = format!("127.0.0.1:{}", ss_port); + let ss_server_env = format!("host.docker.internal:{}", ss_port); let port = format!("{}", obfs_port); let mode = match mode { SimpleOBFSMode::Http => "http", @@ -453,6 +458,7 @@ mod tests { #[tokio::test] #[serial_test::serial] async fn test_ss_obfs_http() -> anyhow::Result<()> { + initialize(); test_ss_obfs_inner(SimpleOBFSMode::Http).await } diff --git a/clash-lib/src/proxy/socks/outbound/mod.rs b/clash-lib/src/proxy/socks/outbound/mod.rs index 0e62689d0..faaaeec4a 100644 --- a/clash-lib/src/proxy/socks/outbound/mod.rs +++ b/clash-lib/src/proxy/socks/outbound/mod.rs @@ -262,102 +262,91 @@ mod tests { use std::sync::Arc; - use crate::proxy::{ - socks::outbound::{Handler, HandlerOptions}, - utils::{ - GLOBAL_DIRECT_CONNECTOR, - test_utils::{ - Suite, - consts::{IMAGE_SOCKS5, LOCAL_ADDR}, - docker_runner::{DockerTestRunner, DockerTestRunnerBuilder}, - run_test_suites_and_cleanup, + use crate::{ + proxy::{ + socks::outbound::{Handler, HandlerOptions}, + utils::{ + GLOBAL_DIRECT_CONNECTOR, + test_utils::{ + Suite, + consts::{IMAGE_SOCKS5, LOCAL_ADDR}, + docker_runner::{DockerTestRunner, DockerTestRunnerBuilder}, + run_test_suites_and_cleanup, + }, }, }, + tests::initialize, }; + use super::super::super::utils::test_utils::docker_utils::config_helper::test_config_base_dir; + const USER: &str = "user"; const PASSWORD: &str = "password"; - async fn get_socks5_runner( - port: u16, - username: Option, - password: Option, - ) -> anyhow::Result { - let host = format!("0.0.0.0:{}", port); - let username = username.unwrap_or_default(); - let password = password.unwrap_or_default(); - let cmd = if !username.is_empty() && !password.is_empty() { - vec![ - "-a", - &host, - "-u", - username.as_str(), - "-p", - password.as_str(), - ] + async fn get_socks5_runner(auth: bool) -> anyhow::Result { + let test_config_dir = test_config_base_dir(); + let conf = if auth { + test_config_dir.join("socks5-auth.json") } else { - vec!["-a", &host] + test_config_dir.join("socks5-noauth.json") }; + DockerTestRunnerBuilder::new() .image(IMAGE_SOCKS5) - .cmd(&cmd) + .mounts(&[(conf.to_str().unwrap(), "/etc/v2ray/config.json")]) .build() .await } + fn server_addr(runner: &DockerTestRunner) -> String { + if cfg!(target_os = "macos") { + runner.container_ip().expect("container IP not found") + } else { + LOCAL_ADDR.to_owned() + } + } + #[tokio::test] #[serial_test::serial] async fn test_socks5_no_auth() -> anyhow::Result<()> { + initialize(); + let port = 10002; + let runner = get_socks5_runner(false).await?; let opts = HandlerOptions { name: "test-socks5-no-auth".to_owned(), common_opts: Default::default(), - server: LOCAL_ADDR.to_owned(), - port: 10002, + server: server_addr(&runner), + port, user: None, password: None, udp: true, ..Default::default() }; - let port = opts.port; let handler = Arc::new(Handler::new(opts)); - run_test_suites_and_cleanup( - handler, - get_socks5_runner(port, None, None).await?, - Suite::all(), - ) - .await + run_test_suites_and_cleanup(handler, runner, Suite::all()).await } #[tokio::test] #[serial_test::serial] async fn test_socks5_auth() -> anyhow::Result<()> { use crate::proxy::DialWithConnector; - + initialize(); + let port = 10002; + let runner = get_socks5_runner(true).await?; let opts = HandlerOptions { - name: "test-socks5-no-auth".to_owned(), + name: "test-socks5-auth".to_owned(), common_opts: Default::default(), - server: LOCAL_ADDR.to_owned(), - port: 10002, + server: server_addr(&runner), + port, user: Some(USER.to_owned()), password: Some(PASSWORD.to_owned()), udp: true, ..Default::default() }; - let port = opts.port; let handler = Arc::new(Handler::new(opts)); handler .register_connector(GLOBAL_DIRECT_CONNECTOR.clone()) .await; - run_test_suites_and_cleanup( - handler, - get_socks5_runner( - port, - Some(USER.to_owned()), - Some(PASSWORD.to_owned()), - ) - .await?, - Suite::all(), - ) - .await + run_test_suites_and_cleanup(handler, runner, Suite::all()).await } } diff --git a/clash-lib/src/proxy/trojan/mod.rs b/clash-lib/src/proxy/trojan/mod.rs index 9f698b3bf..04e44ec56 100644 --- a/clash-lib/src/proxy/trojan/mod.rs +++ b/clash-lib/src/proxy/trojan/mod.rs @@ -217,19 +217,21 @@ mod tests { use std::collections::HashMap; - use crate::proxy::{ - transport, - utils::test_utils::{ - Suite, - config_helper::test_config_base_dir, - consts::*, - docker_runner::{DockerTestRunner, DockerTestRunnerBuilder}, - run_test_suites_and_cleanup, + use super::*; + use crate::{ + proxy::{ + transport, + utils::test_utils::{ + Suite, + config_helper::test_config_base_dir, + consts::*, + docker_runner::{DockerTestRunner, DockerTestRunnerBuilder}, + run_test_suites_and_cleanup, + }, }, + tests::initialize, }; - use super::*; - async fn get_ws_runner() -> anyhow::Result { let test_config_dir = test_config_base_dir(); let trojan_conf = test_config_dir.join("trojan-ws.json"); @@ -305,6 +307,7 @@ mod tests { #[tokio::test] #[serial_test::serial] async fn test_trojan_grpc() -> anyhow::Result<()> { + initialize(); let transport = transport::GrpcClient::new( "example.org".to_owned(), "example" diff --git a/clash-lib/src/proxy/utils/test_utils/docker_utils/consts.rs b/clash-lib/src/proxy/utils/test_utils/docker_utils/consts.rs index 9a3c589c8..090644588 100644 --- a/clash-lib/src/proxy/utils/test_utils/docker_utils/consts.rs +++ b/clash-lib/src/proxy/utils/test_utils/docker_utils/consts.rs @@ -12,7 +12,7 @@ pub const IMAGE_TROJAN_GO: &str = "p4gefau1t/trojan-go:latest"; pub const IMAGE_VMESS: &str = "v2fly/v2fly-core:v4.45.2"; pub const IMAGE_VLESS: &str = "v2fly/v2fly-core:v4.45.2"; pub const IMAGE_XRAY: &str = "teddysun/xray:latest"; -pub const IMAGE_SOCKS5: &str = "ghcr.io/wzshiming/socks5/socks5:v0.4.3"; +pub const IMAGE_SOCKS5: &str = "v2fly/v2fly-core:v4.45.2"; #[cfg(feature = "ssh")] pub const IMAGE_OPENSSH: &str = "docker.io/linuxserver/openssh-server:latest"; pub const IMAGE_HYSTERIA: &str = "tobyxdd/hysteria:latest"; diff --git a/clash-lib/src/proxy/utils/test_utils/docker_utils/docker_runner.rs b/clash-lib/src/proxy/utils/test_utils/docker_utils/docker_runner.rs index fea911a7b..55c0d1501 100644 --- a/clash-lib/src/proxy/utils/test_utils/docker_utils/docker_runner.rs +++ b/clash-lib/src/proxy/utils/test_utils/docker_utils/docker_runner.rs @@ -2,15 +2,14 @@ use std::collections::HashMap; use bollard::{ Docker, + config::ContainerInspectResponse, models::ContainerCreateBody, - query_parameters::{CreateImageOptions, LogsOptions}, + query_parameters::{ + CreateContainerOptions, CreateImageOptions, CreateImageOptionsBuilder, + LogsOptions, RemoveContainerOptions, StartContainerOptions, + }, secret::{HostConfig, Mount, PortBinding}, }; - -use bollard::query_parameters::{ - CreateContainerOptions, CreateImageOptionsBuilder, RemoveContainerOptions, - StartContainerOptions, -}; use futures::{Future, TryStreamExt}; const TIMEOUT_DURATION: u64 = 30; @@ -18,6 +17,7 @@ const TIMEOUT_DURATION: u64 = 30; pub struct DockerTestRunner { instance: Docker, id: String, + inspect: ContainerInspectResponse, } impl DockerTestRunner { @@ -39,15 +39,66 @@ impl DockerTestRunner { ) .await? .id; - docker + + // Try to start the container, cleanup if it fails + if let Err(e) = docker .start_container(&id, Some(StartContainerOptions::default())) - .await?; + .await + { + // Cleanup the created container before returning error + let _ = docker + .remove_container( + &id, + Some(RemoveContainerOptions { + force: true, + ..Default::default() + }), + ) + .await; + return Err(e.into()); + } + + let inspect = docker.inspect_container(&id, None).await?; + Ok(Self { instance: docker, id, + inspect, }) } + #[allow(unused)] + pub fn container_ip(&self) -> Option { + self.inspect + .network_settings + .as_ref() + .map(|i| { + i.networks + .as_ref() + .unwrap() + .values() + .next() + .map(|j| j.ip_address.as_ref().unwrap().to_string()) + }) + .flatten() + } + + #[allow(unused)] + pub fn gateway_ip(&self) -> Option { + self.inspect + .network_settings + .as_ref() + .map(|i| { + i.networks + .as_ref() + .unwrap() + .values() + .next() + .map(|j| j.gateway.as_ref().unwrap().to_string()) + }) + .flatten() + } + // you can run the cleanup manually pub async fn cleanup(self) -> anyhow::Result<()> { let logs = self @@ -104,6 +155,10 @@ impl MultiDockerTestRunner { error: {:?}", e ); + // Cleanup all previously added containers before returning error + for runner in std::mem::take(&mut self.runners) { + let _ = runner.cleanup().await; + } Err(e) } } @@ -328,7 +383,6 @@ pub fn get_host_config(port: u16) -> HostConfig { .into_iter() .collect::>(), ), - // we need to use the host mode to enable the benchmark function #[cfg(not(target_os = "macos"))] network_mode: Some("host".to_owned()), ..Default::default() diff --git a/clash-lib/src/proxy/vless/mod.rs b/clash-lib/src/proxy/vless/mod.rs index 62e4afe53..68ec15516 100644 --- a/clash-lib/src/proxy/vless/mod.rs +++ b/clash-lib/src/proxy/vless/mod.rs @@ -232,6 +232,7 @@ mod tests { DockerTestRunnerBuilder::new() .image(IMAGE_VLESS) + .port(8443) .mounts(&[ (conf.to_str().unwrap(), "/etc/v2ray/config.json"), (cert.to_str().unwrap(), "/etc/ssl/v2ray/fullchain.pem"), diff --git a/clash-lib/src/proxy/wg/device.rs b/clash-lib/src/proxy/wg/device.rs index eb6d6fea2..e517c060e 100644 --- a/clash-lib/src/proxy/wg/device.rs +++ b/clash-lib/src/proxy/wg/device.rs @@ -697,13 +697,39 @@ impl Device for VirtualIpDevice { let next = self.packet_receiver.try_recv().ok(); match next { Some((_proto, data)) => { - let rx_token = RxToken { - buffer: { - let mut buffer = BytesMut::new(); - buffer.put(data); - buffer - }, - }; + // Convert to mutable buffer for potential checksum fix + let mut buffer = BytesMut::from(&data[..]); + + // Fix UDP checksum if needed + // Some environments (NAT, checksum offload, virtualization) may + // corrupt the checksum We recalculate it here since + // WireGuard AEAD already guarantees data integrity + // Note: An alternative approach is to skip RX checksum verification + // by setting `caps.checksum.udp = + // smoltcp::phy::Checksum::Tx` in capabilities(), but + // recalculating feels cleaner than disabling verification entirely + use smoltcp::wire::*; + if let Ok(IpVersion::Ipv4) = IpVersion::of_packet(&buffer) { + if let Ok(ipv4) = Ipv4Packet::new_checked(&buffer[..]) { + if ipv4.next_header() == IpProtocol::Udp { + let src_addr = ipv4.src_addr(); + let dst_addr = ipv4.dst_addr(); + let ip_header_len = ipv4.header_len() as usize; + + // Recalculate UDP checksum + if let Ok(mut udp) = + UdpPacket::new_checked(&mut buffer[ip_header_len..]) + { + udp.fill_checksum( + &IpAddress::Ipv4(src_addr), + &IpAddress::Ipv4(dst_addr), + ); + } + } + } + } + + let rx_token = RxToken { buffer }; let tx_token = TxToken { sender: self.packet_sender.clone(), }; diff --git a/clash-lib/src/proxy/wg/mod.rs b/clash-lib/src/proxy/wg/mod.rs index 37a80fcd6..7a3262c69 100644 --- a/clash-lib/src/proxy/wg/mod.rs +++ b/clash-lib/src/proxy/wg/mod.rs @@ -317,12 +317,13 @@ mod tests { }, }; - use super::super::utils::test_utils::{ - consts::*, docker_runner::DockerTestRunner, + use super::{ + super::utils::test_utils::{consts::*, docker_runner::DockerTestRunner}, + *, + }; + use crate::{ + proxy::utils::test_utils::run_test_suites_and_cleanup, tests::initialize, }; - use crate::proxy::utils::test_utils::run_test_suites_and_cleanup; - - use super::*; // see: https://github.com/linuxserver/docker-wireguard?tab=readme-ov-file#usage // we shouldn't run the wireguard server with host mode, or @@ -356,6 +357,7 @@ mod tests { #[tokio::test] #[serial_test::serial] async fn test_wg() -> anyhow::Result<()> { + initialize(); let opts = HandlerOptions { name: "wg".to_owned(), common_opts: Default::default(), diff --git a/clash-lib/tests/api_tests.rs b/clash-lib/tests/api_tests.rs index 17e9b31aa..71499a889 100644 --- a/clash-lib/tests/api_tests.rs +++ b/clash-lib/tests/api_tests.rs @@ -6,6 +6,7 @@ use std::{path::PathBuf, time::Duration}; mod common; +#[cfg(feature = "shadowsocks")] #[tokio::test(flavor = "current_thread")] #[serial_test::serial] async fn test_get_set_allow_lan() { @@ -83,6 +84,7 @@ async fn test_get_set_allow_lan() { ); } +#[cfg(feature = "shadowsocks")] #[tokio::test(flavor = "current_thread")] #[serial_test::serial] async fn test_connections_returns_proxy_chain_names() { diff --git a/clash-lib/tests/data/config/client/rules.yaml b/clash-lib/tests/data/config/client/rules.yaml index 91ef2e4f3..af550c653 100644 --- a/clash-lib/tests/data/config/client/rules.yaml +++ b/clash-lib/tests/data/config/client/rules.yaml @@ -14,17 +14,17 @@ dns: tcp: 127.0.0.1:53553 dot: addr: 127.0.0.1:53554 - ca-cert: dns.crt - ca-key: dns.key + ca-cert: ../../../../../clash-bin/tests/data/config/dns.crt + ca-key: ../../../../../clash-bin/tests/data/config/dns.key doh: addr: 127.0.0.1:53555 - ca-cert: dns.crt - ca-key: dns.key + ca-cert: ../../../../../clash-bin/tests/data/config/dns.crt + ca-key: ../../../../../clash-bin/tests/data/config/dns.key hostname: dns.example.com doh3: addr: 127.0.0.1:53555 - ca-cert: dns.crt - ca-key: dns.key + ca-cert: ../../../../../clash-bin/tests/data/config/dns.crt + ca-key: ../../../../../clash-bin/tests/data/config/dns.key hostname: dns.example.com # ipv6: false # when the false, response to AAAA questions will be empty diff --git a/clash-lib/tests/smoke_tests.rs b/clash-lib/tests/smoke_tests.rs index 53049089f..a9da026ac 100644 --- a/clash-lib/tests/smoke_tests.rs +++ b/clash-lib/tests/smoke_tests.rs @@ -4,6 +4,7 @@ use std::path::PathBuf; mod common; +#[cfg(feature = "shadowsocks")] #[tokio::test(flavor = "current_thread")] #[serial_test::serial] /// Test Shadowsocks inbound and outbound functionality From 6504d5c6076178f63befe35e29a99beb6d98511f Mon Sep 17 00:00:00 2001 From: litcc Date: Mon, 2 Mar 2026 10:44:52 +0800 Subject: [PATCH 02/25] refactor(docker-utils): simplify container_ip and gateway_ip methods - Replace nested map().flatten() chains with and_then() for cleaner code - Remove .unwrap() calls in gateway_ip to eliminate panic risks - Both methods now safely return None if any intermediate value is missing --- .../test_utils/docker_utils/docker_runner.rs | 26 ++++++------------- 1 file changed, 8 insertions(+), 18 deletions(-) diff --git a/clash-lib/src/proxy/utils/test_utils/docker_utils/docker_runner.rs b/clash-lib/src/proxy/utils/test_utils/docker_utils/docker_runner.rs index 55c0d1501..a62ba3938 100644 --- a/clash-lib/src/proxy/utils/test_utils/docker_utils/docker_runner.rs +++ b/clash-lib/src/proxy/utils/test_utils/docker_utils/docker_runner.rs @@ -72,15 +72,10 @@ impl DockerTestRunner { self.inspect .network_settings .as_ref() - .map(|i| { - i.networks - .as_ref() - .unwrap() - .values() - .next() - .map(|j| j.ip_address.as_ref().unwrap().to_string()) - }) - .flatten() + .and_then(|i| i.networks.as_ref()) + .and_then(|b| b.values().next()) + .and_then(|j| j.ip_address.as_ref()) + .map(|r| r.to_string()) } #[allow(unused)] @@ -88,15 +83,10 @@ impl DockerTestRunner { self.inspect .network_settings .as_ref() - .map(|i| { - i.networks - .as_ref() - .unwrap() - .values() - .next() - .map(|j| j.gateway.as_ref().unwrap().to_string()) - }) - .flatten() + .and_then(|i| i.networks.as_ref()) + .and_then(|b| b.values().next()) + .and_then(|j| j.gateway.as_ref()) + .map(|r| r.to_string()) } // you can run the cleanup manually From 83479d7ad4b1b17aa3f1bdae410737c832787d95 Mon Sep 17 00:00:00 2001 From: litcc Date: Tue, 3 Mar 2026 16:16:34 +0800 Subject: [PATCH 03/25] feat(docker-test): support remote Docker connections via DOCKER_HOST - Add support for remote Docker daemon connections through DOCKER_HOST environment variable with support for http/https/tcp, unix, and npipe protocols --- .../test_utils/docker_utils/docker_runner.rs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/clash-lib/src/proxy/utils/test_utils/docker_utils/docker_runner.rs b/clash-lib/src/proxy/utils/test_utils/docker_utils/docker_runner.rs index a62ba3938..49634a391 100644 --- a/clash-lib/src/proxy/utils/test_utils/docker_utils/docker_runner.rs +++ b/clash-lib/src/proxy/utils/test_utils/docker_utils/docker_runner.rs @@ -1,7 +1,7 @@ use std::collections::HashMap; use bollard::{ - Docker, + API_DEFAULT_VERSION, Docker, config::ContainerInspectResponse, models::ContainerCreateBody, query_parameters::{ @@ -25,7 +25,20 @@ impl DockerTestRunner { image_conf: Option, container_conf: ContainerCreateBody, ) -> anyhow::Result { - let docker: Docker = Docker::connect_with_socket_defaults()?; + let docker: Docker = if let Some(url) = option_env!("DOCKER_HOST") { + if url.starts_with("http://") + || url.starts_with("https://") + || url.starts_with("tcp://") + { + Docker::connect_with_http(url, 60, API_DEFAULT_VERSION)? + } else if url.starts_with("unix://") || url.starts_with("npipe://") { + Docker::connect_with_socket(url, 60, API_DEFAULT_VERSION)? + } else { + anyhow::bail!("invalid DOCKER_HOST url: {}", url); + } + } else { + Docker::connect_with_socket_defaults()? + }; docker .create_image(image_conf, None, None) From 61a3e636bf8fcbf2a5b66d67a56f4f3aa221d7ed Mon Sep 17 00:00:00 2001 From: iHsin Date: Tue, 3 Mar 2026 21:13:18 +0800 Subject: [PATCH 04/25] Add GIT_CURL_VERBOSE and GIT_TRACE to CI config Enable verbose output for Git commands in CI. Signed-off-by: iHsin --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c0656105b..9eecc2724 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,6 +25,8 @@ env: SENTRY_DSN: ${{ secrets.SENTRY_DSN }} CROSS_CONTAINER_UID: 1001 CROSS_CONTAINER_GID: 118 + GIT_CURL_VERBOSE: "1" + GIT_TRACE: "1" # Arm builder https://github.blog/changelog/2024-09-03-github-actions-arm64-linux-and-windows-runners-are-now-generally-available/ jobs: From b20b145464beb7b7fc182e7b661f3f82ee89af95 Mon Sep 17 00:00:00 2001 From: iHsin Date: Tue, 3 Mar 2026 21:18:11 +0800 Subject: [PATCH 05/25] Update ci.yml Signed-off-by: iHsin --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9eecc2724..1b554cc1f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,6 +39,7 @@ jobs: - uses: actions/checkout@v6 with: submodules: true + fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} ref: ${{ github.head_ref }} From de572a4087580f4503087c4302bd37fca26ec620 Mon Sep 17 00:00:00 2001 From: litcc Date: Tue, 3 Mar 2026 21:24:04 +0800 Subject: [PATCH 06/25] feat(docker-test): add docker_gateway_ip method and update tests to use gateway IP --- .../test_utils/docker_utils/docker_runner.rs | 10 + .../utils/test_utils/docker_utils/mod.rs | 220 +++++++++++------- clash-lib/src/proxy/wg/device.rs | 2 +- 3 files changed, 153 insertions(+), 79 deletions(-) diff --git a/clash-lib/src/proxy/utils/test_utils/docker_utils/docker_runner.rs b/clash-lib/src/proxy/utils/test_utils/docker_utils/docker_runner.rs index 49634a391..efcd34fd5 100644 --- a/clash-lib/src/proxy/utils/test_utils/docker_utils/docker_runner.rs +++ b/clash-lib/src/proxy/utils/test_utils/docker_utils/docker_runner.rs @@ -170,6 +170,8 @@ impl MultiDockerTestRunner { #[async_trait::async_trait] pub trait RunAndCleanup { + /// Get the docker gateway IP address. + fn docker_gateway_ip(&self) -> Option; async fn run_and_cleanup( self, f: impl Future> + Send + 'static, @@ -178,6 +180,10 @@ pub trait RunAndCleanup { #[async_trait::async_trait] impl RunAndCleanup for DockerTestRunner { + fn docker_gateway_ip(&self) -> Option { + self.gateway_ip() + } + async fn run_and_cleanup( self, f: impl Future> + Send + 'static, @@ -203,6 +209,10 @@ impl RunAndCleanup for DockerTestRunner { #[async_trait::async_trait] impl RunAndCleanup for MultiDockerTestRunner { + fn docker_gateway_ip(&self) -> Option { + self.runners.iter().find_map(|d| d.gateway_ip()) + } + async fn run_and_cleanup( self, f: impl Future> + Send + 'static, diff --git a/clash-lib/src/proxy/utils/test_utils/docker_utils/mod.rs b/clash-lib/src/proxy/utils/test_utils/docker_utils/mod.rs index 643767980..4ab0f2b81 100644 --- a/clash-lib/src/proxy/utils/test_utils/docker_utils/mod.rs +++ b/clash-lib/src/proxy/utils/test_utils/docker_utils/mod.rs @@ -26,24 +26,23 @@ pub mod docker_runner; // TODO: add the throughput metrics pub async fn ping_pong_test( handler: Arc, + gateway_ip: Option, port: u16, ) -> anyhow::Result<()> { // PATH: our proxy handler -> proxy-server(container) -> target local // server(127.0.0.1:port) - let sess = Session { - destination: ( - if cfg!(any(target_os = "linux", target_os = "android")) { - "127.0.0.1".to_owned() - } else { - "host.docker.internal".to_owned() - }, - port, - ) - .try_into() - .unwrap_or_else(|_| panic!("")), - ..Default::default() - }; + let mut destination_list = vec![ + #[cfg(any(target_os = "linux", target_os = "android"))] + "127.0.0.1".to_owned(), + "host.docker.internal".to_owned(), + ]; + if let Some(ip) = option_env!("CLIENT_IP") { + destination_list.insert(0, ip.to_owned()); + } + if let Some(ip) = gateway_ip { + destination_list.push(ip); + } let resolver = config_helper::build_dns_resolver().await?; @@ -126,13 +125,49 @@ pub async fn ping_pong_test( // give some time for the target local server to start tokio::time::sleep(Duration::from_secs(3)).await; - match handler.connect_stream(&sess, resolver).await { - Ok(stream) => proxy_fn(stream).await, - Err(e) => { - tracing::error!("Failed to proxy connection: {}", e); - Err(anyhow!("Failed to proxy connection: {}", e)) + let mut first_error: Option = None; + + for destination in &destination_list { + let dst: SocksAddr = match (destination.clone(), port).try_into() { + Ok(addr) => addr, + Err(e) => { + tracing::error!("Failed to parse destination address: {}", e); + continue; + } + }; + + let sess = Session { + destination: dst, + ..Default::default() + }; + + let stream = match handler.connect_stream(&sess, resolver.clone()).await + { + Ok(stream) => stream, + Err(e) => { + tracing::error!("Failed to proxy connection: {}", e); + if first_error.is_none() { + first_error = Some(e.into()); + } + continue; + } + }; + + if let Ok(()) = proxy_fn(stream).await { + return Ok(()); } } + + // Return the first connection error if available, otherwise return generic + // error + if let Some(err) = first_error { + Err(err) + } else { + Err(anyhow!( + "all destination test error: [{:?}]", + destination_list + )) + } }); let futs = vec![proxy_task, target_local_server_handler]; @@ -142,29 +177,23 @@ pub async fn ping_pong_test( pub async fn ping_pong_udp_test( handler: Arc, + gateway_ip: Option, port: u16, ) -> anyhow::Result<()> { // PATH: our proxy handler -> proxy-server(container) -> target local // server(127.0.0.1:port) - let src = ("127.0.0.1".to_owned(), 10005) - .try_into() - .unwrap_or_else(|_| panic!("")); - let dst: SocksAddr = ( - if cfg!(any(target_os = "linux", target_os = "android")) { - "127.0.0.1".to_owned() - } else { - "host.docker.internal".to_owned() - }, - port, - ) - .try_into() - .unwrap_or_else(|_| panic!("")); - - let sess = Session { - destination: dst.clone(), - ..Default::default() - }; + let mut destination_list = vec![ + #[cfg(any(target_os = "linux", target_os = "android"))] + "127.0.0.1".to_owned(), + "host.docker.internal".to_owned(), + ]; + if let Some(ip) = option_env!("CLIENT_IP") { + destination_list.insert(0, ip.to_owned()); + } + if let Some(ip) = gateway_ip { + destination_list.push(ip); + } let resolver = config_helper::build_dns_resolver().await?; @@ -201,20 +230,20 @@ pub async fn ping_pong_udp_test( // let (mut sink, mut stream) = datagram.split(); let packet = UdpPacket::new(b"hello".to_vec(), src_addr, dst_addr); - tracing::trace!("proxy_fn start write"); + tracing::trace!("proxy_fn(udp) start write"); datagram.send(packet.clone()).await.map_err(|x| { - tracing::error!("proxy_fn write error: {}", x); + tracing::error!("proxy_fn(udp) write error: {}", x); anyhow::Error::new(x) })?; - tracing::trace!("proxy_fn start read"); + tracing::trace!("proxy_fn(udp) start read"); let pkt = datagram.next().await; let pkt = pkt.ok_or_else(|| anyhow!("no packet received"))?; assert_eq!(pkt.data, b"world"); - tracing::trace!("proxy_fn end"); + tracing::trace!("proxy_fn(udp) end"); Ok(()) } @@ -223,13 +252,41 @@ pub async fn ping_pong_udp_test( // give some time for the target local server to start tokio::time::sleep(Duration::from_secs(3)).await; - match handler.connect_datagram(&sess, resolver).await { - Ok(stream) => proxy_fn(stream, src, dst).await, - Err(e) => { - tracing::error!("Failed to proxy connection: {}", e); - Err(anyhow!("Failed to proxy connection: {}", e)) + for destination in &destination_list { + let src = ("127.0.0.1".to_owned(), 10005) + .try_into() + .expect("Failed to parse source address"); + + let dst: SocksAddr = match (destination.clone(), port).try_into() { + Ok(addr) => addr, + Err(e) => { + tracing::error!("Failed to parse destination address: {}", e); + continue; + } + }; + + let sess = Session { + destination: dst.clone(), + ..Default::default() + }; + + let datagram = + match handler.connect_datagram(&sess, resolver.clone()).await { + Ok(datagram) => datagram, + Err(e) => { + tracing::error!("Failed to proxy connection(udp): {}", e); + continue; + } + }; + + if let Ok(()) = proxy_fn(datagram, src, dst).await { + return Ok(()); } } + Err(anyhow!( + "all destination test error(udp): [{:?}]", + destination_list + )) }); let futs = vec![proxy_task, target_local_server_handler]; @@ -243,8 +300,8 @@ pub async fn latency_test( ) -> anyhow::Result<(Duration, Duration)> { let resolver = config_helper::build_dns_resolver().await?; let proxy_manager = ProxyManager::new(resolver.clone(), None); - let mut retries = 3; - let latency = loop { + + for attempt in 1..=3 { match proxy_manager .url_test( handler.clone(), @@ -253,22 +310,27 @@ pub async fn latency_test( ) .await { - Ok(v) => break v, - Err(e) => { - retries -= 1; - if retries == 0 { - return Err(e.into()); - } + Ok(latency) => return Ok(latency), + Err(e) if attempt < 3 => { tokio::time::sleep(Duration::from_millis(100)).await; } + Err(e) => return Err(e.into()), } - }; - Ok(latency) + } + unreachable!() } pub async fn dns_test(handler: Arc) -> anyhow::Result<()> { - let src = SocksAddr::Ip("127.0.0.1:0".parse().unwrap()); - let dst = SocksAddr::Ip("1.0.0.1:53".parse().unwrap()); + let src = SocksAddr::Ip( + "127.0.0.1:0" + .parse() + .expect("Failed to parse source address"), + ); + let dst = SocksAddr::Ip( + "1.0.0.1:53" + .parse() + .expect("Failed to parse destination address"), + ); let sess = Session { destination: dst.clone(), @@ -276,35 +338,26 @@ pub async fn dns_test(handler: Arc) -> anyhow::Result<()> { }; let resolver = config_helper::build_dns_resolver().await?; - - // we don't need the resolver, so it doesn't matter to create a casual one let stream = handler.connect_datagram(&sess, resolver).await?; - let (mut sink, mut stream) = stream.split(); - // send dns request to domain + // DNS request for www.google.com A record let dns_req = b"\x00\x00\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00\x03www\x06google\x03com\x00\x00\x01\x00\x01"; - let udp_packet: UdpPacket = UdpPacket::new(dns_req.to_vec(), src, dst); + let udp_packet = UdpPacket::new(dns_req.to_vec(), src, dst); let start_time = Instant::now(); - let max_retry = 3; - for _ in 0..max_retry { + for _ in 0..3 { sink.send(udp_packet.clone()).await?; - let pkt = stream.next().await; - if pkt.is_none() { - continue; + + if let Some(pkt) = stream.next().await { + assert!(!pkt.data.is_empty()); + tracing::debug!("dns test time cost: {:?}", start_time.elapsed()); + return Ok(()); } - let pkt = pkt.unwrap(); - assert!(!pkt.data.is_empty()); - let end_time = Instant::now(); - tracing::debug!( - "dns test time cost:{:?}", - end_time.duration_since(start_time) - ); - return Ok(()); } - bail!("fail to receive dns response"); + + bail!("Failed to receive DNS response after 3 attempts") } #[derive(Clone, Copy)] @@ -338,12 +391,18 @@ pub async fn run_test_suites_and_cleanup( suites: &[Suite], ) -> anyhow::Result<()> { let suites = suites.to_owned(); + let gateway_ip = docker_test_runner.docker_gateway_ip(); docker_test_runner .run_and_cleanup(async move { for suite in suites { match suite { Suite::PingPongTcp => { - let rv = ping_pong_test(handler.clone(), 10001).await; + let rv = ping_pong_test( + handler.clone(), + gateway_ip.clone(), + 10001, + ) + .await; if rv.is_err() { tracing::error!("ping_pong_test failed: {:?}", rv); return rv; @@ -352,7 +411,12 @@ pub async fn run_test_suites_and_cleanup( } } Suite::PingPongUdp => { - let rv = ping_pong_udp_test(handler.clone(), 10001).await; + let rv = ping_pong_udp_test( + handler.clone(), + gateway_ip.clone(), + 10001, + ) + .await; if rv.is_err() { tracing::error!("ping_pong_udp_test failed: {:?}", rv); return rv; diff --git a/clash-lib/src/proxy/wg/device.rs b/clash-lib/src/proxy/wg/device.rs index e517c060e..720650057 100644 --- a/clash-lib/src/proxy/wg/device.rs +++ b/clash-lib/src/proxy/wg/device.rs @@ -5,7 +5,7 @@ use std::{ time::Duration, }; -use bytes::{BufMut, Bytes, BytesMut}; +use bytes::{Bytes, BytesMut}; use futures::{SinkExt, StreamExt}; use rand::seq::IndexedRandom; From bf88417da556cc03361b5dfbc99dda05a172cab7 Mon Sep 17 00:00:00 2001 From: iHsin Date: Tue, 3 Mar 2026 21:57:03 +0800 Subject: [PATCH 07/25] Update ci.yml Signed-off-by: iHsin --- .github/workflows/ci.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1b554cc1f..4a0c7f35a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,8 +25,6 @@ env: SENTRY_DSN: ${{ secrets.SENTRY_DSN }} CROSS_CONTAINER_UID: 1001 CROSS_CONTAINER_GID: 118 - GIT_CURL_VERBOSE: "1" - GIT_TRACE: "1" # Arm builder https://github.blog/changelog/2024-09-03-github-actions-arm64-linux-and-windows-runners-are-now-generally-available/ jobs: @@ -39,9 +37,7 @@ jobs: - uses: actions/checkout@v6 with: submodules: true - fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} - ref: ${{ github.head_ref }} - uses: dtolnay/rust-toolchain@master with: From cdce86350e5b144bc12e057037f15fce4aa7a908 Mon Sep 17 00:00:00 2001 From: litcc Date: Wed, 4 Mar 2026 21:34:11 +0800 Subject: [PATCH 08/25] fix(docker-test): docker remote mounts --- Cargo.lock | 32 +++++- clash-lib/Cargo.toml | 1 + .../test_utils/docker_utils/docker_runner.rs | 97 +++++++++++++++++-- 3 files changed, 119 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e5b216c28..ae1b01744 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1242,6 +1242,7 @@ dependencies = [ "sock2proc", "socket2 0.6.2", "ssh-key", + "tar", "tempfile", "thiserror 2.0.18", "time", @@ -3540,7 +3541,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.2", "tokio", "tower-service", "tracing", @@ -3858,7 +3859,7 @@ dependencies = [ "pin-project-lite", "rustc-hash 2.1.1", "rustls", - "socket2 0.5.10", + "socket2 0.6.2", "thiserror 2.0.18", "tokio", "tokio-stream", @@ -3899,7 +3900,7 @@ checksum = "f981dadd5a072a9e0efcd24bdcc388e570073f7e51b33505ceb1ef4668c80c86" dependencies = [ "cfg_aliases", "libc", - "socket2 0.5.10", + "socket2 0.6.2", "tracing", "windows-sys 0.61.0", ] @@ -5710,7 +5711,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.1", "rustls", - "socket2 0.5.10", + "socket2 0.6.2", "thiserror 2.0.18", "tokio", "tracing", @@ -5788,7 +5789,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.2", "tracing", "windows-sys 0.60.2", ] @@ -7597,6 +7598,17 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[package]] +name = "tar" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "tempfile" version = "3.24.0" @@ -10342,6 +10354,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + [[package]] name = "yaml-rust2" version = "0.11.0" diff --git a/clash-lib/Cargo.toml b/clash-lib/Cargo.toml index 6f8450e2e..2f1120a15 100644 --- a/clash-lib/Cargo.toml +++ b/clash-lib/Cargo.toml @@ -202,6 +202,7 @@ mockall = "0.14.0" tokio-test = "0.4.5" axum-macros = "0.5.0" bollard = "0.20" +tar = "0.4.44" serial_test = "3.3" env_logger = "0.11" # donnot change the version, russh is not compatible with the latest version of rand_core diff --git a/clash-lib/src/proxy/utils/test_utils/docker_utils/docker_runner.rs b/clash-lib/src/proxy/utils/test_utils/docker_utils/docker_runner.rs index efcd34fd5..0a6c00c0d 100644 --- a/clash-lib/src/proxy/utils/test_utils/docker_utils/docker_runner.rs +++ b/clash-lib/src/proxy/utils/test_utils/docker_utils/docker_runner.rs @@ -1,16 +1,20 @@ -use std::collections::HashMap; +use std::{collections::HashMap, path::Path}; +use anyhow; use bollard::{ - API_DEFAULT_VERSION, Docker, + API_DEFAULT_VERSION, Docker, body_full, body_try_stream, config::ContainerInspectResponse, models::ContainerCreateBody, query_parameters::{ CreateContainerOptions, CreateImageOptions, CreateImageOptionsBuilder, LogsOptions, RemoveContainerOptions, StartContainerOptions, + UploadToContainerOptions, }, secret::{HostConfig, Mount, PortBinding}, }; +use bytes::Bytes; use futures::{Future, TryStreamExt}; +use tar; const TIMEOUT_DURATION: u64 = 30; @@ -23,7 +27,7 @@ pub struct DockerTestRunner { impl DockerTestRunner { pub async fn try_new( image_conf: Option, - container_conf: ContainerCreateBody, + mut container_conf: ContainerCreateBody, ) -> anyhow::Result { let docker: Docker = if let Some(url) = option_env!("DOCKER_HOST") { if url.starts_with("http://") @@ -45,13 +49,91 @@ impl DockerTestRunner { .try_collect::>() .await?; - let id = docker + // For remote Docker, we need to handle mounts differently + let mounts = container_conf + .host_config + .as_mut() + .and_then(|hc| hc.mounts.take()); + let files_to_copy = if option_env!("DOCKER_HOST") + .map(|url| { + url.starts_with("http://") + || url.starts_with("https://") + || url.starts_with("tcp://") + }) + .unwrap_or(false) + { + // Remote Docker - collect files to copy via API + mounts + } else { + // Local Docker - keep mounts in config + if let Some(mounts) = mounts { + container_conf.host_config.as_mut().unwrap().mounts = Some(mounts); + } + None + }; + + let container = docker .create_container( Some(CreateContainerOptions::default()), container_conf, ) - .await? - .id; + .await?; + let id = container.id; + + // Copy files to container if needed (for remote Docker) + if let Some(mounts) = files_to_copy { + for mount in mounts { + if let (Some(source), Some(target)) = + (mount.source.as_deref(), mount.target.as_deref()) + { + // Create tar archive with full path structure + let mut ar = tar::Builder::new(Vec::new()); + + // Remove leading slash for tar path + let tar_path = if target.starts_with('/') { + &target[1..] + } else { + target + }; + + let source_path = Path::new(source); + let metadata = std::fs::metadata(source_path)?; + + if metadata.is_file() { + // Handle single file + let content = std::fs::read(source_path)?; + let mut header = tar::Header::new_gnu(); + header.set_size(content.len() as u64); + header.set_mode(0o644); + ar.append_data(&mut header, tar_path, &content[..])?; + } else if metadata.is_dir() { + // Handle directory recursively using sync operations + // append_dir_all will recursively add all files from + // source_path with tar_path as the + // prefix in the archive + ar.append_dir_all(tar_path, source_path)?; + } else { + anyhow::bail!( + "Unsupported file type for source: {}", + source + ); + } + let tar_data = ar.into_inner()?; + + // Upload to container root directory + docker + .upload_to_container( + &id, + Some(UploadToContainerOptions { + path: "/".to_string(), + ..Default::default() + }), + body_full(Bytes::from(tar_data)), + ) + .await?; + } + } + } // Try to start the container, cleanup if it fails if let Err(e) = docker @@ -80,6 +162,9 @@ impl DockerTestRunner { }) } + // Removed start() method - file copy logic is now integrated into try_new() + // This method was problematic because it tried to use self in a static method + #[allow(unused)] pub fn container_ip(&self) -> Option { self.inspect From cf2501f1d54674a1a52aba25fcab7bca08381c7c Mon Sep 17 00:00:00 2001 From: i Date: Thu, 5 Mar 2026 22:07:01 +0800 Subject: [PATCH 09/25] fix(docker-test): update container_ip method --- Cargo.lock | 8 +-- clash-bin/Cargo.toml | 2 +- clash-lib/src/proxy/group/relay/mod.rs | 27 +++++++--- clash-lib/src/proxy/group/smart/mod.rs | 7 ++- clash-lib/src/proxy/hysteria2/mod.rs | 9 +++- clash-lib/src/proxy/shadowquic/mod.rs | 21 +++++--- .../src/proxy/shadowsocks/outbound/mod.rs | 45 +++++++++++------ clash-lib/src/proxy/socks/outbound/mod.rs | 6 +-- clash-lib/src/proxy/trojan/mod.rs | 14 ++++-- clash-lib/src/proxy/tuic/mod.rs | 18 ++++--- .../test_utils/docker_utils/docker_runner.rs | 50 +++++++++++-------- clash-lib/src/proxy/vless/mod.rs | 6 +-- clash-lib/src/proxy/vmess/mod.rs | 16 +++--- clash-lib/src/proxy/wg/mod.rs | 7 ++- 14 files changed, 150 insertions(+), 86 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ae1b01744..050396038 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -492,9 +492,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "aws-lc-rs" -version = "1.15.2" +version = "1.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a88aab2464f1f25453baa7a07c84c5b7684e274054ba06817f382357f77a288" +checksum = "94bffc006df10ac2a68c83692d734a465f8ee6c5b384d8545a636f81d858f4bf" dependencies = [ "aws-lc-sys", "untrusted 0.7.1", @@ -503,9 +503,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.35.0" +version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b45afffdee1e7c9126814751f88dddc747f41d91da16c9551a0f1e8a11e788a1" +checksum = "4321e568ed89bb5a7d291a7f37997c2c0df89809d7b6d12062c81ddb54aa782e" dependencies = [ "cc", "cmake", diff --git a/clash-bin/Cargo.toml b/clash-bin/Cargo.toml index a25bec4c0..05a1198a5 100644 --- a/clash-bin/Cargo.toml +++ b/clash-bin/Cargo.toml @@ -47,4 +47,4 @@ sentry = { version = "0.46", default-features = false, features = ["backtrace", human-panic = "2.0" -aws-lc-rs = { version = "1", optional = true, default-features = false } +aws-lc-rs = { version = "1.16", optional = true, default-features = false } diff --git a/clash-lib/src/proxy/group/relay/mod.rs b/clash-lib/src/proxy/group/relay/mod.rs index 69339d39f..1837d23e6 100644 --- a/clash-lib/src/proxy/group/relay/mod.rs +++ b/clash-lib/src/proxy/group/relay/mod.rs @@ -239,17 +239,23 @@ mod tests { #[serial_test::serial] async fn test_relay_1() -> anyhow::Result<()> { initialize(); + let port = 10002; + let container = get_ss_runner(port).await?; + + let container_ip =container.container_ip(); + + debug!("container ip: {:?}", container_ip); let ss_opts = crate::proxy::shadowsocks::outbound::HandlerOptions { name: "test-ss".to_owned(), common_opts: Default::default(), - server: LOCAL_ADDR.to_owned(), - port: 10002, + server: container_ip.unwrap_or(LOCAL_ADDR.to_owned()), + port: port, password: PASSWORD.to_owned(), cipher: CIPHER.to_owned(), plugin: Default::default(), udp: false, }; - let port = ss_opts.port; + let ss_handler: AnyOutboundHandler = Arc::new(crate::proxy::shadowsocks::outbound::Handler::new(ss_opts)) as _; @@ -267,7 +273,7 @@ mod tests { Handler::new(Default::default(), vec![Arc::new(RwLock::new(provider))]); run_test_suites_and_cleanup( handler, - get_ss_runner(port).await?, + container, Suite::all(), ) .await @@ -277,17 +283,22 @@ mod tests { #[serial_test::serial] async fn test_relay_2() -> anyhow::Result<()> { initialize(); + let port = 10002; + let container = get_ss_runner(port).await?; + + let container_ip =container.container_ip(); + let ss_opts = crate::proxy::shadowsocks::outbound::HandlerOptions { name: "test-ss".to_owned(), common_opts: Default::default(), - server: LOCAL_ADDR.to_owned(), - port: 10002, + server: container_ip.unwrap_or(LOCAL_ADDR.to_owned()), + port, password: PASSWORD.to_owned(), cipher: CIPHER.to_owned(), plugin: Default::default(), udp: false, }; - let port = ss_opts.port; + let ss_handler: AnyOutboundHandler = Arc::new(crate::proxy::shadowsocks::outbound::Handler::new(ss_opts)) as _; @@ -305,7 +316,7 @@ mod tests { Handler::new(Default::default(), vec![Arc::new(RwLock::new(provider))]); run_test_suites_and_cleanup( handler, - get_ss_runner(port).await?, + container, Suite::all(), ) .await diff --git a/clash-lib/src/proxy/group/smart/mod.rs b/clash-lib/src/proxy/group/smart/mod.rs index b6b52d439..1efd88b7f 100644 --- a/clash-lib/src/proxy/group/smart/mod.rs +++ b/clash-lib/src/proxy/group/smart/mod.rs @@ -786,10 +786,13 @@ mod tests { async fn test_smart_group_smoke() -> anyhow::Result<()> { initialize(); let ss_port = 10002; + + let docker_runner = get_ss_runner(ss_port).await?; + let ss_opts = crate::proxy::shadowsocks::outbound::HandlerOptions { name: "test-ss-for-smart".to_owned(), common_opts: Default::default(), - server: LOCAL_ADDR.to_owned(), + server: docker_runner.container_ip().unwrap_or(LOCAL_ADDR.to_owned()), port: ss_port, password: PASSWORD.to_owned(), cipher: CIPHER.to_owned(), @@ -840,7 +843,7 @@ mod tests { ); let any_smart_handler: AnyOutboundHandler = Arc::new(smart_handler_instance); - let docker_runner = get_ss_runner(ss_port).await?; + run_test_suites_and_cleanup(any_smart_handler, docker_runner, Suite::all()) .await diff --git a/clash-lib/src/proxy/hysteria2/mod.rs b/clash-lib/src/proxy/hysteria2/mod.rs index d8df83ec4..f4d7738ae 100644 --- a/clash-lib/src/proxy/hysteria2/mod.rs +++ b/clash-lib/src/proxy/hysteria2/mod.rs @@ -634,7 +634,12 @@ mod tests { #[serial_test::serial] async fn test_hysteria() -> anyhow::Result<()> { initialize(); - let ip = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)); + + let container = get_hysteria_runner().await?; + + let container_ip = container.container_ip().unwrap_or("127.0.0.1".to_owned()); + + let ip = IpAddr::from_str(&container_ip).unwrap_or(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))); let port = 10002; let obfs = Some(Obfs::Salamander(SalamanderObfs { @@ -671,7 +676,7 @@ mod tests { .await; run_test_suites_and_cleanup( handler, - get_hysteria_runner().await?, + container, Suite::all(), ) .await diff --git a/clash-lib/src/proxy/shadowquic/mod.rs b/clash-lib/src/proxy/shadowquic/mod.rs index 7619510a4..ec4f709b7 100644 --- a/clash-lib/src/proxy/shadowquic/mod.rs +++ b/clash-lib/src/proxy/shadowquic/mod.rs @@ -263,9 +263,9 @@ mod tests { const PORT: u16 = 10002; - fn gen_options(over_stream: bool) -> anyhow::Result { + fn gen_options(opt_ip: Option, over_stream: bool) -> anyhow::Result { Ok(HandlerOptions { - addr: SocketAddr::new(LOCAL_ADDR.parse().unwrap(), PORT).to_string(), + addr: SocketAddr::new(opt_ip.unwrap_or(LOCAL_ADDR.to_owned()).parse().unwrap(), PORT).to_string(), password: "12345678".into(), username: "87654321".into(), server_name: "echo.free.beeceptor.com".into(), @@ -281,7 +281,12 @@ mod tests { #[serial_test::serial] async fn test_shadowquic_over_datagram() -> anyhow::Result<()> { initialize(); - let opts = gen_options(false)?; + + let container = get_shadowquic_runner().await?; + + let container_ip =container.container_ip(); + + let opts = gen_options(container_ip,false)?; let handler = Arc::new(Handler::new("test-shadowquic".into(), opts)); handler @@ -289,7 +294,7 @@ mod tests { .await; run_test_suites_and_cleanup( handler, - get_shadowquic_runner().await?, + container, Suite::all(), ) .await @@ -298,7 +303,11 @@ mod tests { #[serial_test::serial] async fn test_shadowquic_over_stream() -> anyhow::Result<()> { initialize(); - let mut opts = gen_options(true)?; + let container = get_shadowquic_runner().await?; + + let container_ip =container.container_ip(); + + let mut opts = gen_options(container_ip,true)?; opts.over_stream = true; let handler = Arc::new(Handler::new("test-shadowquic".into(), opts)); @@ -307,7 +316,7 @@ mod tests { .await; run_test_suites_and_cleanup( handler, - get_shadowquic_runner().await?, + container, Suite::all(), ) .await diff --git a/clash-lib/src/proxy/shadowsocks/outbound/mod.rs b/clash-lib/src/proxy/shadowsocks/outbound/mod.rs index ced88fc38..b31b5477a 100644 --- a/clash-lib/src/proxy/shadowsocks/outbound/mod.rs +++ b/clash-lib/src/proxy/shadowsocks/outbound/mod.rs @@ -238,7 +238,7 @@ impl OutboundHandler for Handler { #[cfg(all(test, docker_test))] mod tests { - + use bollard::container; use crate::{ proxy::{ transport::*, @@ -315,11 +315,14 @@ mod tests { #[serial_test::serial] async fn test_ss_plain() -> anyhow::Result<()> { initialize(); + let port = 10002; + let container = get_ss_runner(port).await?; + let opts = HandlerOptions { name: "test-ss".to_owned(), common_opts: Default::default(), - server: LOCAL_ADDR.to_owned(), - port: 10002, + server: container.container_ip().unwrap_or(LOCAL_ADDR.to_owned()), + port, password: PASSWORD.to_owned(), cipher: CIPHER.to_owned(), plugin: Default::default(), @@ -332,19 +335,20 @@ mod tests { .await; run_test_suites_and_cleanup( handler, - get_ss_runner(port).await?, + container, Suite::all(), ) .await } async fn get_shadowtls_runner( + ss_ip: Option, ss_port: u16, stls_port: u16, ) -> anyhow::Result { // Use host.docker.internal to access SS server running in another // container via host port mapping - let ss_server_env = format!("SERVER=host.docker.internal:{}", ss_port); + let ss_server_env = format!("SERVER={}:{}", ss_ip.unwrap_or("host.docker.internal".to_owned()),ss_port); let listen_env = format!("LISTEN=0.0.0.0:{}", stls_port); let password = format!("PASSWORD={}", SHADOW_TLS_PASSWORD); DockerTestRunnerBuilder::new() @@ -373,12 +377,17 @@ mod tests { // not important, you can assign any port that is not conflict with // others let ss_port = 10004; + + let container1 = get_ss_runner(ss_port).await?; + + let container2 = get_shadowtls_runner(container1.container_ip(),ss_port, shadow_tls_port).await?; + let client = Shadowtls::new("www.feishu.cn".to_owned(), "password".to_owned(), true); let opts = HandlerOptions { name: "test-shadowtls".to_owned(), common_opts: Default::default(), - server: LOCAL_ADDR.to_owned(), + server: container2.container_ip().unwrap_or(LOCAL_ADDR.to_owned()), port: shadow_tls_port, password: PASSWORD.to_owned(), cipher: CIPHER.to_owned(), @@ -389,21 +398,21 @@ mod tests { // we need to store all the runners in a container, to make sure all of // them can be destroyed after the test let mut chained = MultiDockerTestRunner::default(); - chained.add(get_ss_runner(ss_port)).await?; + chained.add_with_runner(container1); chained - .add(get_shadowtls_runner(ss_port, shadow_tls_port)) - .await?; + .add_with_runner(container2); // currently, shadow-tls does't support udp proxy // see: https://github.com/ihciah/shadow-tls/issues/54 run_test_suites_and_cleanup(handler, chained, Suite::tcp_tests()).await } async fn get_obfs_runner( + ss_ip: Option, ss_port: u16, obfs_port: u16, mode: SimpleOBFSMode, ) -> anyhow::Result { - let ss_server_env = format!("host.docker.internal:{}", ss_port); + let ss_server_env = format!("{}:{}",ss_ip.unwrap_or("host.docker.internal".to_owned()), ss_port); let port = format!("{}", obfs_port); let mode = match mode { SimpleOBFSMode::Http => "http", @@ -428,6 +437,10 @@ mod tests { async fn test_ss_obfs_inner(mode: SimpleOBFSMode) -> anyhow::Result<()> { let obfs_port = 10002; let ss_port = 10004; + + let container1 = get_ss_runner(ss_port).await?; + let container2 = get_obfs_runner(container1.container_ip(), ss_port, obfs_port, mode).await?; + let host = "www.bing.com".to_owned(); let plugin = match mode { SimpleOBFSMode::Http => { @@ -438,7 +451,7 @@ mod tests { let opts = HandlerOptions { name: "test-obfs".to_owned(), common_opts: Default::default(), - server: LOCAL_ADDR.to_owned(), + server: container2.container_ip().unwrap_or(LOCAL_ADDR.to_owned()), port: obfs_port, password: PASSWORD.to_owned(), cipher: CIPHER.to_owned(), @@ -448,10 +461,9 @@ mod tests { let handler: Arc = Arc::new(Handler::new(opts)); let mut chained = MultiDockerTestRunner::default(); - chained.add(get_ss_runner(ss_port)).await?; + chained.add_with_runner(container1); chained - .add(get_obfs_runner(ss_port, obfs_port, mode)) - .await?; + .add_with_runner(container2); run_test_suites_and_cleanup(handler, chained, Suite::tcp_tests()).await } @@ -474,6 +486,7 @@ mod tests { async fn test_ss_v2ray_plugin() -> anyhow::Result<()> { initialize(); let ss_port = 10004; + let container = get_ss_runner_with_plugin(ss_port).await?; let host = "example.org".to_owned(); let plugin = V2rayWsClient::try_new( host, @@ -487,7 +500,7 @@ mod tests { let opts = HandlerOptions { name: "test-obfs".to_owned(), common_opts: Default::default(), - server: LOCAL_ADDR.to_owned(), + server: container.container_ip().unwrap_or(LOCAL_ADDR.to_owned()), port: ss_port, password: PASSWORD.to_owned(), cipher: CIPHER.to_owned(), @@ -498,7 +511,7 @@ mod tests { let handler: Arc = Arc::new(Handler::new(opts)); run_test_suites_and_cleanup( handler, - get_ss_runner_with_plugin(ss_port).await?, + container, Suite::tcp_tests(), ) .await diff --git a/clash-lib/src/proxy/socks/outbound/mod.rs b/clash-lib/src/proxy/socks/outbound/mod.rs index faaaeec4a..2967ce104 100644 --- a/clash-lib/src/proxy/socks/outbound/mod.rs +++ b/clash-lib/src/proxy/socks/outbound/mod.rs @@ -299,11 +299,7 @@ mod tests { } fn server_addr(runner: &DockerTestRunner) -> String { - if cfg!(target_os = "macos") { - runner.container_ip().expect("container IP not found") - } else { - LOCAL_ADDR.to_owned() - } + runner.container_ip().unwrap_or(LOCAL_ADDR.to_owned()) } #[tokio::test] diff --git a/clash-lib/src/proxy/trojan/mod.rs b/clash-lib/src/proxy/trojan/mod.rs index 04e44ec56..c289c3bb2 100644 --- a/clash-lib/src/proxy/trojan/mod.rs +++ b/clash-lib/src/proxy/trojan/mod.rs @@ -267,11 +267,13 @@ mod tests { ); let tls = transport::TlsClient::new(true, "example.org".to_owned(), None, None); - + + let container = get_ws_runner().await?; + let opts = HandlerOptions { name: "test-trojan-ws".to_owned(), common_opts: Default::default(), - server: "127.0.0.1".to_owned(), + server: container.container_ip().unwrap_or(LOCAL_ADDR.to_owned()), port: 10002, password: "example".to_owned(), udp: true, @@ -283,7 +285,7 @@ mod tests { .register_connector(GLOBAL_DIRECT_CONNECTOR.clone()) .await; // ignore the udp test - run_test_suites_and_cleanup(handler, get_ws_runner().await?, Suite::all()) + run_test_suites_and_cleanup(handler, container, Suite::all()) .await } @@ -322,10 +324,12 @@ mod tests { None, ); + let runner = get_grpc_runner().await?; + let opts = HandlerOptions { name: "test-trojan-grpc".to_owned(), common_opts: Default::default(), - server: "127.0.0.1".to_owned(), + server: runner.container_ip().unwrap_or(LOCAL_ADDR.to_owned()), port: 10002, password: "example".to_owned(), udp: true, @@ -336,7 +340,7 @@ mod tests { handler .register_connector(GLOBAL_DIRECT_CONNECTOR.clone()) .await; - run_test_suites_and_cleanup(handler, get_grpc_runner().await?, Suite::all()) + run_test_suites_and_cleanup(handler, runner, Suite::all()) .await } } diff --git a/clash-lib/src/proxy/tuic/mod.rs b/clash-lib/src/proxy/tuic/mod.rs index e234ea467..e281cbf9b 100644 --- a/clash-lib/src/proxy/tuic/mod.rs +++ b/clash-lib/src/proxy/tuic/mod.rs @@ -405,10 +405,10 @@ mod tests { const PORT: u16 = 10002; - fn gen_options(skip_cert_verify: bool) -> anyhow::Result { + fn gen_options(container_ip: Option,skip_cert_verify: bool) -> anyhow::Result { Ok(HandlerOptions { name: "test-tuic".to_owned(), - server: LOCAL_ADDR.into(), + server: container_ip.unwrap_or(LOCAL_ADDR.to_owned()), port: PORT, common_opts: Default::default(), uuid: "00000000-0000-0000-0000-000000000001".parse()?, @@ -437,13 +437,15 @@ mod tests { #[serial_test::serial] async fn test_tuic_skip_cert_verify() -> anyhow::Result<()> { initialize(); - let opts = gen_options(true)?; + + let container = get_tuic_runner().await?; + let opts = gen_options(container.container_ip(),true)?; let handler = Arc::new(Handler::new(opts)); handler .register_connector(GLOBAL_DIRECT_CONNECTOR.clone()) .await; - run_test_suites_and_cleanup(handler, get_tuic_runner().await?, Suite::all()) + run_test_suites_and_cleanup(handler,container , Suite::all()) .await } @@ -451,7 +453,11 @@ mod tests { #[serial_test::serial] async fn test_tuic_cert_verify_expect_fail() -> anyhow::Result<()> { initialize(); - let opts = gen_options(false)?; + + let container = get_tuic_runner().await?; + + let opts = gen_options(container.container_ip(),false)?; + let handler = Arc::new(Handler::new(opts)); handler @@ -459,7 +465,7 @@ mod tests { .await; let res = run_test_suites_and_cleanup( handler, - get_tuic_runner().await?, + container, Suite::all(), ) .await; diff --git a/clash-lib/src/proxy/utils/test_utils/docker_utils/docker_runner.rs b/clash-lib/src/proxy/utils/test_utils/docker_utils/docker_runner.rs index 0a6c00c0d..4932c34a9 100644 --- a/clash-lib/src/proxy/utils/test_utils/docker_utils/docker_runner.rs +++ b/clash-lib/src/proxy/utils/test_utils/docker_utils/docker_runner.rs @@ -152,39 +152,44 @@ impl DockerTestRunner { .await; return Err(e.into()); } - let inspect = docker.inspect_container(&id, None).await?; - Ok(Self { instance: docker, id, - inspect, + inspect }) } - // Removed start() method - file copy logic is now integrated into try_new() - // This method was problematic because it tried to use self in a static method - #[allow(unused)] pub fn container_ip(&self) -> Option { - self.inspect - .network_settings - .as_ref() + self.inspect.network_settings.as_ref() .and_then(|i| i.networks.as_ref()) - .and_then(|b| b.values().next()) - .and_then(|j| j.ip_address.as_ref()) - .map(|r| r.to_string()) + .and_then(|b| { + b.values().find_map(|j| { + [(&j.gateway, &j.ip_address), (&j.ipv6_gateway, &j.global_ipv6_address)] + .into_iter() + .find(|(gateway, _)| gateway.as_ref().map_or(false, |g| !g.is_empty())) + .and_then(|(_, ip)| ip.as_ref()) + .filter(|ip| !ip.is_empty()) + .map(|ip| ip.to_string()) + }) + }) } #[allow(unused)] pub fn gateway_ip(&self) -> Option { - self.inspect - .network_settings - .as_ref() + self.inspect.network_settings.as_ref() .and_then(|i| i.networks.as_ref()) - .and_then(|b| b.values().next()) - .and_then(|j| j.gateway.as_ref()) - .map(|r| r.to_string()) + .and_then(|b| { + b.values().find_map(|j| { + [(&j.gateway), (&j.ipv6_gateway)] + .into_iter() + .find(|(gateway)| gateway.as_ref().map_or(false, |g| !g.is_empty())) + .and_then(|(gateway)| gateway.as_ref()) + .filter(|ip| !ip.is_empty()) + .map(|ip| ip.to_string()) + }) + }) } // you can run the cleanup manually @@ -251,6 +256,11 @@ impl MultiDockerTestRunner { } } } + + #[allow(unused)] + pub fn add_with_runner(&mut self, runners: DockerTestRunner) { + self.runners.push(runners); + } } #[async_trait::async_trait] @@ -481,8 +491,8 @@ pub fn get_host_config(port: u16) -> HostConfig { .into_iter() .collect::>(), ), - #[cfg(not(target_os = "macos"))] - network_mode: Some("host".to_owned()), + // #[cfg(not(target_os = "macos"))] + // network_mode: Some("host".to_owned()), ..Default::default() } } diff --git a/clash-lib/src/proxy/vless/mod.rs b/clash-lib/src/proxy/vless/mod.rs index 68ec15516..518296659 100644 --- a/clash-lib/src/proxy/vless/mod.rs +++ b/clash-lib/src/proxy/vless/mod.rs @@ -259,11 +259,11 @@ mod tests { 0, "".to_owned(), ); - + let runner = get_ws_runner().await?; let opts = HandlerOptions { name: "test-vless-ws".into(), common_opts: Default::default(), - server: LOCAL_ADDR.into(), + server: runner.container_ip().unwrap_or(LOCAL_ADDR.to_owned()), port: 8443, uuid: "b831381d-6324-4d53-ad4f-8cda48b30811".into(), udp: true, @@ -271,7 +271,7 @@ mod tests { transport: Some(Box::new(ws_client)), }; let handler = Arc::new(Handler::new(opts)); - let runner = get_ws_runner().await?; + run_test_suites_and_cleanup(handler, runner, Suite::all()).await } } diff --git a/clash-lib/src/proxy/vmess/mod.rs b/clash-lib/src/proxy/vmess/mod.rs index 7d27b4222..0fd672fa3 100644 --- a/clash-lib/src/proxy/vmess/mod.rs +++ b/clash-lib/src/proxy/vmess/mod.rs @@ -267,10 +267,12 @@ mod tests { "".to_owned(), ); + let runner = get_ws_runner().await?; + let opts = HandlerOptions { name: "test-vmess-ws".into(), common_opts: Default::default(), - server: LOCAL_ADDR.into(), + server: runner.container_ip().unwrap_or(LOCAL_ADDR.to_owned()), port: 10002, uuid: "b831381d-6324-4d53-ad4f-8cda48b30811".into(), alter_id: 0, @@ -280,7 +282,7 @@ mod tests { transport: Some(Box::new(ws_client)), }; let handler = Arc::new(Handler::new(opts)); - let runner = get_ws_runner().await?; + run_test_suites_and_cleanup(handler, runner, Suite::all()).await } @@ -309,10 +311,11 @@ mod tests { "example.org".to_owned(), "example!".to_owned().try_into()?, ); + let container = get_grpc_runner().await?; let opts = HandlerOptions { name: "test-vmess-grpc".into(), common_opts: Default::default(), - server: LOCAL_ADDR.into(), + server: container.container_ip().unwrap_or(LOCAL_ADDR.to_owned()), port: 10002, uuid: "b831381d-6324-4d53-ad4f-8cda48b30811".into(), alter_id: 0, @@ -322,7 +325,7 @@ mod tests { transport: Some(Box::new(grpc_client)), }; let handler = Arc::new(Handler::new(opts)); - run_test_suites_and_cleanup(handler, get_grpc_runner().await?, Suite::all()) + run_test_suites_and_cleanup(handler, container, Suite::all()) .await } @@ -353,10 +356,11 @@ mod tests { http::Method::POST, "/test".to_owned().try_into()?, ); + let container = get_h2_runner().await?; let opts = HandlerOptions { name: "test-vmess-h2".into(), common_opts: Default::default(), - server: LOCAL_ADDR.into(), + server: container.container_ip().unwrap_or(LOCAL_ADDR.to_owned()), port: 10002, uuid: "b831381d-6324-4d53-ad4f-8cda48b30811".into(), alter_id: 0, @@ -369,7 +373,7 @@ mod tests { handler .register_connector(GLOBAL_DIRECT_CONNECTOR.clone()) .await; - run_test_suites_and_cleanup(handler, get_h2_runner().await?, Suite::all()) + run_test_suites_and_cleanup(handler, container, Suite::all()) .await } } diff --git a/clash-lib/src/proxy/wg/mod.rs b/clash-lib/src/proxy/wg/mod.rs index 7a3262c69..124cf48ef 100644 --- a/clash-lib/src/proxy/wg/mod.rs +++ b/clash-lib/src/proxy/wg/mod.rs @@ -358,10 +358,13 @@ mod tests { #[serial_test::serial] async fn test_wg() -> anyhow::Result<()> { initialize(); + + let runner = get_runner().await?; + let opts = HandlerOptions { name: "wg".to_owned(), common_opts: Default::default(), - server: "127.0.0.1".to_owned(), + server: runner.container_ip().unwrap_or("127.0.0.1".to_owned()), port: 10002, ip: Ipv4Addr::new(10, 13, 13, 2), ipv6: None, @@ -386,7 +389,7 @@ mod tests { // on bridge network mode and the `net.ipv4.conf.all. // src_valid_mark` is not supported in the host network mode the // latency test should be enough - let runner = get_runner().await?; + // FIXME: wait for the startup of the test runner in a more elegant way tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; run_test_suites_and_cleanup( From 7aa059d3aae92a56b759d6fd415f044cd3191b47 Mon Sep 17 00:00:00 2001 From: i Date: Thu, 5 Mar 2026 22:50:16 +0800 Subject: [PATCH 10/25] fix(docker-test): bug --- clash-lib/src/proxy/hysteria2/salamander.rs | 14 ++++++-------- clash-lib/src/proxy/utils/socket_helpers.rs | 13 +++++++++++++ 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/clash-lib/src/proxy/hysteria2/salamander.rs b/clash-lib/src/proxy/hysteria2/salamander.rs index 95d4250bf..a22386122 100644 --- a/clash-lib/src/proxy/hysteria2/salamander.rs +++ b/clash-lib/src/proxy/hysteria2/salamander.rs @@ -116,15 +116,13 @@ impl AsyncUdpSocket for Salamander { .take(packet_nums) .filter(|(_, meta)| meta.len > 8) .for_each(|(v, meta)| { - let x = &mut v.deref_mut()[..meta.len]; + let buf = v.deref_mut(); + let len = meta.len; // decrypt in place, and drop first 8 bytes - self.obfs.decrypt(x); - let data = &mut x[8..]; - unsafe { - // because IoSliceMut is transparent and .0 is also transparent, so it is a &[u8] - let b: IoSliceMut<'_> = std::mem::transmute(data); - *v = b; - } + self.obfs.decrypt(&mut buf[..len]); + // Move decrypted data to the beginning of the buffer + // This avoids unsafe transmute and is more portable + buf.copy_within(8..len, 0); // MUST update meta.len meta.len -= 8; }); diff --git a/clash-lib/src/proxy/utils/socket_helpers.rs b/clash-lib/src/proxy/utils/socket_helpers.rs index f4cb5a18d..be4346711 100644 --- a/clash-lib/src/proxy/utils/socket_helpers.rs +++ b/clash-lib/src/proxy/utils/socket_helpers.rs @@ -147,6 +147,19 @@ pub async fn new_udp_socket( trace!(src = ?src, "udp socket bound: {socket:?}"); } (None, None) => { + // On Windows, UDP sockets must be bound to get a valid local_addr + // which is required for some operations (e.g., quinn/QUIC) + #[cfg(target_os = "windows")] + { + let bind_addr = match family { + socket2::Domain::IPV4 => "0.0.0.0:0".parse::().unwrap(), + socket2::Domain::IPV6 => "[::]:0".parse::().unwrap(), + _ => "0.0.0.0:0".parse::().unwrap(), + }; + socket.bind(&socket2::SockAddr::from(bind_addr))?; + trace!(addr = ?bind_addr, "udp socket bound to default address on Windows: {socket:?}"); + } + #[cfg(not(target_os = "windows"))] trace!("udp socket not bound to any specific address: {socket:?}"); } } From 8300798bd7ce33eaddc0328a908b23c7da00c00b Mon Sep 17 00:00:00 2001 From: litcc Date: Fri, 6 Mar 2026 05:07:39 +0800 Subject: [PATCH 11/25] fix(docker-test): bugs and unit tests --- clash-bin/tests/data/config/tuic.toml | 3 +- clash-lib/Cargo.toml | 1 + clash-lib/src/proxy/group/relay/mod.rs | 20 ++----- clash-lib/src/proxy/group/smart/mod.rs | 6 +- clash-lib/src/proxy/hysteria2/mod.rs | 13 ++--- clash-lib/src/proxy/shadowquic/mod.rs | 33 +++++------ .../src/proxy/shadowsocks/outbound/mod.rs | 45 +++++++------- clash-lib/src/proxy/trojan/mod.rs | 10 ++-- clash-lib/src/proxy/tuic/mod.rs | 21 +++---- clash-lib/src/proxy/utils/socket_helpers.rs | 8 ++- .../test_utils/docker_utils/docker_runner.rs | 31 ++++++---- clash-lib/src/proxy/vless/mod.rs | 2 +- clash-lib/src/proxy/vmess/mod.rs | 6 +- clash-lib/src/proxy/wg/mod.rs | 4 +- clash-lib/tests/smoke_tests.rs | 58 +++++++++++-------- 15 files changed, 133 insertions(+), 128 deletions(-) diff --git a/clash-bin/tests/data/config/tuic.toml b/clash-bin/tests/data/config/tuic.toml index c45946d32..ddf8410bd 100644 --- a/clash-bin/tests/data/config/tuic.toml +++ b/clash-bin/tests/data/config/tuic.toml @@ -6,7 +6,8 @@ zero_rtt_handshake = false dual_stack = false acl = ''' -direct localhost +direct 0.0.0.0/0 +direct ::/0 ''' [users] diff --git a/clash-lib/Cargo.toml b/clash-lib/Cargo.toml index 2f1120a15..dfc2c1182 100644 --- a/clash-lib/Cargo.toml +++ b/clash-lib/Cargo.toml @@ -210,6 +210,7 @@ rand_chacha = "=0.3" httpmock = "0.8.2" # TODO replace with wiremock tracing-test = "0.2" http-body-util = "0.1" +reqwest = { version = "0.12", features = ["socks"] } [build-dependencies] prost-build = "0.14" diff --git a/clash-lib/src/proxy/group/relay/mod.rs b/clash-lib/src/proxy/group/relay/mod.rs index 1837d23e6..0de61872b 100644 --- a/clash-lib/src/proxy/group/relay/mod.rs +++ b/clash-lib/src/proxy/group/relay/mod.rs @@ -242,14 +242,14 @@ mod tests { let port = 10002; let container = get_ss_runner(port).await?; - let container_ip =container.container_ip(); + let container_ip = container.container_ip(); debug!("container ip: {:?}", container_ip); let ss_opts = crate::proxy::shadowsocks::outbound::HandlerOptions { name: "test-ss".to_owned(), common_opts: Default::default(), server: container_ip.unwrap_or(LOCAL_ADDR.to_owned()), - port: port, + port, password: PASSWORD.to_owned(), cipher: CIPHER.to_owned(), plugin: Default::default(), @@ -271,12 +271,7 @@ mod tests { let handler = Handler::new(Default::default(), vec![Arc::new(RwLock::new(provider))]); - run_test_suites_and_cleanup( - handler, - container, - Suite::all(), - ) - .await + run_test_suites_and_cleanup(handler, container, Suite::all()).await } #[tokio::test] @@ -286,7 +281,7 @@ mod tests { let port = 10002; let container = get_ss_runner(port).await?; - let container_ip =container.container_ip(); + let container_ip = container.container_ip(); let ss_opts = crate::proxy::shadowsocks::outbound::HandlerOptions { name: "test-ss".to_owned(), @@ -314,11 +309,6 @@ mod tests { let handler = Handler::new(Default::default(), vec![Arc::new(RwLock::new(provider))]); - run_test_suites_and_cleanup( - handler, - container, - Suite::all(), - ) - .await + run_test_suites_and_cleanup(handler, container, Suite::all()).await } } diff --git a/clash-lib/src/proxy/group/smart/mod.rs b/clash-lib/src/proxy/group/smart/mod.rs index 1efd88b7f..dbfc5b5aa 100644 --- a/clash-lib/src/proxy/group/smart/mod.rs +++ b/clash-lib/src/proxy/group/smart/mod.rs @@ -792,7 +792,9 @@ mod tests { let ss_opts = crate::proxy::shadowsocks::outbound::HandlerOptions { name: "test-ss-for-smart".to_owned(), common_opts: Default::default(), - server: docker_runner.container_ip().unwrap_or(LOCAL_ADDR.to_owned()), + server: docker_runner + .container_ip() + .unwrap_or(LOCAL_ADDR.to_owned()), port: ss_port, password: PASSWORD.to_owned(), cipher: CIPHER.to_owned(), @@ -843,8 +845,6 @@ mod tests { ); let any_smart_handler: AnyOutboundHandler = Arc::new(smart_handler_instance); - - run_test_suites_and_cleanup(any_smart_handler, docker_runner, Suite::all()) .await } diff --git a/clash-lib/src/proxy/hysteria2/mod.rs b/clash-lib/src/proxy/hysteria2/mod.rs index f4d7738ae..2f30f2626 100644 --- a/clash-lib/src/proxy/hysteria2/mod.rs +++ b/clash-lib/src/proxy/hysteria2/mod.rs @@ -637,9 +637,11 @@ mod tests { let container = get_hysteria_runner().await?; - let container_ip = container.container_ip().unwrap_or("127.0.0.1".to_owned()); + let container_ip = + container.container_ip().unwrap_or("127.0.0.1".to_owned()); - let ip = IpAddr::from_str(&container_ip).unwrap_or(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))); + let ip = IpAddr::from_str(&container_ip) + .unwrap_or(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))); let port = 10002; let obfs = Some(Obfs::Salamander(SalamanderObfs { @@ -674,11 +676,6 @@ mod tests { handler .register_connector(GLOBAL_DIRECT_CONNECTOR.clone()) .await; - run_test_suites_and_cleanup( - handler, - container, - Suite::all(), - ) - .await + run_test_suites_and_cleanup(handler, container, Suite::all()).await } } diff --git a/clash-lib/src/proxy/shadowquic/mod.rs b/clash-lib/src/proxy/shadowquic/mod.rs index ec4f709b7..ed2c25be2 100644 --- a/clash-lib/src/proxy/shadowquic/mod.rs +++ b/clash-lib/src/proxy/shadowquic/mod.rs @@ -263,9 +263,16 @@ mod tests { const PORT: u16 = 10002; - fn gen_options(opt_ip: Option, over_stream: bool) -> anyhow::Result { + fn gen_options( + opt_ip: Option, + over_stream: bool, + ) -> anyhow::Result { Ok(HandlerOptions { - addr: SocketAddr::new(opt_ip.unwrap_or(LOCAL_ADDR.to_owned()).parse().unwrap(), PORT).to_string(), + addr: SocketAddr::new( + opt_ip.unwrap_or(LOCAL_ADDR.to_owned()).parse().unwrap(), + PORT, + ) + .to_string(), password: "12345678".into(), username: "87654321".into(), server_name: "echo.free.beeceptor.com".into(), @@ -284,20 +291,15 @@ mod tests { let container = get_shadowquic_runner().await?; - let container_ip =container.container_ip(); + let container_ip = container.container_ip(); - let opts = gen_options(container_ip,false)?; + let opts = gen_options(container_ip, false)?; let handler = Arc::new(Handler::new("test-shadowquic".into(), opts)); handler .register_connector(GLOBAL_DIRECT_CONNECTOR.clone()) .await; - run_test_suites_and_cleanup( - handler, - container, - Suite::all(), - ) - .await + run_test_suites_and_cleanup(handler, container, Suite::all()).await } #[tokio::test] #[serial_test::serial] @@ -305,20 +307,15 @@ mod tests { initialize(); let container = get_shadowquic_runner().await?; - let container_ip =container.container_ip(); + let container_ip = container.container_ip(); - let mut opts = gen_options(container_ip,true)?; + let mut opts = gen_options(container_ip, true)?; opts.over_stream = true; let handler = Arc::new(Handler::new("test-shadowquic".into(), opts)); handler .register_connector(GLOBAL_DIRECT_CONNECTOR.clone()) .await; - run_test_suites_and_cleanup( - handler, - container, - Suite::all(), - ) - .await + run_test_suites_and_cleanup(handler, container, Suite::all()).await } } diff --git a/clash-lib/src/proxy/shadowsocks/outbound/mod.rs b/clash-lib/src/proxy/shadowsocks/outbound/mod.rs index b31b5477a..f0532043b 100644 --- a/clash-lib/src/proxy/shadowsocks/outbound/mod.rs +++ b/clash-lib/src/proxy/shadowsocks/outbound/mod.rs @@ -238,7 +238,6 @@ impl OutboundHandler for Handler { #[cfg(all(test, docker_test))] mod tests { - use bollard::container; use crate::{ proxy::{ transport::*, @@ -254,6 +253,7 @@ mod tests { }, tests::initialize, }; + use bollard::container; use super::*; @@ -333,12 +333,7 @@ mod tests { handler .register_connector(GLOBAL_DIRECT_CONNECTOR.clone()) .await; - run_test_suites_and_cleanup( - handler, - container, - Suite::all(), - ) - .await + run_test_suites_and_cleanup(handler, container, Suite::all()).await } async fn get_shadowtls_runner( @@ -348,7 +343,11 @@ mod tests { ) -> anyhow::Result { // Use host.docker.internal to access SS server running in another // container via host port mapping - let ss_server_env = format!("SERVER={}:{}", ss_ip.unwrap_or("host.docker.internal".to_owned()),ss_port); + let ss_server_env = format!( + "SERVER={}:{}", + ss_ip.unwrap_or("host.docker.internal".to_owned()), + ss_port + ); let listen_env = format!("LISTEN=0.0.0.0:{}", stls_port); let password = format!("PASSWORD={}", SHADOW_TLS_PASSWORD); DockerTestRunnerBuilder::new() @@ -380,7 +379,12 @@ mod tests { let container1 = get_ss_runner(ss_port).await?; - let container2 = get_shadowtls_runner(container1.container_ip(),ss_port, shadow_tls_port).await?; + let container2 = get_shadowtls_runner( + container1.container_ip(), + ss_port, + shadow_tls_port, + ) + .await?; let client = Shadowtls::new("www.feishu.cn".to_owned(), "password".to_owned(), true); @@ -399,8 +403,7 @@ mod tests { // them can be destroyed after the test let mut chained = MultiDockerTestRunner::default(); chained.add_with_runner(container1); - chained - .add_with_runner(container2); + chained.add_with_runner(container2); // currently, shadow-tls does't support udp proxy // see: https://github.com/ihciah/shadow-tls/issues/54 run_test_suites_and_cleanup(handler, chained, Suite::tcp_tests()).await @@ -412,7 +415,11 @@ mod tests { obfs_port: u16, mode: SimpleOBFSMode, ) -> anyhow::Result { - let ss_server_env = format!("{}:{}",ss_ip.unwrap_or("host.docker.internal".to_owned()), ss_port); + let ss_server_env = format!( + "{}:{}", + ss_ip.unwrap_or("host.docker.internal".to_owned()), + ss_port + ); let port = format!("{}", obfs_port); let mode = match mode { SimpleOBFSMode::Http => "http", @@ -439,7 +446,9 @@ mod tests { let ss_port = 10004; let container1 = get_ss_runner(ss_port).await?; - let container2 = get_obfs_runner(container1.container_ip(), ss_port, obfs_port, mode).await?; + let container2 = + get_obfs_runner(container1.container_ip(), ss_port, obfs_port, mode) + .await?; let host = "www.bing.com".to_owned(); let plugin = match mode { @@ -462,8 +471,7 @@ mod tests { let handler: Arc = Arc::new(Handler::new(opts)); let mut chained = MultiDockerTestRunner::default(); chained.add_with_runner(container1); - chained - .add_with_runner(container2); + chained.add_with_runner(container2); run_test_suites_and_cleanup(handler, chained, Suite::tcp_tests()).await } @@ -509,11 +517,6 @@ mod tests { }; let handler: Arc = Arc::new(Handler::new(opts)); - run_test_suites_and_cleanup( - handler, - container, - Suite::tcp_tests(), - ) - .await + run_test_suites_and_cleanup(handler, container, Suite::tcp_tests()).await } } diff --git a/clash-lib/src/proxy/trojan/mod.rs b/clash-lib/src/proxy/trojan/mod.rs index c289c3bb2..75fcbe755 100644 --- a/clash-lib/src/proxy/trojan/mod.rs +++ b/clash-lib/src/proxy/trojan/mod.rs @@ -267,9 +267,9 @@ mod tests { ); let tls = transport::TlsClient::new(true, "example.org".to_owned(), None, None); - + let container = get_ws_runner().await?; - + let opts = HandlerOptions { name: "test-trojan-ws".to_owned(), common_opts: Default::default(), @@ -285,8 +285,7 @@ mod tests { .register_connector(GLOBAL_DIRECT_CONNECTOR.clone()) .await; // ignore the udp test - run_test_suites_and_cleanup(handler, container, Suite::all()) - .await + run_test_suites_and_cleanup(handler, container, Suite::all()).await } async fn get_grpc_runner() -> anyhow::Result { @@ -340,7 +339,6 @@ mod tests { handler .register_connector(GLOBAL_DIRECT_CONNECTOR.clone()) .await; - run_test_suites_and_cleanup(handler, runner, Suite::all()) - .await + run_test_suites_and_cleanup(handler, runner, Suite::all()).await } } diff --git a/clash-lib/src/proxy/tuic/mod.rs b/clash-lib/src/proxy/tuic/mod.rs index e281cbf9b..9e45c33c8 100644 --- a/clash-lib/src/proxy/tuic/mod.rs +++ b/clash-lib/src/proxy/tuic/mod.rs @@ -405,7 +405,10 @@ mod tests { const PORT: u16 = 10002; - fn gen_options(container_ip: Option,skip_cert_verify: bool) -> anyhow::Result { + fn gen_options( + container_ip: Option, + skip_cert_verify: bool, + ) -> anyhow::Result { Ok(HandlerOptions { name: "test-tuic".to_owned(), server: container_ip.unwrap_or(LOCAL_ADDR.to_owned()), @@ -439,14 +442,13 @@ mod tests { initialize(); let container = get_tuic_runner().await?; - let opts = gen_options(container.container_ip(),true)?; + let opts = gen_options(container.container_ip(), true)?; let handler = Arc::new(Handler::new(opts)); handler .register_connector(GLOBAL_DIRECT_CONNECTOR.clone()) .await; - run_test_suites_and_cleanup(handler,container , Suite::all()) - .await + run_test_suites_and_cleanup(handler, container, Suite::all()).await } #[tokio::test] @@ -456,19 +458,14 @@ mod tests { let container = get_tuic_runner().await?; - let opts = gen_options(container.container_ip(),false)?; - + let opts = gen_options(container.container_ip(), false)?; let handler = Arc::new(Handler::new(opts)); handler .register_connector(GLOBAL_DIRECT_CONNECTOR.clone()) .await; - let res = run_test_suites_and_cleanup( - handler, - container, - Suite::all(), - ) - .await; + let res = + run_test_suites_and_cleanup(handler, container, Suite::all()).await; assert!(res.is_err()); assert!(res.unwrap_err().to_string().contains( "the cryptographic handshake failed: error 45: invalid peer \ diff --git a/clash-lib/src/proxy/utils/socket_helpers.rs b/clash-lib/src/proxy/utils/socket_helpers.rs index be4346711..8b51c3b5a 100644 --- a/clash-lib/src/proxy/utils/socket_helpers.rs +++ b/clash-lib/src/proxy/utils/socket_helpers.rs @@ -152,8 +152,12 @@ pub async fn new_udp_socket( #[cfg(target_os = "windows")] { let bind_addr = match family { - socket2::Domain::IPV4 => "0.0.0.0:0".parse::().unwrap(), - socket2::Domain::IPV6 => "[::]:0".parse::().unwrap(), + socket2::Domain::IPV4 => { + "0.0.0.0:0".parse::().unwrap() + } + socket2::Domain::IPV6 => { + "[::]:0".parse::().unwrap() + } _ => "0.0.0.0:0".parse::().unwrap(), }; socket.bind(&socket2::SockAddr::from(bind_addr))?; diff --git a/clash-lib/src/proxy/utils/test_utils/docker_utils/docker_runner.rs b/clash-lib/src/proxy/utils/test_utils/docker_utils/docker_runner.rs index 4932c34a9..86b9db102 100644 --- a/clash-lib/src/proxy/utils/test_utils/docker_utils/docker_runner.rs +++ b/clash-lib/src/proxy/utils/test_utils/docker_utils/docker_runner.rs @@ -156,35 +156,46 @@ impl DockerTestRunner { Ok(Self { instance: docker, id, - inspect + inspect, }) } #[allow(unused)] pub fn container_ip(&self) -> Option { - self.inspect.network_settings.as_ref() + self.inspect + .network_settings + .as_ref() .and_then(|i| i.networks.as_ref()) .and_then(|b| { b.values().find_map(|j| { - [(&j.gateway, &j.ip_address), (&j.ipv6_gateway, &j.global_ipv6_address)] - .into_iter() - .find(|(gateway, _)| gateway.as_ref().map_or(false, |g| !g.is_empty())) - .and_then(|(_, ip)| ip.as_ref()) - .filter(|ip| !ip.is_empty()) - .map(|ip| ip.to_string()) + [ + (&j.gateway, &j.ip_address), + (&j.ipv6_gateway, &j.global_ipv6_address), + ] + .into_iter() + .find(|(gateway, _)| { + gateway.as_ref().map_or(false, |g| !g.is_empty()) + }) + .and_then(|(_, ip)| ip.as_ref()) + .filter(|ip| !ip.is_empty()) + .map(|ip| ip.to_string()) }) }) } #[allow(unused)] pub fn gateway_ip(&self) -> Option { - self.inspect.network_settings.as_ref() + self.inspect + .network_settings + .as_ref() .and_then(|i| i.networks.as_ref()) .and_then(|b| { b.values().find_map(|j| { [(&j.gateway), (&j.ipv6_gateway)] .into_iter() - .find(|(gateway)| gateway.as_ref().map_or(false, |g| !g.is_empty())) + .find(|(gateway)| { + gateway.as_ref().map_or(false, |g| !g.is_empty()) + }) .and_then(|(gateway)| gateway.as_ref()) .filter(|ip| !ip.is_empty()) .map(|ip| ip.to_string()) diff --git a/clash-lib/src/proxy/vless/mod.rs b/clash-lib/src/proxy/vless/mod.rs index 518296659..b3ab63279 100644 --- a/clash-lib/src/proxy/vless/mod.rs +++ b/clash-lib/src/proxy/vless/mod.rs @@ -271,7 +271,7 @@ mod tests { transport: Some(Box::new(ws_client)), }; let handler = Arc::new(Handler::new(opts)); - + run_test_suites_and_cleanup(handler, runner, Suite::all()).await } } diff --git a/clash-lib/src/proxy/vmess/mod.rs b/clash-lib/src/proxy/vmess/mod.rs index 0fd672fa3..0ca2c7e12 100644 --- a/clash-lib/src/proxy/vmess/mod.rs +++ b/clash-lib/src/proxy/vmess/mod.rs @@ -325,8 +325,7 @@ mod tests { transport: Some(Box::new(grpc_client)), }; let handler = Arc::new(Handler::new(opts)); - run_test_suites_and_cleanup(handler, container, Suite::all()) - .await + run_test_suites_and_cleanup(handler, container, Suite::all()).await } async fn get_h2_runner() -> anyhow::Result { @@ -373,7 +372,6 @@ mod tests { handler .register_connector(GLOBAL_DIRECT_CONNECTOR.clone()) .await; - run_test_suites_and_cleanup(handler, container, Suite::all()) - .await + run_test_suites_and_cleanup(handler, container, Suite::all()).await } } diff --git a/clash-lib/src/proxy/wg/mod.rs b/clash-lib/src/proxy/wg/mod.rs index 124cf48ef..a263042c7 100644 --- a/clash-lib/src/proxy/wg/mod.rs +++ b/clash-lib/src/proxy/wg/mod.rs @@ -360,7 +360,7 @@ mod tests { initialize(); let runner = get_runner().await?; - + let opts = HandlerOptions { name: "wg".to_owned(), common_opts: Default::default(), @@ -389,7 +389,7 @@ mod tests { // on bridge network mode and the `net.ipv4.conf.all. // src_valid_mark` is not supported in the host network mode the // latency test should be enough - + // FIXME: wait for the startup of the test runner in a more elegant way tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; run_test_suites_and_cleanup( diff --git a/clash-lib/tests/smoke_tests.rs b/clash-lib/tests/smoke_tests.rs index a9da026ac..c18a5f078 100644 --- a/clash-lib/tests/smoke_tests.rs +++ b/clash-lib/tests/smoke_tests.rs @@ -53,49 +53,57 @@ async fn smoke_test() { then.status(200).body("Mock response for testing"); }); - let curl_cmd = format!("curl -s {}", mock_server.url("/")); - let output = tokio::process::Command::new("sh") - .arg("-c") - .arg(curl_cmd) - .output() + // 使用 reqwest 客户端发送请求 + let client = reqwest::Client::new(); + + let response = client + .get(mock_server.url("/")) + .send() .await - .expect("Failed to execute curl command"); + .expect("Failed to execute HTTP request"); assert!( - output.status.success(), - "Curl command failed with output: {}", - String::from_utf8_lossy(&output.stderr) + response.status().is_success(), + "HTTP request failed with status: {}", + response.status() ); + + let body_str = response.text().await.expect("Failed to read response body"); + assert_eq!(mock.calls(), 1, "Mock server was not hit exactly once"); assert_eq!( - String::from_utf8_lossy(&output.stdout), - "Mock response for testing", + body_str, "Mock response for testing", "Unexpected response from mock server" ); wait_port_ready(8899).expect("Proxy port is not ready"); - let curl_cmd = format!( - "curl -s -x socks5h://127.0.0.1:8899 {}", - mock_server.url("/") - ); - let output = tokio::process::Command::new("sh") - .arg("-c") - .arg(curl_cmd) - .output() + // 使用 reqwest 通过 SOCKS5 代理发送请求 + let proxy = reqwest::Proxy::all("socks5://127.0.0.1:8899") + .expect("Failed to create proxy"); + + let client = reqwest::Client::builder() + .proxy(proxy) + .build() + .expect("Failed to build client with proxy"); + + let response = client + .get(mock_server.url("/")) + .send() .await - .expect("Failed to execute curl command"); + .expect("Failed to send request through proxy"); assert!( - output.status.success(), - "Curl command failed with output: {}", - String::from_utf8_lossy(&output.stderr) + response.status().is_success(), + "HTTP request through proxy failed with status: {}", + response.status() ); + let body_str = response.text().await.expect("Failed to read response body"); + assert_eq!(mock.calls(), 2, "Mock server was not hit exactly twice"); assert_eq!( - String::from_utf8_lossy(&output.stdout), - "Mock response for testing", + body_str, "Mock response for testing", "Unexpected response from mock server" ); } From cd27e66e59df9a9e4cdfa7d810d587618a8343ea Mon Sep 17 00:00:00 2001 From: i Date: Fri, 6 Mar 2026 05:22:31 +0800 Subject: [PATCH 12/25] fix(docker-test): code warnings --- Cargo.lock | 164 +++++++++++++++++- .../src/proxy/shadowsocks/outbound/mod.rs | 3 +- clash-lib/src/proxy/ssh/mod.rs | 4 + .../utils/test_utils/docker_utils/consts.rs | 1 + .../test_utils/docker_utils/docker_runner.rs | 2 +- 5 files changed, 167 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 050396038..30a8a42a2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1227,9 +1227,10 @@ dependencies = [ "rand_chacha 0.3.1", "regex", "register-count", + "reqwest", "russh", "rustls", - "security-framework", + "security-framework 3.5.1", "serde", "serde_json", "serde_yaml", @@ -2610,6 +2611,21 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -3523,6 +3539,22 @@ dependencies = [ "tower-service", ] +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.19" @@ -3542,9 +3574,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2 0.6.2", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -4471,6 +4505,23 @@ dependencies = [ "getrandom 0.2.16", ] +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe 0.1.6", + "openssl-sys", + "schannel", + "security-framework 2.11.1", + "security-framework-sys", + "tempfile", +] + [[package]] name = "netconfig-rs" version = "0.1.5" @@ -4486,7 +4537,7 @@ dependencies = [ "netlink-sys", "nix 0.30.1", "scopeguard", - "system-configuration-sys", + "system-configuration-sys 0.5.0", "thiserror 2.0.18", "widestring", "windows 0.61.3", @@ -4902,12 +4953,56 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "openssl" +version = "0.10.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +dependencies = [ + "bitflags 2.9.4", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + [[package]] name = "openssl-probe" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "openssl-sys" +version = "0.9.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "opentelemetry" version = "0.31.0" @@ -6039,17 +6134,22 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64 0.22.1", "bytes", + "encoding_rs", "futures-channel", "futures-core", "futures-util", + "h2", "http", "http-body", "http-body-util", "hyper", "hyper-rustls", + "hyper-tls", "hyper-util", "js-sys", "log", + "mime", + "native-tls", "percent-encoding", "pin-project-lite", "quinn", @@ -6060,6 +6160,7 @@ dependencies = [ "serde_urlencoded", "sync_wrapper", "tokio", + "tokio-native-tls", "tokio-rustls", "tower", "tower-http", @@ -6387,10 +6488,10 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" dependencies = [ - "openssl-probe", + "openssl-probe 0.2.1", "rustls-pki-types", "schannel", - "security-framework", + "security-framework 3.5.1", ] [[package]] @@ -6601,6 +6702,19 @@ dependencies = [ "zeroize", ] +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.9.4", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + [[package]] name = "security-framework" version = "3.5.1" @@ -7567,6 +7681,17 @@ dependencies = [ "windows 0.61.3", ] +[[package]] +name = "system-configuration" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +dependencies = [ + "bitflags 2.9.4", + "core-foundation 0.9.4", + "system-configuration-sys 0.6.0", +] + [[package]] name = "system-configuration-sys" version = "0.5.0" @@ -7577,6 +7702,16 @@ dependencies = [ "libc", ] +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "tabwriter" version = "1.4.1" @@ -7841,6 +7976,16 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.26.4" @@ -10007,6 +10152,17 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + [[package]] name = "windows-result" version = "0.3.4" diff --git a/clash-lib/src/proxy/shadowsocks/outbound/mod.rs b/clash-lib/src/proxy/shadowsocks/outbound/mod.rs index f0532043b..45936da14 100644 --- a/clash-lib/src/proxy/shadowsocks/outbound/mod.rs +++ b/clash-lib/src/proxy/shadowsocks/outbound/mod.rs @@ -253,7 +253,6 @@ mod tests { }, tests::initialize, }; - use bollard::container; use super::*; @@ -328,7 +327,7 @@ mod tests { plugin: Default::default(), udp: false, }; - let port = opts.port; + let handler = Arc::new(Handler::new(opts)); handler .register_connector(GLOBAL_DIRECT_CONNECTOR.clone()) diff --git a/clash-lib/src/proxy/ssh/mod.rs b/clash-lib/src/proxy/ssh/mod.rs index f214d7867..bb195478c 100644 --- a/clash-lib/src/proxy/ssh/mod.rs +++ b/clash-lib/src/proxy/ssh/mod.rs @@ -288,6 +288,7 @@ mod tests { /// `/config/sshd/sshd_config` in the container. /// before starting the container, we need to generate host key pairs in /// /tmp/.xxx/ssh/ssh_host_keys. + #[allow(unused)] async fn get_openssh_server_runner( ssh_config_path: PathBuf, ) -> anyhow::Result { @@ -308,6 +309,7 @@ mod tests { .await } + #[allow(unused)] fn gen_ssh_key_pair( algo: russh::keys::Algorithm, ) -> anyhow::Result<(String, String)> { @@ -324,12 +326,14 @@ mod tests { } #[derive(Debug)] + #[allow(dead_code)] struct TestOption { password: bool, // password or private key rsa: bool, // rsa or ed25519 host_key: Option>, // host key } + #[allow(unused)] async fn test_ssh_inner(opt: TestOption) -> anyhow::Result<()> { tracing::info!("testing ssh, using option: {:?}", opt); // dirty works: prepare ssh config directory for the docker container & diff --git a/clash-lib/src/proxy/utils/test_utils/docker_utils/consts.rs b/clash-lib/src/proxy/utils/test_utils/docker_utils/consts.rs index 090644588..4b766dccb 100644 --- a/clash-lib/src/proxy/utils/test_utils/docker_utils/consts.rs +++ b/clash-lib/src/proxy/utils/test_utils/docker_utils/consts.rs @@ -14,6 +14,7 @@ pub const IMAGE_VLESS: &str = "v2fly/v2fly-core:v4.45.2"; pub const IMAGE_XRAY: &str = "teddysun/xray:latest"; pub const IMAGE_SOCKS5: &str = "v2fly/v2fly-core:v4.45.2"; #[cfg(feature = "ssh")] +#[allow(unused)] pub const IMAGE_OPENSSH: &str = "docker.io/linuxserver/openssh-server:latest"; pub const IMAGE_HYSTERIA: &str = "tobyxdd/hysteria:latest"; #[cfg(feature = "tuic")] diff --git a/clash-lib/src/proxy/utils/test_utils/docker_utils/docker_runner.rs b/clash-lib/src/proxy/utils/test_utils/docker_utils/docker_runner.rs index 86b9db102..b21f9edba 100644 --- a/clash-lib/src/proxy/utils/test_utils/docker_utils/docker_runner.rs +++ b/clash-lib/src/proxy/utils/test_utils/docker_utils/docker_runner.rs @@ -2,7 +2,7 @@ use std::{collections::HashMap, path::Path}; use anyhow; use bollard::{ - API_DEFAULT_VERSION, Docker, body_full, body_try_stream, + API_DEFAULT_VERSION, Docker, body_full, config::ContainerInspectResponse, models::ContainerCreateBody, query_parameters::{ From 5fb33d6b153dfc49b5331ea5cfc3c02e6592302b Mon Sep 17 00:00:00 2001 From: litcc Date: Fri, 6 Mar 2026 05:48:46 +0800 Subject: [PATCH 13/25] fix(docker-test): bugs and unit tests --- .../src/proxy/shadowsocks/outbound/mod.rs | 2 +- clash-lib/tests/api_tests.rs | 39 ++++++++++--------- 2 files changed, 22 insertions(+), 19 deletions(-) diff --git a/clash-lib/src/proxy/shadowsocks/outbound/mod.rs b/clash-lib/src/proxy/shadowsocks/outbound/mod.rs index 45936da14..c21f8cf22 100644 --- a/clash-lib/src/proxy/shadowsocks/outbound/mod.rs +++ b/clash-lib/src/proxy/shadowsocks/outbound/mod.rs @@ -327,7 +327,7 @@ mod tests { plugin: Default::default(), udp: false, }; - + let handler = Arc::new(Handler::new(opts)); handler .register_connector(GLOBAL_DIRECT_CONNECTOR.clone()) diff --git a/clash-lib/tests/api_tests.rs b/clash-lib/tests/api_tests.rs index 71499a889..f888d342f 100644 --- a/clash-lib/tests/api_tests.rs +++ b/clash-lib/tests/api_tests.rs @@ -128,29 +128,32 @@ async fn test_connections_returns_proxy_chain_names() { wait_port_ready(8899).expect("Proxy port is not ready"); - std::thread::spawn(move || { - // NOTE: use curl here for easy socks5h testing - let curl_args = vec![ - "-s", - "-x", - "socks5h://127.0.0.1:8899", - "https://httpbin.yba.dev/drip?duration=100&delay=1&numbytes=1000", - ]; - - let output = std::process::Command::new("curl") - .args(curl_args) - .output() - .expect("Failed to execute curl command"); + tokio::spawn(async { + let proxy = reqwest::Proxy::all("socks5h://127.0.0.1:8899") + .expect("Failed to create proxy"); + + let client = reqwest::Client::builder() + .proxy(proxy) + .build() + .expect("Failed to build reqwest client"); + + let response = client + .get("https://httpbin.yba.dev/drip?duration=100&delay=1&numbytes=1000") + .send() + .await + .expect("Failed to send request through proxy"); assert!( - output.status.success(), - "Curl command failed with output: {}, stderr: {}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) + response.status().is_success(), + "Request failed with status: {}", + response.status() ); }); - tokio::time::sleep(Duration::from_secs(1)).await; + // Yield to allow the spawned task to start, then wait for connection to + // establish + tokio::task::yield_now().await; + tokio::time::sleep(Duration::from_millis(1500)).await; let connections_url = "http://127.0.0.1:9090/connections"; From 7c5785f87d35dcbcbe0fdf7c3ba6ee18b6c4a93b Mon Sep 17 00:00:00 2001 From: litcc Date: Fri, 6 Mar 2026 07:17:51 +0800 Subject: [PATCH 14/25] fix(docker-test): bug --- clash-lib/src/proxy/hysteria2/salamander.rs | 59 +++++++++++++-------- 1 file changed, 37 insertions(+), 22 deletions(-) diff --git a/clash-lib/src/proxy/hysteria2/salamander.rs b/clash-lib/src/proxy/hysteria2/salamander.rs index a22386122..7df67dfd6 100644 --- a/clash-lib/src/proxy/hysteria2/salamander.rs +++ b/clash-lib/src/proxy/hysteria2/salamander.rs @@ -105,29 +105,44 @@ impl AsyncUdpSocket for Salamander { bufs: &mut [IoSliceMut<'_>], meta: &mut [RecvMeta], ) -> Poll> { - // the number of udp packets received let packet_nums = ready!(self.inner.poll_recv(cx, bufs, meta))?; - meta.iter().take(packet_nums).for_each(|v| { - tracing::trace!("meta addr {:?}, dst_ip: {:?}", v.addr, v.dst_ip); - }); - bufs.iter_mut() - .zip(meta.iter_mut()) - // first step take and then filter - .take(packet_nums) - .filter(|(_, meta)| meta.len > 8) - .for_each(|(v, meta)| { - let buf = v.deref_mut(); - let len = meta.len; - // decrypt in place, and drop first 8 bytes - self.obfs.decrypt(&mut buf[..len]); - // Move decrypted data to the beginning of the buffer - // This avoids unsafe transmute and is more portable - buf.copy_within(8..len, 0); - // MUST update meta.len - meta.len -= 8; - }); - - Poll::Ready(Ok(packet_nums)) + + let mut valid_count = 0; + + for i in 0..packet_nums { + tracing::trace!( + "meta addr {:?}, dst_ip: {:?}", + meta[i].addr, + meta[i].dst_ip + ); + + // Salamander packets must have at least 8 bytes (salt) + 1 byte (data) + if meta[i].len <= 8 { + tracing::debug!( + "invalid salamander packet: len={}, addr={:?}", + meta[i].len, + meta[i].addr + ); + continue; + } + + let len = meta[i].len; + let buf = bufs[i].deref_mut(); + + // Decrypt and strip the 8-byte salt prefix + self.obfs.decrypt(&mut buf[..len]); + buf.copy_within(8..len, 0); + + // Compact valid packets to the front + if i != valid_count { + meta[valid_count] = meta[i]; + bufs.swap(i, valid_count); + } + meta[valid_count].len -= 8; + valid_count += 1; + } + + Poll::Ready(Ok(valid_count)) } fn local_addr(&self) -> std::io::Result { From 298710ce0e8f4bbcf467a1dcc452c52a8c60bbb2 Mon Sep 17 00:00:00 2001 From: litcc Date: Sun, 8 Mar 2026 09:48:39 +0800 Subject: [PATCH 15/25] fix(test_hysteria): resolve Windows-specific test failures --- Cargo.lock | 2 +- clash-lib/src/proxy/hysteria2/datagram.rs | 20 ++- clash-lib/src/proxy/hysteria2/mod.rs | 60 ++++++++- clash-lib/src/proxy/hysteria2/salamander.rs | 123 +++++++++++++++--- .../utils/test_utils/docker_utils/consts.rs | 1 + .../test_utils/docker_utils/docker_runner.rs | 10 ++ .../utils/test_utils/docker_utils/mod.rs | 114 ++++++++++++---- 7 files changed, 284 insertions(+), 46 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 234c2947f..dd240d5d0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9942,7 +9942,7 @@ dependencies = [ [[package]] name = "watfaq-netstack" -version = "0.1.1" +version = "0.1.0" dependencies = [ "bytes", "env_logger", diff --git a/clash-lib/src/proxy/hysteria2/datagram.rs b/clash-lib/src/proxy/hysteria2/datagram.rs index 9a7f4d5e6..9c14bb6ef 100644 --- a/clash-lib/src/proxy/hysteria2/datagram.rs +++ b/clash-lib/src/proxy/hysteria2/datagram.rs @@ -39,6 +39,12 @@ impl HysteriaDatagramOutbound { conn: Arc, local_addr: SocksAddr, ) -> Self { + tracing::trace!( + "HysteriaDatagramOutbound::new: session_id={}, local_addr={:?}", + session_id, + local_addr + ); + let (send_tx, send_rx) = tokio::sync::mpsc::channel::(32); let (recv_tx, recv_rx) = tokio::sync::mpsc::channel::(32); let udp_sessions = conn.udp_sessions.clone(); @@ -46,10 +52,15 @@ impl HysteriaDatagramOutbound { session_id, UdpSession { incoming: recv_tx, - local_addr, + local_addr: local_addr.clone(), defragger: Defragger::default(), }, ); + tracing::trace!( + "HysteriaDatagramOutbound: UDP session {} registered", + session_id + ); + tokio::spawn(async move { // capture vars let mut send_rx = send_rx; @@ -60,6 +71,13 @@ impl HysteriaDatagramOutbound { let pkt_id = next_pkt_id.fetch_add(1, std::sync::atomic::Ordering::Relaxed); let pkt_id = (pkt_id % u16::MAX as u32) as u16; + tracing::trace!( + "HysteriaDatagramOutbound: sending packet for session {}, \ + pkt_id={}, dst={:?}", + session_id, + pkt_id, + next_send.dst_addr + ); if let Err(e) = conn.send_packet( next_send.data.into(), next_send.dst_addr, diff --git a/clash-lib/src/proxy/hysteria2/mod.rs b/clash-lib/src/proxy/hysteria2/mod.rs index 2f30f2626..1e4931be7 100644 --- a/clash-lib/src/proxy/hysteria2/mod.rs +++ b/clash-lib/src/proxy/hysteria2/mod.rs @@ -177,6 +177,10 @@ impl Handler { sess: &Session, resolver: ThreadSafeDNSResolver, ) -> anyhow::Result<(Connection, SendRequest)> { + tracing::trace!( + "hysteria2 new_authed_connection_inner: starting connection to {:?}", + self.opts.addr + ); // Everytime we enstablish a new session, we should lookup the server // address. maybe it changed since it use ddns let server_socket_addr = match self.opts.addr.clone() { @@ -245,10 +249,13 @@ impl Handler { ep.set_default_client_config(self.client_config.clone()); + tracing::trace!("hysteria2 connecting to server: {:?}", server_socket_addr); let session = ep .connect(server_socket_addr, self.opts.sni.as_deref().unwrap_or(""))? .await?; + tracing::trace!("hysteria2 QUIC connection established"); let (guard, _rx, udp) = Self::auth(&session, &self.opts.passwd).await?; + tracing::trace!("hysteria2 authentication successful, udp={}", udp); *self.support_udp.write().unwrap() = udp; // todo set congestion controller according to cc_rx @@ -424,11 +431,15 @@ impl HysteriaConnection { } async fn spawn_tasks(self: Arc) { + tracing::trace!("hysteria2 spawn_tasks: starting datagram receive loop"); let err = loop { tokio::select! { res = self.conn.read_datagram() => { match res { - Ok(pkt) => self.clone().recv_packet(pkt).await, + Ok(pkt) => { + tracing::trace!("hysteria2 received datagram: {} bytes", pkt.len()); + self.clone().recv_packet(pkt).await + }, Err(e) => { tracing::error!("hysteria2 read datagram error: {}", e); break e; @@ -498,32 +509,70 @@ impl HysteriaConnection { session_id: u32, pkt_id: u16, ) -> std::io::Result<()> { + tracing::trace!( + "hysteria2 send_packet: session_id={}, pkt_id={}, addr={:?}, \ + data_len={}", + session_id, + pkt_id, + addr, + pkt.len() + ); + let max_frag_size = match self.udp_mtu.or(self.conn.max_datagram_size()) { - Some(x) => x, + Some(x) => { + tracing::trace!("hysteria2 max_frag_size={}", x); + x + } None => { + tracing::error!("hysteria2 udp mtu not set"); return Err(std::io::Error::other( "hysteria2 udp mtu not set, please check your \ disable_mtu_discovery and udp_mtu option", )); } }; - let fragments = Fragments::new(session_id, pkt_id, addr, max_frag_size, pkt); + let fragments = + Fragments::new(session_id, pkt_id, addr.clone(), max_frag_size, pkt); + let mut frag_count = 0; for frag in fragments { + frag_count += 1; + tracing::trace!( + "hysteria2 sending fragment #{} for session_id={}", + frag_count, + session_id + ); self.conn .send_datagram(frag) .map_err(std::io::Error::other)?; } + tracing::trace!( + "hysteria2 sent {} fragments for session_id={}", + frag_count, + session_id + ); Ok(()) } pub async fn recv_packet(self: Arc, pkt: Bytes) { + tracing::trace!("hysteria2 recv_packet: {} bytes", pkt.len()); let mut buf: BytesMut = pkt.into(); let pkt = codec::HysUdpPacket::decode(&mut buf).unwrap(); let session_id = pkt.session_id; let mut udp_sessions = self.udp_sessions.lock().await; match udp_sessions.get_mut(&session_id) { Some(session) => { + tracing::trace!( + "hysteria2 found session {}, feeding packet", + session_id + ); if let Some(pkt) = session.feed(pkt) { + tracing::trace!( + "hysteria2 complete packet received for session {}: {} \ + bytes to {:?}", + session_id, + pkt.data.len(), + session.local_addr + ); let _ = session .incoming .send(UdpPacket { @@ -532,6 +581,11 @@ impl HysteriaConnection { dst_addr: session.local_addr.clone(), }) .await; + } else { + tracing::trace!( + "hysteria2 packet fragment buffered for session {}", + session_id + ); } } _ => { diff --git a/clash-lib/src/proxy/hysteria2/salamander.rs b/clash-lib/src/proxy/hysteria2/salamander.rs index 7df67dfd6..c3eb21e7c 100644 --- a/clash-lib/src/proxy/hysteria2/salamander.rs +++ b/clash-lib/src/proxy/hysteria2/salamander.rs @@ -111,35 +111,124 @@ impl AsyncUdpSocket for Salamander { for i in 0..packet_nums { tracing::trace!( - "meta addr {:?}, dst_ip: {:?}", + "meta addr {:?}, dst_ip: {:?}, len: {}, stride: {}", meta[i].addr, - meta[i].dst_ip + meta[i].dst_ip, + meta[i].len, + meta[i].stride, ); - // Salamander packets must have at least 8 bytes (salt) + 1 byte (data) - if meta[i].len <= 8 { + let total_len = meta[i].len; + let stride = meta[i].stride; + let buf = bufs[i].deref_mut(); + let buf_len = buf.len(); + + // Validate buffer bounds + if total_len > buf_len { + tracing::error!( + "invalid buffer: total_len={} > buf_len={}, addr={:?}", + total_len, + buf_len, + meta[i].addr + ); + continue; + } + + // Salamander packets must have at least 8 bytes (salt) + 1 byte + // (data) + if total_len <= 8 || stride <= 8 { tracing::debug!( - "invalid salamander packet: len={}, addr={:?}", - meta[i].len, + "invalid salamander packet: len={}, stride={}, addr={:?}", + total_len, + stride, meta[i].addr ); continue; } - let len = meta[i].len; - let buf = bufs[i].deref_mut(); + // Fast path: single packet (no GRO, typical on Windows/Mac) + if total_len == stride { + // Decrypt and strip the 8-byte salt prefix + self.obfs.decrypt(&mut buf[..total_len]); + buf.copy_within(8..total_len, 0); + + // Compact valid packets to the front + if i != valid_count { + meta[valid_count] = meta[i]; + bufs.swap(i, valid_count); + } + meta[valid_count].len = total_len - 8; + meta[valid_count].stride = stride - 8; + valid_count += 1; + continue; + } - // Decrypt and strip the 8-byte salt prefix - self.obfs.decrypt(&mut buf[..len]); - buf.copy_within(8..len, 0); + // Slow path: GRO-merged packets (Linux with GRO enabled) + // When GRO is enabled, a single buffer may contain multiple + // datagrams concatenated together, each of size `stride` (the last + // one may be smaller). Each sub-datagram has its own 8-byte + // salamander salt prefix that must be decrypted and stripped + // independently. + let mut read_offset = 0; + let mut write_offset = 0; + while read_offset < total_len { + let seg_len = stride.min(total_len - read_offset); + if seg_len <= 8 { + // Remaining segment too small to be valid + break; + } + + // Ensure we don't read beyond buffer + if read_offset + seg_len > buf_len { + tracing::error!( + "GRO segment out of bounds: read_offset={}, seg_len={}, \ + buf_len={}", + read_offset, + seg_len, + buf_len + ); + break; + } + + // Decrypt this segment in place + self.obfs + .decrypt(&mut buf[read_offset..read_offset + seg_len]); + + // Ensure we don't write beyond valid range + let payload_len = seg_len - 8; + if write_offset + payload_len > buf_len { + tracing::error!( + "GRO write out of bounds: write_offset={}, payload_len={}, \ + buf_len={}", + write_offset, + payload_len, + buf_len + ); + break; + } + + // Copy decrypted payload (skip 8-byte salt) to compacted + // position + buf.copy_within( + read_offset + 8..read_offset + seg_len, + write_offset, + ); + + read_offset += seg_len; + write_offset += payload_len; + } - // Compact valid packets to the front - if i != valid_count { - meta[valid_count] = meta[i]; - bufs.swap(i, valid_count); + // Only add to valid_count if we processed something + if write_offset > 0 { + // Compact valid packets to the front + if i != valid_count { + meta[valid_count] = meta[i]; + bufs.swap(i, valid_count); + } + meta[valid_count].len = write_offset; + meta[valid_count].stride = stride - 8; + valid_count += 1; } - meta[valid_count].len -= 8; - valid_count += 1; } Poll::Ready(Ok(valid_count)) diff --git a/clash-lib/src/proxy/utils/test_utils/docker_utils/consts.rs b/clash-lib/src/proxy/utils/test_utils/docker_utils/consts.rs index 4b766dccb..28e0742db 100644 --- a/clash-lib/src/proxy/utils/test_utils/docker_utils/consts.rs +++ b/clash-lib/src/proxy/utils/test_utils/docker_utils/consts.rs @@ -1,5 +1,6 @@ pub const LOCAL_ADDR: &str = "127.0.0.1"; +#[allow(dead_code)] pub const IMAGE_WG: &str = "lscr.io/linuxserver/wireguard:1.0.20210914-legacy"; // image with v2ray-plugin pre-installed #[cfg(feature = "shadowsocks")] diff --git a/clash-lib/src/proxy/utils/test_utils/docker_utils/docker_runner.rs b/clash-lib/src/proxy/utils/test_utils/docker_utils/docker_runner.rs index b21f9edba..e9aea5862 100644 --- a/clash-lib/src/proxy/utils/test_utils/docker_utils/docker_runner.rs +++ b/clash-lib/src/proxy/utils/test_utils/docker_utils/docker_runner.rs @@ -181,6 +181,9 @@ impl DockerTestRunner { .map(|ip| ip.to_string()) }) }) + .inspect(|e| { + tracing::trace!("container_ip: {:?}", e); + }) } #[allow(unused)] @@ -201,6 +204,9 @@ impl DockerTestRunner { .map(|ip| ip.to_string()) }) }) + .inspect(|e| { + tracing::trace!("gateway_ip: {:?}", e); + }) } // you can run the cleanup manually @@ -404,6 +410,7 @@ impl DockerTestRunnerBuilder { self } + #[allow(dead_code)] pub fn env(mut self, env: &[&str]) -> Self { self.env = Some(env.iter().map(|x| x.to_string()).collect()); self @@ -433,6 +440,7 @@ impl DockerTestRunnerBuilder { self } + #[allow(dead_code)] pub fn sysctls(mut self, sysctls: &[(&str, &str)]) -> Self { self.host_config.sysctls = Some( sysctls @@ -444,12 +452,14 @@ impl DockerTestRunnerBuilder { self } + #[allow(dead_code)] pub fn cap_add(mut self, caps: &[&str]) -> Self { self.host_config.cap_add = Some(caps.iter().map(|x| x.to_string()).collect()); self } + #[allow(dead_code)] pub fn net_mode(mut self, mode: &str) -> Self { self.host_config.network_mode = Some(mode.to_string()); self diff --git a/clash-lib/src/proxy/utils/test_utils/docker_utils/mod.rs b/clash-lib/src/proxy/utils/test_utils/docker_utils/mod.rs index 4ab0f2b81..2583a7b5b 100644 --- a/clash-lib/src/proxy/utils/test_utils/docker_utils/mod.rs +++ b/clash-lib/src/proxy/utils/test_utils/docker_utils/mod.rs @@ -95,28 +95,34 @@ pub async fn ping_pong_test( let chunk = "hello"; let mut buf = vec![0; 5]; - tracing::info!("proxy_fn start write"); + tracing::info!("proxy_fn(tcp) start write"); - for _ in 0..100 { + for i in 0..100 { write_half .write_all(chunk.as_bytes()) .await .inspect_err(|x| { - tracing::error!("proxy_fn write error: {x:?}"); + tracing::error!( + "proxy_fn(tcp) write error at iteration {}: {x:?}", + i + ); })?; } write_half.flush().await?; tracing::info!("proxy_fn start read"); - for _ in 0..100 { + for i in 0..100 { read_half.read_exact(&mut buf).await.inspect_err(|x| { - tracing::error!("proxy_fn read error: {x:?}"); + tracing::error!( + "proxy_fn(tcp) read error at iteration {}: {x:?}", + i + ); })?; assert_eq!(buf, "world".as_bytes().to_owned()); } - tracing::info!("proxy_fn end"); + tracing::info!("proxy_fn(tcp) end"); Ok(()) } @@ -128,6 +134,8 @@ pub async fn ping_pong_test( let mut first_error: Option = None; for destination in &destination_list { + tracing::trace!("Attempting TCP connection to: {}", destination); + let dst: SocksAddr = match (destination.clone(), port).try_into() { Ok(addr) => addr, Err(e) => { @@ -137,20 +145,38 @@ pub async fn ping_pong_test( }; let sess = Session { - destination: dst, + destination: dst.clone(), ..Default::default() }; - let stream = match handler.connect_stream(&sess, resolver.clone()).await + let stream = match tokio::time::timeout( + Duration::from_secs(5), + handler.connect_stream(&sess, resolver.clone()), + ) + .await { - Ok(stream) => stream, - Err(e) => { - tracing::error!("Failed to proxy connection: {}", e); + Ok(Ok(stream)) => { + tracing::info!("Successfully connected to: {:?}", dst); + stream + } + Ok(Err(e)) => { + tracing::error!( + "Failed to proxy connection to {:?}: {}", + dst, + e + ); if first_error.is_none() { first_error = Some(e.into()); } continue; } + Err(_) => { + tracing::error!( + "connect_stream timeout (5s) for destination: {}", + destination + ); + continue; + } }; if let Ok(()) = proxy_fn(stream).await { @@ -205,14 +231,26 @@ pub async fn ping_pong_udp_test( let chunk = "world"; let mut buf = vec![0; 5]; + tracing::info!( + "destination_fn(udp) waiting for data on {}", + listener.local_addr()? + ); tracing::trace!("destination_fn start read"); - let (_, src) = listener.recv_from(&mut buf).await?; + let (len, src) = listener.recv_from(&mut buf).await?; + tracing::info!( + "destination_fn(udp) received {} bytes from {}: {:?}", + len, + src, + &buf[..len] + ); assert_eq!(&buf, b"hello"); + tracing::info!("destination_fn(udp) sending response to {}", src); tracing::trace!("destination_fn start write"); - listener.send_to(chunk.as_bytes(), src).await?; + let sent = listener.send_to(chunk.as_bytes(), src).await?; + tracing::info!("destination_fn(udp) sent {} bytes", sent); tracing::trace!("destination_fn end"); Ok(()) @@ -228,8 +266,15 @@ pub async fn ping_pong_udp_test( dst_addr: SocksAddr, ) -> anyhow::Result<()> { // let (mut sink, mut stream) = datagram.split(); - let packet = UdpPacket::new(b"hello".to_vec(), src_addr, dst_addr); - + let packet = + UdpPacket::new(b"hello".to_vec(), src_addr.clone(), dst_addr.clone()); + + tracing::info!( + "proxy_fn(udp) sending packet: src={:?}, dst={:?}, data={:?}", + src_addr, + dst_addr, + b"hello" + ); tracing::trace!("proxy_fn(udp) start write"); datagram.send(packet.clone()).await.map_err(|x| { @@ -237,15 +282,36 @@ pub async fn ping_pong_udp_test( anyhow::Error::new(x) })?; + tracing::info!( + "proxy_fn(udp) packet sent successfully, waiting for response..." + ); tracing::trace!("proxy_fn(udp) start read"); - let pkt = datagram.next().await; - let pkt = pkt.ok_or_else(|| anyhow!("no packet received"))?; - assert_eq!(pkt.data, b"world"); - - tracing::trace!("proxy_fn(udp) end"); - - Ok(()) + let pkt = + tokio::time::timeout(Duration::from_secs(5), datagram.next()).await; + + match pkt { + Ok(Some(pkt)) => { + tracing::info!( + "proxy_fn(udp) received response: {} bytes, data={:?}", + pkt.data.len(), + pkt.data + ); + assert_eq!(pkt.data, b"world"); + tracing::trace!("proxy_fn(udp) end"); + Ok(()) + } + Ok(None) => { + tracing::error!( + "proxy_fn(udp) datagram stream closed without response" + ); + Err(anyhow!("datagram stream closed")) + } + Err(_) => { + tracing::error!("proxy_fn(udp) timeout waiting for response (5s)"); + Err(anyhow!("timeout waiting for UDP response")) + } + } } let proxy_task = tokio::spawn(async move { @@ -311,8 +377,8 @@ pub async fn latency_test( .await { Ok(latency) => return Ok(latency), - Err(e) if attempt < 3 => { - tokio::time::sleep(Duration::from_millis(100)).await; + Err(_) if attempt < 3 => { + tokio::time::sleep(Duration::from_secs(1)).await; } Err(e) => return Err(e.into()), } From bfa5c64583f05eff3d8c5ad8f0dd88b99e0ad00d Mon Sep 17 00:00:00 2001 From: litcc Date: Sun, 8 Mar 2026 16:04:45 +0800 Subject: [PATCH 16/25] fix(test): resolve DNS handler test port conflicts Use dynamic port allocation instead of hardcoded ports (53553-53557) to avoid conflicts with running services. --- Cargo.lock | 5 + clash-dns/src/handler.rs | 94 ++++++----- clash-lib/Cargo.toml | 4 +- clash-lib/src/proxy/ssh/mod.rs | 149 ++++++++++++------ .../utils/test_utils/docker_utils/mod.rs | 57 ++++++- 5 files changed, 215 insertions(+), 94 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index dd240d5d0..cba4793b7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3838,6 +3838,7 @@ dependencies = [ "ed25519-dalek", "hex", "hmac", + "num-bigint-dig", "p256", "p384", "p521", @@ -4844,6 +4845,7 @@ dependencies = [ "num-iter", "num-traits", "rand 0.8.5", + "serde", "smallvec 1.15.1", "zeroize", ] @@ -6307,6 +6309,7 @@ dependencies = [ "rand_core 0.10.0-rc-3", "sha2 0.11.0-rc.3", "signature 3.0.0-rc.6", + "spki 0.8.0-rc.4", "zeroize", ] @@ -6384,11 +6387,13 @@ dependencies = [ "p521", "pageant", "pbkdf2", + "pkcs1 0.8.0-rc.4", "pkcs5", "pkcs8 0.10.2", "rand 0.8.5", "rand_core 0.6.4", "ring", + "rsa 0.10.0-rc.12", "russh-cryptovec", "russh-util", "sec1", diff --git a/clash-dns/src/handler.rs b/clash-dns/src/handler.rs index 7727ed4c1..7a8815146 100644 --- a/clash-dns/src/handler.rs +++ b/clash-dns/src/handler.rs @@ -362,7 +362,7 @@ mod tests { tls::{self, global_root_store}, }; use futures::FutureExt; - use hickory_client::client::{self, Client, ClientHandle}; + use hickory_client::client::{Client, ClientHandle}; use hickory_proto::{ h2::HttpsClientStreamBuilder, h3::H3ClientStreamBuilder, @@ -374,18 +374,11 @@ mod tests { }; use rustls::ClientConfig; use std::{sync::Arc, time::Duration}; - use tokio::task::JoinHandle; - mod addr { - use std::net::{IpAddr, Ipv4Addr, SocketAddr}; - - const LOCAL: IpAddr = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)); + use tokio::{ + net::{TcpListener, UdpSocket}, + task::JoinHandle, + }; - pub(super) const UDP: SocketAddr = SocketAddr::new(LOCAL, 53553); - pub(super) const TCP: SocketAddr = SocketAddr::new(LOCAL, 53554); - pub(super) const DOT: SocketAddr = SocketAddr::new(LOCAL, 53555); - pub(super) const DOH: SocketAddr = SocketAddr::new(LOCAL, 53556); - pub(super) const DOH3: SocketAddr = SocketAddr::new(LOCAL, 53557); - } async fn send_query(client: &mut Client) -> anyhow::Result<()> { let name = Name::from_ascii("www.example.com.").unwrap(); @@ -441,22 +434,52 @@ mod tests { .boxed() }); + // Bind to port 0 to get OS-assigned available ports + let udp_sock = UdpSocket::bind("127.0.0.1:0").await?; + let udp_addr = udp_sock.local_addr()?; + drop(udp_sock); + + let tcp_sock = TcpListener::bind("127.0.0.1:0").await?; + let tcp_addr = tcp_sock.local_addr()?; + drop(tcp_sock); + + let dot_sock = TcpListener::bind("127.0.0.1:0").await?; + let dot_addr = dot_sock.local_addr()?; + drop(dot_sock); + + let doh_sock = TcpListener::bind("127.0.0.1:0").await?; + let doh_addr = doh_sock.local_addr()?; + drop(doh_sock); + + let doh3_sock = UdpSocket::bind("127.0.0.1:0").await?; + let doh3_addr = doh3_sock.local_addr()?; + drop(doh3_sock); + + eprintln!( + "Test using ports - UDP:{}, TCP:{}, DoT:{}, DoH:{}, DoH3:{}", + udp_addr.port(), + tcp_addr.port(), + dot_addr.port(), + doh_addr.port(), + doh3_addr.port() + ); + let cfg = DNSListenAddr { - udp: Some(addr::UDP), - tcp: Some(addr::TCP), + udp: Some(udp_addr), + tcp: Some(tcp_addr), dot: Some(DoTConfig { - addr: addr::DOT, + addr: dot_addr, ca_key: None, ca_cert: None, }), doh: Some(DoHConfig { - addr: addr::DOH, + addr: doh_addr, hostname: Some("dns.example.com".to_string()), ca_key: None, ca_cert: None, }), doh3: Some(DoH3Config { - addr: addr::DOH3, + addr: doh3_addr, hostname: Some("dns.example.com".to_string()), ca_key: None, ca_cert: None, @@ -473,18 +496,21 @@ mod tests { Ok(()) }); + // Wait for servers to start + tokio::time::sleep(Duration::from_millis(100)).await; + let stream = - UdpClientStream::builder(addr::UDP, TokioRuntimeProvider::new()).build(); + UdpClientStream::builder(udp_addr, TokioRuntimeProvider::new()).build(); - let (mut client, handle) = client::Client::connect(stream).await?; + let (mut client, handle) = Client::connect(stream).await?; tokio::spawn(handle); send_query(&mut client).await?; let (stream, sender) = - TcpClientStream::new(addr::TCP, None, None, TokioRuntimeProvider::new()); + TcpClientStream::new(tcp_addr, None, None, TokioRuntimeProvider::new()); - let (mut client, handle) = client::Client::new(stream, sender, None).await?; + let (mut client, handle) = Client::new(stream, sender, None).await?; tokio::spawn(handle); send_query(&mut client).await?; @@ -498,22 +524,18 @@ mod tests { .set_certificate_verifier(Arc::new(tls::DummyTlsVerifier::new())); let (stream, sender) = tls_client_connect( - addr::DOT, + dot_addr, "dns.example.com".to_owned(), Arc::new(tls_config), TokioRuntimeProvider::new(), ); - let (mut client, handle) = client::Client::with_timeout( - stream, - sender, - Duration::from_secs(5), - None, - ) - .await - .inspect_err(|e| { - assert!(false, "Failed to connect to DoT server: {}", e); - })?; + let (mut client, handle) = + Client::with_timeout(stream, sender, Duration::from_secs(5), None) + .await + .inspect_err(|e| { + assert!(false, "Failed to connect to DoT server: {}", e); + })?; tokio::spawn(handle); send_query(&mut client).await?; @@ -532,12 +554,12 @@ mod tests { TokioRuntimeProvider::new(), ) .build( - addr::DOH, + doh_addr, "dns.example.com".to_owned(), "/dns-query".to_owned(), ); - let (mut client, handle) = client::Client::connect(stream).await?; + let (mut client, handle) = Client::connect(stream).await?; tokio::spawn(handle); send_query(&mut client).await?; @@ -555,12 +577,12 @@ mod tests { .crypto_config(tls_config) .clone() .build( - addr::DOH3, + doh3_addr, "dns.example.com".to_owned(), "/dns-query".to_owned(), ); - let (mut client, handle) = client::Client::connect(stream).await?; + let (mut client, handle) = Client::connect(stream).await?; tokio::spawn(handle); send_query(&mut client).await?; diff --git a/clash-lib/Cargo.toml b/clash-lib/Cargo.toml index 85686ba5c..e5534a220 100644 --- a/clash-lib/Cargo.toml +++ b/clash-lib/Cargo.toml @@ -181,7 +181,7 @@ criterion = { version = "0.8", features = ["html_reports", "async_tokio"], optio memory-stats = "1.0.0" # ssh -russh = { version = "0.56", default-features = false, features = ["async-trait"], optional = true } +russh = { version = "0.56", default-features = false, features = ["async-trait","rsa"], optional = true } dirs = { version = "6.0", optional = true } totp-rs = { version = "^5.7", features = ["serde_support"], optional = true } @@ -240,4 +240,4 @@ windows = { version = "0.62", features = [ ] } [lints] -workspace = true \ No newline at end of file +workspace = true diff --git a/clash-lib/src/proxy/ssh/mod.rs b/clash-lib/src/proxy/ssh/mod.rs index bb195478c..5212a8eb7 100644 --- a/clash-lib/src/proxy/ssh/mod.rs +++ b/clash-lib/src/proxy/ssh/mod.rs @@ -245,24 +245,24 @@ async fn auth0( #[cfg(all(test, docker_test))] mod tests { - use std::path::PathBuf; + use std::{future::Future, path::PathBuf}; use aead::rand_core::SeedableRng; use russh::keys::HashAlg; use tempfile::tempdir; - use super::super::utils::test_utils::{ - consts::*, docker_runner::DockerTestRunner, + use super::{ + super::utils::test_utils::{consts::*, docker_runner::DockerTestRunner}, + *, }; - use crate::proxy::utils::test_utils::{ - Suite, - config_helper::test_config_base_dir, - docker_runner::{DockerTestRunnerBuilder, MultiDockerTestRunner}, - run_test_suites_and_cleanup, + use crate::{ + proxy::utils::test_utils::{ + Suite, config_helper::test_config_base_dir, + docker_runner::DockerTestRunnerBuilder, run_test_suites_and_cleanup, + }, + tests::initialize, }; - use super::*; - const PASSWORD: &str = "123456789"; /// equals to: @@ -336,52 +336,49 @@ mod tests { #[allow(unused)] async fn test_ssh_inner(opt: TestOption) -> anyhow::Result<()> { tracing::info!("testing ssh, using option: {:?}", opt); - // dirty works: prepare ssh config directory for the docker container & - // generate host key pairs - // under /tmp - // it's ok for cross test's docker in docker, since we declared volume of - // /tmp + + // Prepare SSH config directory for the docker container + // We need a writable temp directory because: + // 1. Host keys must be generated at runtime + // 2. Container needs to write logs let temp_dir = tempdir()?; let test_config_base_dir = test_config_base_dir(); let ssh_config_path = test_config_base_dir.join("ssh"); let ssh_config_tmp_path = temp_dir.path().join("ssh"); - // cp files under ssh_config_path to temp_dir - tokio::process::Command::new("cp") - .args([ - "-r", - ssh_config_path.to_str().unwrap(), - ssh_config_tmp_path.to_str().unwrap(), - ]) - .output() - .await - .expect("failed to copy ssh config files"); - tokio::process::Command::new("chmod") - .args(["-R", "777", ssh_config_tmp_path.to_str().unwrap()]) - .output() - .await - .expect("failed to chmod ssh config files"); + // Copy SSH config files using Rust APIs (cross-platform) + copy_dir_recursive(&ssh_config_path, &ssh_config_tmp_path).await?; + + // Debug: print directory structure + tracing::debug!("SSH config directory structure after copy:"); + print_dir_structure(&ssh_config_tmp_path, 0).await?; + tracing::info!("ssh_config tmp mounting path: {:?}", ssh_config_tmp_path); + + // Create logs directory tokio::fs::create_dir_all(&ssh_config_tmp_path.join("logs").join("openssh")) .await?; - // generate host key pairs - // ignore rsa, it's too slow + // Generate host key pairs (ecdsa, ed25519, and rsa for test_ssh2) + // Note: RSA key generation doesn't need hash parameter (hash is only for + // signing) let name_and_key_pairs = [ ( "ecdsa", - russh::keys::Algorithm::Ecdsa { + Algorithm::Ecdsa { curve: russh::keys::EcdsaCurve::NistP256, }, ), - ("ed25519", russh::keys::Algorithm::Ed25519), + ("ed25519", Algorithm::Ed25519), ] .into_iter() .map(|(name, algo)| { - let (private_key, public_key) = gen_ssh_key_pair(algo).unwrap(); + let (private_key, public_key) = + gen_ssh_key_pair(algo).expect("Key generation failed"); (name, private_key, public_key) }) .collect::>(); + let host_key_path = ssh_config_tmp_path.join("ssh_host_keys"); for (name, private_key, public_key) in name_and_key_pairs { let private_key_path = @@ -392,12 +389,16 @@ mod tests { tokio::fs::write(public_key_path, public_key).await?; } - // now we are fine, real test starts + // Start the container + let container = + get_openssh_server_runner(ssh_config_tmp_path.clone()).await?; + // Configure client to connect to container let ssh_private_key_path = ssh_config_tmp_path .join(".ssh") .join(if opt.rsa { "test_rsa" } else { "test_ed25519" }); let ssh_private_key_path = ssh_private_key_path.to_str().unwrap(); + let password = if opt.password { Some(PASSWORD.to_owned()) } else { @@ -412,8 +413,8 @@ mod tests { let opts = HandlerOptions { name: "test-ssh".to_owned(), common_opts: Default::default(), - server: LOCAL_ADDR.to_owned(), - port: 2222, // in accordance with sshd_config'sport + server: container.container_ip().unwrap_or(LOCAL_ADDR.to_owned()), + port: 2222, password, private_key, private_key_passphrase: None, @@ -421,7 +422,6 @@ mod tests { host_key: opt.host_key.clone(), host_key_algorithms: Some(vec![ Algorithm::Ed25519, - Algorithm::Rsa { hash: None }, Algorithm::Rsa { hash: Some(HashAlg::Sha256), }, @@ -432,19 +432,66 @@ mod tests { totp: None, }; let handler: Arc = Arc::new(Handler::new(opts)); - // we need to store all the runners in a container, to make sure all of - // them can be destroyed after the test - let mut chained = MultiDockerTestRunner::default(); - chained - .add(get_openssh_server_runner(ssh_config_tmp_path)) - .await?; - run_test_suites_and_cleanup(handler, chained, Suite::tcp_tests()).await + + run_test_suites_and_cleanup(handler, container, Suite::tcp_tests()).await + } + + /// Recursively copy a directory using async Rust APIs + #[allow(unused)] + fn copy_dir_recursive<'a>( + src: &'a std::path::Path, + dst: &'a std::path::Path, + ) -> Pin> + 'a>> { + Box::pin(async move { + tokio::fs::create_dir_all(dst).await?; + + let mut entries = tokio::fs::read_dir(src).await?; + while let Some(entry) = entries.next_entry().await? { + let src_path = entry.path(); + let dst_path = dst.join(entry.file_name()); + + if entry.file_type().await?.is_dir() { + copy_dir_recursive(&src_path, &dst_path).await?; + } else { + tokio::fs::copy(&src_path, &dst_path).await?; + } + } + + Ok(()) + }) + } + + /// Print directory structure for debugging + #[allow(unused)] + fn print_dir_structure<'a>( + path: &'a std::path::Path, + indent: usize, + ) -> Pin> + 'a>> { + Box::pin(async move { + let mut entries = tokio::fs::read_dir(path).await?; + while let Some(entry) = entries.next_entry().await? { + let file_name = entry.file_name(); + let indent_str = " ".repeat(indent); + + if entry.file_type().await?.is_dir() { + tracing::debug!( + "{}{}/", + indent_str, + file_name.to_string_lossy() + ); + print_dir_structure(&entry.path(), indent + 1).await?; + } else { + tracing::debug!("{}{}", indent_str, file_name.to_string_lossy()); + } + } + Ok(()) + }) } - #[cfg(target_os = "linux")] #[tokio::test] #[serial_test::serial] async fn test_ssh1() -> anyhow::Result<()> { + initialize(); test_ssh_inner(TestOption { password: true, rsa: false, @@ -453,11 +500,10 @@ mod tests { .await } - #[cfg(target_os = "linux")] #[tokio::test] #[serial_test::serial] - #[ignore = "this does pass locally, but not in CI. TODO: #720"] async fn test_ssh2() -> anyhow::Result<()> { + initialize(); test_ssh_inner(TestOption { password: false, rsa: true, @@ -466,11 +512,10 @@ mod tests { .await } - #[cfg(target_os = "linux")] #[tokio::test] #[serial_test::serial] - #[ignore = "this does pass locally, but not in CI. TODO: #720"] async fn test_ssh3() -> anyhow::Result<()> { + initialize(); test_ssh_inner(TestOption { password: false, rsa: false, @@ -479,10 +524,10 @@ mod tests { .await } - #[cfg(target_os = "linux")] #[tokio::test] #[serial_test::serial] async fn test_ssh4() -> anyhow::Result<()> { + initialize(); // config wrong host key, expect failure let host_key = Some( vec![ diff --git a/clash-lib/src/proxy/utils/test_utils/docker_utils/mod.rs b/clash-lib/src/proxy/utils/test_utils/docker_utils/mod.rs index 2583a7b5b..b6944c4b3 100644 --- a/clash-lib/src/proxy/utils/test_utils/docker_utils/mod.rs +++ b/clash-lib/src/proxy/utils/test_utils/docker_utils/mod.rs @@ -179,8 +179,31 @@ pub async fn ping_pong_test( } }; - if let Ok(()) = proxy_fn(stream).await { - return Ok(()); + match tokio::time::timeout(Duration::from_secs(10), proxy_fn(stream)) + .await + { + Ok(Ok(())) => { + tracing::info!( + "proxy_fn succeeded for destination: {}", + destination + ); + return Ok(()); + } + Ok(Err(e)) => { + tracing::error!( + "proxy_fn failed for destination {}: {}", + destination, + e + ); + continue; + } + Err(_) => { + tracing::error!( + "proxy_fn timeout (10s) for destination: {}", + destination + ); + continue; + } } } @@ -345,8 +368,34 @@ pub async fn ping_pong_udp_test( } }; - if let Ok(()) = proxy_fn(datagram, src, dst).await { - return Ok(()); + match tokio::time::timeout( + Duration::from_secs(10), + proxy_fn(datagram, src, dst), + ) + .await + { + Ok(Ok(())) => { + tracing::info!( + "proxy_fn(udp) succeeded for destination: {}", + destination + ); + return Ok(()); + } + Ok(Err(e)) => { + tracing::error!( + "proxy_fn(udp) failed for destination {}: {}", + destination, + e + ); + continue; + } + Err(_) => { + tracing::error!( + "proxy_fn(udp) timeout (10s) for destination: {}", + destination + ); + continue; + } } } Err(anyhow!( From 973564828a3735d8b5edab6a6b0e816bbdc9046b Mon Sep 17 00:00:00 2001 From: litcc Date: Mon, 9 Mar 2026 21:59:26 +0800 Subject: [PATCH 17/25] fix(docker-test): bug --- Cargo.lock | 328 +++++++++++------- clash-lib/Cargo.toml | 3 +- clash-lib/src/proxy/ssh/mod.rs | 18 + .../test_utils/docker_utils/docker_runner.rs | 72 +++- .../utils/test_utils/docker_utils/mod.rs | 233 ++++++++----- 5 files changed, 440 insertions(+), 214 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cba4793b7..7ffd9701a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1007,6 +1007,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + [[package]] name = "cexpr" version = "0.6.0" @@ -1254,10 +1260,10 @@ dependencies = [ "rand_chacha 0.3.1", "regex", "register-count", - "reqwest", + "reqwest 0.13.2", "russh", "rustls", - "security-framework 3.5.1", + "security-framework", "serde", "serde_json", "serde_yaml", @@ -1270,6 +1276,7 @@ dependencies = [ "sock2proc", "socket2 0.6.2", "ssh-key", + "sysinfo 0.38.3", "tar", "tempfile", "thiserror 2.0.18", @@ -1351,6 +1358,16 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + [[package]] name = "compact_str" version = "0.8.1" @@ -2638,21 +2655,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" -[[package]] -name = "foreign-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" -dependencies = [ - "foreign-types-shared", -] - -[[package]] -name = "foreign-types-shared" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" - [[package]] name = "form_urlencoded" version = "1.2.2" @@ -3566,22 +3568,6 @@ dependencies = [ "tower-service", ] -[[package]] -name = "hyper-tls" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" -dependencies = [ - "bytes", - "http-body-util", - "hyper", - "hyper-util", - "native-tls", - "tokio", - "tokio-native-tls", - "tower-service", -] - [[package]] name = "hyper-util" version = "0.1.19" @@ -4027,6 +4013,28 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + [[package]] name = "jobserver" version = "0.1.34" @@ -4533,23 +4541,6 @@ dependencies = [ "getrandom 0.2.16", ] -[[package]] -name = "native-tls" -version = "0.2.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" -dependencies = [ - "libc", - "log", - "openssl", - "openssl-probe 0.1.6", - "openssl-sys", - "schannel", - "security-framework 2.11.1", - "security-framework-sys", - "tempfile", -] - [[package]] name = "netconfig-rs" version = "0.1.5" @@ -4982,56 +4973,12 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" -[[package]] -name = "openssl" -version = "0.10.75" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" -dependencies = [ - "bitflags 2.9.4", - "cfg-if", - "foreign-types", - "libc", - "once_cell", - "openssl-macros", - "openssl-sys", -] - -[[package]] -name = "openssl-macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - -[[package]] -name = "openssl-probe" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" - [[package]] name = "openssl-probe" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" -[[package]] -name = "openssl-sys" -version = "0.9.111" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - [[package]] name = "opentelemetry" version = "0.31.0" @@ -5056,7 +5003,7 @@ dependencies = [ "bytes", "http", "opentelemetry", - "reqwest", + "reqwest 0.12.28", ] [[package]] @@ -5071,7 +5018,7 @@ dependencies = [ "opentelemetry-proto", "opentelemetry_sdk", "prost", - "reqwest", + "reqwest 0.12.28", "thiserror 2.0.18", "tracing", ] @@ -6163,22 +6110,17 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64 0.22.1", "bytes", - "encoding_rs", "futures-channel", "futures-core", "futures-util", - "h2", "http", "http-body", "http-body-util", "hyper", "hyper-rustls", - "hyper-tls", "hyper-util", "js-sys", "log", - "mime", - "native-tls", "percent-encoding", "pin-project-lite", "quinn", @@ -6189,7 +6131,6 @@ dependencies = [ "serde_urlencoded", "sync_wrapper", "tokio", - "tokio-native-tls", "tokio-rustls", "tower", "tower-http", @@ -6201,6 +6142,44 @@ dependencies = [ "webpki-roots", ] +[[package]] +name = "reqwest" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" +dependencies = [ + "base64 0.22.1", + "bytes", + "encoding_rs", + "futures-core", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "resolv-conf" version = "0.7.5" @@ -6520,10 +6499,10 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" dependencies = [ - "openssl-probe 0.2.1", + "openssl-probe", "rustls-pki-types", "schannel", - "security-framework 3.5.1", + "security-framework", ] [[package]] @@ -6545,6 +6524,33 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki 0.103.5", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.0", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + [[package]] name = "rustls-webpki" version = "0.102.8" @@ -6734,19 +6740,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "security-framework" -version = "2.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" -dependencies = [ - "bitflags 2.9.4", - "core-foundation 0.9.4", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - [[package]] name = "security-framework" version = "3.5.1" @@ -6793,7 +6786,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f925d575b468e88b079faf590a8dd0c9c99e2ec29e9bab663ceb8b45056312f" dependencies = [ "httpdate", - "reqwest", + "reqwest 0.12.28", "rustls", "sentry-backtrace", "sentry-contexts", @@ -7728,6 +7721,20 @@ dependencies = [ "windows 0.61.3", ] +[[package]] +name = "sysinfo" +version = "0.38.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d03c61d2a49c649a15c407338afe7accafde9dac869995dccb73e5f7ef7d9034" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "windows 0.62.2", +] + [[package]] name = "system-configuration" version = "0.6.1" @@ -8023,16 +8030,6 @@ dependencies = [ "syn 2.0.114", ] -[[package]] -name = "tokio-native-tls" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" -dependencies = [ - "native-tls", - "tokio", -] - [[package]] name = "tokio-rustls" version = "0.26.4" @@ -10008,6 +10005,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webpki-roots" version = "1.0.5" @@ -10246,6 +10252,15 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + [[package]] name = "windows-sys" version = "0.48.0" @@ -10291,6 +10306,21 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + [[package]] name = "windows-targets" version = "0.48.5" @@ -10357,6 +10387,12 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" @@ -10375,6 +10411,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + [[package]] name = "windows_aarch64_msvc" version = "0.48.5" @@ -10393,6 +10435,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + [[package]] name = "windows_i686_gnu" version = "0.48.5" @@ -10423,6 +10471,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + [[package]] name = "windows_i686_msvc" version = "0.48.5" @@ -10441,6 +10495,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + [[package]] name = "windows_x86_64_gnu" version = "0.48.5" @@ -10459,6 +10519,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" @@ -10477,6 +10543,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + [[package]] name = "windows_x86_64_msvc" version = "0.48.5" diff --git a/clash-lib/Cargo.toml b/clash-lib/Cargo.toml index e5534a220..71b03c822 100644 --- a/clash-lib/Cargo.toml +++ b/clash-lib/Cargo.toml @@ -210,7 +210,8 @@ rand_chacha = "=0.3" httpmock = "0.8.2" # TODO replace with wiremock tracing-test = "0.2" http-body-util = "0.1" -reqwest = { version = "0.12", features = ["socks"] } +reqwest = { version = "0.13", features = ["socks"] } +sysinfo = { version = "0.38", features = ["network"]} [build-dependencies] prost-build = "0.14" diff --git a/clash-lib/src/proxy/ssh/mod.rs b/clash-lib/src/proxy/ssh/mod.rs index 5212a8eb7..07a791d19 100644 --- a/clash-lib/src/proxy/ssh/mod.rs +++ b/clash-lib/src/proxy/ssh/mod.rs @@ -349,6 +349,24 @@ mod tests { // Copy SSH config files using Rust APIs (cross-platform) copy_dir_recursive(&ssh_config_path, &ssh_config_tmp_path).await?; + // IMPORTANT: Container expects sshd_config at /config/sshd/sshd_config + // Our source has it at ssh_host_keys/sshd_config, but the container + // startup script will ignore/delete it from there and generate a default + // config if /config/sshd/sshd_config doesn't exist. + // So we need to copy it to the correct location. + let source_sshd_config = ssh_config_tmp_path + .join("ssh_host_keys") + .join("sshd_config"); + let target_sshd_dir = ssh_config_tmp_path.join("sshd"); + tokio::fs::create_dir_all(&target_sshd_dir).await?; + let target_sshd_config = target_sshd_dir.join("sshd_config"); + tokio::fs::copy(&source_sshd_config, &target_sshd_config).await?; + tracing::info!( + "Copied sshd_config from {:?} to {:?}", + source_sshd_config, + target_sshd_config + ); + // Debug: print directory structure tracing::debug!("SSH config directory structure after copy:"); print_dir_structure(&ssh_config_tmp_path, 0).await?; diff --git a/clash-lib/src/proxy/utils/test_utils/docker_utils/docker_runner.rs b/clash-lib/src/proxy/utils/test_utils/docker_utils/docker_runner.rs index e9aea5862..81e9f7d0c 100644 --- a/clash-lib/src/proxy/utils/test_utils/docker_utils/docker_runner.rs +++ b/clash-lib/src/proxy/utils/test_utils/docker_utils/docker_runner.rs @@ -13,7 +13,7 @@ use bollard::{ secret::{HostConfig, Mount, PortBinding}, }; use bytes::Bytes; -use futures::{Future, TryStreamExt}; +use futures::{Future, StreamExt, TryStreamExt}; use tar; const TIMEOUT_DURATION: u64 = 30; @@ -120,6 +120,36 @@ impl DockerTestRunner { } let tar_data = ar.into_inner()?; + // Debug: Print all files in the tar archive + tracing::trace!( + "=== TAR Archive Contents for mount {} -> {} ===", + source, + target + ); + let mut archive = tar::Archive::new(&tar_data[..]); + for (idx, entry) in archive.entries()?.enumerate() { + match entry { + Ok(e) => { + let path = e.path().ok(); + let size = e.header().size().ok(); + tracing::trace!( + " [{}] {:?} (size: {:?})", + idx, + path, + size + ); + } + Err(e) => { + tracing::warn!( + " [{}] Error reading entry: {}", + idx, + e + ); + } + } + } + tracing::trace!("=== End TAR Archive Contents ==="); + // Upload to container root directory docker .upload_to_container( @@ -209,6 +239,46 @@ impl DockerTestRunner { }) } + /// For debugging use + #[allow(unused)] + pub async fn exec_command(&self, cmd: &[&str]) -> anyhow::Result { + use bollard::exec::{CreateExecOptions, StartExecResults}; + + let exec = self + .instance + .create_exec( + &self.id, + CreateExecOptions { + cmd: Some(cmd.iter().map(|s| s.to_string()).collect()), + attach_stdout: Some(true), + attach_stderr: Some(true), + ..Default::default() + }, + ) + .await?; + + let start_result = self.instance.start_exec(&exec.id, None).await?; + + match start_result { + StartExecResults::Attached { mut output, .. } => { + let mut result = String::new(); + while let Some(log) = output.next().await { + match log { + Ok(log_output) => { + result.push_str(&log_output.to_string()); + } + Err(e) => { + tracing::warn!("Error reading exec output: {}", e); + break; + } + } + } + Ok(result) + } + StartExecResults::Detached => Ok(String::new()), + } + } + // you can run the cleanup manually pub async fn cleanup(self) -> anyhow::Result<()> { let logs = self diff --git a/clash-lib/src/proxy/utils/test_utils/docker_utils/mod.rs b/clash-lib/src/proxy/utils/test_utils/docker_utils/mod.rs index b6944c4b3..3e69f1454 100644 --- a/clash-lib/src/proxy/utils/test_utils/docker_utils/mod.rs +++ b/clash-lib/src/proxy/utils/test_utils/docker_utils/mod.rs @@ -13,16 +13,67 @@ use std::{ sync::Arc, time::{Duration, Instant}, }; +use sysinfo::Networks; use tokio::{ io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, split}, net::{TcpListener, UdpSocket}, }; -use tracing::info; +use tracing::{debug, info, trace}; pub mod config_helper; pub mod consts; pub mod docker_runner; +fn destination_list(gateway_ip: Option) -> Vec { + let mut destination_list = vec!["host.docker.internal".to_owned()]; + if let Some(ip) = gateway_ip { + debug!("gateway_ip Ip: {}", ip); + destination_list.push(ip); + } + if let Some(ip) = option_env!("CLIENT_IP") { + debug!("client Ip: {}", ip); + destination_list.insert(0, ip.to_owned()); + } else { + debug!("CLIENT_IP env not set, "); + let mut networks = Networks::new_with_refreshed_list(); + networks.refresh(true); + + trace!("networks: {:?}", networks); + // 收集所有有流量的网卡的 IPv4 地址 + let mut active_interfaces = networks + .iter() + .filter(|(_, data)| { + data.mac_address().to_string() != "00:00:00:00:00:00" + }) + .collect::>(); + + // 按流量排序:优先按发送流量降序,其次按接收流量降序 + active_interfaces.sort_by(|a, b| { + b.1.total_transmitted() + .cmp(&a.1.total_transmitted()) + .then_with(|| b.1.total_received().cmp(&a.1.total_received())) + }); + for (iface_name, data) in active_interfaces { + trace!("Processing interface: {}, {:#?}", iface_name, data); + + // 获取该网卡的所有 IP 地址 + for ip_network in data.ip_networks() { + let addr = ip_network.addr; + // 只添加 IPv4 地址,排除 loopback + if addr.is_ipv4() && !addr.is_loopback() { + let ip_str = addr.to_string(); + // 跳过已存在的 IP + if !destination_list.contains(&ip_str) { + debug!("Found IPv4 address on {}: {}", iface_name, ip_str); + destination_list.push(ip_str); + } + } + } + } + } + destination_list +} + // TODO: add the throughput metrics pub async fn ping_pong_test( handler: Arc, @@ -32,17 +83,7 @@ pub async fn ping_pong_test( // PATH: our proxy handler -> proxy-server(container) -> target local // server(127.0.0.1:port) - let mut destination_list = vec![ - #[cfg(any(target_os = "linux", target_os = "android"))] - "127.0.0.1".to_owned(), - "host.docker.internal".to_owned(), - ]; - if let Some(ip) = option_env!("CLIENT_IP") { - destination_list.insert(0, ip.to_owned()); - } - if let Some(ip) = gateway_ip { - destination_list.push(ip); - } + let destination_list = destination_list(gateway_ip); let resolver = config_helper::build_dns_resolver().await?; @@ -59,33 +100,49 @@ pub async fn ping_pong_test( let chunk = "world"; let mut buf = vec![0; 5]; - tracing::info!("destination_fn start read"); + info!("destination_fn(tcp) start read"); for _ in 0..100 { read_half.read_exact(&mut buf).await?; assert_eq!(&buf, b"hello"); } - tracing::info!("destination_fn start write"); - + info!("destination_fn(tcp) start write"); for _ in 0..100 { write_half.write_all(chunk.as_bytes()).await?; write_half.flush().await?; } - tracing::info!("destination_fn end"); + info!("destination_fn(tcp) end"); Ok(()) } - + let (tx, rx) = tokio::sync::oneshot::channel::<()>(); let target_local_server_handler = tokio::spawn(async move { + let mut rx = rx; loop { - let (stream, _) = listener.accept().await?; - - tracing::info!( - "Accepted connection from: {}", - stream.peer_addr().unwrap() - ); - destination_fn(stream).await? + tokio::select! { + data = listener.accept() => { + match data { + Ok((stream, _)) => { + info!( + "Accepted connection(tcp) from: {:?}", + stream.peer_addr().ok() + ); + if let Err(e) = destination_fn(stream).await { + info!("Error handling connection(tcp): {}", e); + } + }, + Err(e) => { + info!("Error accepting connection(tcp): {}", e); + continue; + } + } + } + _ = &mut rx => { + info!("target_local_server_handler(tcp) received shutdown signal, exiting..."); + return Ok(()); + } + } } }); @@ -95,7 +152,7 @@ pub async fn ping_pong_test( let chunk = "hello"; let mut buf = vec![0; 5]; - tracing::info!("proxy_fn(tcp) start write"); + info!("proxy_fn(tcp) start write"); for i in 0..100 { write_half @@ -110,7 +167,7 @@ pub async fn ping_pong_test( } write_half.flush().await?; - tracing::info!("proxy_fn start read"); + info!("proxy_fn start(tcp) read"); for i in 0..100 { read_half.read_exact(&mut buf).await.inspect_err(|x| { @@ -122,7 +179,7 @@ pub async fn ping_pong_test( assert_eq!(buf, "world".as_bytes().to_owned()); } - tracing::info!("proxy_fn(tcp) end"); + info!("proxy_fn(tcp) end"); Ok(()) } @@ -134,12 +191,15 @@ pub async fn ping_pong_test( let mut first_error: Option = None; for destination in &destination_list { - tracing::trace!("Attempting TCP connection to: {}", destination); + tracing::trace!("Attempting TCP connection(tcp) to: {}", destination); let dst: SocksAddr = match (destination.clone(), port).try_into() { Ok(addr) => addr, Err(e) => { - tracing::error!("Failed to parse destination address: {}", e); + tracing::error!( + "Failed to parse destination address(tcp): {}", + e + ); continue; } }; @@ -150,18 +210,18 @@ pub async fn ping_pong_test( }; let stream = match tokio::time::timeout( - Duration::from_secs(5), + Duration::from_secs(3), handler.connect_stream(&sess, resolver.clone()), ) .await { Ok(Ok(stream)) => { - tracing::info!("Successfully connected to: {:?}", dst); + tracing::info!("Successfully connected(tcp) to: {:?}", dst); stream } Ok(Err(e)) => { tracing::error!( - "Failed to proxy connection to {:?}: {}", + "Failed to proxy connection(tcp) to {:?}: {}", dst, e ); @@ -172,26 +232,26 @@ pub async fn ping_pong_test( } Err(_) => { tracing::error!( - "connect_stream timeout (5s) for destination: {}", + "connect_stream timeout (5s) for destination(tcp): {}", destination ); continue; } }; - match tokio::time::timeout(Duration::from_secs(10), proxy_fn(stream)) + match tokio::time::timeout(Duration::from_secs(3), proxy_fn(stream)) .await { Ok(Ok(())) => { tracing::info!( - "proxy_fn succeeded for destination: {}", + "proxy_fn succeeded for destination(tcp): {}", destination ); return Ok(()); } Ok(Err(e)) => { tracing::error!( - "proxy_fn failed for destination {}: {}", + "proxy_fn failed for destination(tcp) {}: {}", destination, e ); @@ -199,7 +259,7 @@ pub async fn ping_pong_test( } Err(_) => { tracing::error!( - "proxy_fn timeout (10s) for destination: {}", + "proxy_fn timeout (3s) for destination(tcp): {}", destination ); continue; @@ -213,7 +273,7 @@ pub async fn ping_pong_test( Err(err) } else { Err(anyhow!( - "all destination test error: [{:?}]", + "all destination test error(tcp): [{:?}]", destination_list )) } @@ -221,7 +281,9 @@ pub async fn ping_pong_test( let futs = vec![proxy_task, target_local_server_handler]; - select_all(futs).await.0? + let res = select_all(futs).await.0?; + tx.send(()).ok(); // signal the target local server to shutdown + res } pub async fn ping_pong_udp_test( @@ -232,56 +294,62 @@ pub async fn ping_pong_udp_test( // PATH: our proxy handler -> proxy-server(container) -> target local // server(127.0.0.1:port) - let mut destination_list = vec![ - #[cfg(any(target_os = "linux", target_os = "android"))] - "127.0.0.1".to_owned(), - "host.docker.internal".to_owned(), - ]; - if let Some(ip) = option_env!("CLIENT_IP") { - destination_list.insert(0, ip.to_owned()); - } - if let Some(ip) = gateway_ip { - destination_list.push(ip); - } + let destination_list = destination_list(gateway_ip); let resolver = config_helper::build_dns_resolver().await?; let listener = UdpSocket::bind(format!("0.0.0.0:{}", port).as_str()).await?; info!("target local server started at: {}", listener.local_addr()?); - async fn destination_fn(listener: UdpSocket) -> anyhow::Result<()> { + async fn destination_fn( + mut rx: tokio::sync::oneshot::Receiver<()>, + listener: UdpSocket, + ) -> anyhow::Result<()> { // Use inbound_stream here let chunk = "world"; let mut buf = vec![0; 5]; - tracing::info!( + info!( "destination_fn(udp) waiting for data on {}", listener.local_addr()? ); tracing::trace!("destination_fn start read"); - let (len, src) = listener.recv_from(&mut buf).await?; - tracing::info!( - "destination_fn(udp) received {} bytes from {}: {:?}", - len, - src, - &buf[..len] - ); - assert_eq!(&buf, b"hello"); - - tracing::info!("destination_fn(udp) sending response to {}", src); - tracing::trace!("destination_fn start write"); - - let sent = listener.send_to(chunk.as_bytes(), src).await?; - tracing::info!("destination_fn(udp) sent {} bytes", sent); - - tracing::trace!("destination_fn end"); - Ok(()) + loop { + tokio::select! { + data = listener.recv_from(&mut buf) => { + match data { + Ok((len, src) ) => { + info!( + "destination_fn(udp) received {} bytes from {}: {:?}", + len, + src, + &buf[..len] + ); + assert_eq!(&buf, b"hello"); + info!("destination_fn(udp) sending response to {}", src); + tracing::trace!("destination_fn start write"); + let sent = listener.send_to(chunk.as_bytes(), src).await?; + info!("destination_fn(udp) sent {} bytes", sent); + tracing::trace!("destination_fn end"); + }, + Err(e) => { + info!("Error accepting connection(tcp): {}", e); + continue; + } + } + } + _ = &mut rx => { + info!("target_local_server_handler(tcp) received shutdown signal, exiting..."); + return Ok(()); + } + } + } } - + let (tx, rx) = tokio::sync::oneshot::channel::<()>(); let target_local_server_handler: tokio::task::JoinHandle< Result<(), anyhow::Error>, - > = tokio::spawn(async move { destination_fn(listener).await }); + > = tokio::spawn(async move { destination_fn(rx, listener).await }); async fn proxy_fn( mut datagram: BoxedChainedDatagram, @@ -292,23 +360,19 @@ pub async fn ping_pong_udp_test( let packet = UdpPacket::new(b"hello".to_vec(), src_addr.clone(), dst_addr.clone()); - tracing::info!( + info!( "proxy_fn(udp) sending packet: src={:?}, dst={:?}, data={:?}", - src_addr, - dst_addr, - b"hello" + src_addr, dst_addr, b"hello" ); - tracing::trace!("proxy_fn(udp) start write"); + trace!("proxy_fn(udp) start write"); datagram.send(packet.clone()).await.map_err(|x| { tracing::error!("proxy_fn(udp) write error: {}", x); anyhow::Error::new(x) })?; - tracing::info!( - "proxy_fn(udp) packet sent successfully, waiting for response..." - ); - tracing::trace!("proxy_fn(udp) start read"); + info!("proxy_fn(udp) packet sent successfully, waiting for response..."); + trace!("proxy_fn(udp) start read"); let pkt = tokio::time::timeout(Duration::from_secs(5), datagram.next()).await; @@ -369,7 +433,7 @@ pub async fn ping_pong_udp_test( }; match tokio::time::timeout( - Duration::from_secs(10), + Duration::from_secs(3), proxy_fn(datagram, src, dst), ) .await @@ -391,7 +455,7 @@ pub async fn ping_pong_udp_test( } Err(_) => { tracing::error!( - "proxy_fn(udp) timeout (10s) for destination: {}", + "proxy_fn(udp) timeout (3s) for destination: {}", destination ); continue; @@ -405,8 +469,9 @@ pub async fn ping_pong_udp_test( }); let futs = vec![proxy_task, target_local_server_handler]; - - select_all(futs).await.0? + let res = select_all(futs).await.0?; + tx.send(()).ok(); + res } // latency test of the proxy, will reuse the `url_test` ability From a4ed9311b0a586387a9aecff3bfc571adc393d2b Mon Sep 17 00:00:00 2001 From: litcc Date: Tue, 10 Mar 2026 09:53:33 +0800 Subject: [PATCH 18/25] fix(docker-test): bug --- clash-lib/src/proxy/ssh/mod.rs | 57 ++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/clash-lib/src/proxy/ssh/mod.rs b/clash-lib/src/proxy/ssh/mod.rs index 07a791d19..55339ad5c 100644 --- a/clash-lib/src/proxy/ssh/mod.rs +++ b/clash-lib/src/proxy/ssh/mod.rs @@ -470,8 +470,65 @@ mod tests { if entry.file_type().await?.is_dir() { copy_dir_recursive(&src_path, &dst_path).await?; + + // Fix ownership and permissions for .ssh directory + #[cfg(unix)] + if dst_path.file_name().and_then(|n| n.to_str()) == Some(".ssh") + { + use std::os::unix::fs::PermissionsExt; + // Set .ssh directory to 700 and owned by 1000:1000 + let mut perms = + tokio::fs::metadata(&dst_path).await?.permissions(); + perms.set_mode(0o700); + tokio::fs::set_permissions(&dst_path, perms).await?; + + // Change ownership to UID 1000, GID 1000 (container user) + std::os::unix::fs::chown(&dst_path, Some(1000), Some(1000))?; + } } else { tokio::fs::copy(&src_path, &dst_path).await?; + + // Fix permissions and ownership for files in .ssh directory + #[cfg(unix)] + if let Some(parent) = dst_path.parent() { + if parent.file_name().and_then(|n| n.to_str()) + == Some(".ssh") + { + use std::os::unix::fs::PermissionsExt; + let file_name = dst_path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(""); + + let mut perms = + tokio::fs::metadata(&dst_path).await?.permissions(); + + // Set appropriate permissions based on file type + if !file_name.ends_with(".pub") + && file_name != "authorized_keys" + && file_name != "known_hosts" + { + // Private keys must be 600 + perms.set_mode(0o600); + } else if file_name == "authorized_keys" { + // authorized_keys should be 600 + perms.set_mode(0o600); + } else { + // Public keys and other files can be 644 + perms.set_mode(0o644); + } + + tokio::fs::set_permissions(&dst_path, perms).await?; + + // Change ownership to UID 1000, GID 1000 (container + // user) + std::os::unix::fs::chown( + &dst_path, + Some(1000), + Some(1000), + )?; + } + } } } From e0a9e61cf36696173d0db6b182c244be97c627c2 Mon Sep 17 00:00:00 2001 From: litcc Date: Tue, 10 Mar 2026 11:59:53 +0800 Subject: [PATCH 19/25] fix(docker-test): bug --- clash-lib/src/proxy/ssh/mod.rs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/clash-lib/src/proxy/ssh/mod.rs b/clash-lib/src/proxy/ssh/mod.rs index 55339ad5c..ba3c2b29b 100644 --- a/clash-lib/src/proxy/ssh/mod.rs +++ b/clash-lib/src/proxy/ssh/mod.rs @@ -483,7 +483,12 @@ mod tests { tokio::fs::set_permissions(&dst_path, perms).await?; // Change ownership to UID 1000, GID 1000 (container user) - std::os::unix::fs::chown(&dst_path, Some(1000), Some(1000))?; + + let _ = std::os::unix::fs::chown( + &dst_path, + Some(1000), + Some(1000), + ); } } else { tokio::fs::copy(&src_path, &dst_path).await?; @@ -520,13 +525,11 @@ mod tests { tokio::fs::set_permissions(&dst_path, perms).await?; - // Change ownership to UID 1000, GID 1000 (container - // user) - std::os::unix::fs::chown( + let _ = std::os::unix::fs::chown( &dst_path, Some(1000), Some(1000), - )?; + ); } } } From b7549ff27543a938418a7b1dee372b561b214fff Mon Sep 17 00:00:00 2001 From: litcc Date: Tue, 10 Mar 2026 14:17:38 +0800 Subject: [PATCH 20/25] fix(docker-test): bug --- clash-lib/src/proxy/ssh/mod.rs | 68 +++------------------------------- 1 file changed, 5 insertions(+), 63 deletions(-) diff --git a/clash-lib/src/proxy/ssh/mod.rs b/clash-lib/src/proxy/ssh/mod.rs index ba3c2b29b..681082464 100644 --- a/clash-lib/src/proxy/ssh/mod.rs +++ b/clash-lib/src/proxy/ssh/mod.rs @@ -412,9 +412,11 @@ mod tests { get_openssh_server_runner(ssh_config_tmp_path.clone()).await?; // Configure client to connect to container - let ssh_private_key_path = ssh_config_tmp_path - .join(".ssh") - .join(if opt.rsa { "test_rsa" } else { "test_ed25519" }); + let ssh_private_key_path = ssh_config_path.join(".ssh").join(if opt.rsa { + "test_rsa" + } else { + "test_ed25519" + }); let ssh_private_key_path = ssh_private_key_path.to_str().unwrap(); let password = if opt.password { @@ -470,68 +472,8 @@ mod tests { if entry.file_type().await?.is_dir() { copy_dir_recursive(&src_path, &dst_path).await?; - - // Fix ownership and permissions for .ssh directory - #[cfg(unix)] - if dst_path.file_name().and_then(|n| n.to_str()) == Some(".ssh") - { - use std::os::unix::fs::PermissionsExt; - // Set .ssh directory to 700 and owned by 1000:1000 - let mut perms = - tokio::fs::metadata(&dst_path).await?.permissions(); - perms.set_mode(0o700); - tokio::fs::set_permissions(&dst_path, perms).await?; - - // Change ownership to UID 1000, GID 1000 (container user) - - let _ = std::os::unix::fs::chown( - &dst_path, - Some(1000), - Some(1000), - ); - } } else { tokio::fs::copy(&src_path, &dst_path).await?; - - // Fix permissions and ownership for files in .ssh directory - #[cfg(unix)] - if let Some(parent) = dst_path.parent() { - if parent.file_name().and_then(|n| n.to_str()) - == Some(".ssh") - { - use std::os::unix::fs::PermissionsExt; - let file_name = dst_path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or(""); - - let mut perms = - tokio::fs::metadata(&dst_path).await?.permissions(); - - // Set appropriate permissions based on file type - if !file_name.ends_with(".pub") - && file_name != "authorized_keys" - && file_name != "known_hosts" - { - // Private keys must be 600 - perms.set_mode(0o600); - } else if file_name == "authorized_keys" { - // authorized_keys should be 600 - perms.set_mode(0o600); - } else { - // Public keys and other files can be 644 - perms.set_mode(0o644); - } - - tokio::fs::set_permissions(&dst_path, perms).await?; - - let _ = std::os::unix::fs::chown( - &dst_path, - Some(1000), - Some(1000), - ); - } - } } } From 4f009cf0502e28c9a6170dc9752230c8dc50f23f Mon Sep 17 00:00:00 2001 From: litcc Date: Tue, 10 Mar 2026 16:32:28 +0800 Subject: [PATCH 21/25] fix(clippy): resolve clippy warnings and improve code quality - Remove unnecessary clone in hysteria2 fragment creation - Refactor nested if-let to let-chain pattern in wireguard device --- clash-lib/src/proxy/hysteria2/mod.rs | 3 +-- clash-lib/src/proxy/wg/device.rs | 33 ++++++++++++++-------------- 2 files changed, 17 insertions(+), 19 deletions(-) diff --git a/clash-lib/src/proxy/hysteria2/mod.rs b/clash-lib/src/proxy/hysteria2/mod.rs index 1e4931be7..a24de7489 100644 --- a/clash-lib/src/proxy/hysteria2/mod.rs +++ b/clash-lib/src/proxy/hysteria2/mod.rs @@ -531,8 +531,7 @@ impl HysteriaConnection { )); } }; - let fragments = - Fragments::new(session_id, pkt_id, addr.clone(), max_frag_size, pkt); + let fragments = Fragments::new(session_id, pkt_id, addr, max_frag_size, pkt); let mut frag_count = 0; for frag in fragments { frag_count += 1; diff --git a/clash-lib/src/proxy/wg/device.rs b/clash-lib/src/proxy/wg/device.rs index 720650057..15536e6df 100644 --- a/clash-lib/src/proxy/wg/device.rs +++ b/clash-lib/src/proxy/wg/device.rs @@ -709,23 +709,22 @@ impl Device for VirtualIpDevice { // smoltcp::phy::Checksum::Tx` in capabilities(), but // recalculating feels cleaner than disabling verification entirely use smoltcp::wire::*; - if let Ok(IpVersion::Ipv4) = IpVersion::of_packet(&buffer) { - if let Ok(ipv4) = Ipv4Packet::new_checked(&buffer[..]) { - if ipv4.next_header() == IpProtocol::Udp { - let src_addr = ipv4.src_addr(); - let dst_addr = ipv4.dst_addr(); - let ip_header_len = ipv4.header_len() as usize; - - // Recalculate UDP checksum - if let Ok(mut udp) = - UdpPacket::new_checked(&mut buffer[ip_header_len..]) - { - udp.fill_checksum( - &IpAddress::Ipv4(src_addr), - &IpAddress::Ipv4(dst_addr), - ); - } - } + if let Ok(IpVersion::Ipv4) = IpVersion::of_packet(&buffer) + && let Ok(ipv4) = Ipv4Packet::new_checked(&buffer[..]) + && ipv4.next_header() == IpProtocol::Udp + { + let src_addr = ipv4.src_addr(); + let dst_addr = ipv4.dst_addr(); + let ip_header_len = ipv4.header_len() as usize; + + // Recalculate UDP checksum + if let Ok(mut udp) = + UdpPacket::new_checked(&mut buffer[ip_header_len..]) + { + udp.fill_checksum( + &IpAddress::Ipv4(src_addr), + &IpAddress::Ipv4(dst_addr), + ); } } From 1d2e1d7015b9311cdddef162a5a4e2f27269a6d1 Mon Sep 17 00:00:00 2001 From: litcc Date: Wed, 11 Mar 2026 22:29:13 +0800 Subject: [PATCH 22/25] fix(test): some writing issues --- .../test_utils/docker_utils/docker_runner.rs | 134 +++++++++--------- .../utils/test_utils/docker_utils/mod.rs | 6 +- clash-lib/tests/api_tests.rs | 58 +++++--- clash-lib/tests/common/mod.rs | 59 ++++++++ 4 files changed, 164 insertions(+), 93 deletions(-) diff --git a/clash-lib/src/proxy/utils/test_utils/docker_utils/docker_runner.rs b/clash-lib/src/proxy/utils/test_utils/docker_utils/docker_runner.rs index 81e9f7d0c..f7af5dc9c 100644 --- a/clash-lib/src/proxy/utils/test_utils/docker_utils/docker_runner.rs +++ b/clash-lib/src/proxy/utils/test_utils/docker_utils/docker_runner.rs @@ -18,6 +18,61 @@ use tar; const TIMEOUT_DURATION: u64 = 30; +/// Creates a tar archive from a source path with the given target path. +/// This is a blocking operation and should be called from `spawn_blocking`. +fn create_tar_archive(source: &str, target: &str) -> anyhow::Result> { + let mut ar = tar::Builder::new(Vec::new()); + + // Remove leading slash for tar path + let tar_path = if target.starts_with('/') { + &target[1..] + } else { + target + }; + + let source_path = Path::new(source); + let metadata = std::fs::metadata(source_path)?; + + if metadata.is_file() { + // Handle single file + let content = std::fs::read(source_path)?; + let mut header = tar::Header::new_gnu(); + header.set_size(content.len() as u64); + header.set_mode(0o644); + ar.append_data(&mut header, tar_path, &content[..])?; + } else if metadata.is_dir() { + // Handle directory recursively + ar.append_dir_all(tar_path, source_path)?; + } else { + anyhow::bail!("Unsupported file type for source: {}", source); + } + + let tar_data = ar.into_inner()?; + + // Debug: Print all files in the tar archive + tracing::trace!( + "=== TAR Archive Contents for mount {} -> {} ===", + source, + target + ); + let mut archive = tar::Archive::new(&tar_data[..]); + for (idx, entry) in archive.entries()?.enumerate() { + match entry { + Ok(e) => { + let path = e.path().ok(); + let size = e.header().size().ok(); + tracing::trace!(" [{}] {:?} (size: {:?})", idx, path, size); + } + Err(e) => { + tracing::warn!(" [{}] Error reading entry: {}", idx, e); + } + } + } + tracing::trace!("=== End TAR Archive Contents ==="); + + Ok(tar_data) +} + pub struct DockerTestRunner { instance: Docker, id: String, @@ -29,14 +84,14 @@ impl DockerTestRunner { image_conf: Option, mut container_conf: ContainerCreateBody, ) -> anyhow::Result { - let docker: Docker = if let Some(url) = option_env!("DOCKER_HOST") { + let docker: Docker = if let Some(url) = std::env::var("DOCKER_HOST").ok() { if url.starts_with("http://") || url.starts_with("https://") || url.starts_with("tcp://") { - Docker::connect_with_http(url, 60, API_DEFAULT_VERSION)? + Docker::connect_with_http(&url, 60, API_DEFAULT_VERSION)? } else if url.starts_with("unix://") || url.starts_with("npipe://") { - Docker::connect_with_socket(url, 60, API_DEFAULT_VERSION)? + Docker::connect_with_socket(&url, 60, API_DEFAULT_VERSION)? } else { anyhow::bail!("invalid DOCKER_HOST url: {}", url); } @@ -54,7 +109,8 @@ impl DockerTestRunner { .host_config .as_mut() .and_then(|hc| hc.mounts.take()); - let files_to_copy = if option_env!("DOCKER_HOST") + let files_to_copy = if std::env::var("DOCKER_HOST") + .ok() .map(|url| { url.starts_with("http://") || url.starts_with("https://") @@ -86,69 +142,13 @@ impl DockerTestRunner { if let (Some(source), Some(target)) = (mount.source.as_deref(), mount.target.as_deref()) { - // Create tar archive with full path structure - let mut ar = tar::Builder::new(Vec::new()); - - // Remove leading slash for tar path - let tar_path = if target.starts_with('/') { - &target[1..] - } else { - target - }; - - let source_path = Path::new(source); - let metadata = std::fs::metadata(source_path)?; - - if metadata.is_file() { - // Handle single file - let content = std::fs::read(source_path)?; - let mut header = tar::Header::new_gnu(); - header.set_size(content.len() as u64); - header.set_mode(0o644); - ar.append_data(&mut header, tar_path, &content[..])?; - } else if metadata.is_dir() { - // Handle directory recursively using sync operations - // append_dir_all will recursively add all files from - // source_path with tar_path as the - // prefix in the archive - ar.append_dir_all(tar_path, source_path)?; - } else { - anyhow::bail!( - "Unsupported file type for source: {}", - source - ); - } - let tar_data = ar.into_inner()?; - - // Debug: Print all files in the tar archive - tracing::trace!( - "=== TAR Archive Contents for mount {} -> {} ===", - source, - target - ); - let mut archive = tar::Archive::new(&tar_data[..]); - for (idx, entry) in archive.entries()?.enumerate() { - match entry { - Ok(e) => { - let path = e.path().ok(); - let size = e.header().size().ok(); - tracing::trace!( - " [{}] {:?} (size: {:?})", - idx, - path, - size - ); - } - Err(e) => { - tracing::warn!( - " [{}] Error reading entry: {}", - idx, - e - ); - } - } - } - tracing::trace!("=== End TAR Archive Contents ==="); + // Create tar archive in blocking context + let source = source.to_string(); + let target = target.to_string(); + let tar_data = tokio::task::spawn_blocking(move || { + create_tar_archive(&source, &target) + }) + .await??; // Upload to container root directory docker diff --git a/clash-lib/src/proxy/utils/test_utils/docker_utils/mod.rs b/clash-lib/src/proxy/utils/test_utils/docker_utils/mod.rs index 3e69f1454..7c34f637f 100644 --- a/clash-lib/src/proxy/utils/test_utils/docker_utils/mod.rs +++ b/clash-lib/src/proxy/utils/test_utils/docker_utils/mod.rs @@ -30,9 +30,9 @@ fn destination_list(gateway_ip: Option) -> Vec { debug!("gateway_ip Ip: {}", ip); destination_list.push(ip); } - if let Some(ip) = option_env!("CLIENT_IP") { - debug!("client Ip: {}", ip); - destination_list.insert(0, ip.to_owned()); + if let Some(ip) = std::env::var("CLIENT_IP").ok() { + debug!("client Ip: {}", &ip); + destination_list.insert(0, ip); } else { debug!("CLIENT_IP env not set, "); let mut networks = Networks::new_with_refreshed_list(); diff --git a/clash-lib/tests/api_tests.rs b/clash-lib/tests/api_tests.rs index f888d342f..e848a989d 100644 --- a/clash-lib/tests/api_tests.rs +++ b/clash-lib/tests/api_tests.rs @@ -1,4 +1,4 @@ -use crate::common::{send_http_request, start_clash, wait_port_ready}; +use crate::common::{ClashInstance, send_http_request}; use bytes::{Buf, Bytes}; use clash_lib::{Config, Options}; use http_body_util::BodyExt; @@ -19,17 +19,17 @@ async fn test_get_set_allow_lan() { config_path.to_string_lossy() ); - std::thread::spawn(move || { - start_clash(Options { + // Start Clash instance with RAII guard - will auto-cleanup on drop + let _clash = ClashInstance::start( + Options { config: Config::File(config_path.to_string_lossy().to_string()), cwd: Some(wd.to_string_lossy().to_string()), rt: None, log_file: None, - }) - .expect("Failed to start clash"); - }); - - wait_port_ready(9090).expect("Clash server is not ready"); + }, + vec![9090, 8888, 8889, 8899, 53553, 53554, 53555], + ) + .expect("Failed to start clash"); async fn get_allow_lan() -> bool { let get_configs_url = "http://127.0.0.1:9090/configs"; @@ -82,6 +82,8 @@ async fn test_get_set_allow_lan() { !get_allow_lan().await, "'allow_lan' should be false after update" ); + + // _clash will be dropped here, automatically cleaning up } #[cfg(feature = "shadowsocks")] @@ -106,39 +108,42 @@ async fn test_connections_returns_proxy_chain_names() { client_config.to_string_lossy() ); - std::thread::spawn(move || { - start_clash(Options { + // Start server instance with RAII guard + let _server = ClashInstance::start( + Options { config: Config::File(server_config.to_string_lossy().to_string()), cwd: Some(wd_server.to_string_lossy().to_string()), rt: None, log_file: None, - }) - .expect("Failed to start server"); - }); - - std::thread::spawn(move || { - start_clash(Options { + }, + vec![9091, 8901], + ) + .expect("Failed to start server"); + + // Start client instance with RAII guard + let _client = ClashInstance::start( + Options { config: Config::File(client_config.to_string_lossy().to_string()), cwd: Some(wd_client.to_string_lossy().to_string()), rt: None, log_file: None, - }) - .expect("Failed to start client"); - }); - - wait_port_ready(8899).expect("Proxy port is not ready"); + }, + vec![9090, 8888, 8889, 8899, 53553, 53554, 53555], + ) + .expect("Failed to start client"); - tokio::spawn(async { + let request_handle = tokio::spawn(async { let proxy = reqwest::Proxy::all("socks5h://127.0.0.1:8899") .expect("Failed to create proxy"); let client = reqwest::Client::builder() .proxy(proxy) + .timeout(Duration::from_secs(15)) .build() .expect("Failed to build reqwest client"); let response = client - .get("https://httpbin.yba.dev/drip?duration=100&delay=1&numbytes=1000") + .get("https://httpbin.yba.dev/drip?duration=5&delay=1&numbytes=1000") .send() .await .expect("Failed to send request through proxy"); @@ -194,4 +199,11 @@ async fn test_connections_returns_proxy_chain_names() { &["DIRECT", "url-test", "test 🌏"], "Chains do not match expected values" ); + + // Ensure the request task completed successfully + request_handle + .await + .expect("Request task panicked or failed"); + + // Both _server and _client will be dropped here, automatically cleaning up } diff --git a/clash-lib/tests/common/mod.rs b/clash-lib/tests/common/mod.rs index 72e2aceba..006092117 100644 --- a/clash-lib/tests/common/mod.rs +++ b/clash-lib/tests/common/mod.rs @@ -25,6 +25,65 @@ pub fn wait_port_ready(port: u16) -> Result<(), clash_lib::Error> { ))) } +fn wait_port_closed(port: u16) -> Result<(), clash_lib::Error> { + let addr = format!("127.0.0.1:{}", port); + let mut attempts = 0; + while attempts < 30 { + if TcpStream::connect(&addr).is_err() { + return Ok(()); + } + attempts += 1; + std::thread::sleep(std::time::Duration::from_millis(500)); + } + Err(clash_lib::Error::Io(std::io::Error::new( + std::io::ErrorKind::TimedOut, + format!("Port {} is still open after 15 seconds", port), + ))) +} + +/// RAII guard for Clash instance that ensures proper cleanup +pub struct ClashInstance { + ports: Vec, +} + +impl ClashInstance { + pub fn start( + options: clash_lib::Options, + ports: Vec, + ) -> Result { + std::thread::spawn(move || { + start_clash(options).expect("Failed to start clash"); + }); + + // Wait for the main port (usually API port) to be ready + if let Some(&main_port) = ports.first() { + wait_port_ready(main_port)?; + } + + Ok(Self { ports }) + } +} + +impl Drop for ClashInstance { + fn drop(&mut self) { + // Trigger shutdown + clash_lib::shutdown(); + + // Wait for all ports to be released + for &port in &self.ports { + if let Err(e) = wait_port_closed(port) { + eprintln!( + "Warning: Failed to wait for port {} to close: {}", + port, e + ); + } + } + + // Give a bit more time for full cleanup + std::thread::sleep(std::time::Duration::from_millis(500)); + } +} + /// Sends an HTTP request to the specified URL using a TCP connection. /// Don't use any domain name in the URL, which will trigger DNS resolution. /// And libnss_files will likely cause a coredump(in static crt build). From 8e2601bde5effb01bf959acd66e31f18ad5b5960 Mon Sep 17 00:00:00 2001 From: litcc Date: Wed, 11 Mar 2026 22:47:30 +0800 Subject: [PATCH 23/25] fix(clippy): some --- clash-lib/tests/common/mod.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/clash-lib/tests/common/mod.rs b/clash-lib/tests/common/mod.rs index 006092117..470b3c909 100644 --- a/clash-lib/tests/common/mod.rs +++ b/clash-lib/tests/common/mod.rs @@ -25,6 +25,7 @@ pub fn wait_port_ready(port: u16) -> Result<(), clash_lib::Error> { ))) } +#[allow(dead_code)] fn wait_port_closed(port: u16) -> Result<(), clash_lib::Error> { let addr = format!("127.0.0.1:{}", port); let mut attempts = 0; @@ -42,11 +43,14 @@ fn wait_port_closed(port: u16) -> Result<(), clash_lib::Error> { } /// RAII guard for Clash instance that ensures proper cleanup +#[allow(dead_code)] pub struct ClashInstance { ports: Vec, } impl ClashInstance { + #[allow(dead_code)] + pub fn start( options: clash_lib::Options, ports: Vec, From fdae7b874f33955cb872f3416cc51be2004fecb4 Mon Sep 17 00:00:00 2001 From: litcc Date: Thu, 12 Mar 2026 13:51:56 +0800 Subject: [PATCH 24/25] fix(test): test_connections_returns_proxy_chain_names --- clash-lib/tests/api_tests.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/clash-lib/tests/api_tests.rs b/clash-lib/tests/api_tests.rs index e848a989d..4d6d3a542 100644 --- a/clash-lib/tests/api_tests.rs +++ b/clash-lib/tests/api_tests.rs @@ -138,12 +138,12 @@ async fn test_connections_returns_proxy_chain_names() { let client = reqwest::Client::builder() .proxy(proxy) - .timeout(Duration::from_secs(15)) + .timeout(Duration::from_secs(30)) .build() .expect("Failed to build reqwest client"); let response = client - .get("https://httpbin.yba.dev/drip?duration=5&delay=1&numbytes=1000") + .get("https://httpbin.yba.dev/drip?duration=2&delay=1&numbytes=500") .send() .await .expect("Failed to send request through proxy"); From a17b40b0d851337d504a2c41bfce1e238457722c Mon Sep 17 00:00:00 2001 From: litcc Date: Thu, 12 Mar 2026 15:23:19 +0800 Subject: [PATCH 25/25] fix(deps): Handling merged dependency issues --- Cargo.lock | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5b2ab4865..525fe64be 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6315,7 +6315,7 @@ dependencies = [ "pkcs1 0.8.0-rc.4", "pkcs8 0.11.0-rc.8", "rand_core 0.10.0-rc-3", - "sha2 0.11.0-rc.3", + "sha2 0.11.0-rc.5", "signature 3.0.0-rc.6", "spki 0.8.0-rc.4", "zeroize", @@ -7172,9 +7172,9 @@ dependencies = [ [[package]] name = "sha2" -version = "0.11.0-rc.3" +version = "0.11.0-rc.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19d43dc0354d88b791216bb5c1bfbb60c0814460cc653ae0ebd71f286d0bd927" +checksum = "7c5f3b1e2dc8aad28310d8410bd4d7e180eca65fca176c52ab00d364475d0024" dependencies = [ "cfg-if", "cpufeatures", @@ -7767,9 +7767,9 @@ dependencies = [ [[package]] name = "system-configuration" -version = "0.6.1" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ "bitflags 2.9.4", "core-foundation 0.9.4",