diff --git a/Cargo.lock b/Cargo.lock index 6bfa3db4b..44406c2eb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -695,6 +695,50 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bollard" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83545367eb6428eb35c29cdec3a1f350fa8d6d9085d59a7d7bcb637f2e38db5a" +dependencies = [ + "base64 0.21.7", + "bollard-stubs", + "bytes", + "futures-core", + "futures-util", + "hex", + "http 1.1.0", + "http-body-util", + "hyper 1.1.0", + "hyper-named-pipe", + "hyper-util", + "hyperlocal-next", + "log", + "pin-project-lite", + "serde", + "serde_derive", + "serde_json", + "serde_repr", + "serde_urlencoded", + "thiserror", + "tokio", + "tokio-util", + "tower-service", + "url", + "winapi", +] + +[[package]] +name = "bollard-stubs" +version = "1.44.0-rc.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709d9aa1c37abb89d40f19f5d0ad6f0d88cb1581264e571c9350fc5bb89cf1c5" +dependencies = [ + "serde", + "serde_repr", + "serde_with", +] + [[package]] name = "boring" version = "4.5.0" @@ -1037,6 +1081,7 @@ dependencies = [ "axum 0.7.4", "axum-macros", "base64 0.22.0", + "bollard", "boring", "boring-sys", "boringtun", @@ -2672,6 +2717,22 @@ dependencies = [ "itoa", "pin-project-lite", "tokio", + "want", +] + +[[package]] +name = "hyper-named-pipe" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73b7d8abf35697b81a825e386fc151e0d503e8cb5fcb93cc8669c376dfd6f278" +dependencies = [ + "hex", + "hyper 1.1.0", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", + "winapi", ] [[package]] @@ -2717,9 +2778,26 @@ dependencies = [ "pin-project-lite", "socket2", "tokio", + "tower", + "tower-service", "tracing", ] +[[package]] +name = "hyperlocal-next" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acf569d43fa9848e510358c07b80f4adf34084ddc28c6a4a651ee8474c070dcc" +dependencies = [ + "hex", + "http-body-util", + "hyper 1.1.0", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + [[package]] name = "iana-time-zone" version = "0.1.59" @@ -4690,6 +4768,17 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_repr" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b2e6b945e9d3df726b65d6ee24060aff8e3533d431f677a9695db04eff9dfdb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.48", +] + [[package]] name = "serde_spanned" version = "0.6.5" diff --git a/clash/tests/data/config/example.org-key.pem b/clash/tests/data/config/example.org-key.pem new file mode 100644 index 000000000..dbe9a3db3 --- /dev/null +++ b/clash/tests/data/config/example.org-key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDQ+c++LkDTdaw5 +5spCu9MWMcvVdrYBZZ5qZy7DskphSUSQp25cIu34GJXVPNxtbWx1CQCmdLlwqXvo +PfUt5/pz9qsfhdAbzFduZQgGd7GTQOTJBDrAhm2+iVsQyGHHhF68muN+SgT+AtRE +sJyZoHNYtjjWEIHQ++FHEDqwUVnj6Ut99LHlyfCjOZ5+WyBiKCjyMNots/gDep7R +i4X2kMTqNMIIqPUcAaP5EQk41bJbFhKe915qN9b1dRISKFKmiWeOsxgTB/O/EaL5 +LsBYwZ/BiIMDk30aZvzRJeloasIR3z4hrKQqBfB0lfeIdiPpJIs5rXJQEiWH89ge +gplsLbfrAgMBAAECggEBAKpMGaZzDPMF/v8Ee6lcZM2+cMyZPALxa+JsCakCvyh+ +y7hSKVY+RM0cQ+YM/djTBkJtvrDniEMuasI803PAitI7nwJGSuyMXmehP6P9oKFO +jeLeZn6ETiSqzKJlmYE89vMeCevdqCnT5mW/wy5Smg0eGj0gIJpM2S3PJPSQpv9Z +ots0JXkwooJcpGWzlwPkjSouY2gDbE4Coi+jmYLNjA1k5RbggcutnUCZZkJ6yMNv +H52VjnkffpAFHRouK/YgF+5nbMyyw5YTLOyTWBq7qfBMsXynkWLU73GC/xDZa3yG +o/Ph2knXCjgLmCRessTOObdOXedjnGWIjiqF8fVboDECgYEA6x5CteYiwthDBULZ +CG5nE9VKkRHJYdArm+VjmGbzK51tKli112avmU4r3ol907+mEa4tWLkPqdZrrL49 +aHltuHizZJixJcw0rcI302ot/Ov0gkF9V55gnAQS/Kemvx9FHWm5NHdYvbObzj33 +bYRLJBtJWzYg9M8Bw9ZrUnegc/MCgYEA44kq5OSYCbyu3eaX8XHTtFhuQHNFjwl7 +Xk/Oel6PVZzmt+oOlDHnOfGSB/KpR3YXxFRngiiPZzbrOwFyPGe7HIfg03HAXiJh +ivEfrPHbQqQUI/4b44GpDy6bhNtz777ivFGYEt21vpwd89rFiye+RkqF8eL/evxO +pUayDZYvwikCgYEA07wFoZ/lkAiHmpZPsxsRcrfzFd+pto9splEWtumHdbCo3ajT +4W5VFr9iHF8/VFDT8jokFjFaXL1/bCpKTOqFl8oC68XiSkKy8gPkmFyXm5y2LhNi +GGTFZdr5alRkgttbN5i9M/WCkhvMZRhC2Xp43MRB9IUzeqNtWHqhXbvjYGcCgYEA +vTMOztviLJ6PjYa0K5lp31l0+/SeD21j/y0/VPOSHi9kjeN7EfFZAw6DTkaSShDB +fIhutYVCkSHSgfMW6XGb3gKCiW/Z9KyEDYOowicuGgDTmoYu7IOhbzVjLhtJET7Z +zJvQZ0eiW4f3RBFTF/4JMuu+6z7FD6ADSV06qx+KQNkCgYBw26iQxmT5e/4kVv8X +DzBJ1HuliKBnnzZA1YRjB4H8F6Yrq+9qur1Lurez4YlbkGV8yPFt+Iu82ViUWL28 +9T7Jgp3TOpf8qOqsWFv8HldpEZbE0Tcib4x6s+zOg/aw0ac/xOPY1sCVFB81VODP +XCar+uxMBXI1zbXqd9QdEwy4Ig== +-----END PRIVATE KEY----- diff --git a/clash/tests/data/config/example.org.pem b/clash/tests/data/config/example.org.pem new file mode 100644 index 000000000..9b99259a3 --- /dev/null +++ b/clash/tests/data/config/example.org.pem @@ -0,0 +1,25 @@ +-----BEGIN CERTIFICATE----- +MIIESzCCArOgAwIBAgIQIi5xRZvFZaSweWU9Y5mExjANBgkqhkiG9w0BAQsFADCB +hzEeMBwGA1UEChMVbWtjZXJ0IGRldmVsb3BtZW50IENBMS4wLAYDVQQLDCVkcmVh +bWFjcm9ARHJlYW1hY3JvLmxvY2FsIChEcmVhbWFjcm8pMTUwMwYDVQQDDCxta2Nl +cnQgZHJlYW1hY3JvQERyZWFtYWNyby5sb2NhbCAoRHJlYW1hY3JvKTAeFw0yMTAz +MTcxNDQwMzZaFw0yMzA2MTcxNDQwMzZaMFkxJzAlBgNVBAoTHm1rY2VydCBkZXZl +bG9wbWVudCBjZXJ0aWZpY2F0ZTEuMCwGA1UECwwlZHJlYW1hY3JvQERyZWFtYWNy +by5sb2NhbCAoRHJlYW1hY3JvKTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoC +ggEBAND5z74uQNN1rDnmykK70xYxy9V2tgFlnmpnLsOySmFJRJCnblwi7fgYldU8 +3G1tbHUJAKZ0uXCpe+g99S3n+nP2qx+F0BvMV25lCAZ3sZNA5MkEOsCGbb6JWxDI +YceEXrya435KBP4C1ESwnJmgc1i2ONYQgdD74UcQOrBRWePpS330seXJ8KM5nn5b +IGIoKPIw2i2z+AN6ntGLhfaQxOo0wgio9RwBo/kRCTjVslsWEp73Xmo31vV1EhIo +UqaJZ46zGBMH878RovkuwFjBn8GIgwOTfRpm/NEl6WhqwhHfPiGspCoF8HSV94h2 +I+kkizmtclASJYfz2B6CmWwtt+sCAwEAAaNgMF4wDgYDVR0PAQH/BAQDAgWgMBMG +A1UdJQQMMAoGCCsGAQUFBwMBMB8GA1UdIwQYMBaAFO800LQ6Pa85RH4EbMmFH6ln +F150MBYGA1UdEQQPMA2CC2V4YW1wbGUub3JnMA0GCSqGSIb3DQEBCwUAA4IBgQAP +TsF53h7bvJcUXT3Y9yZ2vnW6xr9r92tNnM1Gfo3D2Yyn9oLf2YrfJng6WZ04Fhqa +Wh0HOvE0n6yPNpm/Q7mh64DrgolZ8Ce5H4RTJDAabHU9XhEzfGSVtzRSFsz+szu1 +Y30IV+08DxxqMmNPspYdpAET2Lwyk2WhnARGiGw11CRkQCEkVEe6d702vS9UGBUz +Du6lmCYCm0SbFrZ0CGgmHSHoTcCtf3EjVam7dPg3yWiPbWjvhXxgip6hz9sCqkhG +WA5f+fPgSZ1I9U4i+uYnqjfrzwgC08RwUYordm15F6gPvXw+KVwDO8yUYQoEH0b6 +AFJtbzoAXDysvBC6kWYFFOr62EaisaEkELTS/NrPD9ux1eKbxcxHCwEtVjgC0CL6 +gAxEAQ+9maJMbrAFhsOBbGGFC+mMCGg4eEyx6+iMB0oQe0W7QFeRUAFi7Ptc/ocS +tZ9lbrfX1/wrcTTWIYWE+xH6oeb4fhs29kxjHcf2l+tQzmpl0aP3Z/bMW4BSB+w= +-----END CERTIFICATE----- diff --git a/clash/tests/data/config/ss.yaml b/clash/tests/data/config/ss.yaml index b93a31855..557096067 100644 --- a/clash/tests/data/config/ss.yaml +++ b/clash/tests/data/config/ss.yaml @@ -60,5 +60,5 @@ proxies: udp: true rules: - - MATCH, ss + - MATCH, ss-01 ... diff --git a/clash/tests/data/config/trojan-grpc.json b/clash/tests/data/config/trojan-grpc.json new file mode 100644 index 000000000..eb0dcc990 --- /dev/null +++ b/clash/tests/data/config/trojan-grpc.json @@ -0,0 +1,40 @@ +{ + "inbounds": [ + { + "port": 10002, + "listen": "0.0.0.0", + "protocol": "trojan", + "settings": { + "clients": [ + { + "password": "example", + "email": "grpc@example.com" + } + ] + }, + "streamSettings": { + "network": "grpc", + "security": "tls", + "tlsSettings": { + "certificates": [ + { + "certificateFile": "/etc/ssl/v2ray/fullchain.pem", + "keyFile": "/etc/ssl/v2ray/privkey.pem" + } + ] + }, + "grpcSettings": { + "serviceName": "example" + } + } + } + ], + "outbounds": [ + { + "protocol": "freedom" + } + ], + "log": { + "loglevel": "debug" + } +} \ No newline at end of file diff --git a/clash/tests/data/config/trojan-ws.json b/clash/tests/data/config/trojan-ws.json new file mode 100644 index 000000000..efc0acbd0 --- /dev/null +++ b/clash/tests/data/config/trojan-ws.json @@ -0,0 +1,20 @@ +{ + "run_type": "server", + "local_addr": "0.0.0.0", + "local_port": 10002, + "disable_http_check": true, + "password": [ + "example" + ], + "websocket": { + "enabled": true, + "path": "/", + "host": "example.org" + }, + "ssl": { + "verify": true, + "cert": "/fullchain.pem", + "key": "/privkey.pem", + "sni": "example.org" + } +} \ No newline at end of file diff --git a/clash/tests/data/config/vmess-grpc.json b/clash/tests/data/config/vmess-grpc.json new file mode 100644 index 000000000..178e0685f --- /dev/null +++ b/clash/tests/data/config/vmess-grpc.json @@ -0,0 +1,39 @@ +{ + "inbounds": [ + { + "port": 10002, + "listen": "0.0.0.0", + "protocol": "vmess", + "settings": { + "clients": [ + { + "id": "b831381d-6324-4d53-ad4f-8cda48b30811" + } + ] + }, + "streamSettings": { + "network": "grpc", + "security": "tls", + "tlsSettings": { + "certificates": [ + { + "certificateFile": "/etc/ssl/v2ray/fullchain.pem", + "keyFile": "/etc/ssl/v2ray/privkey.pem" + } + ] + }, + "grpcSettings": { + "serviceName": "example!" + } + } + } + ], + "outbounds": [ + { + "protocol": "freedom" + } + ], + "log": { + "loglevel": "debug" + } +} \ No newline at end of file diff --git a/clash/tests/data/config/vmess-http2.json b/clash/tests/data/config/vmess-http2.json new file mode 100644 index 000000000..c6916a1b5 --- /dev/null +++ b/clash/tests/data/config/vmess-http2.json @@ -0,0 +1,42 @@ +{ + "inbounds": [ + { + "port": 10002, + "listen": "0.0.0.0", + "protocol": "vmess", + "settings": { + "clients": [ + { + "id": "b831381d-6324-4d53-ad4f-8cda48b30811" + } + ] + }, + "streamSettings": { + "network": "http", + "security": "tls", + "tlsSettings": { + "certificates": [ + { + "certificateFile": "/etc/ssl/v2ray/fullchain.pem", + "keyFile": "/etc/ssl/v2ray/privkey.pem" + } + ] + }, + "httpSettings": { + "host": [ + "example.org" + ], + "path": "/test" + } + } + } + ], + "outbounds": [ + { + "protocol": "freedom" + } + ], + "log": { + "loglevel": "debug" + } +} \ No newline at end of file diff --git a/clash/tests/data/config/vmess-ws.json b/clash/tests/data/config/vmess-ws.json new file mode 100644 index 000000000..2bcb604dd --- /dev/null +++ b/clash/tests/data/config/vmess-ws.json @@ -0,0 +1,25 @@ +{ + "inbounds": [ + { + "port": 10002, + "listen": "0.0.0.0", + "protocol": "vmess", + "settings": { + "clients": [ + { + "id": "b831381d-6324-4d53-ad4f-8cda48b30811" + } + ] + }, + "streamSettings": { + "network": "ws", + "security": "none" + } + } + ], + "outbounds": [ + { + "protocol": "freedom" + } + ] +} \ No newline at end of file diff --git a/clash_lib/Cargo.toml b/clash_lib/Cargo.toml index 81d45c526..eb9d88e09 100644 --- a/clash_lib/Cargo.toml +++ b/clash_lib/Cargo.toml @@ -118,6 +118,7 @@ ctor = "0.2" mockall = "0.12.1" tokio-test = "0.4.4" axum-macros = "0.4.0" +bollard = "0.16" [target.'cfg(macos)'.dependencies] diff --git a/clash_lib/src/proxy/shadowsocks/mod.rs b/clash_lib/src/proxy/shadowsocks/mod.rs index f3e42e743..c715137a0 100644 --- a/clash_lib/src/proxy/shadowsocks/mod.rs +++ b/clash_lib/src/proxy/shadowsocks/mod.rs @@ -287,3 +287,86 @@ impl OutboundHandler for Handler { Ok(Box::new(d)) } } + +#[cfg(test)] +mod tests { + + use super::super::utils::test_utils::{ + benchmark_proxy, + consts::*, + docker_runner::{default_export_ports, default_host_config, DockerTestRunner}, + latency_test_proxy, LatencyTestOption, + }; + + use super::*; + + const PASSWORD: &str = "FzcLbKs2dY9mhL"; + const CIPHER: &str = "aes-256-gcm"; + + async fn get_runner() -> anyhow::Result { + use bollard::{container::Config, image::CreateImageOptions}; + + let host_config = default_host_config(); + let export_ports = default_export_ports(); + + DockerTestRunner::new( + Some(CreateImageOptions { + from_image: IMAGE_SS_RUST, + ..Default::default() + }), + Config { + image: Some(IMAGE_SS_RUST), + tty: Some(true), + entrypoint: Some(vec!["ssserver"]), + cmd: Some(vec![ + "-s", + "0.0.0.0:10002", + "-m", + CIPHER, + "-k", + PASSWORD, + "-U", + ]), + exposed_ports: Some(export_ports), + host_config: Some(host_config), + ..Default::default() + }, + ) + .await + .map_err(Into::into) + } + + #[tokio::test] + async fn test_ss() -> anyhow::Result<()> { + let opts = HandlerOptions { + name: "test-ss".to_owned(), + common_opts: Default::default(), + server: LOCAL_ADDR.to_owned(), + port: 10002, + password: PASSWORD.to_owned(), + cipher: CIPHER.to_owned(), + plugin_opts: Default::default(), + udp: false, + }; + let handler = Handler::new(opts); + + let watch = get_runner().await?; + + watch + .run_and_cleanup(async move { + benchmark_proxy(handler.clone(), 10001).await?; + latency_test_proxy( + handler, + LatencyTestOption { + dst: SocksAddr::Domain("google.com".to_owned(), 80), + req: GOOGLE_REQ, + expected_resp: GOOGLE_RESP_301, + read_exact: true, + }, + ) + .await?; + Ok(()) + }) + .await + } +} diff --git a/clash_lib/src/proxy/trojan/mod.rs b/clash_lib/src/proxy/trojan/mod.rs index b11915dab..d2d7b79fb 100644 --- a/clash_lib/src/proxy/trojan/mod.rs +++ b/clash_lib/src/proxy/trojan/mod.rs @@ -219,3 +219,174 @@ impl OutboundHandler for Handler { Ok(Box::new(chained)) } } + +#[cfg(test)] +mod tests { + + use std::collections::HashMap; + + use crate::proxy::utils::test_utils::{ + benchmark_proxy, + config_helper::test_config_base_dir, + consts::*, + docker_runner::{ + default_export_ports, default_host_config, mount_config, DockerTestRunner, + }, + latency_test_proxy, LatencyTestOption, + }; + + use super::*; + + async fn get_ws_runner() -> anyhow::Result { + use bollard::{container::Config, image::CreateImageOptions}; + + let mut host_config = default_host_config(); + let test_config_dir = test_config_base_dir(); + let trojan_conf = test_config_dir.join("trojan-ws.json"); + let trojan_cert = test_config_dir.join("example.org.pem"); + let trojan_key = test_config_dir.join("example.org-key.pem"); + + host_config.mounts = Some(mount_config(&[ + (trojan_conf.to_str().unwrap(), "/etc/trojan-go/config.json"), + (trojan_cert.to_str().unwrap(), "/fullchain.pem"), + (trojan_key.to_str().unwrap(), "/privkey.pem"), + ])); + let export_ports = default_export_ports(); + + DockerTestRunner::new( + Some(CreateImageOptions { + from_image: IMAGE_TROJAN_GO, + ..Default::default() + }), + Config { + image: Some(IMAGE_TROJAN_GO), + tty: Some(true), + exposed_ports: Some(export_ports), + host_config: Some(host_config), + ..Default::default() + }, + ) + .await + .map_err(Into::into) + } + + #[tokio::test] + async fn test_trojan_ws() -> anyhow::Result<()> { + let opts = Opts { + name: "test-trojan-ws".to_owned(), + common_opts: Default::default(), + server: "127.0.0.1".to_owned(), + port: 10002, + password: "example".to_owned(), + udp: true, + sni: "example.org".to_owned(), + alpn: None, + skip_cert_verify: true, + transport: Some(Transport::Ws(WsOption { + path: "".to_owned(), + headers: [("Host".to_owned(), "example.org".to_owned())] + .into_iter() + .collect::>(), + // ignore the rest by setting max_early_data to 0 + max_early_data: 0, + early_data_header_name: "".to_owned(), + })), + }; + let handler = Handler::new(opts); + + let runner = get_ws_runner().await?; + + runner + .run_and_cleanup(async move { + benchmark_proxy(handler.clone(), 10001).await?; + latency_test_proxy( + handler, + LatencyTestOption { + dst: SocksAddr::Domain("google.com".to_owned(), 80), + req: GOOGLE_REQ, + expected_resp: GOOGLE_RESP_301, + read_exact: true, + }, + ) + .await?; + Ok(()) + }) + .await?; + + Ok(()) + } + + async fn get_grpc_runner() -> anyhow::Result { + use bollard::{container::Config, image::CreateImageOptions}; + + let mut host_config = default_host_config(); + let test_config_dir = test_config_base_dir(); + let conf = test_config_dir.join("trojan-grpc.json"); + let cert = test_config_dir.join("example.org.pem"); + let key = test_config_dir.join("example.org-key.pem"); + + host_config.mounts = Some(mount_config(&[ + (conf.to_str().unwrap(), "/etc/xray/config.json"), + (cert.to_str().unwrap(), "/etc/ssl/v2ray/fullchain.pem"), + (key.to_str().unwrap(), "/etc/ssl/v2ray/privkey.pem"), + ])); + let export_ports = default_export_ports(); + + DockerTestRunner::new( + Some(CreateImageOptions { + from_image: IMAGE_XRAY, + ..Default::default() + }), + Config { + image: Some(IMAGE_XRAY), + tty: Some(true), + exposed_ports: Some(export_ports), + host_config: Some(host_config), + ..Default::default() + }, + ) + .await + .map_err(Into::into) + } + + #[tokio::test] + async fn test_trojan_grpc() -> anyhow::Result<()> { + let opts = Opts { + name: "test-trojan-grpc".to_owned(), + common_opts: Default::default(), + server: "127.0.0.1".to_owned(), + port: 10002, + password: "example".to_owned(), + udp: true, + sni: "example.org".to_owned(), + alpn: None, + skip_cert_verify: true, + transport: Some(Transport::Grpc(GrpcOption { + host: "example.org".to_owned(), + service_name: "example".to_owned(), + })), + }; + let handler = Handler::new(opts); + + let runner = get_grpc_runner().await?; + + runner + .run_and_cleanup(async move { + benchmark_proxy(handler.clone(), 10001).await?; + latency_test_proxy( + handler, + LatencyTestOption { + dst: SocksAddr::Domain("google.com".to_owned(), 80), + req: GOOGLE_REQ, + expected_resp: GOOGLE_RESP_301, + read_exact: true, + }, + ) + .await?; + Ok(()) + }) + .await?; + + Ok(()) + } +} diff --git a/clash_lib/src/proxy/utils/mod.rs b/clash_lib/src/proxy/utils/mod.rs index 01b86bbd4..6fbadf571 100644 --- a/clash_lib/src/proxy/utils/mod.rs +++ b/clash_lib/src/proxy/utils/mod.rs @@ -3,6 +3,9 @@ use std::{ net::{IpAddr, SocketAddr}, }; +#[cfg(test)] +pub mod test_utils; + pub mod provider_helper; mod socket_helpers; diff --git a/clash_lib/src/proxy/utils/test_utils/config_helper.rs b/clash_lib/src/proxy/utils/test_utils/config_helper.rs new file mode 100644 index 000000000..e6c342713 --- /dev/null +++ b/clash_lib/src/proxy/utils/test_utils/config_helper.rs @@ -0,0 +1,59 @@ +use crate::Error; +use std::path::PathBuf; +use std::sync::Arc; +use tracing::debug; + +use crate::{ + app::{ + dns::{self, ClashResolver, SystemResolver}, + profile, + }, + common::{http::new_http_client, mmdb}, + Config, +}; + +pub fn root_dir() -> PathBuf { + let mut root = PathBuf::from(env!("CARGO_MANIFEST_DIR").to_owned()); + // remove the clash_lib + root.pop(); + root +} + +pub fn test_config_base_dir() -> PathBuf { + root_dir().join("clash/tests/data/config") +} + +// load the config from test dir +// and return the dns resolver for the proxy +pub async fn load_config() -> anyhow::Result<( + crate::config::internal::config::Config, + Arc, +)> { + let root = root_dir(); + let test_base_dir = test_config_base_dir(); + let config_path = test_base_dir.join("ss.yaml").to_str().unwrap().to_owned(); + let config = Config::File(config_path).try_parse()?; + let mmdb_path = test_base_dir.join("Country.mmdb"); + let system_resolver = + Arc::new(SystemResolver::new().map_err(|x| Error::DNSError(x.to_string()))?); + let client = new_http_client(system_resolver).map_err(|x| Error::DNSError(x.to_string()))?; + + let mmdb = Arc::new( + mmdb::Mmdb::new(mmdb_path, config.general.mmdb_download_url.clone(), client).await?, + ); + + debug!("initializing cache store"); + let cache_store = profile::ThreadSafeCacheFile::new( + PathBuf::from(root) + .join("cache.db") + .as_path() + .to_str() + .unwrap(), + config.profile.store_selected, + ); + + let dns_resolver: Arc = + dns::Resolver::new_resolver(&config.dns, cache_store.clone(), mmdb.clone()).await; + + Ok((config, dns_resolver)) +} diff --git a/clash_lib/src/proxy/utils/test_utils/consts.rs b/clash_lib/src/proxy/utils/test_utils/consts.rs new file mode 100644 index 000000000..e9416cf45 --- /dev/null +++ b/clash_lib/src/proxy/utils/test_utils/consts.rs @@ -0,0 +1,10 @@ +pub const LOCAL_ADDR: &str = "127.0.0.1"; +pub const GOOGLE_REQ: &[u8] = b"GET / HTTP/1.1\r\nHost: google.com\r\nAccept: */*\r\n\r\n"; +pub const EXAMPLE_REQ: &[u8] = b"GET / HTTP/1.1\r\nHost: example.com\r\nAccept: */*\r\n\r\n"; +pub const GOOGLE_RESP_301: &[u8] = b"HTTP/1.1 301"; +pub const EXAMLE_RESP_200: &[u8] = b"HTTP/1.1 200"; + +pub const IMAGE_SS_RUST: &str = "ghcr.io/shadowsocks/ssserver-rust:latest"; +pub const IMAGE_TROJAN_GO: &str = "p4gefau1t/trojan-go:latest"; +pub const IMAGE_VMESS: &str = "v2fly/v2fly-core:v4.45.2"; +pub const IMAGE_XRAY: &str = "teddysun/xray:latest"; diff --git a/clash_lib/src/proxy/utils/test_utils/docker_runner.rs b/clash_lib/src/proxy/utils/test_utils/docker_runner.rs new file mode 100644 index 000000000..69df25ace --- /dev/null +++ b/clash_lib/src/proxy/utils/test_utils/docker_runner.rs @@ -0,0 +1,158 @@ +//! This example will run a non-interactive command inside the container using `docker exec` + +use std::collections::HashMap; + +use bollard::container::{Config, RemoveContainerOptions}; +use bollard::secret::{HostConfig, Mount, PortBinding}; +use bollard::Docker; + +use bollard::exec::{CreateExecOptions, StartExecResults}; +use bollard::image::CreateImageOptions; + +use anyhow::Result; +use futures::{Future, StreamExt, TryStreamExt}; + +pub struct DockerTestRunner { + _instance: Docker, + id: String, +} + +impl DockerTestRunner { + pub async fn new< + 'a, + T1: serde::ser::Serialize + Into + std::fmt::Debug + Clone, + Z: Into + std::hash::Hash + Eq + serde::Serialize, + >( + image_conf: Option>, + container_conf: Config, + ) -> Result { + let docker: Docker = Docker::connect_with_socket_defaults()?; + + docker + .create_image(image_conf, None, None) + .try_collect::>() + .await?; + + let id = docker + .create_container::<&str, Z>(None, container_conf) + .await? + .id; + docker.start_container::(&id, None).await?; + Ok(Self { + _instance: docker, + id, + }) + } + + #[allow(unused)] + pub async fn exec(&self, cmd: Vec<&str>) -> anyhow::Result<()> { + // non interactive + let exec = self + ._instance + .create_exec( + &self.id, + CreateExecOptions { + attach_stdout: Some(true), + attach_stderr: Some(true), + cmd: Some(cmd), + ..Default::default() + }, + ) + .await? + .id; + if let StartExecResults::Attached { mut output, .. } = + self._instance.start_exec(&exec, None).await? + { + while let Some(Ok(msg)) = output.next().await { + print!("{msg}"); + } + return Ok(()); + } else { + anyhow::bail!("failed to execute cmd") + } + } + + // will make sure the container is cleaned up after the future is finished + pub async fn run_and_cleanup( + self, + f: impl Future> + ) -> anyhow::Result<()> { + let fut = Box::pin(f); + let res = fut.await; + // make sure the container is cleaned up + // TODO: select a timeout future as well, make sure it can quit smoothly + self.cleanup().await?; + + res + } + + // you can run the cleanup manually + pub async fn cleanup(self) -> anyhow::Result<()> { + let s = self + ._instance + .remove_container( + &self.id, + Some(RemoveContainerOptions { + force: true, + ..Default::default() + }), + ) + .await?; + Ok(s) + } +} + +pub fn mount_config(pairs: &[(&str, &str)]) -> Vec { + pairs + .iter() + .map(|(src, dst)| Mount { + target: Some(dst.to_string()), + source: Some(src.to_string()), + typ: Some(bollard::secret::MountTypeEnum::BIND), + read_only: Some(false), + ..Default::default() + }) + .collect::>() +} + +pub fn default_host_config() -> HostConfig { + let mut host_config = HostConfig::default(); + // we need to use the host mode to enable the benchmark function + #[cfg(not(target_os = "macos"))] + { + host_config.network_mode = Some("host".to_owned()); + } + host_config.port_bindings = Some( + [ + ( + "10002/tcp".to_owned(), + Some(vec![PortBinding { + host_ip: Some("0.0.0.0".to_owned()), + host_port: Some("10002".to_owned()), + }]), + ), + ( + "10002/udp".to_owned(), + Some(vec![PortBinding { + host_ip: Some("0.0.0.0".to_owned()), + host_port: Some("10002".to_owned()), + }]), + ), + ] + .into_iter() + .collect::>(), + ); + + host_config +} + +pub fn default_export_ports() -> HashMap<&'static str, HashMap<(), ()>> { + let export_ports: HashMap<&str, HashMap<(), ()>> = [ + ("10002/tcp", Default::default()), + ("10002/udp", Default::default()), + ] + .into_iter() + .collect::>(); + + export_ports +} diff --git a/clash_lib/src/proxy/utils/test_utils/mod.rs b/clash_lib/src/proxy/utils/test_utils/mod.rs new file mode 100644 index 000000000..197ca4b26 --- /dev/null +++ b/clash_lib/src/proxy/utils/test_utils/mod.rs @@ -0,0 +1,149 @@ +use std::sync::Arc; + +use tokio::{ + io::{split, AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}, + join, + net::TcpListener, +}; +use crate::{app::dispatcher::ChainedStream, proxy::OutboundHandler, session::{Session, SocksAddr}}; + +pub mod config_helper; +pub mod consts; +pub mod docker_runner; + +// TODO: add the thoroughput metrics +pub async fn benchmark_proxy(handler: Arc, port: u16) -> anyhow::Result<()> { + // proxy -> proxy-server -> destination(127.0.0.1:port) + + // the destination is a local server + let sess = Session { + destination: ("127.0.0.1".to_owned(), port) + .try_into() + .unwrap_or_else(|_| panic!("")), + ..Default::default() + }; + + let (_, resolver) = config_helper::load_config().await?; + + let listener = TcpListener::bind(format!("0.0.0.0:{}", port).as_str()).await?; + + async fn destination_fn(incoming: T) -> anyhow::Result<()> + where + T: AsyncRead + AsyncWrite, + { + // Use inbound_stream here + let (mut read_half, mut write_half) = split(incoming); + let chunk = "world"; + let mut buf = vec![0; 5]; + + for _ in 0..100 { + read_half.read_exact(&mut buf).await?; + assert_eq!(&buf, b"hello"); + } + + for _ in 0..100 { + write_half.write_all(chunk.as_bytes()).await?; + write_half.flush().await?; + } + Ok(()) + } + + let target_local_server_handler = tokio::spawn(async move { + match listener.accept().await { + Ok((stream, _)) => match destination_fn(stream).await { + Ok(_) => {} + Err(e) => eprintln!("Failed to serve: {}", e), + }, + Err(e) => { + // Handle error e, log it, or ignore it + eprintln!("Failed to accept connection: {}", e); + } + } + println!("server task finished"); + }); + + async fn proxy_fn(stream: Box) -> anyhow::Result<()> { + let (mut read_half, mut write_half) = split(stream); + + let chunk = "hello"; + let mut buf = vec![0; 5]; + + for _ in 0..100 { + write_half.write_all(chunk.as_bytes()).await?; + } + write_half.flush().await?; + drop(write_half); + + for _ in 0..100 { + read_half.read_exact(&mut buf).await?; + assert_eq!(buf, "world".as_bytes().to_owned()); + } + drop(read_half); + Ok(()) + } + + let proxy_task = tokio::spawn(async move { + match handler.connect_stream(&sess, resolver).await { + Ok(stream) => match proxy_fn(stream).await { + Ok(_) => {} + Err(e) => eprintln!("Failed to to proxy: {}", e), + }, + Err(e) => eprintln!("Failed to accept connection: {}", e), + } + println!("proxy task finished"); + }); + + let _ = join!(proxy_task, target_local_server_handler); + + Ok(()) +} + +pub struct LatencyTestOption<'a> { + pub dst: SocksAddr, + pub req: &'a [u8], + pub expected_resp: &'a [u8], + pub read_exact: bool, +} + +pub async fn latency_test_proxy( + handler: Arc, + option: LatencyTestOption<'_>, +) -> anyhow::Result<()> { + // proxy -> proxy-server -> destination(google.com) + + // the destination is a local server + let sess = Session { + destination: option.dst, + ..Default::default() + }; + + let (_, resolver) = config_helper::load_config().await?; + + let stream = handler.connect_stream(&sess, resolver).await?; + + let (mut read_half, mut write_half) = split(stream); + + write_half.write_all(option.req).await?; + write_half.flush().await?; + drop(write_half); + + let start_time = std::time::SystemTime::now(); + let mut response = vec![0; option.expected_resp.len()]; + + if option.read_exact { + read_half.read_exact(&mut response).await?; + println!("response:\n{}", String::from_utf8_lossy(&response)); + assert_eq!(&response, option.expected_resp); + } else { + read_half.read_to_end(&mut response).await?; + println!("response:\n{}", String::from_utf8_lossy(&response)); + assert_eq!(&response, option.expected_resp); + } + + let end_time = std::time::SystemTime::now(); + println!( + "time cost:{:?}", + end_time.duration_since(start_time).unwrap() + ); + Ok(()) +} diff --git a/clash_lib/src/proxy/vmess/mod.rs b/clash_lib/src/proxy/vmess/mod.rs index 30fc4e503..de0dc1422 100644 --- a/clash_lib/src/proxy/vmess/mod.rs +++ b/clash_lib/src/proxy/vmess/mod.rs @@ -250,3 +250,253 @@ impl OutboundHandler for Handler { Ok(Box::new(chained)) } } + +#[cfg(test)] +mod tests { + + use crate::proxy::utils::test_utils::{ + benchmark_proxy, + config_helper::test_config_base_dir, + consts::*, + docker_runner::{ + default_export_ports, default_host_config, mount_config, DockerTestRunner, + }, + latency_test_proxy, LatencyTestOption, + }; + + use super::*; + + async fn get_ws_runner() -> anyhow::Result { + use bollard::{container::Config, image::CreateImageOptions}; + + let mut host_config = default_host_config(); + let test_config_dir = test_config_base_dir(); + let trojan_conf = test_config_dir.join("vmess-ws.json"); + + host_config.mounts = Some(mount_config(&[( + trojan_conf.to_str().unwrap(), + "/etc/v2ray/config.json", + )])); + let export_ports = default_export_ports(); + + DockerTestRunner::new( + Some(CreateImageOptions { + from_image: IMAGE_VMESS, + ..Default::default() + }), + Config { + image: Some(IMAGE_VMESS), + tty: Some(true), + exposed_ports: Some(export_ports), + host_config: Some(host_config), + ..Default::default() + }, + ) + .await + .map_err(Into::into) + } + + #[tokio::test] + async fn test_vmess_ws() -> anyhow::Result<()> { + let opts = HandlerOptions { + name: "test-vmess-ws".into(), + common_opts: Default::default(), + server: LOCAL_ADDR.into(), + port: 10002, + uuid: "b831381d-6324-4d53-ad4f-8cda48b30811".into(), + alter_id: 0, + security: "none".into(), + udp: true, + tls: None, + transport: Some(VmessTransport::Ws(WsOption { + path: "".to_owned(), + headers: [("Host".to_owned(), "example.org".to_owned())] + .into_iter() + .collect::>(), + // ignore the rest by setting max_early_data to 0 + max_early_data: 0, + early_data_header_name: "".to_owned(), + })), + }; + let handler = Handler::new(opts); + + let runner = get_ws_runner().await?; + + runner + .run_and_cleanup(async move { + benchmark_proxy(handler.clone(), 10001).await?; + latency_test_proxy( + handler, + LatencyTestOption { + dst: SocksAddr::Domain("google.com".to_owned(), 80), + req: GOOGLE_REQ, + expected_resp: GOOGLE_RESP_301, + read_exact: true, + }, + ) + .await?; + Ok(()) + }) + .await?; + + Ok(()) + } + + async fn get_grpc_runner() -> anyhow::Result { + use bollard::{container::Config, image::CreateImageOptions}; + + let mut host_config = default_host_config(); + let test_config_dir = test_config_base_dir(); + let conf = test_config_dir.join("vmess-grpc.json"); + let cert = test_config_dir.join("example.org.pem"); + let key = test_config_dir.join("example.org-key.pem"); + + host_config.mounts = Some(mount_config(&[ + (conf.to_str().unwrap(), "/etc/v2ray/config.json"), + (cert.to_str().unwrap(), "/etc/ssl/v2ray/fullchain.pem"), + (key.to_str().unwrap(), "/etc/ssl/v2ray/privkey.pem"), + ])); + + let export_ports = default_export_ports(); + + DockerTestRunner::new( + Some(CreateImageOptions { + from_image: IMAGE_VMESS, + ..Default::default() + }), + Config { + image: Some(IMAGE_VMESS), + tty: Some(true), + exposed_ports: Some(export_ports), + host_config: Some(host_config), + ..Default::default() + }, + ) + .await + .map_err(Into::into) + } + + #[tokio::test] + async fn test_vmess_grpc() -> anyhow::Result<()> { + let opts = HandlerOptions { + name: "test-vmess-grpc".into(), + common_opts: Default::default(), + server: LOCAL_ADDR.into(), + port: 10002, + uuid: "b831381d-6324-4d53-ad4f-8cda48b30811".into(), + alter_id: 0, + security: "auto".into(), + udp: true, + tls: Some(transport::TLSOptions { + skip_cert_verify: true, + sni: "example.org".into(), + alpn: None, + }), + transport: Some(VmessTransport::Grpc(GrpcOption { + host: "example.org".to_owned(), + service_name: "example!".to_owned(), + })), + }; + let handler = Handler::new(opts); + + let runner = get_grpc_runner().await?; + + runner + .run_and_cleanup(async move { + benchmark_proxy(handler.clone(), 10001).await?; + latency_test_proxy( + handler, + LatencyTestOption { + dst: SocksAddr::Domain("example.org".to_owned(), 80), + req: EXAMPLE_REQ, + expected_resp: EXAMLE_RESP_200, + read_exact: true, + }, + ) + .await?; + Ok(()) + }) + .await?; + + Ok(()) + } + + async fn get_h2_runner() -> anyhow::Result { + use bollard::{container::Config, image::CreateImageOptions}; + + let mut host_config = default_host_config(); + let test_config_dir = test_config_base_dir(); + let conf = test_config_dir.join("vmess-http2.json"); + let cert = test_config_dir.join("example.org.pem"); + let key = test_config_dir.join("example.org-key.pem"); + + host_config.mounts = Some(mount_config(&[ + (conf.to_str().unwrap(), "/etc/v2ray/config.json"), + (cert.to_str().unwrap(), "/etc/ssl/v2ray/fullchain.pem"), + (key.to_str().unwrap(), "/etc/ssl/v2ray/privkey.pem"), + ])); + + let export_ports = default_export_ports(); + + DockerTestRunner::new( + Some(CreateImageOptions { + from_image: IMAGE_VMESS, + ..Default::default() + }), + Config { + image: Some(IMAGE_VMESS), + tty: Some(true), + exposed_ports: Some(export_ports), + host_config: Some(host_config), + ..Default::default() + }, + ) + .await + .map_err(Into::into) + } + + #[tokio::test] + async fn test_vmess_h2() -> anyhow::Result<()> { + let opts = HandlerOptions { + name: "test-vmess-h2".into(), + common_opts: Default::default(), + server: LOCAL_ADDR.into(), + port: 10002, + uuid: "b831381d-6324-4d53-ad4f-8cda48b30811".into(), + alter_id: 0, + security: "auto".into(), + udp: false, + tls: Some(transport::TLSOptions { + skip_cert_verify: true, + sni: "example.org".into(), + alpn: None, + }), + transport: Some(VmessTransport::H2(Http2Option { + host: vec!["example.org".into()], + path: "/testlollol".into(), + })), + }; + let handler = Handler::new(opts); + + let runner = get_h2_runner().await?; + + runner + .run_and_cleanup(async move { + benchmark_proxy(handler.clone(), 10001).await?; + latency_test_proxy( + handler, + LatencyTestOption { + dst: SocksAddr::Domain("google.com".to_owned(), 80), + req: GOOGLE_REQ, + expected_resp: GOOGLE_RESP_301, + read_exact: true, + }, + ) + .await?; + Ok(()) + }) + .await?; + + Ok(()) + } +}