From af9e09148e36a4f877c7262cdb2efa3bad3afba4 Mon Sep 17 00:00:00 2001 From: chuan Date: Tue, 23 Jun 2026 21:09:00 +0800 Subject: [PATCH] feat: add auto pipeline as default top-level command --- src/cli.rs | 56 +++++++++- src/commands.rs | 278 +++++++++++++++++++++++++++++++++++------------- src/main.rs | 9 +- 3 files changed, 264 insertions(+), 79 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index cf64824..287b443 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -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(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, + + /// Read the vless:// node URL from a file (avoids shell escaping of `&`). + #[arg(long)] + pub(crate) node_file: Option, + + /// 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. diff --git a/src/commands.rs b/src/commands.rs index 8219810..c2eec39 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -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 = 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> { + 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 { + 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 { + 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 +} diff --git a/src/main.rs b/src/main.rs index f7ab3ce..63e6527 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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, } }