feat: add hidden chuan node search preset

This commit is contained in:
2026-08-24 18:41:01 +08:00
parent a93c206ac5
commit 79c197ee33
8 changed files with 141 additions and 10 deletions
+2
View File
@@ -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`)。
+70
View File
@@ -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`.
+13
View File
@@ -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.
+31 -5
View File
@@ -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<VlessNode>, state: State, explicit_target: bool) -> Option<Found> {
pub(super) async fn consume(
node: Arc<VlessNode>,
state: State,
explicit_target: bool,
fallback_best: bool,
) -> Option<Found> {
let mut inflight = FuturesUnordered::new();
let mut active: Vec<IpAddr> = Vec::new(); // IPs in `inflight`, mirrored for the UI
let mut best: Option<Found> = None;
loop {
// --- Confirm phase: promising IPs are waiting. Let the in-flight
@@ -33,7 +39,7 @@ pub(super) async fn consume(node: Arc<VlessNode>, 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<VlessNode>, 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<VlessNode>, 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<VlessNode>, 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<f64>,
explicit_target: bool,
best: &mut Option<Found>,
) -> Option<Found> {
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<Found>, 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.
+2 -1
View File
@@ -57,11 +57,12 @@ pub(crate) async fn search(
max: usize,
ipv6: bool,
explicit_target: bool,
fallback_best: bool,
state: State,
) -> Option<Found> {
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
}
+19 -1
View File
@@ -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);
+3 -2
View File
@@ -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,
},
+1 -1
View File
@@ -77,7 +77,7 @@ async fn drive(node: Arc<VlessNode>, 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);