feat: add auto pipeline as default top-level command

This commit is contained in:
chuan
2026-06-23 21:09:00 +08:00
Unverified
parent 39080678bb
commit af9e09148e
3 changed files with 264 additions and 79 deletions
+54 -2
View File
@@ -4,11 +4,21 @@ use std::path::PathBuf;
use clap::{Args, Parser, Subcommand};
/// With no subcommand, the top-level args run the full auto pipeline
/// (ping → latency → speed → export).
#[derive(Parser)]
#[command(name = "fast-xray", about = "Fast Cloudflare IP optimizer for CDN-fronted Xray/VLESS nodes.")]
#[command(
name = "fast-xray",
about = "Fast Cloudflare IP optimizer for CDN-fronted Xray/VLESS nodes.",
arg_required_else_help = true,
args_conflicts_with_subcommands = true
)]
pub(crate) struct Cli {
#[command(subcommand)]
pub(crate) command: Command,
pub(crate) command: Option<Command>,
#[command(flatten)]
pub(crate) auto: AutoArgs,
}
#[derive(Subcommand)]
@@ -23,6 +33,48 @@ pub(crate) enum Command {
Export(ExportArgs),
}
#[derive(Args)]
pub(crate) struct AutoArgs {
/// Input vless:// node URL (or use --node-file).
pub(crate) node: Option<String>,
/// Read the vless:// node URL from a file (avoids shell escaping of `&`).
#[arg(long)]
pub(crate) node_file: Option<PathBuf>,
/// Stage 1: how many valid IPs the ping stage collects.
#[arg(short = 'n', long, default_value_t = 50)]
pub(crate) count: usize,
/// Stage 2: keep the fastest N after latency (feeds the speed stage).
#[arg(long, default_value_t = 30)]
pub(crate) lat_top: usize,
/// Stage 3: final node count written to result.txt.
#[arg(long, default_value_t = 10)]
pub(crate) speed_top: usize,
/// Also test IPv6 ranges (off by default).
#[arg(short = '6', long = "ipv6")]
pub(crate) ipv6: bool,
/// Quality gate: drop IPs whose real latency exceeds this (ms). 0 = off.
#[arg(long, default_value_t = 0.0)]
pub(crate) max_latency: f64,
/// Quality gate: drop IPs slower than this (Mbps). 0 = off.
#[arg(long, default_value_t = 0.0)]
pub(crate) min_speed: f64,
/// Output directory for all intermediate files and result.txt.
#[arg(short = 'o', long, default_value = "result")]
pub(crate) output: PathBuf,
/// Print each stage's result table.
#[arg(short = 'v', long)]
pub(crate) verbose: bool,
}
#[derive(Args)]
pub(crate) struct PingArgs {
/// Target number of valid (reachable) IPs to collect.
+205 -73
View File
@@ -1,34 +1,37 @@
//! Subcommand orchestration: wire CLI args to the stage modules and report.
use std::net::IpAddr;
use std::time::Duration;
use anyhow::{Result, anyhow};
use console::style;
use indicatif::ProgressBar;
use crate::cli::{ExportArgs, LatencyArgs, PingArgs, SpeedArgs};
use crate::cli::{AutoArgs, ExportArgs, LatencyArgs, PingArgs, SpeedArgs};
use crate::cloudflare::{self, Family};
use crate::ping::{self, Progress};
use crate::report::{
LiveProgress, print_speed_table, print_table, read_export_rows, read_ip_list, read_latency_csv,
resolve_node, write_csv, write_file, write_ips, write_speed_csv,
};
use crate::vless::VlessNode;
use crate::{latency, speed};
// Stage defaults the auto pipeline uses for the knobs it doesn't expose.
const PING_TIMEOUT: f64 = 3.0;
const PING_CONCURRENCY: usize = 100;
const PING_PROBE_SAMPLE: usize = 5;
const PING_MIN_LATENCY_MS: f64 = 10.0;
const PING_MAX_LATENCY_MS: f64 = 300.0;
const LAT_CONCURRENCY: usize = 50;
const LAT_TIMEOUT: f64 = 5.0;
const SPEED_CONCURRENCY: usize = 1;
const SPEED_TIMEOUT: f64 = 10.0;
const SPEED_BYTES: u64 = 10_000_000;
// ---- individual subcommands ------------------------------------------------
pub(crate) async fn run_ping(args: PingArgs) -> Result<()> {
let family = if args.ipv6 { Family::Both } else { Family::V4 };
let spinner = ProgressBar::new_spinner();
spinner.enable_steady_tick(Duration::from_millis(90));
spinner.set_message("Fetching Cloudflare ranges…");
let ranges = cloudflare::fetch_ranges(family).await?;
spinner.finish_with_message(format!(
"{} Cloudflare ranges: {} v4, {} v6",
style("").green().bold(),
ranges.v4.len(),
ranges.v6.len()
));
let cfg = ping::PingConfig {
count: args.count,
timeout: Duration::from_secs_f64(args.timeout),
@@ -43,31 +46,11 @@ pub(crate) async fn run_ping(args: PingArgs) -> Result<()> {
max_loss: args.max_loss,
};
let target = cfg.count;
let mut progress = LiveProgress::new(cfg.count as u64, "valid");
let results = ping::run(&ranges, &cfg, |event| match event {
Progress::Probing { ip, ok, valid, probed } => {
let mark = if ok { style("").green() } else { style("x").red() };
progress.push(valid.min(target) as u64, probed, format!("{mark} {ip}"));
}
Progress::PreScan { live, segments, valid } => {
progress.println(format!(
" pre-scan: {} live / {} segments, {} already valid",
style(live).cyan(),
segments,
style(valid).green(),
));
progress.set_position(valid.min(target) as u64);
}
})
.await?;
progress.finish();
let results = stage_ping(&cfg).await?;
if results.is_empty() {
eprintln!("{} no reachable IP found", style("").red().bold());
return Ok(());
}
if args.verbose {
print_table(&results);
}
@@ -77,7 +60,6 @@ pub(crate) async fn run_ping(args: PingArgs) -> Result<()> {
pub(crate) async fn run_latency(args: LatencyArgs) -> Result<()> {
let node = resolve_node(&args.node_file, &args.node)?;
let ips = read_ip_list(&args.input)?;
if ips.is_empty() {
return Err(anyhow!("no valid IPs in {}", args.input.display()));
@@ -89,34 +71,19 @@ pub(crate) async fn run_latency(args: LatencyArgs) -> Result<()> {
ips.len()
);
let mut progress = LiveProgress::new(ips.len() as u64, "tested");
let mut kept = 0usize;
let results = latency::run(
let results = stage_latency(
&node,
&ips,
args.concurrency.max(1),
Duration::from_secs_f64(args.timeout),
Duration::from_secs_f64(args.max_latency / 1000.0),
args.top,
|p| {
let line = match p.latency {
Some(d) => {
kept += 1;
format!("{} {} {:.0}ms", style("").green(), p.ip, d.as_secs_f64() * 1000.0)
}
None => format!("{} {}", style("x").red(), p.ip),
};
progress.push(p.done as u64, kept, line);
},
)
.await;
progress.finish();
if results.is_empty() {
eprintln!("{} no IP passed the real latency test", style("").red().bold());
return Ok(());
}
if args.verbose {
print_table(&results);
}
@@ -127,44 +94,26 @@ pub(crate) async fn run_latency(args: LatencyArgs) -> Result<()> {
pub(crate) async fn run_speed(args: SpeedArgs) -> Result<()> {
let node = resolve_node(&args.node_file, &args.node)?;
let inputs = read_latency_csv(&args.input)?;
if inputs.is_empty() {
return Err(anyhow!("no IP+latency rows in {}", args.input.display()));
}
eprintln!("Node host: {} | speed-testing {} IPs", style(&node.host).cyan(), inputs.len());
let mut progress = LiveProgress::new(inputs.len() as u64, "tested");
let mut kept = 0usize;
let mut results = speed::run(
let results = stage_speed(
&node,
&inputs,
args.concurrency.max(1),
Duration::from_secs_f64(args.timeout),
args.bytes,
args.min_speed,
|p| {
let line = match p.speed_mbps {
Some(mbps) => {
kept += 1;
format!("{} {} {:.2}Mbps", style("").green(), p.ip, mbps)
}
None => format!("{} {}", style("x").red(), p.ip),
};
progress.push(p.done as u64, kept, line);
},
args.top,
)
.await;
progress.finish();
if results.is_empty() {
eprintln!("{} no IP passed the speed test", style("").red().bold());
return Ok(());
}
if args.top > 0 {
results.truncate(args.top);
}
if args.verbose {
print_speed_table(&results);
}
@@ -181,12 +130,12 @@ pub(crate) fn run_export(args: ExportArgs) -> Result<()> {
if rows.is_empty() {
return Err(anyhow!("no valid IPs in {}", args.input.display()));
}
let path = args.output.join("result.txt");
let mut body = String::new();
for row in &rows {
body.push_str(&node.to_url(row.ip, &row.alias()));
body.push('\n');
}
let path = args.output.join("result.txt");
write_file(&path, &body)?;
eprintln!(
"{} built {} nodes -> {}",
@@ -196,3 +145,186 @@ pub(crate) fn run_export(args: ExportArgs) -> Result<()> {
);
Ok(())
}
// ---- auto pipeline ---------------------------------------------------------
pub(crate) async fn run_auto(args: AutoArgs) -> Result<()> {
let node = resolve_node(&args.node_file, &args.node)?;
let out = &args.output;
eprintln!("{}", style("[1/4] ping").bold().cyan());
let cfg = ping::PingConfig {
count: args.count,
timeout: Duration::from_secs_f64(PING_TIMEOUT),
concurrency: PING_CONCURRENCY,
ipv6: args.ipv6,
port: 443,
probe_sample: PING_PROBE_SAMPLE,
max_probe: args.count.saturating_mul(100),
times: 1,
min_latency: Duration::from_secs_f64(PING_MIN_LATENCY_MS / 1000.0),
max_latency: Duration::from_secs_f64(PING_MAX_LATENCY_MS / 1000.0),
max_loss: 0.0,
};
let ping_results = stage_ping(&cfg).await?;
if ping_results.is_empty() {
eprintln!("{} no reachable IP found", style("").red().bold());
return Ok(());
}
write_ips(&ping_results, &out.join("ip.txt"))?;
let ips: Vec<IpAddr> = ping_results.iter().map(|r| r.ip).collect();
eprintln!("{}", style("[2/4] latency").bold().cyan());
eprintln!("Node host: {} path: {}", style(&node.host).cyan(), style(&node.path).cyan());
let lat_results = stage_latency(
&node,
&ips,
LAT_CONCURRENCY,
Duration::from_secs_f64(LAT_TIMEOUT),
Duration::from_secs_f64(args.max_latency / 1000.0),
args.lat_top,
)
.await;
if lat_results.is_empty() {
eprintln!("{} no IP passed the real latency test", style("").red().bold());
return Ok(());
}
write_csv(&lat_results, &out.join("latency.csv"))?;
write_ips(&lat_results, &out.join("latency.txt"))?;
let inputs: Vec<(IpAddr, Duration)> = lat_results.iter().map(|r| (r.ip, r.latency)).collect();
eprintln!("{}", style("[3/4] speed").bold().cyan());
let speed_results = stage_speed(
&node,
&inputs,
SPEED_CONCURRENCY,
Duration::from_secs_f64(SPEED_TIMEOUT),
SPEED_BYTES,
args.min_speed,
args.speed_top,
)
.await;
if speed_results.is_empty() {
eprintln!("{} no IP passed the speed test", style("").red().bold());
return Ok(());
}
write_speed_csv(&speed_results, &out.join("speed.csv"))?;
write_ips(&speed_results, &out.join("speed.txt"))?;
eprintln!("{}", style("[4/4] export").bold().cyan());
let mut body = String::new();
for r in &speed_results {
let alias =
format!("{:.2}M-{:.0}ms-{}", r.speed_mbps, r.latency.as_secs_f64() * 1000.0, r.ip);
body.push_str(&node.to_url(r.ip, &alias));
body.push('\n');
}
let path = out.join("result.txt");
write_file(&path, &body)?;
eprintln!(
"{} built {} nodes -> {}",
style("").green().bold(),
speed_results.len(),
style(path.display()).cyan()
);
if args.verbose {
print_speed_table(&speed_results);
}
Ok(())
}
// ---- shared stage runners (used by both subcommands and auto) --------------
/// Fetch ranges and run the ping stage with the live progress UI.
async fn stage_ping(cfg: &ping::PingConfig) -> Result<Vec<ping::PingResult>> {
let family = if cfg.ipv6 { Family::Both } else { Family::V4 };
let spinner = ProgressBar::new_spinner();
spinner.enable_steady_tick(Duration::from_millis(90));
spinner.set_message("Fetching Cloudflare ranges…");
let ranges = cloudflare::fetch_ranges(family).await?;
spinner.finish_with_message(format!(
"{} Cloudflare ranges: {} v4, {} v6",
style("").green().bold(),
ranges.v4.len(),
ranges.v6.len()
));
let target = cfg.count;
let mut progress = LiveProgress::new(cfg.count as u64, "valid");
let results = ping::run(&ranges, cfg, |event| match event {
Progress::Probing { ip, ok, valid, probed } => {
let mark = if ok { style("").green() } else { style("x").red() };
progress.push(valid.min(target) as u64, probed, format!("{mark} {ip}"));
}
Progress::PreScan { live, segments, valid } => {
progress.println(format!(
" pre-scan: {} live / {} segments, {} already valid",
style(live).cyan(),
segments,
style(valid).green(),
));
progress.set_position(valid.min(target) as u64);
}
})
.await?;
progress.finish();
Ok(results)
}
/// Run the latency stage with the live progress UI.
async fn stage_latency(
node: &VlessNode,
ips: &[IpAddr],
concurrency: usize,
timeout: Duration,
max_latency: Duration,
top: usize,
) -> Vec<latency::LatencyResult> {
let mut progress = LiveProgress::new(ips.len() as u64, "tested");
let mut kept = 0usize;
let results = latency::run(node, ips, concurrency, timeout, max_latency, top, |p| {
let line = match p.latency {
Some(d) => {
kept += 1;
format!("{} {} {:.0}ms", style("").green(), p.ip, d.as_secs_f64() * 1000.0)
}
None => format!("{} {}", style("x").red(), p.ip),
};
progress.push(p.done as u64, kept, line);
})
.await;
progress.finish();
results
}
/// Run the speed stage with the live progress UI, keeping the best `top`.
async fn stage_speed(
node: &VlessNode,
inputs: &[(IpAddr, Duration)],
concurrency: usize,
timeout: Duration,
bytes: u64,
min_speed: f64,
top: usize,
) -> Vec<speed::SpeedResult> {
let mut progress = LiveProgress::new(inputs.len() as u64, "tested");
let mut kept = 0usize;
let mut results =
speed::run(node, inputs, concurrency, timeout, bytes, min_speed, |p| {
let line = match p.speed_mbps {
Some(mbps) => {
kept += 1;
format!("{} {} {:.2}Mbps", style("").green(), p.ip, mbps)
}
None => format!("{} {}", style("x").red(), p.ip),
};
progress.push(p.done as u64, kept, line);
})
.await;
progress.finish();
if top > 0 {
results.truncate(top);
}
results
}
+5 -4
View File
@@ -27,9 +27,10 @@ async fn main() -> Result<()> {
let cli = Cli::parse();
match cli.command {
Command::Ping(args) => commands::run_ping(args).await,
Command::Latency(args) => commands::run_latency(args).await,
Command::Speed(args) => commands::run_speed(args).await,
Command::Export(args) => commands::run_export(args),
Some(Command::Ping(args)) => commands::run_ping(args).await,
Some(Command::Latency(args)) => commands::run_latency(args).await,
Some(Command::Speed(args)) => commands::run_speed(args).await,
Some(Command::Export(args)) => commands::run_export(args),
None => commands::run_auto(cli.auto).await,
}
}