diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index c3209c9a4..776a38518 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -1,62 +1,113 @@ -name: Coverage - -on: - push: - tags: ["v*"] - branches: ["master"] - pull_request: - branches: ["master"] - -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - -jobs: - compile: - name: Coverage - runs-on: ${{ matrix.os }} - strategy: - matrix: - os: ["macos-latest", "windows-latest", "ubuntu-latest"] - steps: - - uses: actions/checkout@v6 - with: - submodules: true - - - uses: actions/cache@v5 - with: - path: | - ~/.cargo/registry - ~/.cargo/git - ~/.cargo/bin/ - ~/.cargo/registry/index/ - ~/.cargo/registry/cache/ - ~/.cargo/git/db/ - key: coverage-${{ hashFiles('**/Cargo.toml') }}-${{ matrix.os }} - - uses: ilammy/setup-nasm@v1 - - name: Install cargo-llvm-cov - uses: taiki-e/install-action@cargo-llvm-cov - - name: Install Protoc - uses: arduino/setup-protoc@v3 - with: - version: "23.x" - repo-token: ${{ secrets.GITHUB_TOKEN }} - - - name: Cargo test and coverage - uses: clechasseur/rs-cargo@v4 - with: - tool: cross - command: "llvm-cov" - args: --workspace --exclude clash-ffi -F "plus" --codecov --output-path codecov.json - env: - CROSS_CONTAINER_OPTS: "--network host" - CLASH_DOCKER_TEST: ${{ startsWith(matrix.os, 'ubuntu') && 'true' || 'false' }} - TS_AUTH_KEY: ${{ secrets.TS_AUTH_KEY }} - RUST_LOG: ts_control=debug,ts_runtime=debug,tailscale=debug - - - name: Upload coverage to Codecov - uses: codecov/codecov-action@v6 - with: - token: ${{ secrets.CODECOV_TOKEN }} - files: codecov.json - fail_ci_if_error: true +name: Coverage + +on: + push: + tags: ["v*"] + branches: ["master"] + pull_request: + branches: ["master"] + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + compile: + name: Coverage + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: ["macos-15-intel", "windows-latest", "ubuntu-latest"] + steps: + - uses: actions/checkout@v6 + with: + submodules: true + + - uses: actions/cache@v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + key: coverage-${{ hashFiles('**/Cargo.toml') }}-${{ matrix.os }} + - uses: ilammy/setup-nasm@v1 + - name: Install cargo-llvm-cov + uses: taiki-e/install-action@cargo-llvm-cov + - name: Install Protoc + uses: arduino/setup-protoc@v3 + with: + version: "23.x" + repo-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker (macOS via colima) + if: runner.os == 'macOS' + run: | + set -euo pipefail + brew install socket_vmnet colima docker + + # Lima checks ownership of the socket_vmnet binary AND every ancestor + # directory up to /. On GitHub runners, everything under /usr/local + # (including /usr/local/bin, /usr/local/Cellar) is owned by the runner + # user (uid 501). The Lima docs recommend installing socket_vmnet under + # /opt/socket_vmnet — /opt is root-owned on macOS Intel runners. + sudo mkdir -p /opt/socket_vmnet/bin + sudo cp "$(brew --prefix socket_vmnet)/bin/socket_vmnet" \ + /opt/socket_vmnet/bin/socket_vmnet + + # Override Lima's networks.yaml to point at the root-owned copy. + # Must include ALL paths fields — Lima doesn't merge with system defaults, + # unspecified fields become zero values (varRun="" → resolved to "."). + mkdir -p ~/.lima/_config + cat > ~/.lima/_config/networks.yaml << 'EOF' +paths: + socketVMNet: /opt/socket_vmnet/bin/socket_vmnet + varRun: /private/var/run/lima + sudoers: /private/etc/sudoers.d/lima +EOF + + # Generate a sudoers snippet so Lima can run socket_vmnet as root + # without a password prompt. Install to the path declared in networks.yaml. + sudo mkdir -p /private/etc/sudoers.d + limactl sudoers | sudo tee /private/etc/sudoers.d/lima > /dev/null + + # --network-address assigns a vmnet-host IP (e.g. 192.168.105.x) that is + # routable from macOS for both TCP *and* UDP — unlike SLIRP which only + # forwards TCP via SSH tunnels (breaks QUIC/WireGuard tests). + colima start --cpu 2 --memory 4 --network-address + sudo ln -sf "$HOME/.colima/default/docker.sock" /var/run/docker.sock + + COLIMA_JSON=$(colima ls --json) + echo "colima status: $COLIMA_JSON" + COLIMA_IP=$(echo "$COLIMA_JSON" | python3 -c " + import sys, json + d = json.load(sys.stdin) + entry = d[0] if isinstance(d, list) else d + addr = entry.get('address', '') + if not addr: + raise RuntimeError('colima address is empty; socket_vmnet may not have assigned an IP') + print(addr) + ") + echo "VM routable IP: ${COLIMA_IP}" + echo "CLASH_DOCKER_HOST_IP=${COLIMA_IP}" >> $GITHUB_ENV + + - name: Cargo test and coverage + uses: clechasseur/rs-cargo@v4 + with: + tool: cross + command: "llvm-cov" + args: --workspace --exclude clash-ffi -F "plus" --codecov --output-path codecov.json + env: + CROSS_CONTAINER_OPTS: "--network host" + CLASH_DOCKER_TEST: ${{ runner.os != 'Windows' && 'true' || 'false' }} + TS_AUTH_KEY: ${{ secrets.TS_AUTH_KEY }} + RUST_LOG: ts_control=debug,ts_runtime=debug,tailscale=debug + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v6 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: codecov.json + fail_ci_if_error: true diff --git a/Cargo.lock b/Cargo.lock index 52ce899ff..8147614c1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11426,7 +11426,7 @@ dependencies = [ [[package]] name = "watfaq-netstack" -version = "0.1.1" +version = "0.1.0" dependencies = [ "bytes", "env_logger", diff --git a/Cross.toml b/Cross.toml index 9781f3fef..e18cdffb4 100644 --- a/Cross.toml +++ b/Cross.toml @@ -1,6 +1,6 @@ [build.env] volumes = ["/var/run/docker.sock=/var/run/docker.sock", "/tmp=/tmp"] # Docker in docker -passthrough = ["CLASH_GIT_REF", "CLASH_GIT_SHA", "RUSTFLAGS", "RUST_LOG", "CLASH_DOCKER_TEST", "SENTRY_DSN", "RUSTC_BOOTSTRAP", "TS_AUTH_KEY"] +passthrough = ["CLASH_GIT_REF", "CLASH_GIT_SHA", "RUSTFLAGS", "RUST_LOG", "CLASH_DOCKER_TEST", "CLASH_DOCKER_HOST_IP", "CLASH_DOCKER_USE_HOST_IP", "SENTRY_DSN", "RUSTC_BOOTSTRAP", "TS_AUTH_KEY"] [target.mips-unknown-linux-musl] image = "ghcr.io/cross-rs/mips-unknown-linux-musl:edge" diff --git a/clash-lib/src/proxy/anytls/mod.rs b/clash-lib/src/proxy/anytls/mod.rs index 162efee83..c6a0a25c2 100644 --- a/clash-lib/src/proxy/anytls/mod.rs +++ b/clash-lib/src/proxy/anytls/mod.rs @@ -878,7 +878,7 @@ mod tests { name: "test-anytls".to_owned(), common_opts: Default::default(), server: runner.container_ip().unwrap_or(LOCAL_ADDR.to_owned()), - port: 10002, + port: runner.server_port(10002), password: "example".to_owned(), udp: true, tls: Some(Box::new(tls)), diff --git a/clash-lib/src/proxy/hysteria2/mod.rs b/clash-lib/src/proxy/hysteria2/mod.rs index 736484549..4f50c3df4 100644 --- a/clash-lib/src/proxy/hysteria2/mod.rs +++ b/clash-lib/src/proxy/hysteria2/mod.rs @@ -773,7 +773,7 @@ mod tests { let ip = IpAddr::from_str(&container_ip) .unwrap_or(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))); - let port = 10002; + let port = container.server_port(10002); let obfs = Some(Obfs::Salamander(SalamanderObfs { key: "beauty will save the world".to_owned().into(), diff --git a/clash-lib/src/proxy/socks/outbound/mod.rs b/clash-lib/src/proxy/socks/outbound/mod.rs index f7035531c..0ee726ea9 100644 --- a/clash-lib/src/proxy/socks/outbound/mod.rs +++ b/clash-lib/src/proxy/socks/outbound/mod.rs @@ -391,13 +391,12 @@ mod tests { async fn test_socks5_no_auth() -> anyhow::Result<()> { initialize(); let host_port = alloc_docker_port(); - let port = 10002_u16; let runner = get_socks5_runner(false, host_port).await?; let opts = HandlerOptions { name: "test-socks5-no-auth".to_owned(), common_opts: Default::default(), server: server_addr(&runner), - port, + port: runner.server_port(10002), user: None, password: None, udp: true, @@ -412,13 +411,12 @@ mod tests { use crate::proxy::DialWithConnector; initialize(); let host_port = alloc_docker_port(); - let port = 10002_u16; let runner = get_socks5_runner(true, host_port).await?; let opts = HandlerOptions { name: "test-socks5-auth".to_owned(), common_opts: Default::default(), server: server_addr(&runner), - port, + port: runner.server_port(10002), user: Some(USER.to_owned()), password: Some(PASSWORD.to_owned()), udp: true, diff --git a/clash-lib/src/proxy/ssh/mod.rs b/clash-lib/src/proxy/ssh/mod.rs index 2cdb1ca7f..751952df7 100644 --- a/clash-lib/src/proxy/ssh/mod.rs +++ b/clash-lib/src/proxy/ssh/mod.rs @@ -466,7 +466,7 @@ mod tests { name: "test-ssh".to_owned(), common_opts: Default::default(), server: container.container_ip().unwrap_or(LOCAL_ADDR.to_owned()), - port: 2222, + port: container.server_port(2222), password, private_key, private_key_passphrase: None, diff --git a/clash-lib/src/proxy/trojan/mod.rs b/clash-lib/src/proxy/trojan/mod.rs index 33d50f976..d97864f68 100644 --- a/clash-lib/src/proxy/trojan/mod.rs +++ b/clash-lib/src/proxy/trojan/mod.rs @@ -369,7 +369,7 @@ mod tests { name: "test-trojan-ws".to_owned(), common_opts: Default::default(), server: container.container_ip().unwrap_or(LOCAL_ADDR.to_owned()), - port: 10002, + port: container.server_port(10002), password: "example".to_owned(), udp: true, tls: Some(Box::new(tls)), @@ -429,7 +429,7 @@ mod tests { name: "test-trojan-grpc".to_owned(), common_opts: Default::default(), server: runner.container_ip().unwrap_or(LOCAL_ADDR.to_owned()), - port: 10002, + port: runner.server_port(10002), password: "example".to_owned(), udp: true, tls: Some(Box::new(tls)), 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 922944749..83f1ddc39 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 @@ -76,12 +76,16 @@ pub struct DockerTestRunner { instance: Docker, id: String, inspect: ContainerInspectResponse, + /// The host-side published port (equals the container port for most tests; + /// differs for WireGuard which uses `host_port(alloc, 10002)`). + host_port: u16, } impl DockerTestRunner { pub async fn try_new( image_conf: Option, mut container_conf: ContainerCreateBody, + host_port: u16, ) -> anyhow::Result { let docker: Docker = if let Some(url) = std::env::var("DOCKER_HOST").ok() { if url.starts_with("http://") @@ -108,16 +112,22 @@ impl DockerTestRunner { .host_config .as_mut() .and_then(|hc| hc.mounts.take()); - let files_to_copy = if std::env::var("DOCKER_HOST") - .ok() - .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 + // Use the API-based file-copy path when: + // a) DOCKER_HOST points to a remote HTTP/TCP endpoint, OR + // b) CLASH_DOCKER_HOST_IP is set (macOS + colima): bind-mount + // source paths are macOS /var/folders/… paths that don't exist + // inside the colima VM, so we must upload files via Docker API. + let use_api_copy = Self::colima_host_ip().is_some() + || std::env::var("DOCKER_HOST") + .ok() + .map(|url| { + url.starts_with("http://") + || url.starts_with("https://") + || url.starts_with("tcp://") + }) + .unwrap_or(false); + let files_to_copy = if use_api_copy { + // Remote/colima Docker - collect files to copy via API mounts } else { // Local Docker - keep mounts in config @@ -186,10 +196,21 @@ impl DockerTestRunner { instance: docker, id, inspect, + host_port, }) } pub fn container_ip(&self) -> Option { + // On macOS with colima --network-address, the VM has a routable IP + // (CLASH_DOCKER_HOST_IP) reachable from macOS for both TCP and UDP. + // Docker publishes ports on that IP, so callers should connect there. + if let Some(host_ip) = Self::colima_host_ip() { + return Some(host_ip); + } + // Legacy fallback: 127.0.0.1 via Lima TCP-only SSH port-forwarding. + if Self::use_host_ip_127() { + return Some("127.0.0.1".to_string()); + } self.inspect .network_settings .as_ref() @@ -236,6 +257,46 @@ impl DockerTestRunner { }) } + /// When `CLASH_DOCKER_HOST_IP` is set, returns the colima VM's routable IP. + /// This IP is reachable from macOS for both TCP and UDP (unlike the default + /// QEMU SLIRP forwarding which only handles TCP via SSH tunnels). + fn colima_host_ip() -> Option { + std::env::var("CLASH_DOCKER_HOST_IP") + .ok() + .filter(|v| !v.is_empty()) + } + + /// Returns true when `CLASH_DOCKER_USE_HOST_IP` is set (non-empty). + /// In this mode tests connect to `127.0.0.1:host_port` via Lima's TCP-only + /// SSH port-forwarding. UDP-transport protocols must be skipped. + fn use_host_ip_127() -> bool { + std::env::var("CLASH_DOCKER_USE_HOST_IP") + .map(|v| !v.is_empty()) + .unwrap_or(false) + } + + /// Returns the host-side published port for this container. + /// + /// For most tests `host_port == container_port` (set via `.port(p)`). + /// Returns the correct port to use in the proxy handler configuration. + /// + /// - When `CLASH_DOCKER_HOST_IP` is set (macOS + colima): Docker binds + /// `host_port` on the VM's routable IP — use `host_port`. + /// - When `container_ip()` returns a real container IP (Linux): use the + /// container's internal port directly. + /// - Otherwise: fall back to `host_port`. + pub fn server_port(&self, container_port: u16) -> u16 { + if Self::colima_host_ip().is_some() || Self::use_host_ip_127() { + // Docker publishes host_port on the VM's routable IP or on 127.0.0.1 + // (via Lima SSH forwarding). Either way, use the published host port. + self.host_port + } else if self.container_ip().is_some() { + container_port + } else { + self.host_port + } + } + /// For debugging use #[allow(dead_code)] pub async fn exec_command(&self, cmd: &[&str]) -> anyhow::Result { @@ -694,6 +755,7 @@ impl DockerTestRunnerBuilder { host_config: Some(self.host_config), ..Default::default() }, + self._server_port, ) .await .map_err(Into::into) 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 d8e81d7aa..db60480ca 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 @@ -1059,7 +1059,29 @@ pub async fn run_test_suites_and_cleanup( docker_test_runner: impl RunAndCleanup, suites: &[Suite], ) -> anyhow::Result<()> { - let suites = suites.to_owned(); + // On macOS with colima --network-address (CLASH_DOCKER_HOST_IP set), the + // VM has a routable IP for outbound proxy tests. However PingPong tests + // require the container to connect BACK to an echo server on the macOS + // host — the Docker bridge gateway (172.17.0.1) is VM-local and cannot + // reach macOS directly in this topology. Skip PingPong and DnsUdp suites + // when using any host-IP mode. + let use_host_ip = std::env::var("CLASH_DOCKER_HOST_IP") + .ok() + .map_or(false, |v| !v.is_empty()) + || std::env::var("CLASH_DOCKER_USE_HOST_IP") + .ok() + .map_or(false, |v| !v.is_empty()); + let suites: Vec = suites + .iter() + .filter(|s| { + if use_host_ip { + !matches!(s, Suite::PingPongTcp | Suite::PingPongUdp | Suite::DnsUdp) + } else { + true + } + }) + .cloned() + .collect(); let gateway_ip = docker_test_runner.docker_gateway_ip(); docker_test_runner .run_and_cleanup(async move { diff --git a/clash-lib/src/proxy/vless/mod.rs b/clash-lib/src/proxy/vless/mod.rs index 374321a68..55404cff8 100644 --- a/clash-lib/src/proxy/vless/mod.rs +++ b/clash-lib/src/proxy/vless/mod.rs @@ -383,7 +383,7 @@ mod tests { name: "test-vless-ws".into(), common_opts: Default::default(), server: runner.container_ip().unwrap_or(LOCAL_ADDR.to_owned()), - port: 8443, + port: runner.server_port(8443), uuid: "b831381d-6324-4d53-ad4f-8cda48b30811".into(), udp: true, tls: tls_client(None), diff --git a/clash-lib/src/proxy/vmess/mod.rs b/clash-lib/src/proxy/vmess/mod.rs index d35ebca33..3906b958c 100644 --- a/clash-lib/src/proxy/vmess/mod.rs +++ b/clash-lib/src/proxy/vmess/mod.rs @@ -425,7 +425,7 @@ mod tests { name: "test-vmess-ws".into(), common_opts: Default::default(), server: runner.container_ip().unwrap_or(LOCAL_ADDR.to_owned()), - port: 10002, + port: runner.server_port(10002), uuid: "b831381d-6324-4d53-ad4f-8cda48b30811".into(), alter_id: 0, security: "auto".into(), @@ -473,7 +473,7 @@ mod tests { name: "test-vmess-grpc".into(), common_opts: Default::default(), server: container.container_ip().unwrap_or(LOCAL_ADDR.to_owned()), - port: 10002, + port: container.server_port(10002), uuid: "b831381d-6324-4d53-ad4f-8cda48b30811".into(), alter_id: 0, security: "auto".into(), @@ -522,7 +522,7 @@ mod tests { name: "test-vmess-h2".into(), common_opts: Default::default(), server: container.container_ip().unwrap_or(LOCAL_ADDR.to_owned()), - port: 10002, + port: container.server_port(10002), uuid: "b831381d-6324-4d53-ad4f-8cda48b30811".into(), alter_id: 0, security: "auto".into(), diff --git a/clash-lib/src/proxy/wg/mod.rs b/clash-lib/src/proxy/wg/mod.rs index 115a2e576..f8adf07df 100644 --- a/clash-lib/src/proxy/wg/mod.rs +++ b/clash-lib/src/proxy/wg/mod.rs @@ -386,8 +386,12 @@ mod tests { let opts = HandlerOptions { name: "wg".to_owned(), common_opts: Default::default(), + // WireGuard container exposes port 10002 externally (mapped to + // internal port 51820). On macOS with colima --network-address, + // container_ip() returns the VM's routable IP and server_port() + // returns host_port (the Docker-published port on that IP). server: runner.container_ip().unwrap_or("127.0.0.1".to_owned()), - port: 10002, + port: runner.server_port(10002), ip: Ipv4Addr::new(10, 13, 13, 2), ipv6: None, private_key: "KIlDUePHyYwzjgn18przw/ZwPioJhh2aEyhxb/dtCXI=".to_owned(),