feat: provide a easy way to search the best ip

This commit is contained in:
chuan
2026-06-24 21:50:19 +08:00
parent 4126f78682
commit a0425324c0
8 changed files with 607 additions and 10 deletions
+22
View File
@@ -63,6 +63,12 @@ fast-xray --node-file node.txt -n 50 --lat-top 30 --speed-top 10
节点 URL 含 `&`,可直接加引号传入,或用 `--node-file` 从文件读取。结果写入 `result/result.txt`
只要一个够快的 IP,用 `easy`——边发现边测速,第一个达标即停:
```bash
fast-xray easy --node-file node.txt --speed 5
```
各阶段也可单独运行用于调试或定制。
## 流水线
@@ -104,6 +110,22 @@ flowchart LR
auto 使用各阶段默认的超时、并发、丢包等参数;需精调时单独运行子命令。
### easy — `fast-xray easy [NODE] --speed <MB/s> [OPTIONS]`
只要一个够快的 IP 就停。后台持续用 `ping + latency` 往池子(桶)里补充有效 IP;前台每轮并发筛一批(5 个),并发下就达标的直接命中,有潜力的(≥ `目标/5`)再单独确认,第一个达到 `--speed` 的即为结果,直接输出可复制的节点。
先直连国内镜像(清华 TUNA)测一次本机不走代理的下载速度,作为带宽上限:`--speed` 高于它直接判定不可能;镜像偶发不可用则跳过、照常搜索。
| 参数 | 默认 | 说明 |
| --- | --- | --- |
| `[NODE]` / `--node-file` | — | 节点 URL,或从文件读取 |
| `--speed` | 直连×80% | 目标下载速度 MB/s,返回首个达标 IP;不填则取直连测速的 80% |
| `--max` | 100 | 最多遍历多少个**有效** IP(通过 ping + latency);用尽仍无达标即「找不到」 |
| `-6, --ipv6` | off | 同时搜索 IPv6 |
| `-o, --output` | result | 输出目录(写 result.txt) |
固定参数:ping 并发 100、≤200ms;latency 并发 10、≤200ms;测速并发筛 5 个再单独确认、每次上限 5s。
### ping — `fast-xray ping [OPTIONS]`
| 参数 | 默认 | 说明 |
+30
View File
@@ -31,6 +31,8 @@ pub(crate) enum Command {
Speed(SpeedArgs),
/// Build importable vless:// nodes from a valid-IP list and a node.
Export(ExportArgs),
/// Find one IP fast enough to hit a target speed, then stop.
Easy(EasyArgs),
}
#[derive(Args)]
@@ -216,6 +218,34 @@ pub(crate) struct SpeedArgs {
pub(crate) verbose: bool,
}
#[derive(Args)]
pub(crate) struct EasyArgs {
/// 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>,
/// Target download speed in MB/s; returns the first IP that reaches it.
/// Omit to default to 80% of the measured direct (no-proxy) speed.
#[arg(long)]
pub(crate) speed: Option<f64>,
/// Give up after this many valid IPs (those that pass both ping and
/// latency). Reaching it without a fast-enough IP means "not found".
#[arg(long, default_value_t = 100)]
pub(crate) max: usize,
/// Also search IPv6 ranges (off by default).
#[arg(short = '6', long = "ipv6")]
pub(crate) ipv6: bool,
/// Output directory. Writes <dir>/result.txt with the chosen node.
#[arg(short = 'o', long, default_value = "result")]
pub(crate) output: PathBuf,
}
#[derive(Args)]
pub(crate) struct ExportArgs {
/// Input vless:// node URL (or use --node-file).
+2 -2
View File
@@ -11,8 +11,8 @@ use crate::cli::{AutoArgs, ExportArgs, LatencyArgs, PingArgs, SpeedArgs};
use crate::cloudflare::{self, Family};
use crate::ping::{self, Progress, ProbeStatus};
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,
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::latency::{self, LatStatus};
use crate::speed;
+467
View File
@@ -0,0 +1,467 @@
//! `easy`: find one IP fast enough to hit a target speed, then stop.
//!
//! A producer keeps a bounded *bucket* of validated IPs (those that pass ping +
//! latency) topped up. A consumer pulls a batch and screens it concurrently:
//! under n-way contention each stream gets roughly link/n, so anything reaching
//! `target/n` is worth a solo confirm and anything already at `target` is a
//! guaranteed pass. The promising ones are then re-tested single-threaded (for
//! an accurate number) until one clears `target`. A live "bucket / recent" view
//! shows what's queued, what's being tested, and the last few finished results.
use std::collections::VecDeque;
use std::net::IpAddr;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use anyhow::{Result, anyhow};
use console::style;
use futures::future::join_all;
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
use crate::cli::EasyArgs;
use crate::cloudflare::{self, CfRanges, Family};
use crate::latency;
use crate::ping;
use crate::report::{print_easy_found, resolve_node, write_file};
use crate::speed;
use crate::vless::VlessNode;
// Fixed knobs — the point of `easy` is to not expose these.
const POOL: usize = 10; // bucket capacity (testing + waiting), also the live row count
const BATCH: usize = 5; // IPs screened concurrently per round
const RECENT: usize = 5; // finished results kept on screen for review
const PING_CONCURRENCY: usize = 100;
const PING_TIMEOUT: f64 = 3.0;
const PING_MIN_MS: f64 = 10.0;
const PING_MAX_MS: f64 = 200.0;
const LAT_CONCURRENCY: usize = 10;
const LAT_TIMEOUT: f64 = 5.0;
const LAT_MAX_MS: f64 = 200.0;
const SPEED_TIMEOUT: f64 = 5.0;
const SPEED_BYTES: u64 = 50_000_000; // file is large; time/steady-state caps first
const MAX_BARREN_ROUNDS: usize = 8; // give up after this many empty discovery rounds
const DEFAULT_SPEED_FRACTION: f64 = 0.80; // default target = this × direct speed
const POLL: Duration = Duration::from_millis(50); // producer/consumer idle poll
const FRAME: Duration = Duration::from_millis(120); // render tick
const FRAMES: [&str; 10] = ["", "", "", "", "", "", "", "", "", ""];
/// One finished speed test, kept for the "recent" panel.
struct Finished {
ip: IpAddr,
mbs: Option<f64>,
pass: bool,
}
/// Shared, observable bucket. A plain mutex (never held across `.await`) is
/// enough: producer and consumer poll it, the renderer reads snapshots.
struct Bucket {
waiting: VecDeque<(IpAddr, Duration)>, // validated, not yet speed-tested
testing: Vec<IpAddr>, // the batch being screened / confirmed
recent: VecDeque<Finished>, // last RECENT finished, oldest first
valid: usize, // total minted (gates --max + header)
producer_done: bool,
found: bool,
}
type Shared = Arc<Mutex<Bucket>>;
pub(crate) async fn run(args: EasyArgs) -> Result<()> {
let node = Arc::new(resolve_node(&args.node_file, &args.node)?);
if matches!(args.speed, Some(s) if s <= 0.0) {
return Err(anyhow!("--speed must be greater than 0"));
}
// 0. Direct (no-proxy) speed via a domestic mirror — the link ceiling, and
// the source of the default target when --speed is omitted.
let spinner = spin("Measuring direct (no-proxy) download speed…");
let baseline = speed::measure_direct(secs(SPEED_TIMEOUT), SPEED_BYTES).await;
match &baseline {
Ok(mbs) => spinner.finish_with_message(format!(
"{} direct download speed: {} MB/s",
style("").green().bold(),
style(format!("{mbs:.2}")).cyan()
)),
Err(_) => spinner.finish_with_message(format!(
"{} direct speed unavailable (direct access blocked?)",
style("!").yellow().bold()
)),
}
// Resolve the target: explicit --speed, else a fraction of the measured
// direct speed. Reject a target the local link can't reach; with neither a
// target nor a measured link there's nothing to aim for.
let (target, note) = match (args.speed, baseline) {
(Some(s), Ok(base)) if s > base => {
return Err(anyhow!(
"direct speed is only {base:.2} MB/s — can't find a node ≥ {s:.2} MB/s"
));
}
(Some(s), _) => (s, String::new()),
(None, Ok(base)) => (
base * DEFAULT_SPEED_FRACTION,
format!(" ({:.0}% of direct {base:.2})", DEFAULT_SPEED_FRACTION * 100.0),
),
(None, Err(_)) => {
return Err(anyhow!(
"couldn't measure direct speed to pick a default target — pass --speed <MB/s>"
));
}
};
let family = if args.ipv6 { Family::Both } else { Family::V4 };
let spinner = spin("Fetching Cloudflare ranges…");
let ranges = Arc::new(cloudflare::fetch_ranges(family).await?);
spinner.finish_with_message(format!(
"{} Cloudflare ranges: {} v4, {} v6",
style("").green().bold(),
ranges.v4.len(),
ranges.v6.len()
));
eprintln!(
"{} target ≥ {} MB/s{}, up to {} valid IPs",
style("easy").bold().cyan(),
style(format!("{target:.2}")).cyan(),
style(note).dim(),
args.max
);
let shared: Shared = Arc::new(Mutex::new(Bucket {
waiting: VecDeque::new(),
testing: Vec::new(),
recent: VecDeque::new(),
valid: 0,
producer_done: false,
found: false,
}));
let producer = tokio::spawn(produce(ranges.clone(), node.clone(), args.max, args.ipv6, shared.clone()));
let stop = Arc::new(AtomicBool::new(false));
let ui = tokio::spawn(render_loop(shared.clone(), BucketView::new(), target, args.max, stop.clone()));
// Consumer: pull a batch, screen it concurrently, then confirm the
// promising ones single-threaded (for an accurate number).
let mut hit: Option<(IpAddr, Duration, f64)> = None;
let mut tested = 0usize;
// What the locked acquire step decided — kept tiny so the guard is released
// before any await.
enum Step {
Stop,
Wait,
Go(Vec<(IpAddr, Duration)>),
}
'consume: loop {
let step = {
let mut b = shared.lock().unwrap();
let n = b.waiting.len().min(BATCH);
if b.found || (n == 0 && b.producer_done) {
Step::Stop
} else if n == 0 {
Step::Wait
} else {
let batch: Vec<_> = b.waiting.drain(..n).collect();
b.testing = batch.iter().map(|(ip, _)| *ip).collect();
Step::Go(batch)
}
};
let batch = match step {
Step::Stop => break,
Step::Wait => {
tokio::time::sleep(POLL).await;
continue;
}
Step::Go(batch) => batch,
};
let n = batch.len();
tested += n;
// Screen the whole batch at once.
let screened = join_all(batch.into_iter().map(|(ip, lat)| {
let node = node.clone();
async move {
let r =
speed::measure_download(node.as_ref(), ip, secs(SPEED_TIMEOUT), SPEED_BYTES).await;
(ip, lat, r)
}
}))
.await;
// Anything at target/n is promising; below that (or failed) is dropped.
let floor = target / n as f64;
let mut candidates: Vec<(IpAddr, Duration, f64)> = Vec::new();
{
let mut b = shared.lock().unwrap();
for (ip, lat, r) in screened {
match r {
Ok(s) if s >= floor => candidates.push((ip, lat, s)),
Ok(s) => b.recent.push_back(Finished { ip, mbs: Some(s), pass: false }),
Err(_) => b.recent.push_back(Finished { ip, mbs: None, pass: false }),
}
}
while b.recent.len() > RECENT {
b.recent.pop_front();
}
b.testing = candidates.iter().map(|(ip, _, _)| *ip).collect();
}
// Confirm promising IPs solo, fastest screen result first.
candidates.sort_by(|a, b| b.2.partial_cmp(&a.2).unwrap_or(std::cmp::Ordering::Equal));
for (ip, lat, _) in candidates {
if shared.lock().unwrap().found {
break 'consume;
}
let r = speed::measure_download(node.as_ref(), ip, secs(SPEED_TIMEOUT), SPEED_BYTES).await;
let (mbs, pass) = match &r {
Ok(s) => (Some(*s), *s >= target),
Err(_) => (None, false),
};
let mut b = shared.lock().unwrap();
b.testing.retain(|x| *x != ip);
b.recent.push_back(Finished { ip, mbs, pass });
while b.recent.len() > RECENT {
b.recent.pop_front();
}
if pass {
b.found = true;
hit = Some((ip, lat, mbs.unwrap()));
break 'consume;
}
}
}
stop.store(true, Ordering::Relaxed);
let _ = ui.await; // render loop clears the view before returning
producer.abort();
match hit {
Some((ip, latency, mbs)) => {
let alias = format!("{mbs:.2}MB-{:.0}ms-{}", latency.as_secs_f64() * 1000.0, ip);
let url = node.to_url(ip, &alias);
print_easy_found(ip, mbs, latency, &url);
write_file(&args.output.join("result.txt"), &format!("{url}\n"))?;
}
None => eprintln!(
"{} no IP reached {target:.2} MB/s after testing {tested} valid IP(s)",
style("").red().bold()
),
}
Ok(())
}
/// Background producer: ping → latency to mint validated IPs into the bucket,
/// blocking while the bucket is full, until `max_valid` are minted, the
/// consumer signals `found`, or the source dries up.
async fn produce(
ranges: Arc<CfRanges>,
node: Arc<VlessNode>,
max_valid: usize,
ipv6: bool,
shared: Shared,
) {
let mut barren = 0usize;
'outer: loop {
{
let b = shared.lock().unwrap();
if b.found || b.valid >= max_valid {
break;
}
}
let cfg = ping::PingConfig {
count: POOL,
timeout: secs(PING_TIMEOUT),
concurrency: PING_CONCURRENCY,
ipv6,
port: 443,
max_probe: POOL.saturating_mul(200),
times: 1,
min_latency: millis(PING_MIN_MS),
max_latency: millis(PING_MAX_MS),
max_loss: 0.0,
};
let reachable = match ping::run(ranges.as_ref(), &cfg, |_| {}).await {
Ok(r) => r,
Err(_) => break,
};
if reachable.is_empty() {
barren += 1;
if barren >= MAX_BARREN_ROUNDS {
break;
}
continue;
}
let ips: Vec<IpAddr> = reachable.iter().map(|r| r.ip).collect();
let passed = latency::run(
node.as_ref(),
&ips,
LAT_CONCURRENCY,
secs(LAT_TIMEOUT),
millis(LAT_MAX_MS),
0,
|_| {},
)
.await;
let mut minted = 0usize;
for r in passed {
if !enqueue(&shared, (r.ip, r.latency), max_valid).await {
break 'outer; // found, or hit the valid cap
}
minted += 1;
}
if minted == 0 {
barren += 1;
if barren >= MAX_BARREN_ROUNDS {
break;
}
} else {
barren = 0;
}
}
shared.lock().unwrap().producer_done = true;
}
/// Wait for a free bucket slot and enqueue `item`; return false if we should
/// stop minting (consumer found a hit, or the valid cap is reached).
async fn enqueue(shared: &Shared, item: (IpAddr, Duration), max_valid: usize) -> bool {
loop {
{
let mut b = shared.lock().unwrap();
if b.found || b.valid >= max_valid {
return false;
}
if b.waiting.len() + b.testing.len() < POOL {
b.waiting.push_back(item);
b.valid += 1;
return true;
}
}
tokio::time::sleep(POLL).await;
}
}
/// Periodically redraw the bucket view until `stop`, then clear it.
async fn render_loop(shared: Shared, view: BucketView, target: f64, max: usize, stop: Arc<AtomicBool>) {
let mut tick = 0usize;
loop {
view.render(&shared, target, max, tick);
if stop.load(Ordering::Relaxed) {
break;
}
tick += 1;
tokio::time::sleep(FRAME).await;
}
view.clear();
}
/// The fixed, in-place "bucket / recent" panel: a header line, the bucket rows,
/// and the recent-results rows, each an indicatif line whose message we set.
struct BucketView {
_mp: MultiProgress,
header: ProgressBar,
bucket_label: ProgressBar,
bucket: Vec<ProgressBar>,
recent_label: ProgressBar,
recent: Vec<ProgressBar>,
}
impl BucketView {
fn new() -> Self {
let mp = MultiProgress::new();
let line = |mp: &MultiProgress| {
let pb = mp.add(ProgressBar::new(1));
pb.set_style(ProgressStyle::with_template("{msg}").unwrap());
pb
};
let header = line(&mp);
let bucket_label = line(&mp);
let bucket = (0..POOL).map(|_| line(&mp)).collect();
let recent_label = line(&mp);
let recent = (0..RECENT).map(|_| line(&mp)).collect();
bucket_label.set_message(format!(" {}", style("bucket").dim()));
recent_label.set_message(format!(" {}", style("recent").dim()));
Self { _mp: mp, header, bucket_label, bucket, recent_label, recent }
}
fn render(&self, shared: &Shared, target: f64, max: usize, tick: usize) {
let frame = FRAMES[tick % FRAMES.len()];
let (testing, waiting, recent, valid) = {
let b = shared.lock().unwrap();
(
b.testing.clone(),
b.waiting.iter().map(|(ip, _)| *ip).collect::<Vec<_>>(),
b.recent
.iter()
.rev() // newest first
.map(|f| (f.ip, f.mbs, f.pass))
.collect::<Vec<_>>(),
b.valid,
)
};
self.header.set_message(format!(
"{} {}/{} valid target ≥ {:.2} MB/s",
style(frame).cyan(),
valid,
max,
target
));
// Bucket rows: the batch under test (spinner), then the ones waiting.
let mut rows: Vec<String> = Vec::new();
for ip in &testing {
rows.push(format!(
" {} {:<15} {}",
style(frame).cyan(),
ip.to_string(),
style("testing…").cyan()
));
}
for ip in &waiting {
rows.push(format!(" {:<15} {}", ip.to_string(), style("waiting").dim()));
}
for (pb, row) in self.bucket.iter().zip(rows.iter().chain(std::iter::repeat(&String::new()))) {
pb.set_message(row.clone());
}
// Recent results, newest first.
for (i, pb) in self.recent.iter().enumerate() {
match recent.get(i) {
Some((ip, mbs, pass)) => {
let mark = if *pass { style("").green() } else { style("").red() };
let value = match mbs {
Some(s) => format!("{s:.2} MB/s"),
None => "failed".to_string(),
};
let value = if *pass { style(value).green() } else { style(value).dim() };
pb.set_message(format!(" {mark} {:<15} {value}", ip.to_string()));
}
None => pb.set_message(String::new()),
}
}
}
fn clear(&self) {
for pb in std::iter::once(&self.header)
.chain(std::iter::once(&self.bucket_label))
.chain(self.bucket.iter())
.chain(std::iter::once(&self.recent_label))
.chain(self.recent.iter())
{
pb.finish_and_clear();
}
}
}
fn spin(msg: &str) -> ProgressBar {
let pb = ProgressBar::new_spinner();
pb.enable_steady_tick(Duration::from_millis(90));
pb.set_message(msg.to_string());
pb
}
fn secs(s: f64) -> Duration {
Duration::from_secs_f64(s)
}
fn millis(ms: f64) -> Duration {
Duration::from_secs_f64(ms / 1000.0)
}
+2
View File
@@ -9,6 +9,7 @@
mod cli;
mod cloudflare;
mod commands;
mod easy;
mod latency;
mod ping;
mod report;
@@ -31,6 +32,7 @@ async fn main() -> Result<()> {
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),
Some(Command::Easy(args)) => easy::run(args).await,
None => commands::run_auto(cli.auto).await,
}
}
+3 -2
View File
@@ -19,7 +19,8 @@ use std::time::{Duration, Instant};
use anyhow::Result;
use futures::stream::{FuturesUnordered, StreamExt};
use ipnet::IpNet;
use rand::Rng;
use rand::rngs::StdRng;
use rand::{Rng, SeedableRng};
use tokio::net::TcpStream;
use tokio::time::timeout;
@@ -132,7 +133,7 @@ pub async fn run(
let mut attempts = vec![0u32; nets.len()];
let mut hits = vec![0u32; nets.len()];
let mut seen: HashSet<IpAddr> = HashSet::new();
let mut rng = rand::thread_rng();
let mut rng = StdRng::from_entropy();
// Keep `concurrency` probes in flight, refilling one as each finishes so the
// weights learned so far steer every new draw.
+23 -2
View File
@@ -180,7 +180,7 @@ pub(crate) fn print_table<T: Ranked>(results: &[T]) {
eprintln!();
eprintln!("{}", style(format!(" Fastest {} IPs", results.len())).bold().underlined());
let ip_w = ip_col_width(results.iter().map(|r| r.ip()));
eprintln!(" {:>3} {:<ip_w$} {}", "#", "IP", "Latency");
eprintln!(" {:>3} {:<ip_w$} Latency", "#", "IP");
for (i, r) in results.iter().enumerate() {
let ms = r.latency().as_secs_f64() * 1000.0;
let latency = format!("{ms:.1} ms");
@@ -206,7 +206,7 @@ pub(crate) fn print_speed_table(results: &[speed::SpeedResult]) {
eprintln!();
eprintln!("{}", style(format!(" Top {} nodes", results.len())).bold().underlined());
let ip_w = ip_col_width(results.iter().map(|r| r.ip));
eprintln!(" {:>3} {:<ip_w$} {:<9} {}", "#", "IP", "Latency", "Speed");
eprintln!(" {:>3} {:<ip_w$} {:<9} Speed", "#", "IP", "Latency");
for (i, r) in results.iter().enumerate() {
let latency = format!("{:.0} ms", r.latency.as_secs_f64() * 1000.0);
// Speed is the last column, so it can stay colored without padding.
@@ -223,6 +223,27 @@ pub(crate) fn print_speed_table(results: &[speed::SpeedResult]) {
eprintln!();
}
/// Print the single node `easy` settled on: a one-row table plus the
/// importable URL, ready to copy.
pub(crate) fn print_easy_found(ip: IpAddr, speed_mbs: f64, latency: Duration, url: &str) {
let ip_s = ip.to_string();
let ip_w = ip_s.len().max(2);
eprintln!();
eprintln!("{}", style(" Found a node").bold().underlined());
eprintln!(" {:<ip_w$} {:<9} Speed", "IP", "Latency");
// Speed is last so its color codes don't throw off column padding.
eprintln!(
" {:<ip_w$} {:<9} {}",
ip_s,
format!("{:.0} ms", latency.as_secs_f64() * 1000.0),
style(format!("{speed_mbs:.2} MB/s")).green()
);
eprintln!();
eprintln!(" {}", style("Result (copy):").bold());
eprintln!(" {}", style(url).cyan());
eprintln!();
}
/// Write IPs only (one per line, fastest first) to `path`.
pub(crate) fn write_ips<T: Ranked>(results: &[T], path: &Path) -> Result<()> {
let mut body = String::with_capacity(results.len() * 16);
+58 -4
View File
@@ -1,13 +1,14 @@
//! Stage 3 (`speed`): download through the node via each candidate IP and
//! measure real throughput, then rank by speed bucket then latency.
use std::net::IpAddr;
use std::net::{IpAddr, SocketAddr};
use std::time::{Duration, Instant};
use anyhow::{Result, anyhow};
use anyhow::{Context, Result, anyhow};
use futures::stream::{self, StreamExt};
use rustls::pki_types::ServerName;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpStream, lookup_host};
use crate::vless::{self, VlessNode};
@@ -15,6 +16,14 @@ const TARGET_HOST: &str = "cachefly.cachefly.net";
const TARGET_PORT: u16 = 443;
const TARGET_PATH: &str = "/50mb.test";
// Direct baseline target: a domestic, no-proxy-reachable speedtest file. The
// proxied path is bounded by the local link, and abroad hosts are unreachable
// without a proxy here, so we gauge the link ceiling against a CN mirror. TUNA's
// /speedtest/ files are large (1GB) and stable; swap if it ever moves.
const DIRECT_HOST: &str = "mirrors.tuna.tsinghua.edu.cn";
const DIRECT_PORT: u16 = 443;
const DIRECT_PATH: &str = "/speedtest/1000mb.bin";
/// One IP with its (carried-over) latency and measured download speed (MB/s).
#[derive(Clone, Debug)]
pub struct SpeedResult {
@@ -92,12 +101,57 @@ pub async fn measure_download(
.await
.map_err(|_| anyhow!("connect timeout"))??;
let sni = ServerName::try_from(TARGET_HOST.to_string())?;
let mut stream = tokio::time::timeout(timeout, vless::tls_connector().connect(sni, tunnel))
let stream = tokio::time::timeout(timeout, vless::tls_connector().connect(sni, tunnel))
.await
.map_err(|_| anyhow!("tls timeout"))??;
download_speed(stream, TARGET_HOST, TARGET_PATH, timeout, limit_bytes).await
}
/// Baseline download with no proxy: connect straight to a domestic test host
/// and measure. A proxied node can never beat the local link, so this bounds
/// what the `easy` command can sensibly target.
pub async fn measure_direct(timeout: Duration, limit_bytes: u64) -> Result<f64> {
let addr = resolve_preferring_v4(DIRECT_HOST, DIRECT_PORT).await?;
let tcp = tokio::time::timeout(timeout, TcpStream::connect(addr))
.await
.map_err(|_| anyhow!("connect timeout"))??;
tcp.set_nodelay(true).ok();
let sni = ServerName::try_from(DIRECT_HOST.to_string())?;
let stream = tokio::time::timeout(timeout, vless::tls_connector().connect(sni, tcp))
.await
.map_err(|_| anyhow!("tls timeout"))??;
download_speed(stream, DIRECT_HOST, DIRECT_PATH, timeout, limit_bytes).await
}
/// Resolve `host:port`, preferring an IPv4 address. Networks this tool targets
/// often have broken IPv6; `TcpStream::connect((host, port))` would try a dead
/// AAAA first and stall, so we resolve and pick the address ourselves.
async fn resolve_preferring_v4(host: &str, port: u16) -> Result<SocketAddr> {
let addrs: Vec<SocketAddr> =
lookup_host((host, port)).await.context("resolve host")?.collect();
addrs
.iter()
.copied()
.find(SocketAddr::is_ipv4)
.or_else(|| addrs.first().copied())
.ok_or_else(|| anyhow!("no address for {host}"))
}
/// GET the test file over an already-connected TLS stream and measure
/// throughput. Stops at steady state, the byte cap, or the time cap — whichever
/// comes first. Shared by the proxied and direct paths.
async fn download_speed<S>(
mut stream: S,
host: &str,
path: &str,
timeout: Duration,
limit_bytes: u64,
) -> Result<f64>
where
S: AsyncReadExt + AsyncWriteExt + Unpin,
{
let request = format!(
"GET {TARGET_PATH} HTTP/1.1\r\nHost: {TARGET_HOST}\r\n\
"GET {path} HTTP/1.1\r\nHost: {host}\r\n\
User-Agent: fast-xray\r\nAccept: */*\r\nConnection: close\r\n\r\n"
);
stream.write_all(request.as_bytes()).await?;