diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cd9c0d3..8a76660 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,3 +43,70 @@ jobs: - name: Build release binaries run: cargo build --release --workspace + + linux-e2e-distros: + name: linux-e2e (${{ matrix.distro }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - distro: ubuntu-24.04 + family: deb + image: ubuntu:24.04 + - distro: debian-12 + family: deb + image: debian:12 + - distro: fedora-41 + family: rpm + image: fedora:41 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Run e2e in distro container + shell: bash + env: + DISTRO_IMAGE: ${{ matrix.image }} + DISTRO_FAMILY: ${{ matrix.family }} + run: | + set -euo pipefail + docker run --rm -e DISTRO_FAMILY="${DISTRO_FAMILY}" -v "$PWD:/work" -w /work "${DISTRO_IMAGE}" /bin/bash -lc ' + set -euo pipefail + if [ "${DISTRO_FAMILY}" = "deb" ]; then + apt-get update + DEBIAN_FRONTEND=noninteractive apt-get install -y \ + curl ca-certificates git build-essential pkg-config python3 python3-pip + else + dnf install -y \ + curl ca-certificates git gcc gcc-c++ make pkgconf-pkg-config python3 python3-pip + fi + + curl https://sh.rustup.rs -sSf | sh -s -- -y --profile minimal --default-toolchain stable + export PATH="$HOME/.cargo/bin:$PATH" + rustup --version + cargo --version + + pip3 install --no-input proxy.py pproxy + nohup python3 -m pproxy -l socks5://127.0.0.1:19080 >/tmp/pproxy-socks5.log 2>&1 & + nohup python3 -m proxy --hostname 127.0.0.1 --port 18080 >/tmp/proxy-http.log 2>&1 & + sleep 2 + + printf "%s\n" \ + "dynamic_chain" \ + "proxy_dns" \ + "tcp_connect_time_out 3000" \ + "tcp_read_time_out 8000" \ + "[ProxyList]" \ + "socks5 127.0.0.1 19080" \ + "http 127.0.0.1 18080" > ci.e2e.conf + + cargo run -p proxychains-bin -- -f ./ci.e2e.conf --probe + cargo run -p proxychains-bin -- -f ./ci.e2e.conf --probe --probe-json > probe-report.json + grep -q "\"summary\"" probe-report.json + + PROXYCHAINS_TEST_SOCKS5_ADDR=127.0.0.1:19080 \ + PROXYCHAINS_TEST_HTTP_ADDR=127.0.0.1:18080 \ + cargo test -p proxychains --test real_backends -- --nocapture + ' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b8c91f4..d4c6aa2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -96,15 +96,110 @@ jobs: runs-on: ubuntu-latest if: startsWith(github.ref, 'refs/tags/') steps: + - name: Checkout (for changelog generation) + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Download artifacts uses: actions/download-artifact@v4 with: path: release-assets + - name: Generate checksums + shell: bash + run: | + set -euo pipefail + find release-assets -type f \( -name '*.tar.gz' -o -name '*.zip' -o -name '*.deb' \) -print0 \ + | sort -z \ + | xargs -0 sha256sum > SHA256SUMS.txt + + - name: Generate release notes and changelog + shell: bash + run: | + set -euo pipefail + TAG="${GITHUB_REF_NAME}" + PREV_TAG="$(git tag --sort=-creatordate | grep -v "^${TAG}$" | head -n1 || true)" + if [ -n "${PREV_TAG}" ]; then + RANGE="${PREV_TAG}..${TAG}" + else + RANGE="${TAG}" + fi + + mapfile -t COMMITS < <(git log --no-merges --pretty=format:'%s' ${RANGE}) + + section() { + local name="$1" + shift + echo "### ${name}" >> CHANGELOG_AUTO.md + local found=0 + for c in "${COMMITS[@]}"; do + for prefix in "$@"; do + if [[ "${c}" == ${prefix}* ]]; then + echo "- ${c}" >> CHANGELOG_AUTO.md + found=1 + break + fi + done + done + if [ "${found}" -eq 0 ]; then + echo "- (none)" >> CHANGELOG_AUTO.md + fi + echo "" >> CHANGELOG_AUTO.md + } + + { + echo "## Changelog (${TAG})" + echo "" + } > CHANGELOG_AUTO.md + section "Features" "feat:" "feature:" + section "Fixes" "fix:" + section "Docs" "docs:" + section "CI/Build" "ci:" "build:" "chore:" + echo "### Other" >> CHANGELOG_AUTO.md + OTHER_FOUND=0 + for c in "${COMMITS[@]}"; do + if [[ "${c}" != feat:* && "${c}" != feature:* && "${c}" != fix:* && "${c}" != docs:* && "${c}" != ci:* && "${c}" != build:* && "${c}" != chore:* ]]; then + echo "- ${c}" >> CHANGELOG_AUTO.md + OTHER_FOUND=1 + fi + done + if [ "${OTHER_FOUND}" -eq 0 ]; then + echo "- (none)" >> CHANGELOG_AUTO.md + fi + echo "" >> CHANGELOG_AUTO.md + + cat > RELEASE_NOTES.md <> RELEASE_NOTES.md + - name: Publish release assets uses: softprops/action-gh-release@v2 with: + body_path: RELEASE_NOTES.md files: | release-assets/**/*.tar.gz release-assets/**/*.zip release-assets/**/*.deb + SHA256SUMS.txt diff --git a/Cargo.toml b/Cargo.toml index 4c17153..5aa9df4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,3 +31,5 @@ socket2 = { version = "0.5", features = ["all"] } rand = "0.8" sha2 = "0.10" hex = "0.4" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" diff --git a/README.md b/README.md index 4388174..a2445c3 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ Recommendation: - `--list-groups` - `--check` - `--probe` (quick per-node reachability/latency check) + - `--probe-json` (machine-readable probe output) - DNS: - `proxy_dns` - compatibility aliases: `proxy_dns_old`, `proxy_dns_daemon` @@ -91,6 +92,7 @@ proxychains4 -f ./proxychains.conf --list-groups proxychains4 -f ./proxychains.conf --group jp --check proxychains4 -f ./proxychains.conf --group jp --probe proxychains4 -f ./proxychains.conf --probe --probe-timeout-ms 1200 +proxychains4 -f ./proxychains.conf --probe --probe-json ``` ## Example Config diff --git a/README.zh-CN.md b/README.zh-CN.md index ab1698c..57e608e 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -41,6 +41,7 @@ - `--list-groups` - `--check` - `--probe`(快速探测每个节点连通性与时延) + - `--probe-json`(机器可读 JSON 输出) - DNS: - `proxy_dns` - 兼容别名:`proxy_dns_old`、`proxy_dns_daemon` @@ -91,6 +92,7 @@ proxychains4 -f ./proxychains.conf --list-groups proxychains4 -f ./proxychains.conf --group jp --check proxychains4 -f ./proxychains.conf --group jp --probe proxychains4 -f ./proxychains.conf --probe --probe-timeout-ms 1200 +proxychains4 -f ./proxychains.conf --probe --probe-json ``` ## 示例配置 diff --git a/proxychains-bin/Cargo.toml b/proxychains-bin/Cargo.toml index 6bedf52..e2a0037 100644 --- a/proxychains-bin/Cargo.toml +++ b/proxychains-bin/Cargo.toml @@ -18,6 +18,8 @@ clap.workspace = true tracing.workspace = true tracing-subscriber.workspace = true anyhow.workspace = true +serde.workspace = true +serde_json.workspace = true [target.'cfg(unix)'.dependencies] nix.workspace = true diff --git a/proxychains-bin/src/main.rs b/proxychains-bin/src/main.rs index 79a26e3..ec9d414 100644 --- a/proxychains-bin/src/main.rs +++ b/proxychains-bin/src/main.rs @@ -7,12 +7,14 @@ //! - Windows: Uses DLL injection use std::env; +use std::io::ErrorKind; use std::net::{SocketAddr, SocketAddrV4, TcpStream}; use std::path::PathBuf; use std::process; use std::time::{Duration, Instant}; use clap::Parser; +use serde::Serialize; use tracing::{debug, error, info, Level}; use tracing_subscriber::FmtSubscriber; @@ -57,6 +59,10 @@ struct Args { #[arg(long, value_name = "MS")] probe_timeout_ms: Option, + /// Print probe result as JSON (machine-readable) + #[arg(long)] + probe_json: bool, + /// The command to run #[arg( required_unless_present_any = ["list_groups", "check", "probe"], @@ -211,13 +217,8 @@ fn run_probe(config: &Config, args: &Args) -> usize { .map(Duration::from_millis) .unwrap_or(config.tcp_connect_timeout); let mut failed = 0usize; - - println!("Proxy probe:"); - println!(" timeout_ms={}", timeout.as_millis()); - println!( - " group={}", - args.group.as_deref().unwrap_or("default/all") - ); + let mut results = Vec::with_capacity(config.proxies.len()); + let selected_group = args.group.as_deref().unwrap_or("default/all").to_string(); for (idx, proxy) in config.proxies.iter().enumerate() { let target_v4 = SocketAddrV4::new(proxy.ip, proxy.port); @@ -227,38 +228,159 @@ fn run_probe(config: &Config, args: &Args) -> usize { Ok(stream) => { let elapsed = start.elapsed().as_millis(); let _ = stream.shutdown(std::net::Shutdown::Both); - println!( - " [{}] OK {:<6} {}:{} {} ms", - idx + 1, - proxy.proxy_type, - proxy.ip, - proxy.port, - elapsed - ); + results.push(ProbeNode { + index: idx + 1, + proxy_type: proxy.proxy_type.to_string(), + address: format!("{}:{}", proxy.ip, proxy.port), + ok: true, + latency_ms: elapsed, + failure_type: None, + error: None, + }); } Err(e) => { failed += 1; let elapsed = start.elapsed().as_millis(); - println!( - " [{}] FAIL {:<6} {}:{} {} ms ({})", - idx + 1, - proxy.proxy_type, - proxy.ip, - proxy.port, - elapsed, - e - ); + let failure_type = classify_probe_error(&e).to_string(); + results.push(ProbeNode { + index: idx + 1, + proxy_type: proxy.proxy_type.to_string(), + address: format!("{}:{}", proxy.ip, proxy.port), + ok: false, + latency_ms: elapsed, + failure_type: Some(failure_type), + error: Some(e.to_string()), + }); } } } + let report = build_probe_report(results, timeout, selected_group); + if args.probe_json { + println!( + "{}", + serde_json::to_string_pretty(&report).unwrap_or_else(|_| "{}".to_string()) + ); + } else { + print_probe_report(&report); + } + failed +} + +fn classify_probe_error(err: &std::io::Error) -> &'static str { + match err.kind() { + ErrorKind::TimedOut => "timeout", + ErrorKind::ConnectionRefused => "refused", + ErrorKind::ConnectionReset => "reset", + ErrorKind::NetworkUnreachable => "network_unreachable", + ErrorKind::AddrNotAvailable => "addr_unavailable", + ErrorKind::NotConnected => "not_connected", + _ => "other", + } +} + +fn build_probe_report(results: Vec, timeout: Duration, group: String) -> ProbeReport { + let total = results.len(); + let ok = results.iter().filter(|r| r.ok).count(); + let fail = total.saturating_sub(ok); + let mut stats = ProbeFailureStats::default(); + for r in &results { + if r.ok { + continue; + } + match r.failure_type.as_deref() { + Some("timeout") => stats.timeout += 1, + Some("refused") => stats.refused += 1, + Some("reset") => stats.reset += 1, + Some("network_unreachable") => stats.network_unreachable += 1, + Some("addr_unavailable") => stats.addr_unavailable += 1, + Some("not_connected") => stats.not_connected += 1, + _ => stats.other += 1, + } + } + ProbeReport { + timeout_ms: timeout.as_millis(), + selected_group: group, + summary: ProbeSummary { total, ok, fail }, + failure_stats: stats, + nodes: results, + } +} + +fn print_probe_report(report: &ProbeReport) { + println!("Proxy probe:"); + println!(" timeout_ms={}", report.timeout_ms); + println!(" group={}", report.selected_group); + for n in &report.nodes { + if n.ok { + println!( + " [{}] OK {:<8} {} {} ms", + n.index, n.proxy_type, n.address, n.latency_ms + ); + } else { + println!( + " [{}] FAIL {:<8} {} {} ms [{}] ({})", + n.index, + n.proxy_type, + n.address, + n.latency_ms, + n.failure_type.as_deref().unwrap_or("other"), + n.error.as_deref().unwrap_or("unknown") + ); + } + } println!( "Probe summary: total={}, ok={}, fail={}", - config.proxies.len(), - config.proxies.len().saturating_sub(failed), - failed + report.summary.total, report.summary.ok, report.summary.fail ); - failed + println!( + "Failure stats: timeout={}, refused={}, reset={}, net_unreach={}, addr_unavail={}, not_connected={}, other={}", + report.failure_stats.timeout, + report.failure_stats.refused, + report.failure_stats.reset, + report.failure_stats.network_unreachable, + report.failure_stats.addr_unavailable, + report.failure_stats.not_connected, + report.failure_stats.other + ); +} + +#[derive(Debug, Serialize)] +struct ProbeReport { + timeout_ms: u128, + selected_group: String, + summary: ProbeSummary, + failure_stats: ProbeFailureStats, + nodes: Vec, +} + +#[derive(Debug, Serialize)] +struct ProbeSummary { + total: usize, + ok: usize, + fail: usize, +} + +#[derive(Debug, Serialize, Default)] +struct ProbeFailureStats { + timeout: usize, + refused: usize, + reset: usize, + network_unreachable: usize, + addr_unavailable: usize, + not_connected: usize, + other: usize, +} + +#[derive(Debug, Serialize)] +struct ProbeNode { + index: usize, + proxy_type: String, + address: String, + ok: bool, + latency_ms: u128, + failure_type: Option, + error: Option, } /// Set proxychains-specific environment variables @@ -505,4 +627,12 @@ mod tests { assert!(args.probe); assert!(args.command.is_empty()); } + + #[test] + fn test_args_probe_json() { + let args = Args::try_parse_from(["proxychains4", "--probe", "--probe-json"]); + assert!(args.is_ok()); + let args = args.unwrap(); + assert!(args.probe_json); + } } diff --git a/proxychains/tests/real_backends.rs b/proxychains/tests/real_backends.rs new file mode 100644 index 0000000..1c1ec08 --- /dev/null +++ b/proxychains/tests/real_backends.rs @@ -0,0 +1,85 @@ +use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpStream}; +use std::time::Duration; + +use proxychains::config::{ProxyData, ProxyType}; +use proxychains::proxy::{ + http_connect, socks5_connect, establish_proxy_chain, TargetAddr, TargetAddress, +}; + +fn proxy_from_env(var: &str, proxy_type: ProxyType) -> Option { + let raw = std::env::var(var).ok()?; + let addr: SocketAddr = raw.parse().ok()?; + let ip = match addr.ip() { + IpAddr::V4(v4) => v4, + IpAddr::V6(_) => return None, + }; + Some(ProxyData::new(ip, addr.port(), proxy_type)) +} + +#[test] +fn socks5_backend_handshake_works() { + let Some(proxy) = proxy_from_env("PROXYCHAINS_TEST_SOCKS5_ADDR", ProxyType::Socks5) else { + eprintln!("skip: PROXYCHAINS_TEST_SOCKS5_ADDR not set"); + return; + }; + + let mut stream = TcpStream::connect_timeout( + &SocketAddr::new(IpAddr::V4(proxy.ip), proxy.port), + Duration::from_secs(2), + ) + .expect("connect socks5 proxy"); + + let target = TargetAddr::from_domain("example.com"); + socks5_connect(&mut stream, &proxy, &target, 80, Duration::from_secs(8)) + .expect("socks5 connect to target"); +} + +#[test] +fn http_connect_backend_handshake_works() { + let Some(proxy) = proxy_from_env("PROXYCHAINS_TEST_HTTP_ADDR", ProxyType::Http) else { + eprintln!("skip: PROXYCHAINS_TEST_HTTP_ADDR not set"); + return; + }; + + let mut stream = TcpStream::connect_timeout( + &SocketAddr::new(IpAddr::V4(proxy.ip), proxy.port), + Duration::from_secs(2), + ) + .expect("connect http proxy"); + + http_connect( + &mut stream, + &proxy, + "example.com", + 443, + Duration::from_secs(8), + ) + .expect("http CONNECT to target"); +} + +#[test] +fn establish_chain_with_real_socks5_works() { + let Some(proxy) = proxy_from_env("PROXYCHAINS_TEST_SOCKS5_ADDR", ProxyType::Socks5) else { + eprintln!("skip: PROXYCHAINS_TEST_SOCKS5_ADDR not set"); + return; + }; + + let target = TargetAddress::from_domain("example.com"); + let stream = establish_proxy_chain( + &[proxy], + &target, + 80, + Duration::from_secs(3), + Duration::from_secs(8), + ) + .expect("establish chain to target via real socks5"); + + let _ = stream.shutdown(std::net::Shutdown::Both); +} + +#[test] +fn supports_group_probe_demo_shape() { + // Sanity regression around types expected by CLI probe json output. + let proxy = ProxyData::new(Ipv4Addr::new(127, 0, 0, 1), 1080, ProxyType::Socks5); + assert_eq!(proxy.proxy_type.to_string(), "socks5"); +}