feat: init

This commit is contained in:
chuan
2026-06-23 19:29:34 +08:00
Unverified
commit b3d6a5352e
6 changed files with 2207 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
# Rust build output
/target/
# Backup files
**/*.rs.bk
# IDE/editor
.vscode/
.idea/
result/
Generated
+1643
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
[package]
name = "fast-xray"
version = "0.1.0"
edition = "2024"
rust-version = "1.85"
description = "Fast Cloudflare IP optimizer for CDN-fronted Xray/VLESS nodes."
[dependencies]
anyhow = "1"
clap = { version = "4", features = ["derive"] }
console = "0.15"
futures = "0.3"
indicatif = "0.17"
ipnet = "2"
rand = "0.8"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "time"] }
[profile.release]
opt-level = 3
lto = "thin"
codegen-units = 1
strip = true
+118
View File
@@ -0,0 +1,118 @@
//! Fetch Cloudflare's published CDN IP ranges from the web.
//!
//! Cloudflare publishes its anycast ranges as plain-text CIDR lists, one per
//! line. This module fetches them and returns parsed, typed CIDR lists for
//! other stages (sampling, probing) to consume. It does not write files or
//! shell out.
use anyhow::{Context, Result};
use ipnet::{IpNet, Ipv4Net, Ipv6Net};
pub const CF_IPV4_URL: &str = "https://www.cloudflare.com/ips-v4";
pub const CF_IPV6_URL: &str = "https://www.cloudflare.com/ips-v6";
const USER_AGENT: &str = "cfopt/0.1";
/// Which address families to fetch.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[allow(dead_code)] // V4 / V6 reserved for a future --family CLI flag.
pub enum Family {
V4,
V6,
Both,
}
/// Parsed Cloudflare CDN ranges, split by address family.
#[derive(Clone, Debug, Default)]
pub struct CfRanges {
pub v4: Vec<Ipv4Net>,
pub v6: Vec<Ipv6Net>,
}
impl CfRanges {
#[allow(dead_code)] // paired with len(); used by later stages.
pub fn is_empty(&self) -> bool {
self.v4.is_empty() && self.v6.is_empty()
}
#[allow(dead_code)] // used by later stages.
pub fn len(&self) -> usize {
self.v4.len() + self.v6.len()
}
}
/// Fetch Cloudflare ranges for the requested family and return them parsed.
pub async fn fetch_ranges(family: Family) -> Result<CfRanges> {
let client = reqwest::Client::builder()
.user_agent(USER_AGENT)
.timeout(std::time::Duration::from_secs(15))
.build()
.context("build http client")?;
let mut ranges = CfRanges::default();
if matches!(family, Family::V4 | Family::Both) {
let text = fetch_text(&client, CF_IPV4_URL).await?;
ranges.v4 = parse_v4_lines(&text);
}
if matches!(family, Family::V6 | Family::Both) {
let text = fetch_text(&client, CF_IPV6_URL).await?;
ranges.v6 = parse_v6_lines(&text);
}
Ok(ranges)
}
async fn fetch_text(client: &reqwest::Client, url: &str) -> Result<String> {
client
.get(url)
.send()
.await
.with_context(|| format!("request {url}"))?
.error_for_status()
.with_context(|| format!("bad status from {url}"))?
.text()
.await
.with_context(|| format!("read body from {url}"))
}
/// Parse a plain-text CIDR list, keeping only IPv4 networks.
pub fn parse_v4_lines(text: &str) -> Vec<Ipv4Net> {
parse_cidr_lines(text)
.into_iter()
.filter_map(|net| match net {
IpNet::V4(v4) => Some(v4),
IpNet::V6(_) => None,
})
.collect()
}
/// Parse a plain-text CIDR list, keeping only IPv6 networks.
pub fn parse_v6_lines(text: &str) -> Vec<Ipv6Net> {
parse_cidr_lines(text)
.into_iter()
.filter_map(|net| match net {
IpNet::V6(v6) => Some(v6),
IpNet::V4(_) => None,
})
.collect()
}
/// Parse a plain-text CIDR list, skipping blanks and anything unparseable.
fn parse_cidr_lines(text: &str) -> Vec<IpNet> {
text.lines()
.filter_map(|line| line.split_whitespace().next())
.filter_map(|token| token.parse::<IpNet>().ok())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn splits_v4_and_v6() {
let text = "173.245.48.0/20\n# comment\n\n103.21.244.0/22 trailing\n2400:cb00::/32\n";
assert_eq!(parse_v4_lines(text).len(), 2);
assert_eq!(parse_v6_lines(text).len(), 1);
assert_eq!(parse_v4_lines(text)[0].to_string(), "173.245.48.0/20");
}
}
+188
View File
@@ -0,0 +1,188 @@
//! cfopt — Cloudflare IP optimizer for CDN-fronted VLESS nodes.
//!
//! Subcommands mirror the optimization pipeline. Implemented so far:
//! ping — collect the fastest reachable Cloudflare IPs over TCP.
mod cloudflare;
mod ping;
use std::path::{Path, PathBuf};
use std::time::Duration;
use anyhow::{Context, Result};
use clap::{Args, Parser, Subcommand};
use console::style;
use indicatif::{ProgressBar, ProgressStyle};
use cloudflare::Family;
use ping::{PingResult, Progress};
#[derive(Parser)]
#[command(name = "fast-xray", about = "Fast Cloudflare IP optimizer for CDN-fronted Xray/VLESS nodes.")]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
/// Probe Cloudflare IPs over TCP 443 and collect the fastest reachable ones.
Ping(PingArgs),
}
#[derive(Args)]
struct PingArgs {
/// Target number of valid (reachable) IPs to collect.
#[arg(short = 'n', long, default_value_t = 50)]
count: usize,
/// TCP connect timeout in seconds; slower than this counts as unreachable.
#[arg(short = 't', long, default_value_t = 3.0)]
timeout: f64,
/// Concurrent probes.
#[arg(short = 'c', long, default_value_t = 100)]
concurrency: usize,
/// Also test IPv6 ranges (off by default).
#[arg(short = '6', long = "ipv6")]
ipv6: bool,
/// TCP port to probe.
#[arg(short = 'p', long, default_value_t = 443)]
port: u16,
/// Random IPs sampled per segment in the availability pre-scan.
#[arg(long, default_value_t = 5)]
probe_sample: usize,
/// Safety cap: stop after probing this many IPs. Defaults to count * 100.
#[arg(long)]
max_probe: Option<usize>,
/// Write the resulting IPs (one per line, fastest first) to this file.
#[arg(short = 'o', long, default_value = "result/ip.txt")]
output: PathBuf,
/// Print the full result table (IP + latency) at the end.
#[arg(short = 'v', long)]
verbose: bool,
}
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
match cli.command {
Command::Ping(args) => run_ping(args).await,
}
}
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),
concurrency: args.concurrency.max(1),
ipv6: args.ipv6,
port: args.port,
probe_sample: args.probe_sample,
max_probe: args.max_probe.unwrap_or(args.count.saturating_mul(100)),
};
let bar = ProgressBar::new(cfg.count as u64);
bar.set_style(
ProgressStyle::with_template(
"{spinner:.cyan} [{bar:32.green/dim}] {pos}/{len} valid {msg}",
)
.unwrap()
.progress_chars("=>-"),
);
bar.enable_steady_tick(Duration::from_millis(90));
let target = cfg.count;
let results = ping::run(&ranges, &cfg, |progress| match progress {
Progress::Probing { ip, ok, valid, probed } => {
bar.set_position(valid.min(target) as u64);
let mark = if ok { style("").green() } else { style("·").dim() };
bar.set_message(format!("probed {probed} {mark} {ip}"));
}
Progress::PreScan { live, segments, valid } => {
bar.println(format!(
" pre-scan: {} live / {} segments, {} already valid",
style(live).cyan(),
segments,
style(valid).green(),
));
bar.set_position(valid.min(target) as u64);
}
})
.await?;
bar.finish_and_clear();
if results.is_empty() {
eprintln!("{} no reachable IP found", style("").red().bold());
return Ok(());
}
if args.verbose {
print_table(&results);
}
write_output(&results, &args.output)?;
Ok(())
}
/// Pretty, colored result table on stderr (human-facing).
fn print_table(results: &[PingResult]) {
eprintln!();
eprintln!(
"{}",
style(format!(" Fastest {} IPs", results.len())).bold().underlined()
);
eprintln!(" {:>3} {:<39} {:>9}", "#", "IP", "Latency");
for (i, r) in results.iter().enumerate() {
let ms = r.latency.as_secs_f64() * 1000.0;
let latency = format!("{ms:>6.1} ms");
let colored = if ms < 150.0 {
style(latency).green()
} else if ms < 250.0 {
style(latency).yellow()
} else {
style(latency).red()
};
eprintln!(" {:>3} {:<39} {}", i + 1, r.ip.to_string(), colored);
}
eprintln!();
}
/// Write IPs only (one per line, fastest first) to `path`.
fn write_output(results: &[PingResult], path: &Path) -> Result<()> {
let mut body = String::with_capacity(results.len() * 16);
for r in results {
body.push_str(&r.ip.to_string());
body.push('\n');
}
if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
std::fs::create_dir_all(parent)
.with_context(|| format!("create dir for {}", path.display()))?;
}
std::fs::write(path, &body).with_context(|| format!("write {}", path.display()))?;
eprintln!(
"{} saved {} IPs -> {}",
style("").green().bold(),
results.len(),
style(path.display()).cyan()
);
Ok(())
}
+224
View File
@@ -0,0 +1,224 @@
//! Stage 1 (`ping`): TCP-probe Cloudflare IPs and collect the fastest
//! reachable ones.
//!
//! We do NOT use ICMP: optimization cares about whether TCP 443 (the port the
//! real VLESS+TLS connection uses) is reachable and fast. ICMP only tells us
//! the host answers pings — a different thing, often throttled or blocked —
//! and would need raw sockets or an external `ping` binary.
//!
//! Flow:
//! Phase A — availability pre-scan: sample a few IPs per segment, keep only
//! segments that answer (CF segment usability is very uneven).
//! Phase B — collection: sample uniformly across live segments and probe in
//! concurrent batches until `count` valid IPs are gathered (or the
//! `max_probe` budget is hit), then return the lowest-latency ones.
use std::collections::{HashMap, HashSet};
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
use std::time::{Duration, Instant};
use anyhow::Result;
use futures::stream::{self, StreamExt};
use ipnet::IpNet;
use rand::Rng;
use rand::seq::SliceRandom;
use tokio::net::TcpStream;
use tokio::time::timeout;
use crate::cloudflare::CfRanges;
/// One reachable IP and its TCP-connect latency.
#[derive(Clone, Debug)]
pub struct PingResult {
pub ip: IpAddr,
pub latency: Duration,
}
/// Progress events emitted during a run so the caller can render UI. Keeps this
/// module free of any console/progress-bar dependency.
pub enum Progress {
/// A single IP just finished probing (emitted for every probe, live feed).
Probing { ip: IpAddr, ok: bool, valid: usize, probed: usize },
/// Availability pre-scan finished.
PreScan { live: usize, segments: usize, valid: usize },
}
/// Parameters for the ping stage.
pub struct PingConfig {
/// Target number of valid IPs to collect.
pub count: usize,
/// Per-probe TCP connect timeout; slower than this is treated as down.
pub timeout: Duration,
/// Number of probes in flight at once.
pub concurrency: usize,
/// Whether IPv6 segments are included.
pub ipv6: bool,
/// TCP port to probe.
pub port: u16,
/// IPs sampled per segment during the availability pre-scan.
pub probe_sample: usize,
/// Hard cap on how many IPs we probe before giving up.
pub max_probe: usize,
}
/// Run the ping stage and return collected valid IPs, lowest latency first.
///
/// `on_progress` is invoked between probe batches with [`Progress`] events.
pub async fn run(
ranges: &CfRanges,
cfg: &PingConfig,
mut on_progress: impl FnMut(Progress),
) -> Result<Vec<PingResult>> {
let mut nets: Vec<IpNet> = ranges.v4.iter().map(|n| IpNet::V4(*n)).collect();
if cfg.ipv6 {
nets.extend(ranges.v6.iter().map(|n| IpNet::V6(*n)));
}
if nets.is_empty() {
return Ok(Vec::new());
}
let mut seen: HashSet<IpAddr> = HashSet::new();
let mut collected: Vec<PingResult> = Vec::new();
// Phase A: availability pre-scan, learn which segments are live.
let mut a_samples: Vec<(usize, IpAddr)> = Vec::new();
{
let mut rng = rand::thread_rng();
for (idx, net) in nets.iter().enumerate() {
for _ in 0..cfg.probe_sample {
let ip = random_host(net, &mut rng);
if seen.insert(ip) {
a_samples.push((idx, ip));
}
}
}
}
let a_ips: Vec<IpAddr> = a_samples.iter().map(|(_, ip)| *ip).collect();
let mut probed = 0usize;
let mut a_hits: HashMap<IpAddr, Duration> = HashMap::new();
probe_each(a_ips, cfg.port, cfg.timeout, cfg.concurrency, |ip, lat| {
probed += 1;
if let Some(d) = lat {
a_hits.insert(ip, d);
}
on_progress(Progress::Probing { ip, ok: lat.is_some(), valid: a_hits.len(), probed });
})
.await;
let mut live_idx: HashSet<usize> = HashSet::new();
for (idx, ip) in &a_samples {
if let Some(d) = a_hits.get(ip) {
live_idx.insert(*idx);
collected.push(PingResult { ip: *ip, latency: *d });
}
}
let live_nets: Vec<IpNet> = nets
.iter()
.enumerate()
.filter(|(idx, _)| live_idx.contains(idx))
.map(|(_, net)| *net)
.collect();
on_progress(Progress::PreScan {
live: live_nets.len(),
segments: nets.len(),
valid: collected.len(),
});
if live_nets.is_empty() {
return Ok(finalize(collected, cfg.count));
}
// Phase B: collect until we have `count` valid IPs (or run out of budget).
while collected.len() < cfg.count && probed < cfg.max_probe {
let want = cfg.count - collected.len();
let budget = cfg.max_probe - probed;
let batch_size = (want * 2).max(cfg.concurrency).min(budget);
let batch = {
let mut rng = rand::thread_rng();
let mut cand: Vec<IpAddr> = Vec::with_capacity(batch_size);
let mut tries = 0;
let max_tries = batch_size.saturating_mul(20).max(1);
while cand.len() < batch_size && tries < max_tries {
if let Some(net) = live_nets.choose(&mut rng) {
let ip = random_host(net, &mut rng);
if seen.insert(ip) {
cand.push(ip);
}
}
tries += 1;
}
cand
};
if batch.is_empty() {
break;
}
probe_each(batch, cfg.port, cfg.timeout, cfg.concurrency, |ip, lat| {
probed += 1;
let ok = lat.is_some();
if let Some(d) = lat {
collected.push(PingResult { ip, latency: d });
}
on_progress(Progress::Probing { ip, ok, valid: collected.len(), probed });
})
.await;
}
Ok(finalize(collected, cfg.count))
}
/// Sort by latency ascending and keep the best `count`.
fn finalize(mut results: Vec<PingResult>, count: usize) -> Vec<PingResult> {
results.sort_by_key(|r| r.latency);
results.truncate(count);
results
}
/// Probe a batch of IPs with bounded concurrency, invoking `on_each` as soon
/// as every individual probe finishes (drives the live progress feed).
async fn probe_each(
ips: Vec<IpAddr>,
port: u16,
to: Duration,
concurrency: usize,
mut on_each: impl FnMut(IpAddr, Option<Duration>),
) {
let mut probes = stream::iter(
ips.into_iter().map(|ip| async move { (ip, probe_one(ip, port, to).await) }),
)
.buffer_unordered(concurrency.max(1));
while let Some((ip, lat)) = probes.next().await {
on_each(ip, lat);
}
}
/// One TCP-connect probe. Returns the connect latency, or None if it failed
/// or timed out.
async fn probe_one(ip: IpAddr, port: u16, to: Duration) -> Option<Duration> {
let addr = SocketAddr::new(ip, port);
let started = Instant::now();
match timeout(to, TcpStream::connect(addr)).await {
Ok(Ok(_stream)) => Some(started.elapsed()),
_ => None,
}
}
/// Pick a uniformly random usable host inside a CIDR segment.
fn random_host(net: &IpNet, rng: &mut impl Rng) -> IpAddr {
match net {
IpNet::V4(n) => {
let first = u32::from(n.network());
let last = u32::from(n.broadcast());
let (lo, hi) = if last - first >= 2 { (first + 1, last - 1) } else { (first, last) };
IpAddr::V4(Ipv4Addr::from(rng.gen_range(lo..=hi)))
}
IpNet::V6(n) => {
let first = u128::from(n.network());
let last = u128::from(n.broadcast());
let (lo, hi) = if last - first >= 2 { (first + 1, last - 1) } else { (first, last) };
IpAddr::V6(Ipv6Addr::from(rng.gen_range(lo..=hi)))
}
}
}