From 79c197ee33f5ac42b83c18fcea17dfa33227f3e2 Mon Sep 17 00:00:00 2001 From: chuan Date: Mon, 24 Aug 2026 18:41:01 +0800 Subject: [PATCH] feat: add hidden chuan node search preset --- README.md | 2 ++ docs/NODE_CONFIGURATION.md | 70 ++++++++++++++++++++++++++++++++++++++ src/cli.rs | 13 +++++++ src/easy/consumer.rs | 36 +++++++++++++++++--- src/easy/engine.rs | 3 +- src/easy/mod.rs | 20 ++++++++++- src/main.rs | 5 +-- src/web/session.rs | 2 +- 8 files changed, 141 insertions(+), 10 deletions(-) create mode 100644 docs/NODE_CONFIGURATION.md diff --git a/README.md b/README.md index e1d1476..e41d7a5 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # fast-xray +节点配置要求见 [docs/NODE_CONFIGURATION.md](docs/NODE_CONFIGURATION.md)。 + Cloudflare 优选 IP 工具,面向 CDN 落地的 VLESS 节点。纯 Rust,内置 `VLESS + WebSocket + TLS` 客户端,**无需 xray**。 从 Cloudflare 的 IP 段中筛出延迟低、下载快的 IP,输出可直接导入的节点(别名 `速度-延迟-ip`,如 `12.34M-156ms-104.19.53.168`)。 diff --git a/docs/NODE_CONFIGURATION.md b/docs/NODE_CONFIGURATION.md new file mode 100644 index 0000000..806f783 --- /dev/null +++ b/docs/NODE_CONFIGURATION.md @@ -0,0 +1,70 @@ +# VLESS Node Configuration + +`fast-xray` tests Cloudflare edge IPs by replacing only the connection address. The TLS SNI, WebSocket Host, and WebSocket path continue to point to the original domain. + +## Required settings + +The node must use these settings: + +- Protocol: VLESS +- Port: `443` +- Transport: WebSocket +- TLS: enabled +- Encryption: `none` +- Flow: empty +- Domain: proxied through Cloudflare +- Certificate: valid for the configured domain and trusted by the operating system +- WebSocket path: identical to the server setting, for example `/demo` +- Host: the certificate domain +- SNI: the certificate domain +- UUID: the UUID configured in x-ui + +Reality, XTLS Vision, non-WebSocket transports, and direct-origin nodes are not supported. + +## URL format + +```text +vless://UUID@cloudcone.pchuan.top:443?encryption=none&security=tls&type=ws&host=cloudcone.pchuan.top&sni=cloudcone.pchuan.top&path=%2Fdemo#remark +``` + +Replace these values: + +- `UUID`: the UUID from the x-ui VLESS inbound +- `cloudcone.pchuan.top`: your Cloudflare-proxied domain in the address, `host`, and `sni` fields +- `%2Fdemo`: the URL-encoded WebSocket path `/demo` +- `remark`: an optional node name + +The address before `:443` must be the original domain when supplying the source node. The generated result replaces this address with the selected Cloudflare IP while preserving the domain in the TLS and WebSocket settings. + +## Current node + +```text +vless://e7755581-7c0d-4e19-935f-3c908f74241e@cloudcone.pchuan.top:443?encryption=none&security=tls&type=ws&host=cloudcone.pchuan.top&sni=cloudcone.pchuan.top&path=%2Fdemo#cloudcone +``` + +## Usage + +Pass the URL directly in a shell that preserves `&` inside quotes: + +```bash +fast-xray easy "vless://UUID@cloudcone.pchuan.top:443?encryption=none&security=tls&type=ws&host=cloudcone.pchuan.top&sni=cloudcone.pchuan.top&path=%2Fdemo#remark" +``` + +Alternatively, put one URL in a text file and avoid shell escaping: + +```bash +fast-xray easy --node-file node.txt +``` + +The selected node is written to `result/result.txt`. + +## x-ui checklist + +1. Create or edit a VLESS inbound. +2. Set the listening or externally exposed port to `443`. +3. Leave Flow empty and set encryption to `none`. +4. Select WebSocket transport and set its path. +5. Enable TLS with a certificate for the Cloudflare-proxied domain. +6. Configure Cloudflare DNS proxying for the domain. +7. Build the URL with the same UUID, domain, Host, SNI, and WebSocket path. +8. Verify the source URL in a compatible VLESS client before running `fast-xray`. diff --git a/src/cli.rs b/src/cli.rs index a228c37..3160005 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -248,6 +248,19 @@ pub(crate) struct EasyArgs { pub(crate) output: PathBuf, } +impl EasyArgs { + pub(crate) fn chuan() -> Self { + Self { + node: Some(crate::easy::CHUAN_NODE.to_string()), + node_file: None, + speed: None, + max: 100, + ipv6: false, + output: PathBuf::from("result"), + } + } +} + #[derive(Args)] pub(crate) struct WebArgs { /// Address to bind. Use 0.0.0.0 to expose on the LAN or behind a reverse proxy. diff --git a/src/easy/consumer.rs b/src/easy/consumer.rs index 4590766..14ad5f5 100644 --- a/src/easy/consumer.rs +++ b/src/easy/consumer.rs @@ -21,9 +21,15 @@ use super::{ /// Run the screen/confirm loop until an IP clears `target`, returning the winner /// (or `None` if the source dries up). -pub(super) async fn consume(node: Arc, state: State, explicit_target: bool) -> Option { +pub(super) async fn consume( + node: Arc, + state: State, + explicit_target: bool, + fallback_best: bool, +) -> Option { let mut inflight = FuturesUnordered::new(); let mut active: Vec = Vec::new(); // IPs in `inflight`, mirrored for the UI + let mut best: Option = None; loop { // --- Confirm phase: promising IPs are waiting. Let the in-flight @@ -33,7 +39,7 @@ pub(super) async fn consume(node: Arc, state: State, explicit_target: while let Some((ip, lat, r)) = inflight.next().await { active.retain(|x| *x != ip); state.set_testing(&active); - if let Some(found) = classify(&state, ip, lat, r, explicit_target).await { + if let Some(found) = classify(&state, ip, lat, r, explicit_target, &mut best).await { return Some(found); } } @@ -47,7 +53,10 @@ pub(super) async fn consume(node: Arc, state: State, explicit_target: state.mark_found(ip, s); return Some(Found { ip, latency: lat, mbs: s }); } - Ok(s) => state.record_fail(ip, Some(s)), + Ok(s) => { + keep_best(&mut best, ip, lat, s); + state.record_fail(ip, Some(s)); + } Err(_) => state.record_fail(ip, None), } maybe_recalibrate(&state, explicit_target).await; @@ -71,6 +80,12 @@ pub(super) async fn consume(node: Arc, state: State, explicit_target: if inflight.is_empty() { if state.is_drained() { + if fallback_best { + if let Some(found) = best { + state.mark_found(found.ip, found.mbs); + return Some(found); + } + } return None; } tokio::time::sleep(POLL).await; @@ -80,7 +95,7 @@ pub(super) async fn consume(node: Arc, state: State, explicit_target: if let Some((ip, lat, r)) = inflight.next().await { active.retain(|x| *x != ip); state.set_testing(&active); - if let Some(found) = classify(&state, ip, lat, r, explicit_target).await { + if let Some(found) = classify(&state, ip, lat, r, explicit_target, &mut best).await { return Some(found); } } @@ -103,6 +118,7 @@ async fn classify( lat: Duration, r: Result, explicit_target: bool, + best: &mut Option, ) -> Option { let target = state.target(); let floor = target / CONCURRENCY as f64; @@ -111,8 +127,12 @@ async fn classify( state.mark_found(ip, s); return Some(Found { ip, latency: lat, mbs: s }); } - Ok(s) if s >= floor => state.push_confirm(ip, lat), + Ok(s) if s >= floor => { + keep_best(best, ip, lat, s); + state.push_confirm(ip, lat); + } Ok(s) => { + keep_best(best, ip, lat, s); state.record_fail(ip, Some(s)); maybe_recalibrate(state, explicit_target).await; } @@ -124,6 +144,12 @@ async fn classify( None } +fn keep_best(best: &mut Option, ip: IpAddr, latency: Duration, mbs: f64) { + if best.as_ref().is_none_or(|current| mbs > current.mbs) { + *best = Some(Found { ip, latency, mbs }); + } +} + /// Every `FAIL_RECALIBRATE` failures, re-measure the direct link. If the local /// link itself slowed down, a default (direct-derived) target would otherwise be /// unreachable forever; an explicit --speed target is left untouched. diff --git a/src/easy/engine.rs b/src/easy/engine.rs index cd64f8b..d5c06b6 100644 --- a/src/easy/engine.rs +++ b/src/easy/engine.rs @@ -57,11 +57,12 @@ pub(crate) async fn search( max: usize, ipv6: bool, explicit_target: bool, + fallback_best: bool, state: State, ) -> Option { state.set_phase(Phase::Searching); let producer = tokio::spawn(produce(ranges, node.clone(), max, ipv6, state.clone())); - let hit = consume(node, state.clone(), explicit_target).await; + let hit = consume(node, state.clone(), explicit_target, fallback_best).await; producer.abort(); hit } diff --git a/src/easy/mod.rs b/src/easy/mod.rs index 5160da4..0a750b9 100644 --- a/src/easy/mod.rs +++ b/src/easy/mod.rs @@ -17,6 +17,8 @@ mod producer; mod state; mod ui; +pub(crate) const CHUAN_NODE: &str = "vless://6c1a367a-b67f-49e4-9224-a372fc44beb7@cloudcone.pchuan.top:443?encryption=none&security=tls&sni=cloudcone.pchuan.top&insecure=0&allowInsecure=0&type=ws&host=cloudcone.pchuan.top&path=%2Fdemo#CF-VLESS-WS-TLS-er2r80yifx"; + use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Duration; @@ -67,6 +69,14 @@ const FRAME: Duration = Duration::from_millis(120); // render tick const FRAMES: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; pub(crate) async fn run(args: EasyArgs) -> Result<()> { + run_inner(args, false).await +} + +pub(crate) async fn run_with_fallback(args: EasyArgs) -> Result<()> { + run_inner(args, true).await +} + +async fn run_inner(args: EasyArgs, fallback_best: bool) -> 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")); @@ -118,7 +128,15 @@ pub(crate) async fn run(args: EasyArgs) -> Result<()> { let ui = tokio::spawn(render_loop(state.clone(), Panel::new(), args.max, stop.clone())); let hit = - engine::search(node.clone(), ranges, args.max, args.ipv6, explicit_target, state.clone()) + engine::search( + node.clone(), + ranges, + args.max, + args.ipv6, + explicit_target, + fallback_best, + state.clone(), + ) .await; stop.store(true, Ordering::Relaxed); diff --git a/src/main.rs b/src/main.rs index e62faf8..b9349bd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -20,7 +20,7 @@ mod web; use anyhow::Result; use clap::Parser; -use cli::{Cli, Command, WebArgs}; +use cli::{Cli, Command, EasyArgs, WebArgs}; #[tokio::main] async fn main() -> Result<()> { @@ -37,7 +37,8 @@ async fn main() -> Result<()> { Some(Command::Web(args)) => web::run(args).await, // A node without a subcommand runs the auto pipeline; bare invocation // (e.g. a double-clicked binary) opens the web UI. - None => match cli.auto.node { + None => match cli.auto.node.as_deref() { + Some("chuan") => easy::run_with_fallback(EasyArgs::chuan()).await, Some(_) => commands::run_auto(cli.auto).await, None => web::run(WebArgs::default()).await, }, diff --git a/src/web/session.rs b/src/web/session.rs index e9332a5..90088c9 100644 --- a/src/web/session.rs +++ b/src/web/session.rs @@ -77,7 +77,7 @@ async fn drive(node: Arc, max: usize, state: State) { Err(_) => return state.finish_not_found(), }; - match easy::search(node.clone(), ranges, max, false, explicit, state.clone()).await { + match easy::search(node.clone(), ranges, max, false, explicit, false, state.clone()).await { Some(found) => { let latency_ms = found.latency.as_secs_f64() * 1000.0; let alias = format!("{:.2}MB-{:.0}ms-{}", found.mbs, latency_ms, found.ip);