Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
175 changes: 113 additions & 62 deletions .github/workflows/coverage.yml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cross.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
2 changes: 1 addition & 1 deletion clash-lib/src/proxy/anytls/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand Down
2 changes: 1 addition & 1 deletion clash-lib/src/proxy/hysteria2/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
6 changes: 2 additions & 4 deletions clash-lib/src/proxy/socks/outbound/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion clash-lib/src/proxy/ssh/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions clash-lib/src/proxy/trojan/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand Down Expand Up @@ -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)),
Expand Down
82 changes: 72 additions & 10 deletions clash-lib/src/proxy/utils/test_utils/docker_utils/docker_runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<CreateImageOptions>,
mut container_conf: ContainerCreateBody,
host_port: u16,
) -> anyhow::Result<Self> {
let docker: Docker = if let Some(url) = std::env::var("DOCKER_HOST").ok() {
if url.starts_with("http://")
Expand All @@ -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
Expand Down Expand Up @@ -186,10 +196,21 @@ impl DockerTestRunner {
instance: docker,
id,
inspect,
host_port,
})
}

pub fn container_ip(&self) -> Option<String> {
// 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()
Expand Down Expand Up @@ -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<String> {
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<String> {
Expand Down Expand Up @@ -694,6 +755,7 @@ impl DockerTestRunnerBuilder {
host_config: Some(self.host_config),
..Default::default()
},
self._server_port,
)
.await
.map_err(Into::into)
Expand Down
Loading
Loading