feat: add probe json/stats and strengthen ci-release automation

This commit is contained in:
tianrking committed 2026-03-20 14:07:27 +08:00
1 parent e70e6c3b4c
commit bd0d6205ac
8 files changed
+413 -28

No files matched your search

+67
View File
@@ -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
'
+95
View File
@@ -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 <<EOF
## proxychains-rs ${TAG}
### Minimal Usage
\`\`\`bash
# Linux/macOS
./proxychains4 -f ./proxychains.conf --probe
./proxychains4 -f ./proxychains.conf curl https://ifconfig.me
# Windows
.\\proxychains4.exe -f .\\proxychains.conf --probe
.\\proxychains4.exe -f .\\proxychains.conf curl https://ifconfig.me
\`\`\`
### Assets
- Windows zip (\`proxychains4.exe\`, \`proxychains.dll\`)
- Linux tar.gz
- Linux deb
- macOS tar.gz
- SHA256 checksums: \`SHA256SUMS.txt\`
EOF
cat CHANGELOG_AUTO.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
+2
View File
@@ -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"
+2
View File
@@ -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
+2
View File
@@ -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
```
## 示例配置
+2
View File
@@ -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
+158 -28
View File
@@ -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<u64>,
/// 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<ProbeNode>, 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<ProbeNode>,
}
#[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<String>,
error: Option<String>,
}
/// 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);
}
}
+85
View File
@@ -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<ProxyData> {
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");
}